From f993038bb9a61b00fcb716e0864a3b6da99cd560 Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Sun, 20 Sep 2026 13:54:43 +0800 Subject: [PATCH 01/18] chore(weather): add native widget refresh foundations Platform: ios --- .../WidgetLocationCatalog.swift | 54 --- ios/DPIPWidgets/CurrentWeatherRemoteDTO.swift | 137 +++++++ .../SavedWidgetLocationResolver.swift | 36 ++ ios/Runner.xcodeproj/project.pbxproj | 8 +- .../CurrentWeatherWidgetTests.swift | 232 ++++++++++++ .../WidgetLocationCatalogTests.swift | 340 ++++++++++++++++-- .../WidgetLocationIntentTests.swift | 64 +++- ios/Shared/WidgetLocationCatalog.swift | 184 ++++++++++ 8 files changed, 952 insertions(+), 103 deletions(-) delete mode 100644 ios/DPIPWidgetIntentsExtension/WidgetLocationCatalog.swift create mode 100644 ios/DPIPWidgets/CurrentWeatherRemoteDTO.swift create mode 100644 ios/DPIPWidgets/SavedWidgetLocationResolver.swift create mode 100644 ios/Shared/WidgetLocationCatalog.swift diff --git a/ios/DPIPWidgetIntentsExtension/WidgetLocationCatalog.swift b/ios/DPIPWidgetIntentsExtension/WidgetLocationCatalog.swift deleted file mode 100644 index 7bee14cae..000000000 --- a/ios/DPIPWidgetIntentsExtension/WidgetLocationCatalog.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation - -struct WidgetLocationCatalog: Decodable { - let schemaVersion: Int - let locations: [WidgetLocationCatalogLocation] -} - -struct WidgetLocationCatalogLocation: Decodable { - let regionCode: String - let displayName: String - let administrativeAreaName: String - let latitude: Double - let longitude: Double -} - -struct WidgetLocationCatalogStore { - private let appGroupIdentifier = - "group.com.exptech.dpip.dpip.widgets" - - func load() -> WidgetLocationCatalog? { - guard let containerURL = - FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: appGroupIdentifier - ) - else { - return nil - } - - let url = containerURL - .appendingPathComponent("WidgetSnapshots") - .appendingPathComponent("location-catalog.json") - - guard let data = try? Data(contentsOf: url) else { - return nil - } - - return Self.decode(data) - } - - static func decode(_ data: Data) -> WidgetLocationCatalog? { - guard let catalog = try? JSONDecoder().decode( - WidgetLocationCatalog.self, - from: data - ) else { - return nil - } - - guard catalog.schemaVersion == 1 else { - return nil - } - - return catalog - } -} diff --git a/ios/DPIPWidgets/CurrentWeatherRemoteDTO.swift b/ios/DPIPWidgets/CurrentWeatherRemoteDTO.swift new file mode 100644 index 000000000..1584f8ed9 --- /dev/null +++ b/ios/DPIPWidgets/CurrentWeatherRemoteDTO.swift @@ -0,0 +1,137 @@ +import Foundation + +/// The subset of `/api/v5/meteor/weather/realtime/:coords` used by the +/// current-weather widget. +struct CurrentWeatherRemoteDTO: Decodable, Sendable { + let stationName: String + let time: Int + let weather: String + let weatherCode: Int + let temperature: Double? + let humidity: Int? + let rain: Double? + + var condition: CurrentWeatherWidgetCondition { + currentWeatherWidgetCondition(for: weatherCode) + } + + private enum CodingKeys: String, CodingKey { + case station + case time + case data + } + + private struct Station: Decodable, Sendable { + let name: String + } + + private struct WeatherData: Decodable, Sendable { + let weather: String + let weatherCode: Int + let temperature: Double? + let humidity: Int? + let rain: Double? + + private enum CodingKeys: String, CodingKey { + case weather + case weatherCode + case temperature + case humidity + case rain + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + weather = try container.decode(String.self, forKey: .weather) + weatherCode = try container.decode(Int.self, forKey: .weatherCode) + temperature = try Self.decodeNullableDouble( + from: container, + forKey: .temperature + ) + humidity = try Self.decodeNullableInt( + from: container, + forKey: .humidity + ) + rain = try Self.decodeNullableDouble( + from: container, + forKey: .rain + ) + } + + private static func decodeNullableDouble( + from container: KeyedDecodingContainer, + forKey key: CodingKeys + ) throws -> Double? { + guard let value = try container.decodeIfPresent( + Double.self, + forKey: key + ) else { + return nil + } + + return value == -99 ? nil : value + } + + private static func decodeNullableInt( + from container: KeyedDecodingContainer, + forKey key: CodingKeys + ) throws -> Int? { + guard let value = try container.decodeIfPresent( + Int.self, + forKey: key + ) else { + return nil + } + + return value == -99 ? nil : value + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let station = try container.decode(Station.self, forKey: .station) + let data = try container.decode(WeatherData.self, forKey: .data) + + stationName = station.name + time = try container.decode(Int.self, forKey: .time) + weather = data.weather + weatherCode = data.weatherCode + temperature = data.temperature + humidity = data.humidity + rain = data.rain + } +} + +/// Mirrors Dart's authoritative `weatherConditionForCode()` classification. +func currentWeatherWidgetCondition( + for weatherCode: Int +) -> CurrentWeatherWidgetCondition { + guard weatherCode > 0 else { + return .unknown + } + + switch weatherCode % 100 { + case 1, 2, 5: + return .fog + case 3, 4, 14, 15, 16, 17, 18, 19: + return .thunderstorm + case 6, 7, 11, 13: + return .rain + case 8, 9, 10, 12: + return .snow + default: + break + } + + switch weatherCode / 100 { + case 1: + return .clear + case 2: + return .cloudy + case 3: + return .overcast + default: + return .unknown + } +} diff --git a/ios/DPIPWidgets/SavedWidgetLocationResolver.swift b/ios/DPIPWidgets/SavedWidgetLocationResolver.swift new file mode 100644 index 000000000..979fe3a93 --- /dev/null +++ b/ios/DPIPWidgets/SavedWidgetLocationResolver.swift @@ -0,0 +1,36 @@ +import Foundation + +struct ResolvedSavedLocation: Equatable, Sendable { + let sourceIdentifier: String + let regionCode: String + let regionName: String + let latitude: Double + let longitude: Double +} + +struct SavedWidgetLocationResolver: Sendable { + let catalog: WidgetLocationCatalog? + + func resolve( + target: WidgetLocationTarget + ) -> ResolvedSavedLocation? { + guard + case let .saved(regionCode) = target, + let catalog, + let location = catalog.locations.first(where: { + $0.regionCode == regionCode + }), + let sourceIdentifier = target.sourceIdentifier + else { + return nil + } + + return ResolvedSavedLocation( + sourceIdentifier: sourceIdentifier, + regionCode: location.regionCode, + regionName: location.displayName, + latitude: location.latitude, + longitude: location.longitude + ) + } +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 90ad709f2..abe20c680 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -47,6 +47,7 @@ D91A00000000000000000011 /* CurrentWeatherWidgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D91A00000000000000000001 /* CurrentWeatherWidgetTests.swift */; }; D91A00000000000000000012 /* CurrentWeatherWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = D91A00000000000000000002 /* CurrentWeatherWidgetSnapshot.swift */; }; D91A00000000000000000013 /* CurrentWeatherWidgetTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = D91A00000000000000000003 /* CurrentWeatherWidgetTimeline.swift */; }; + D91A00000000000000000014 /* CurrentWeatherRemoteDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D91A00000000000000000004 /* CurrentWeatherRemoteDTO.swift */; }; DB8E84D957A6C2B12EEB0AB7 /* Sounds/report.aiff in Resources */ = {isa = PBXBuildFile; fileRef = FD769D73A7C4BE3619C1F9FB /* Sounds/report.aiff */; }; DP1PF1REBASE0001PL1ST010 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = DP1PF1REBASE0002PL1ST020 /* GoogleService-Info.plist */; }; E1EBD3F0B2991F7D60DF56C5 /* Sounds/weather.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 762B2690D1D999F97D84162C /* Sounds/weather.aiff */; }; @@ -157,6 +158,7 @@ D91A00000000000000000001 /* CurrentWeatherWidgetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentWeatherWidgetTests.swift; sourceTree = ""; }; D91A00000000000000000002 /* CurrentWeatherWidgetSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CurrentWeatherWidgetSnapshot.swift; path = ../DPIPWidgets/CurrentWeatherWidgetSnapshot.swift; sourceTree = ""; }; D91A00000000000000000003 /* CurrentWeatherWidgetTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CurrentWeatherWidgetTimeline.swift; path = ../DPIPWidgets/CurrentWeatherWidgetTimeline.swift; sourceTree = ""; }; + D91A00000000000000000004 /* CurrentWeatherRemoteDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CurrentWeatherRemoteDTO.swift; path = ../DPIPWidgets/CurrentWeatherRemoteDTO.swift; sourceTree = ""; }; DP1PF1REBASE0002PL1ST020 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; F9BCED8E5498E9FD7A454E8F /* Sounds/info.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/info.aiff; sourceTree = ""; }; FD769D73A7C4BE3619C1F9FB /* Sounds/report.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/report.aiff; sourceTree = ""; }; @@ -167,6 +169,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( CurrentWeatherSnapshotAddress.swift, + WidgetLocationCatalog.swift, ); target = 2715FCD430570C1C0014DC8A /* DPIPWidgetsExtension */; }; @@ -181,6 +184,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( CurrentWeatherSnapshotAddress.swift, + WidgetLocationCatalog.swift, ); target = 331C8080294A63A400263BE5 /* RunnerTests */; }; @@ -217,7 +221,6 @@ 27CACC5E305C66560046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - WidgetLocationCatalog.swift, WidgetLocationOptions.swift, ); target = 331C8080294A63A400263BE5 /* RunnerTests */; @@ -225,6 +228,7 @@ 27CACD20305C6E2D0046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + SavedWidgetLocationResolver.swift, WidgetLocationTarget.swift, ); target = 331C8080294A63A400263BE5 /* RunnerTests */; @@ -290,6 +294,7 @@ D91A00000000000000000001 /* CurrentWeatherWidgetTests.swift */, D91A00000000000000000002 /* CurrentWeatherWidgetSnapshot.swift */, D91A00000000000000000003 /* CurrentWeatherWidgetTimeline.swift */, + D91A00000000000000000004 /* CurrentWeatherRemoteDTO.swift */, 27CACC3D305C64AA0046F79C /* WidgetLocationIntentTests.swift */, 27CACC7F305C67530046F79C /* WidgetLocationCatalogTests.swift */, 270FCEA4305CD2D6003D1E62 /* CurrentWeatherSnapshotAddressTests.swift */, @@ -630,6 +635,7 @@ 270FCEF5305D1EB2003D1E62 /* WidgetSnapshotFileTests.swift in Sources */, 27CACC3E305C64AA0046F79C /* WidgetLocationIntentTests.swift in Sources */, D91A00000000000000000013 /* CurrentWeatherWidgetTimeline.swift in Sources */, + D91A00000000000000000014 /* CurrentWeatherRemoteDTO.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/RunnerTests/CurrentWeatherWidgetTests.swift b/ios/RunnerTests/CurrentWeatherWidgetTests.swift index 7ec4cb963..d1f7e5d8c 100644 --- a/ios/RunnerTests/CurrentWeatherWidgetTests.swift +++ b/ios/RunnerTests/CurrentWeatherWidgetTests.swift @@ -1,6 +1,238 @@ import Foundation import XCTest +final class CurrentWeatherRemoteDTOTests: XCTestCase { + func testDecodesValidClearResponse() throws { + let weather = try decode( + """ + { + "id": "C0X16", + "station": { + "name": "仁德", + "lat": 22.9683, + "lon": 120.2577, + "altitude": 26, + "distance": 0.81 + }, + "time": 1789567200, + "data": { + "weather": "晴", + "weatherCode": 100, + "temperature": 28.5, + "humidity": 70, + "rain": 0.0, + "wind": { "speed": 1.5, "beaufort": 1 }, + "gust": { "speed": 3.0, "beaufort": 2 } + } + } + """ + ) + + XCTAssertEqual(weather.stationName, "仁德") + XCTAssertEqual(weather.time, 1_789_567_200) + XCTAssertEqual(weather.weather, "晴") + XCTAssertEqual(weather.weatherCode, 100) + XCTAssertEqual(weather.condition, .clear) + XCTAssertEqual(weather.temperature, 28.5) + XCTAssertEqual(weather.humidity, 70) + XCTAssertEqual(weather.rain, 0) + } + + func testDecodesValidRainResponse() throws { + let weather = try decode( + validJSON( + weather: "有雨", + weatherCode: 106 + ) + ) + + XCTAssertEqual(weather.weather, "有雨") + XCTAssertEqual(weather.weatherCode, 106) + XCTAssertEqual(weather.condition, .rain) + } + + func testMissingSentinelsDecodeAsNil() throws { + let weather = try decode( + validJSON( + temperature: "-99", + humidity: "-99", + rain: "-99" + ) + ) + + XCTAssertNil(weather.temperature) + XCTAssertNil(weather.humidity) + XCTAssertNil(weather.rain) + } + + func testRejectsMissingStationName() { + XCTAssertThrowsError( + try decode( + validJSON(station: "{}") + ) + ) + } + + func testRejectsMissingTime() { + XCTAssertThrowsError( + try decode( + """ + { + "station": { "name": "仁德" }, + "data": { + "weather": "晴", + "weatherCode": 100 + } + } + """ + ) + ) + } + + func testRejectsMissingWeather() { + XCTAssertThrowsError( + try decode( + validJSON(weather: nil) + ) + ) + } + + func testRejectsMissingWeatherCode() { + XCTAssertThrowsError( + try decode( + validJSON(weatherCode: nil) + ) + ) + } + + func testRejectsMalformedDataStructure() { + XCTAssertThrowsError( + try decode( + """ + { + "station": { "name": "仁德" }, + "time": 1789567200, + "data": [] + } + """ + ) + ) + } + + func testRejectsMalformedRequiredFieldType() { + XCTAssertThrowsError( + try decode( + """ + { + "station": { "name": "仁德" }, + "time": "1789567200", + "data": { + "weather": "晴", + "weatherCode": 100 + } + } + """ + ) + ) + } + + func testMatchesDartWeatherCodeSemantics() { + let cases: [(Int, CurrentWeatherWidgetCondition)] = [ + (100, .clear), + (200, .cloudy), + (300, .overcast), + (101, .fog), + (102, .fog), + (105, .fog), + (103, .thunderstorm), + (104, .thunderstorm), + (114, .thunderstorm), + (115, .thunderstorm), + (116, .thunderstorm), + (117, .thunderstorm), + (118, .thunderstorm), + (119, .thunderstorm), + (106, .rain), + (107, .rain), + (111, .rain), + (113, .rain), + (108, .snow), + (109, .snow), + (110, .snow), + (112, .snow), + ] + + for (code, condition) in cases { + XCTAssertEqual( + currentWeatherWidgetCondition(for: code), + condition, + "weather code \(code)" + ) + } + } + + func testPhenomenonTakesPrecedenceOverSkyState() { + let cases: [(Int, CurrentWeatherWidgetCondition)] = [ + (106, .rain), + (214, .thunderstorm), + (305, .fog), + ] + + for (code, condition) in cases { + XCTAssertEqual( + currentWeatherWidgetCondition(for: code), + condition, + "weather code \(code)" + ) + } + } + + func testUnknownWeatherCodesReturnUnknown() { + for code in [0, -1, 420] { + XCTAssertEqual( + currentWeatherWidgetCondition(for: code), + .unknown, + "weather code \(code)" + ) + } + } + + private func decode(_ json: String) throws -> CurrentWeatherRemoteDTO { + try JSONDecoder().decode( + CurrentWeatherRemoteDTO.self, + from: Data(json.utf8) + ) + } + + private func validJSON( + station: String = #"{ "name": "仁德" }"#, + weather: String? = "晴", + weatherCode: Int? = 100, + temperature: String = "28.5", + humidity: String = "70", + rain: String = "0.0" + ) -> String { + let weatherField = weather.map { #""weather": "\#($0)","# } ?? "" + let weatherCodeField = weatherCode.map { + #""weatherCode": \#($0),"# + } ?? "" + + return """ + { + "station": \(station), + "time": 1789567200, + "data": { + \(weatherField) + \(weatherCodeField) + "temperature": \(temperature), + "humidity": \(humidity), + "rain": \(rain) + } + } + """ + } +} + final class CurrentWeatherWidgetSnapshotTests: XCTestCase { func testDecodesSchemaVersionFiveSnapshot() throws { let snapshot = try decode( diff --git a/ios/RunnerTests/WidgetLocationCatalogTests.swift b/ios/RunnerTests/WidgetLocationCatalogTests.swift index 55672317d..7eb8c39e0 100644 --- a/ios/RunnerTests/WidgetLocationCatalogTests.swift +++ b/ios/RunnerTests/WidgetLocationCatalogTests.swift @@ -3,67 +3,349 @@ import XCTest final class WidgetLocationCatalogTests: XCTestCase { func testDecodesSchemaVersionOneCatalog() throws { + let catalog = try XCTUnwrap( + WidgetLocationCatalog.decode( + validCatalogData() + ) + ) + + XCTAssertEqual(catalog.schemaVersion, 1) + XCTAssertEqual(catalog.locations.count, 1) + XCTAssertEqual(catalog.locations[0].regionCode, "242") + XCTAssertEqual(catalog.locations[0].displayName, "新莊區") + XCTAssertEqual( + catalog.locations[0].administrativeAreaName, + "新北市" + ) + XCTAssertEqual(catalog.locations[0].latitude, 25.0358303) + XCTAssertEqual(catalog.locations[0].longitude, 121.4500307) + } + + func testRejectsUnsupportedSchemaVersion() { + XCTAssertNil( + WidgetLocationCatalog.decode( + validCatalogData(schemaVersion: 2) + ) + ) + } + + func testRejectsMalformedTopLevelJSON() { + XCTAssertNil( + WidgetLocationCatalog.decode( + Data(#"{"schemaVersion":1,"locations":["#.utf8) + ) + ) + XCTAssertNil( + WidgetLocationCatalog.decode( + Data(#"[]"#.utf8) + ) + ) + } + + func testRejectsMalformedEntryWithoutPartialCatalog() { let json = """ { "schemaVersion": 1, "locations": [ { - "regionCode": "220", - "displayName": "板橋區", + "regionCode": "242", + "displayName": "新莊區", "administrativeAreaName": "新北市", - "latitude": 25.0096156, - "longitude": 121.4592358 + "latitude": 25.0358303, + "longitude": 121.4500307 + }, + { + "regionCode": "433" } ] } """ - let catalog = WidgetLocationCatalogStore.decode( - Data(json.utf8) + XCTAssertNil( + WidgetLocationCatalog.decode(Data(json.utf8)) ) + } - XCTAssertNotNil(catalog) - XCTAssertEqual(catalog?.schemaVersion, 1) - XCTAssertEqual(catalog?.locations.count, 1) - XCTAssertEqual(catalog?.locations[0].regionCode, "220") - XCTAssertEqual(catalog?.locations[0].displayName, "板橋區") - XCTAssertEqual( - catalog?.locations[0].administrativeAreaName, - "新北市" - ) + func testRejectsInvalidRegionCode() { + for regionCode in ["", "24", "2420", "abc"] { + XCTAssertNil( + WidgetLocationCatalog.decode( + validCatalogData(regionCode: regionCode) + ), + regionCode + ) + } } - func testRejectsUnsupportedSchemaVersion() { - let json = """ - { - "schemaVersion": 2, - "locations": [] + func testRejectsUnicodeNumericRegionCode() { + for regionCode in ["242", "٢٤٢"] { + XCTAssertNil( + WidgetLocationCatalog.decode( + validCatalogData(regionCode: regionCode) + ), + regionCode + ) } - """ + } - XCTAssertNil( - WidgetLocationCatalogStore.decode( - Data(json.utf8) + func testRejectsNonFiniteLatitudeThroughDirectValidation() { + for latitude in [Double.nan, .infinity, -.infinity] { + XCTAssertNil( + WidgetLocationCatalogLocation( + regionCode: "242", + displayName: "新莊區", + administrativeAreaName: "新北市", + latitude: latitude, + longitude: 121.4500307 + ) ) - ) + } + } + + func testRejectsNonFiniteLongitudeThroughDirectValidation() { + for longitude in [Double.nan, .infinity, -.infinity] { + XCTAssertNil( + WidgetLocationCatalogLocation( + regionCode: "242", + displayName: "新莊區", + administrativeAreaName: "新北市", + latitude: 25.0358303, + longitude: longitude + ) + ) + } + } + + func testRejectsOutOfBoundsLatitude() { + for latitude in [-90.000_001, 90.000_001] { + XCTAssertNil( + WidgetLocationCatalog.decode( + validCatalogData(latitude: latitude) + ), + "latitude \(latitude)" + ) + } + } + + func testRejectsOutOfBoundsLongitude() { + for longitude in [-180.000_001, 180.000_001] { + XCTAssertNil( + WidgetLocationCatalog.decode( + validCatalogData(longitude: longitude) + ), + "longitude \(longitude)" + ) + } } - func testRejectsMalformedCatalog() { + func testRejectsDuplicateRegionCodes() { let json = """ { "schemaVersion": 1, "locations": [ { - "regionCode": "220" + "regionCode": "242", + "displayName": "新莊區", + "administrativeAreaName": "新北市", + "latitude": 25.0358303, + "longitude": 121.4500307 + }, + { + "regionCode": "242", + "displayName": "另一個地區", + "administrativeAreaName": "新北市", + "latitude": 25.1, + "longitude": 121.5 } ] } """ XCTAssertNil( - WidgetLocationCatalogStore.decode( - Data(json.utf8) + WidgetLocationCatalog.decode(Data(json.utf8)) + ) + } + + private func validCatalogData( + schemaVersion: Int = 1, + regionCode: String = "242", + latitude: Double = 25.0358303, + longitude: Double = 121.4500307 + ) -> Data { + Data( + """ + { + "schemaVersion": \(schemaVersion), + "locations": [ + { + "regionCode": "\(regionCode)", + "displayName": "新莊區", + "administrativeAreaName": "新北市", + "latitude": \(latitude), + "longitude": \(longitude) + } + ] + } + """.utf8 + ) + } +} + +final class SavedWidgetLocationResolverTests: XCTestCase { + func testRegion242ResolvesExactEntry() throws { + let resolver = try makeResolver(codes: ["242", "433"]) + + XCTAssertEqual( + resolver.resolve( + target: WidgetLocationTarget(identifier: "region:242") + ), + ResolvedSavedLocation( + sourceIdentifier: "region:242", + regionCode: "242", + regionName: "新莊區", + latitude: 25.0358303, + longitude: 121.4500307 + ) + ) + } + + func testRegion433ResolvesIndependently() throws { + let resolver = try makeResolver(codes: ["242", "433"]) + + XCTAssertEqual( + resolver.resolve( + target: WidgetLocationTarget(identifier: "region:433") + )?.regionCode, + "433" + ) + } + + func testMissingRegionReturnsUnresolved() throws { + let resolver = try makeResolver(codes: ["242"]) + + XCTAssertNil( + resolver.resolve( + target: WidgetLocationTarget(identifier: "region:433") + ) + ) + } + + func testRemovedRegionReturnsUnresolved() throws { + let resolverBeforeRemoval = try makeResolver(codes: ["242", "433"]) + let resolverAfterRemoval = try makeResolver(codes: ["433"]) + let target = WidgetLocationTarget(identifier: "region:242") + + XCTAssertNotNil(resolverBeforeRemoval.resolve(target: target)) + XCTAssertNil(resolverAfterRemoval.resolve(target: target)) + } + + func testMissingCatalogReturnsUnresolved() { + let resolver = SavedWidgetLocationResolver(catalog: nil) + + XCTAssertNil( + resolver.resolve( + target: WidgetLocationTarget(identifier: "region:242") + ) + ) + } + + func testUnsupportedCatalogReturnsUnresolved() { + let catalog = WidgetLocationCatalog.decode( + Data(#"{"schemaVersion":2,"locations":[]}"#.utf8) + ) + let resolver = SavedWidgetLocationResolver(catalog: catalog) + + XCTAssertNil( + resolver.resolve( + target: WidgetLocationTarget(identifier: "region:242") ) ) } + + func testMalformedCatalogReturnsUnresolved() { + let catalog = WidgetLocationCatalog.decode( + Data(#"{"schemaVersion":1,"locations":[{}]}"#.utf8) + ) + let resolver = SavedWidgetLocationResolver(catalog: catalog) + + XCTAssertNil( + resolver.resolve( + target: WidgetLocationTarget(identifier: "region:242") + ) + ) + } + + func testCurrentLocationIsNotResolved() throws { + let resolver = try makeResolver(codes: ["242"]) + + XCTAssertNil( + resolver.resolve(target: .currentLocation) + ) + } + + func testInvalidTargetIsNotResolved() throws { + let resolver = try makeResolver(codes: ["242"]) + + XCTAssertNil( + resolver.resolve( + target: WidgetLocationTarget(identifier: "region:242") + ) + ) + } + + func testEntryOrderDoesNotChangeExactMatching() throws { + let target = WidgetLocationTarget(identifier: "region:242") + let forward = try makeResolver(codes: ["242", "433"]) + let reversed = try makeResolver(codes: ["433", "242"]) + + XCTAssertEqual( + forward.resolve(target: target), + reversed.resolve(target: target) + ) + } + + private func makeResolver( + codes: [String] + ) throws -> SavedWidgetLocationResolver { + let locations = try codes.map(makeLocation) + let catalog = try XCTUnwrap( + WidgetLocationCatalog( + schemaVersion: 1, + locations: locations + ) + ) + + return SavedWidgetLocationResolver(catalog: catalog) + } + + private func makeLocation( + regionCode: String + ) throws -> WidgetLocationCatalogLocation { + switch regionCode { + case "242": + return try XCTUnwrap( + WidgetLocationCatalogLocation( + regionCode: "242", + displayName: "新莊區", + administrativeAreaName: "新北市", + latitude: 25.0358303, + longitude: 121.4500307 + ) + ) + + case "433": + return try XCTUnwrap( + WidgetLocationCatalogLocation( + regionCode: "433", + displayName: "沙鹿區", + administrativeAreaName: "臺中市", + latitude: 24.2338622, + longitude: 120.565703 + ) + ) + + default: + throw NSError(domain: "SavedWidgetLocationResolverTests", code: 1) + } + } } diff --git a/ios/RunnerTests/WidgetLocationIntentTests.swift b/ios/RunnerTests/WidgetLocationIntentTests.swift index 69efaf0db..b00b59496 100644 --- a/ios/RunnerTests/WidgetLocationIntentTests.swift +++ b/ios/RunnerTests/WidgetLocationIntentTests.swift @@ -1,3 +1,4 @@ +import Foundation import XCTest @testable import Runner @@ -52,25 +53,30 @@ final class WidgetLocationIntentTests: XCTestCase { XCTAssertEqual(options.first?.displayString, "所在地") } - func testCatalogLocationsPreserveSavedOrder() { - let catalog = WidgetLocationCatalog( - schemaVersion: 1, - locations: [ - WidgetLocationCatalogLocation( - regionCode: "220", - displayName: "板橋區", - administrativeAreaName: "新北市", - latitude: 25.0096156, - longitude: 121.4592358 - ), - WidgetLocationCatalogLocation( - regionCode: "302", - displayName: "竹北市", - administrativeAreaName: "新竹縣", - latitude: 24.8395807, - longitude: 121.0040235 - ) - ] + func testCatalogLocationsPreserveSavedOrder() throws { + let firstLocation = try XCTUnwrap( + WidgetLocationCatalogLocation( + regionCode: "220", + displayName: "板橋區", + administrativeAreaName: "新北市", + latitude: 25.0096156, + longitude: 121.4592358 + ) + ) + let secondLocation = try XCTUnwrap( + WidgetLocationCatalogLocation( + regionCode: "302", + displayName: "竹北市", + administrativeAreaName: "新竹縣", + latitude: 24.8395807, + longitude: 121.0040235 + ) + ) + let catalog = try XCTUnwrap( + WidgetLocationCatalog( + schemaVersion: 1, + locations: [firstLocation, secondLocation] + ) ) let options = makeWidgetLocationOptions( @@ -97,6 +103,26 @@ final class WidgetLocationIntentTests: XCTestCase { ) } + func testMalformedCatalogStillYieldsCurrentLocationOnly() { + let malformedCatalog = WidgetLocationCatalog.decode( + Data(#"{"schemaVersion":1,"locations":[{}]}"#.utf8) + ) + + XCTAssertNil(malformedCatalog) + XCTAssertEqual( + makeWidgetLocationOptions( + from: malformedCatalog, + currentLocationDisplayString: "Current Location" + ), + [ + WidgetLocationOption( + identifier: "current-location", + displayString: "Current Location" + ) + ] + ) + } + func testLocationTargetDefaultsToCurrentLocation() { XCTAssertEqual( WidgetLocationTarget(identifier: nil), diff --git a/ios/Shared/WidgetLocationCatalog.swift b/ios/Shared/WidgetLocationCatalog.swift new file mode 100644 index 000000000..1a2e36ea9 --- /dev/null +++ b/ios/Shared/WidgetLocationCatalog.swift @@ -0,0 +1,184 @@ +import Foundation + +struct WidgetLocationCatalog: Equatable, Sendable { + static let supportedSchemaVersion = 1 + + let schemaVersion: Int + let locations: [WidgetLocationCatalogLocation] + + init?( + schemaVersion: Int, + locations: [WidgetLocationCatalogLocation] + ) { + guard schemaVersion == Self.supportedSchemaVersion else { + return nil + } + + let regionCodes = Set(locations.map(\.regionCode)) + + guard regionCodes.count == locations.count else { + return nil + } + + self.schemaVersion = schemaVersion + self.locations = locations + } + + static func decode(_ data: Data) -> Self? { + try? JSONDecoder().decode(Self.self, from: data) + } +} + +extension WidgetLocationCatalog: Decodable { + private enum CodingKeys: String, CodingKey { + case schemaVersion + case locations + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let schemaVersion = try container.decode( + Int.self, + forKey: .schemaVersion + ) + let locations = try container.decode( + [WidgetLocationCatalogLocation].self, + forKey: .locations + ) + + guard let catalog = Self( + schemaVersion: schemaVersion, + locations: locations + ) else { + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: + "Unsupported schema version or duplicate region code." + ) + ) + } + + self = catalog + } +} + +struct WidgetLocationCatalogLocation: Equatable, Sendable { + let regionCode: String + let displayName: String + let administrativeAreaName: String + let latitude: Double + let longitude: Double + + init?( + regionCode: String, + displayName: String, + administrativeAreaName: String, + latitude: Double, + longitude: Double + ) { + guard + Self.isValidRegionCode(regionCode), + latitude.isFinite, + longitude.isFinite, + (-90 ... 90).contains(latitude), + (-180 ... 180).contains(longitude) + else { + return nil + } + + self.regionCode = regionCode + self.displayName = displayName + self.administrativeAreaName = administrativeAreaName + self.latitude = latitude + self.longitude = longitude + } + + private static func isValidRegionCode( + _ regionCode: String + ) -> Bool { + let bytes = regionCode.utf8 + + return bytes.count == 3 + && bytes.allSatisfy { byte in + byte >= 48 && byte <= 57 + } + } +} + +extension WidgetLocationCatalogLocation: Decodable { + private enum CodingKeys: String, CodingKey { + case regionCode + case displayName + case administrativeAreaName + case latitude + case longitude + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let regionCode = try container.decode( + String.self, + forKey: .regionCode + ) + let displayName = try container.decode( + String.self, + forKey: .displayName + ) + let administrativeAreaName = try container.decode( + String.self, + forKey: .administrativeAreaName + ) + let latitude = try container.decode( + Double.self, + forKey: .latitude + ) + let longitude = try container.decode( + Double.self, + forKey: .longitude + ) + + guard let location = Self( + regionCode: regionCode, + displayName: displayName, + administrativeAreaName: administrativeAreaName, + latitude: latitude, + longitude: longitude + ) else { + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: + "Invalid region code or coordinate." + ) + ) + } + + self = location + } +} + +struct WidgetLocationCatalogStore: Sendable { + private let appGroupIdentifier = + "group.com.exptech.dpip.dpip.widgets" + + func load() -> WidgetLocationCatalog? { + guard let containerURL = + FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) + else { + return nil + } + + let url = containerURL + .appendingPathComponent("WidgetSnapshots") + .appendingPathComponent("location-catalog.json") + + guard let data = try? Data(contentsOf: url) else { + return nil + } + + return WidgetLocationCatalog.decode(data) + } +} From b3ac34be499fc303cdfc47361f4dbb4b399e3fc8 Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Sun, 20 Sep 2026 19:44:45 +0800 Subject: [PATCH 02/18] chore(weather): add native widget weather client Platform: ios --- ios/DPIPWidgets/CurrentWeatherClient.swift | 87 ++++ ios/Runner.xcodeproj/project.pbxproj | 1 + .../CurrentWeatherWidgetTests.swift | 483 +++++++++++++++++- 3 files changed, 570 insertions(+), 1 deletion(-) create mode 100644 ios/DPIPWidgets/CurrentWeatherClient.swift diff --git a/ios/DPIPWidgets/CurrentWeatherClient.swift b/ios/DPIPWidgets/CurrentWeatherClient.swift new file mode 100644 index 000000000..b5619caed --- /dev/null +++ b/ios/DPIPWidgets/CurrentWeatherClient.swift @@ -0,0 +1,87 @@ +import Foundation + +enum CurrentWeatherClientError: Error, Equatable { + case invalidCoordinate + case invalidURL + case invalidResponse + case httpStatus(Int) + case responseTooLarge +} + +struct CurrentWeatherClient { + private static let scheme = "https" + private static let host = "api.core-tnn1.exptech.dev" + private static let realtimePath = "/api/v5/meteor/weather/realtime" + + private static let requestTimeout: TimeInterval = 6 + private static let maximumResponseSize = 128 * 1024 + + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + func makeURL(latitude: Double, longitude: Double) throws -> URL { + guard latitude.isFinite, + longitude.isFinite, + (-90.0...90.0).contains(latitude), + (-180.0...180.0).contains(longitude) + else { + throw CurrentWeatherClientError.invalidCoordinate + } + + var components = URLComponents() + components.scheme = Self.scheme + components.host = Self.host + components.path = "\(Self.realtimePath)/\(latitude),\(longitude)" + + guard let url = components.url else { + throw CurrentWeatherClientError.invalidURL + } + + return url + } + + func fetch( + latitude: Double, + longitude: Double + ) async throws -> CurrentWeatherRemoteDTO? { + let url = try makeURL( + latitude: latitude, + longitude: longitude + ) + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = Self.requestTimeout + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw CurrentWeatherClientError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + throw CurrentWeatherClientError.httpStatus( + httpResponse.statusCode + ) + } + + guard data.count <= Self.maximumResponseSize else { + throw CurrentWeatherClientError.responseTooLarge + } + + let jsonObject = try JSONSerialization.jsonObject(with: data) + + if let object = jsonObject as? [String: Any], + object.isEmpty { + return nil + } + + return try JSONDecoder().decode( + CurrentWeatherRemoteDTO.self, + from: data + ) + } +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index abe20c680..d6374a41d 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -228,6 +228,7 @@ 27CACD20305C6E2D0046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + CurrentWeatherClient.swift, SavedWidgetLocationResolver.swift, WidgetLocationTarget.swift, ); diff --git a/ios/RunnerTests/CurrentWeatherWidgetTests.swift b/ios/RunnerTests/CurrentWeatherWidgetTests.swift index d1f7e5d8c..8475f47cd 100644 --- a/ios/RunnerTests/CurrentWeatherWidgetTests.swift +++ b/ios/RunnerTests/CurrentWeatherWidgetTests.swift @@ -65,6 +65,85 @@ final class CurrentWeatherRemoteDTOTests: XCTestCase { XCTAssertNil(weather.rain) } + func testMissingOptionalKeysDecodeAsNil() throws { + let weather = try decode( + """ + { + "station": { "name": "仁德" }, + "time": 1789567200, + "data": { + "weather": "晴", + "weatherCode": 100 + } + } + """ + ) + + XCTAssertNil(weather.temperature) + XCTAssertNil(weather.humidity) + XCTAssertNil(weather.rain) + } + + func testExplicitNullOptionalValuesDecodeAsNil() throws { + let weather = try decode( + validJSON( + temperature: "null", + humidity: "null", + rain: "null" + ) + ) + + XCTAssertNil(weather.temperature) + XCTAssertNil(weather.humidity) + XCTAssertNil(weather.rain) + } + + func testIntegerJSONValuesDecodeAsDouble() throws { + let weather = try decode( + validJSON( + temperature: "28", + rain: "1" + ) + ) + + XCTAssertEqual(weather.temperature, 28.0) + XCTAssertEqual(weather.rain, 1.0) + } + + func testIntegralFloatingPointJSONValueDecodesAsInt() throws { + let weather = try decode( + validJSON(humidity: "70.0") + ) + + XCTAssertEqual(weather.humidity, 70) + } + + func testRejectsFractionalJSONValueForInt() { + XCTAssertThrowsError( + try decode(validJSON(humidity: "70.5")) + ) + } + + func testRejectsBooleanForNumericField() { + XCTAssertThrowsError( + try decode(validJSON(temperature: "true")) + ) + } + + func testRejectsOversizedInt() { + XCTAssertThrowsError( + try decode( + validJSON(time: "9223372036854775808") + ) + ) + } + + func testNegativeTimestampPreservesCurrentDecodingBehavior() throws { + let weather = try decode(validJSON(time: "-1")) + + XCTAssertEqual(weather.time, -1) + } + func testRejectsMissingStationName() { XCTAssertThrowsError( try decode( @@ -206,6 +285,7 @@ final class CurrentWeatherRemoteDTOTests: XCTestCase { private func validJSON( station: String = #"{ "name": "仁德" }"#, + time: String = "1789567200", weather: String? = "晴", weatherCode: Int? = 100, temperature: String = "28.5", @@ -220,7 +300,7 @@ final class CurrentWeatherRemoteDTOTests: XCTestCase { return """ { "station": \(station), - "time": 1789567200, + "time": \(time), "data": { \(weatherField) \(weatherCodeField) @@ -772,3 +852,404 @@ final class CurrentWeatherWidgetTimelineTests: XCTestCase { Date(timeIntervalSince1970: timestamp) } } + +private final class MockURLProtocol: URLProtocol { + static var requestHandler: ((URLRequest) throws -> (URLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest( + for request: URLRequest + ) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.requestHandler else { + client?.urlProtocol( + self, + didFailWithError: URLError(.badServerResponse) + ) + return + } + + do { + let (response, data) = try handler(request) + + client?.urlProtocol( + self, + didReceive: response, + cacheStoragePolicy: .notAllowed + ) + + client?.urlProtocol( + self, + didLoad: data + ) + + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol( + self, + didFailWithError: error + ) + } + } + + override func stopLoading() {} +} + +final class CurrentWeatherClientTests: XCTestCase { + private var session: URLSession! + private var client: CurrentWeatherClient! + + override func setUp() { + super.setUp() + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + + session = URLSession(configuration: configuration) + client = CurrentWeatherClient(session: session) + } + + override func tearDown() { + MockURLProtocol.requestHandler = nil + session.invalidateAndCancel() + + client = nil + session = nil + + super.tearDown() + } + + func testMakeURLBuildsRealtimeEndpoint() throws { + let url = try client.makeURL( + latitude: 24.1658, + longitude: 120.6336 + ) + + let components = try XCTUnwrap( + URLComponents(url: url, resolvingAgainstBaseURL: false) + ) + + XCTAssertEqual( + url.absoluteString, + "https://api.core-tnn1.exptech.dev/api/v5/meteor/weather/realtime/24.1658,120.6336" + ) + XCTAssertEqual(components.scheme, "https") + XCTAssertEqual(components.host, "api.core-tnn1.exptech.dev") + XCTAssertEqual( + components.path, + "/api/v5/meteor/weather/realtime/24.1658,120.6336" + ) + XCTAssertNil(components.query) + } + + func testMakeURLPreservesNegativeCoordinatesAndOrdering() throws { + let url = try client.makeURL( + latitude: -24.1658, + longitude: -120.6336 + ) + + XCTAssertEqual( + url.absoluteString, + "https://api.core-tnn1.exptech.dev/api/v5/meteor/weather/realtime/-24.1658,-120.6336" + ) + } + + func testMakeURLAcceptsCoordinateBoundaries() throws { + XCTAssertEqual( + try client.makeURL( + latitude: 90, + longitude: 180 + ).absoluteString, + "https://api.core-tnn1.exptech.dev/api/v5/meteor/weather/realtime/90.0,180.0" + ) + + XCTAssertEqual( + try client.makeURL( + latitude: -90, + longitude: -180 + ).absoluteString, + "https://api.core-tnn1.exptech.dev/api/v5/meteor/weather/realtime/-90.0,-180.0" + ) + } + + func testMakeURLRejectsInvalidLatitude() { + XCTAssertThrowsError( + try client.makeURL(latitude: 90.1, longitude: 120) + ) { error in + XCTAssertEqual( + error as? CurrentWeatherClientError, + .invalidCoordinate + ) + } + + XCTAssertThrowsError( + try client.makeURL(latitude: -90.1, longitude: 120) + ) { error in + XCTAssertEqual( + error as? CurrentWeatherClientError, + .invalidCoordinate + ) + } + } + + func testMakeURLRejectsInvalidLongitude() { + XCTAssertThrowsError( + try client.makeURL(latitude: 24, longitude: 180.1) + ) { error in + XCTAssertEqual( + error as? CurrentWeatherClientError, + .invalidCoordinate + ) + } + + XCTAssertThrowsError( + try client.makeURL(latitude: 24, longitude: -180.1) + ) { error in + XCTAssertEqual( + error as? CurrentWeatherClientError, + .invalidCoordinate + ) + } + } + + func testMakeURLRejectsNonFiniteCoordinates() { + let invalidCoordinates: [(Double, Double)] = [ + (.nan, 120), + (.infinity, 120), + (-.infinity, 120), + (24, .nan), + (24, .infinity), + (24, -.infinity), + ] + + for (latitude, longitude) in invalidCoordinates { + XCTAssertThrowsError( + try client.makeURL( + latitude: latitude, + longitude: longitude + ) + ) { error in + XCTAssertEqual( + error as? CurrentWeatherClientError, + .invalidCoordinate + ) + } + } + } + + func testFetchReturnsDecodedWeather() async throws { + let json = """ + { + "id": "C0F9T", + "station": { + "name": "西屯" + }, + "time": 1789877400, + "data": { + "weather": "晴", + "weatherCode": 100, + "temperature": 30.6, + "humidity": 66, + "rain": 0 + } + } + """ + + MockURLProtocol.requestHandler = { request in + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.timeoutInterval, 6) + XCTAssertEqual( + request.url?.absoluteString, + "https://api.core-tnn1.exptech.dev/api/v5/meteor/weather/realtime/24.1658,120.6336" + ) + + let response = try XCTUnwrap( + HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + ) + ) + + return ( + response, + Data(json.utf8) + ) + } + + let result = try await client.fetch( + latitude: 24.1658, + longitude: 120.6336 + ) + + let weather = try XCTUnwrap(result) + + XCTAssertEqual(weather.stationName, "西屯") + XCTAssertEqual(weather.time, 1789877400) + XCTAssertEqual(weather.weather, "晴") + XCTAssertEqual(weather.weatherCode, 100) + XCTAssertEqual(weather.temperature, 30.6) + XCTAssertEqual(weather.humidity, 66) + XCTAssertEqual(weather.rain, 0) + } + + func testFetchReturnsNilForStructurallyEmptyObjects() async throws { + for body in ["{}", " { \n } \n"] { + setHTTPResponse(data: Data(body.utf8)) + + let result = try await fetch() + + XCTAssertNil(result, body) + } + } + + func testFetchRejectsMalformedOrWrongShapeResponses() async { + let cases = [ + ("malformed JSON", #"{"station":"#), + ("top-level array", "[]"), + ("missing realtime structure", #"{"message":"ok"}"#), + ( + "invalid required field type", + """ + { + "station": { "name": "西屯" }, + "time": "1789877400", + "data": { + "weather": "晴", + "weatherCode": 100 + } + } + """ + ), + ] + + for (name, body) in cases { + setHTTPResponse(data: Data(body.utf8)) + + do { + _ = try await fetch() + XCTFail("Expected decoding failure for \(name)") + } catch { + XCTAssertFalse( + error is CurrentWeatherClientError, + "\(name): \(error)" + ) + } + } + } + + func testFetchRejectsHTTPFailures() async { + for statusCode in [404, 500] { + setHTTPResponse( + statusCode: statusCode, + data: Data("{}".utf8) + ) + + await assertFetchThrows( + .httpStatus(statusCode), + context: "HTTP \(statusCode)" + ) + } + } + + func testFetchAcceptsOnlyHTTP200() async { + setHTTPResponse(statusCode: 204, data: Data()) + + await assertFetchThrows(.httpStatus(204)) + } + + func testFetchRejectsOversizedResponse() async { + setHTTPResponse( + data: Data(repeating: 0x20, count: 128 * 1024 + 1) + ) + + await assertFetchThrows(.responseTooLarge) + } + + func testFetchPropagatesTransportFailure() async { + MockURLProtocol.requestHandler = { _ in + throw URLError(.timedOut) + } + + do { + _ = try await fetch() + XCTFail("Expected transport failure") + } catch { + XCTAssertEqual((error as? URLError)?.code, .timedOut) + XCTAssertFalse(error is CurrentWeatherClientError) + } + } + + func testFetchRejectsNonHTTPResponse() async { + MockURLProtocol.requestHandler = { request in + let url = try XCTUnwrap(request.url) + let response = URLResponse( + url: url, + mimeType: "application/json", + expectedContentLength: 2, + textEncodingName: "utf-8" + ) + + return (response, Data("{}".utf8)) + } + + await assertFetchThrows(.invalidResponse) + } + + private func fetch() async throws -> CurrentWeatherRemoteDTO? { + try await client.fetch( + latitude: 24.1658, + longitude: 120.6336 + ) + } + + private func setHTTPResponse( + statusCode: Int = 200, + data: Data + ) { + MockURLProtocol.requestHandler = { request in + let url = try XCTUnwrap(request.url) + let response = try XCTUnwrap( + HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + ) + ) + + return (response, data) + } + } + + private func assertFetchThrows( + _ expectedError: CurrentWeatherClientError, + context: String = "", + file: StaticString = #filePath, + line: UInt = #line + ) async { + do { + _ = try await fetch() + XCTFail( + "Expected \(expectedError) \(context)", + file: file, + line: line + ) + } catch { + XCTAssertEqual( + error as? CurrentWeatherClientError, + expectedError, + context, + file: file, + line: line + ) + } + } +} From 15b0a415f7692fa78ec72ec5648fc0c35432dc19 Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Sun, 20 Sep 2026 19:50:28 +0800 Subject: [PATCH 03/18] test(widget): cover location catalog edge cases Platform: ios --- .../WidgetLocationCatalogTests.swift | 83 ++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/ios/RunnerTests/WidgetLocationCatalogTests.swift b/ios/RunnerTests/WidgetLocationCatalogTests.swift index 7eb8c39e0..53a7b40b4 100644 --- a/ios/RunnerTests/WidgetLocationCatalogTests.swift +++ b/ios/RunnerTests/WidgetLocationCatalogTests.swift @@ -21,6 +21,86 @@ final class WidgetLocationCatalogTests: XCTestCase { XCTAssertEqual(catalog.locations[0].longitude, 121.4500307) } + func testEmptyCatalogIsValid() throws { + let catalog = try XCTUnwrap( + WidgetLocationCatalog.decode( + Data( + #"{"schemaVersion":1,"locations":[]}"#.utf8 + ) + ) + ) + + XCTAssertEqual(catalog.locations, []) + } + + func testAcceptsCoordinateBoundaries() throws { + let coordinates = [ + (-90.0, -180.0), + (-90.0, 180.0), + (90.0, -180.0), + (90.0, 180.0), + ] + + for (latitude, longitude) in coordinates { + let catalog = try XCTUnwrap( + WidgetLocationCatalog.decode( + validCatalogData( + latitude: latitude, + longitude: longitude + ) + ) + ) + + XCTAssertEqual(catalog.locations[0].latitude, latitude) + XCTAssertEqual(catalog.locations[0].longitude, longitude) + } + } + + func testIntegerJSONCoordinatesDecodeAsDouble() throws { + let json = """ + { + "schemaVersion": 1, + "locations": [ + { + "regionCode": "242", + "displayName": "新莊區", + "administrativeAreaName": "新北市", + "latitude": 25, + "longitude": 121 + } + ] + } + """ + + let catalog = try XCTUnwrap( + WidgetLocationCatalog.decode(Data(json.utf8)) + ) + + XCTAssertEqual(catalog.locations[0].latitude, 25.0) + XCTAssertEqual(catalog.locations[0].longitude, 121.0) + } + + func testEmptyDisplayNamePreservesCurrentBehavior() throws { + let catalog = try XCTUnwrap( + WidgetLocationCatalog.decode( + validCatalogData(displayName: "") + ) + ) + + XCTAssertEqual(catalog.locations[0].displayName, "") + } + + func testUnicodeDisplayNamePreservesCurrentBehavior() throws { + let displayName = "臺北🌧️" + let catalog = try XCTUnwrap( + WidgetLocationCatalog.decode( + validCatalogData(displayName: displayName) + ) + ) + + XCTAssertEqual(catalog.locations[0].displayName, displayName) + } + func testRejectsUnsupportedSchemaVersion() { XCTAssertNil( WidgetLocationCatalog.decode( @@ -169,6 +249,7 @@ final class WidgetLocationCatalogTests: XCTestCase { private func validCatalogData( schemaVersion: Int = 1, regionCode: String = "242", + displayName: String = "新莊區", latitude: Double = 25.0358303, longitude: Double = 121.4500307 ) -> Data { @@ -179,7 +260,7 @@ final class WidgetLocationCatalogTests: XCTestCase { "locations": [ { "regionCode": "\(regionCode)", - "displayName": "新莊區", + "displayName": "\(displayName)", "administrativeAreaName": "新北市", "latitude": \(latitude), "longitude": \(longitude) From e198dcf07abb7b18e4b7cf11fd72a03fc276f1e9 Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Sun, 20 Sep 2026 23:20:36 +0800 Subject: [PATCH 04/18] chore(weather): add native widget solar parity Platform: ios --- ios/DPIPWidgets/WidgetSolarTime.swift | 329 ++++++++++++++++++ ios/Runner.xcodeproj/project.pbxproj | 1 + .../CurrentWeatherWidgetTests.swift | 308 ++++++++++++++++ 3 files changed, 638 insertions(+) create mode 100644 ios/DPIPWidgets/WidgetSolarTime.swift diff --git a/ios/DPIPWidgets/WidgetSolarTime.swift b/ios/DPIPWidgets/WidgetSolarTime.swift new file mode 100644 index 000000000..c16c61fa7 --- /dev/null +++ b/ios/DPIPWidgets/WidgetSolarTime.swift @@ -0,0 +1,329 @@ +import Foundation + +enum WidgetSolarTime { + private static let millisecondsPerDay = 86_400_000.0 + private static let j2000UnixDayOffset = 10_957.5 + private static let degreesToRadians = Double.pi / 180 + + static func positiveModulo( + _ value: Double, + modulus: Double + ) -> Double { + let remainder = value.truncatingRemainder( + dividingBy: modulus + ) + + if remainder == 0 { + return 0 + } + + return remainder > 0 + ? remainder + : remainder + modulus + } + + static func julianDays( + unixMilliseconds: Int64 + ) -> Double { + Double(unixMilliseconds) / Self.millisecondsPerDay + - Self.j2000UnixDayOffset + } + + static func solarTerms( + unixMilliseconds: Int64 + ) -> SolarTerms { + let n = julianDays( + unixMilliseconds: unixMilliseconds + ) + + let meanLongitudeDegrees = positiveModulo( + 280.460 + 0.9856474 * n, + modulus: 360 + ) + + let meanAnomalyRadians = positiveModulo( + 357.528 + 0.9856003 * n, + modulus: 360 + ) * Self.degreesToRadians + + let eclipticLongitudeRadians = ( + meanLongitudeDegrees + + 1.915 * sin(meanAnomalyRadians) + + 0.020 * sin(2 * meanAnomalyRadians) + ) * Self.degreesToRadians + + let obliquityRadians = ( + 23.439 - 0.0000004 * n + ) * Self.degreesToRadians + + let rightAscensionRadians = atan2( + cos(obliquityRadians) + * sin(eclipticLongitudeRadians), + cos(eclipticLongitudeRadians) + ) + + let declinationRadians = asin( + sin(obliquityRadians) + * sin(eclipticLongitudeRadians) + ) + + return SolarTerms( + meanLongitudeDegrees: meanLongitudeDegrees, + rightAscensionRadians: rightAscensionRadians, + declinationRadians: declinationRadians + ) + } + + static func sunTimes( + unixMilliseconds: Int64, + latitude: Double, + longitude: Double, + utcOffsetHours: Double = 8 + ) -> SunTimes { + let terms = solarTerms( + unixMilliseconds: unixMilliseconds + ) + + let latitudeRadians = + latitude * Self.degreesToRadians + + let altitudeRadians = + -0.833 * Self.degreesToRadians + + let cosHourAngle = ( + sin(altitudeRadians) + - sin(latitudeRadians) + * sin(terms.declinationRadians) + ) / ( + cos(latitudeRadians) + * cos(terms.declinationRadians) + ) + + if cosHourAngle <= -1 { + return SunTimes( + sunriseLocalHours: 0, + sunsetLocalHours: 24 + ) + } + + if cosHourAngle >= 1 { + return SunTimes( + sunriseLocalHours: 12, + sunsetLocalHours: 12 + ) + } + + let hourAngleHours = + acos(cosHourAngle) + / Self.degreesToRadians + / 15 + + var equationOfTimeHours = ( + terms.meanLongitudeDegrees + * Self.degreesToRadians + - terms.rightAscensionRadians + ) / Self.degreesToRadians / 15 + + equationOfTimeHours = positiveModulo( + equationOfTimeHours + 12, + modulus: 24 + ) - 12 + + let solarNoon = + 12 + - longitude / 15 + + utcOffsetHours + - equationOfTimeHours + + return SunTimes( + sunriseLocalHours: + solarNoon - hourAngleHours, + sunsetLocalHours: + solarNoon + hourAngleHours + ) + } + + private static var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + private static func localDayContext( + unixMilliseconds: Int64, + utcOffsetHours: Double + ) -> LocalDayContext { + let offsetMinutes = Int( + (utcOffsetHours * 60) + .rounded(.toNearestOrAwayFromZero) + ) + + let offsetMilliseconds = + Int64(offsetMinutes) * 60 * 1_000 + + let shiftedUnixMilliseconds = + unixMilliseconds + offsetMilliseconds + + let shiftedDate = Date( + timeIntervalSince1970: + Double(shiftedUnixMilliseconds) / 1_000 + ) + + let components = Self.utcCalendar.dateComponents( + [.year, .month, .day, .hour, .minute, .second], + from: shiftedDate + ) + + let year = components.year! + let month = components.month! + let day = components.day! + let hour = components.hour! + let minute = components.minute! + let second = components.second! + + let localNoon = Self.utcCalendar.date( + from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: year, + month: month, + day: day, + hour: 12 + ) + )! + + let localNoonMilliseconds = Int64( + ( + localNoon.timeIntervalSince1970 * 1_000 + ).rounded(.toNearestOrAwayFromZero) + ) + + return LocalDayContext( + anchorUnixMilliseconds: + localNoonMilliseconds - offsetMilliseconds, + localSecond: + hour * 3_600 + + minute * 60 + + second + ) + } + + static func isNight( + unixMilliseconds: Int64, + latitude: Double, + longitude: Double, + utcOffsetHours: Double = 8 + ) -> Bool { + let context = localDayContext( + unixMilliseconds: unixMilliseconds, + utcOffsetHours: utcOffsetHours + ) + + let times = sunTimes( + unixMilliseconds: + context.anchorUnixMilliseconds, + latitude: latitude, + longitude: longitude, + utcOffsetHours: utcOffsetHours + ) + + let sunSeconds = roundedSunSeconds(times) + + return context.localSecond < sunSeconds.sunrise + || context.localSecond >= sunSeconds.sunset + } + + static func nextDayNightTransition( + unixMilliseconds: Int64, + latitude: Double, + longitude: Double, + utcOffsetHours: Double = 8 + ) -> Int64 { + let context = localDayContext( + unixMilliseconds: unixMilliseconds, + utcOffsetHours: utcOffsetHours + ) + + let todayTimes = sunTimes( + unixMilliseconds: + context.anchorUnixMilliseconds, + latitude: latitude, + longitude: longitude, + utcOffsetHours: utcOffsetHours + ) + + let today = roundedSunSeconds(todayTimes) + + let todayLocalMidnightMilliseconds = + context.anchorUnixMilliseconds + - 12 * 60 * 60 * 1_000 + + if context.localSecond < today.sunrise { + return ( + todayLocalMidnightMilliseconds + + Int64(today.sunrise) * 1_000 + ) / 1_000 + } + + if context.localSecond < today.sunset { + return ( + todayLocalMidnightMilliseconds + + Int64(today.sunset) * 1_000 + ) / 1_000 + } + + let tomorrowAnchorMilliseconds = + context.anchorUnixMilliseconds + + 86_400_000 + + let tomorrowTimes = sunTimes( + unixMilliseconds: + tomorrowAnchorMilliseconds, + latitude: latitude, + longitude: longitude, + utcOffsetHours: utcOffsetHours + ) + + let tomorrow = roundedSunSeconds(tomorrowTimes) + + let tomorrowLocalMidnightMilliseconds = + tomorrowAnchorMilliseconds + - 12 * 60 * 60 * 1_000 + + return ( + tomorrowLocalMidnightMilliseconds + + Int64(tomorrow.sunrise) * 1_000 + ) / 1_000 + } + + private static func roundedSunSeconds( + _ times: SunTimes + ) -> (sunrise: Int, sunset: Int) { + let sunrise = Int( + (times.sunriseLocalHours * 3_600) + .rounded(.toNearestOrAwayFromZero) + ) + + let sunset = Int( + (times.sunsetLocalHours * 3_600) + .rounded(.toNearestOrAwayFromZero) + ) + + return (sunrise, sunset) + } +} + +struct SolarTerms { + let meanLongitudeDegrees: Double + let rightAscensionRadians: Double + let declinationRadians: Double +} + +struct SunTimes { + let sunriseLocalHours: Double + let sunsetLocalHours: Double +} + +private struct LocalDayContext { + let anchorUnixMilliseconds: Int64 + let localSecond: Int +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index d6374a41d..4783b4eca 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -231,6 +231,7 @@ CurrentWeatherClient.swift, SavedWidgetLocationResolver.swift, WidgetLocationTarget.swift, + WidgetSolarTime.swift, ); target = 331C8080294A63A400263BE5 /* RunnerTests */; }; diff --git a/ios/RunnerTests/CurrentWeatherWidgetTests.swift b/ios/RunnerTests/CurrentWeatherWidgetTests.swift index 8475f47cd..dd1b10aea 100644 --- a/ios/RunnerTests/CurrentWeatherWidgetTests.swift +++ b/ios/RunnerTests/CurrentWeatherWidgetTests.swift @@ -1253,3 +1253,311 @@ final class CurrentWeatherClientTests: XCTestCase { } } } + +private struct SolarGolden { + let id: String + let latitude: Double + let longitude: Double + let nowUnixSeconds: Int64 + let expectedIsNight: Bool + let expectedNextTransitionUnixSeconds: Int64 +} + +private let solarGoldens: [SolarGolden] = [ + // 臺北市中正區, region 100 + .init(id: "100-2024-02-29-noon", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_709_179_200, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_709_200_528), + .init(id: "100-2024-03-20-noon", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_710_907_200, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_710_929_103), + .init(id: "100-2024-06-21-noon", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_718_942_400, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_718_966_796), + .init(id: "100-2024-09-22-noon", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_726_977_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_726_998_631), + .init(id: "100-2024-12-21-noon", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_734_753_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_734_772_166), + .init(id: "100-2024-12-31-noon", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_735_617_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_735_636_511), + + // 臺中市中區, region 400 + .init(id: "400-2024-02-29-noon", latitude: 24.1439458, longitude: 120.6794414, nowUnixSeconds: 1_709_179_200, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_709_200_763), + .init(id: "400-2024-03-20-noon", latitude: 24.1439458, longitude: 120.6794414, nowUnixSeconds: 1_710_907_200, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_710_929_303), + .init(id: "400-2024-06-21-noon", latitude: 24.1439458, longitude: 120.6794414, nowUnixSeconds: 1_718_942_400, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_718_966_881), + .init(id: "400-2024-09-22-noon", latitude: 24.1439458, longitude: 120.6794414, nowUnixSeconds: 1_726_977_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_726_998_830), + .init(id: "400-2024-12-21-noon", latitude: 24.1439458, longitude: 120.6794414, nowUnixSeconds: 1_734_753_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_734_772_480), + .init(id: "400-2024-12-31-noon", latitude: 24.1439458, longitude: 120.6794414, nowUnixSeconds: 1_735_617_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_735_636_822), + + // 高雄市新興區, region 800 + .init(id: "800-2024-02-29-noon", latitude: 22.6310347, longitude: 120.3101095, nowUnixSeconds: 1_709_179_200, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_709_200_908), + .init(id: "800-2024-03-20-noon", latitude: 22.6310347, longitude: 120.3101095, nowUnixSeconds: 1_710_907_200, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_710_929_389), + .init(id: "800-2024-06-21-noon", latitude: 22.6310347, longitude: 120.3101095, nowUnixSeconds: 1_718_942_400, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_718_966_776), + .init(id: "800-2024-09-22-noon", latitude: 22.6310347, longitude: 120.3101095, nowUnixSeconds: 1_726_977_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_726_998_915), + .init(id: "800-2024-12-21-noon", latitude: 22.6310347, longitude: 120.3101095, nowUnixSeconds: 1_734_753_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_734_772_755), + .init(id: "800-2024-12-31-noon", latitude: 22.6310347, longitude: 120.3101095, nowUnixSeconds: 1_735_617_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_735_637_094), + + // 花蓮縣花蓮市, region 970 + .init(id: "970-2024-02-29-noon", latitude: 23.9820651, longitude: 121.6067705, nowUnixSeconds: 1_709_179_200, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_709_200_546), + .init(id: "970-2024-03-20-noon", latitude: 23.9820651, longitude: 121.6067705, nowUnixSeconds: 1_710_907_200, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_710_929_080), + .init(id: "970-2024-06-21-noon", latitude: 23.9820651, longitude: 121.6067705, nowUnixSeconds: 1_718_942_400, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_718_966_637), + .init(id: "970-2024-09-22-noon", latitude: 23.9820651, longitude: 121.6067705, nowUnixSeconds: 1_726_977_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_726_998_607), + .init(id: "970-2024-12-21-noon", latitude: 23.9820651, longitude: 121.6067705, nowUnixSeconds: 1_734_753_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_734_772_277), + .init(id: "970-2024-12-31-noon", latitude: 23.9820651, longitude: 121.6067705, nowUnixSeconds: 1_735_617_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_735_636_619), + + // 臺東縣蘭嶼鄉, region 952 + .init(id: "952-2024-02-29-noon", latitude: 22.0244984, longitude: 121.5560627, nowUnixSeconds: 1_709_179_200, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_709_200_631), + .init(id: "952-2024-03-20-noon", latitude: 22.0244984, longitude: 121.5560627, nowUnixSeconds: 1_710_907_200, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_710_929_089), + .init(id: "952-2024-06-21-noon", latitude: 22.0244984, longitude: 121.5560627, nowUnixSeconds: 1_718_942_400, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_718_966_401), + .init(id: "952-2024-09-22-noon", latitude: 22.0244984, longitude: 121.5560627, nowUnixSeconds: 1_726_977_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_726_998_615), + .init(id: "952-2024-12-21-noon", latitude: 22.0244984, longitude: 121.5560627, nowUnixSeconds: 1_734_753_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_734_772_530), + .init(id: "952-2024-12-31-noon", latitude: 22.0244984, longitude: 121.5560627, nowUnixSeconds: 1_735_617_600, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_735_636_867), + + // Taipei exact boundaries, 2024-03-20 + .init(id: "100-before-sunrise", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_710_885_457, expectedIsNight: true, expectedNextTransitionUnixSeconds: 1_710_885_458), + .init(id: "100-at-sunrise", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_710_885_458, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_710_929_103), + .init(id: "100-after-sunrise", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_710_885_459, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_710_929_103), + .init(id: "100-before-sunset", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_710_929_102, expectedIsNight: false, expectedNextTransitionUnixSeconds: 1_710_929_103), + .init(id: "100-at-sunset", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_710_929_103, expectedIsNight: true, expectedNextTransitionUnixSeconds: 1_710_971_796), + .init(id: "100-after-sunset", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_710_929_104, expectedIsNight: true, expectedNextTransitionUnixSeconds: 1_710_971_796), + + // 2024-12-31 after sunset -> 2025-01-01 sunrise + .init(id: "100-year-rollover-after-sunset", latitude: 25.032188, longitude: 121.5183226, nowUnixSeconds: 1_735_636_512, expectedIsNight: true, expectedNextTransitionUnixSeconds: 1_735_684_745), +] + +final class WidgetSolarTimeTests: XCTestCase { + func testSolarGoldensMatchDartAuthority() { + for golden in solarGoldens { + let (nowUnixMilliseconds, overflow) = + golden.nowUnixSeconds.multipliedReportingOverflow( + by: 1_000 + ) + + XCTAssertFalse( + overflow, + "Unix millisecond conversion overflowed for \(golden.id)" + ) + + guard !overflow else { + continue + } + + XCTAssertEqual( + WidgetSolarTime.isNight( + unixMilliseconds: nowUnixMilliseconds, + latitude: golden.latitude, + longitude: golden.longitude + ), + golden.expectedIsNight, + "Unexpected day/night state for \(golden.id)" + ) + + XCTAssertEqual( + WidgetSolarTime.nextDayNightTransition( + unixMilliseconds: nowUnixMilliseconds, + latitude: golden.latitude, + longitude: golden.longitude + ), + golden.expectedNextTransitionUnixSeconds, + "Unexpected next transition for \(golden.id)" + ) + } + } + + func testPositiveModuloKeepsPositiveValue() { + XCTAssertEqual( + WidgetSolarTime.positiveModulo( + 10, + modulus: 360 + ), + 10 + ) + } + + func testPositiveModuloWrapsOverflow() { + XCTAssertEqual( + WidgetSolarTime.positiveModulo( + 370, + modulus: 360 + ), + 10 + ) + } + + func testPositiveModuloWrapsNegativeValue() { + XCTAssertEqual( + WidgetSolarTime.positiveModulo( + -10, + modulus: 360 + ), + 350 + ) + } + + func testPositiveModuloWrapsMultipleNegativeCycles() { + XCTAssertEqual( + WidgetSolarTime.positiveModulo( + -730, + modulus: 360 + ), + 350 + ) + } + + func testPositiveModuloNormalizesNegativeZero() { + let result = WidgetSolarTime.positiveModulo( + -0.0, + modulus: 360 + ) + + XCTAssertEqual(result, 0) + XCTAssertEqual(result.sign, .plus) + } + + func testJulianDaysIsZeroAtJ2000Noon() { + XCTAssertEqual( + WidgetSolarTime.julianDays( + unixMilliseconds: 946_728_000_000 + ), + 0 + ) + } + + func testJulianDaysIsNegativeHalfAtJ2000Midnight() { + XCTAssertEqual( + WidgetSolarTime.julianDays( + unixMilliseconds: 946_684_800_000 + ), + -0.5 + ) + } + + func testJulianDaysAdvancesOnePerDay() { + XCTAssertEqual( + WidgetSolarTime.julianDays( + unixMilliseconds: 946_814_400_000 + ), + 1 + ) + } + + func testJulianDaysMatchesUnixEpochOffset() { + XCTAssertEqual( + WidgetSolarTime.julianDays( + unixMilliseconds: 0 + ), + -10_957.5 + ) + } + + func testSolarTermsAtJ2000() { + let terms = WidgetSolarTime.solarTerms( + unixMilliseconds: 946_728_000_000 + ) + + XCTAssertEqual( + terms.meanLongitudeDegrees, + 280.460, + accuracy: 0.000_001 + ) + + XCTAssertEqual( + terms.rightAscensionRadians, + -1.3738212627, + accuracy: 0.000_000_001 + ) + + XCTAssertEqual( + terms.declinationRadians, + -0.4020091673, + accuracy: 0.000_000_001 + ) + } + + func testIsNightMatchesTaipeiSolarBoundaries() { + let latitude = 25.032188 + let longitude = 121.5183226 + + let cases: [(Int64, Bool)] = [ + (1_710_885_457_000, true), + (1_710_885_458_000, false), + (1_710_885_459_000, false), + + (1_710_929_102_000, false), + (1_710_929_103_000, true), + (1_710_929_104_000, true), + ] + + for (unixMilliseconds, expectedIsNight) in cases { + XCTAssertEqual( + WidgetSolarTime.isNight( + unixMilliseconds: unixMilliseconds, + latitude: latitude, + longitude: longitude + ), + expectedIsNight, + "Unexpected day/night state at \(unixMilliseconds)" + ) + } + } + + func testIsNightIgnoresSubsecondWithinBoundarySecond() { + let latitude = 25.032188 + let longitude = 121.5183226 + + XCTAssertFalse( + WidgetSolarTime.isNight( + unixMilliseconds: 1_710_885_458_999, + latitude: latitude, + longitude: longitude + ) + ) + + XCTAssertTrue( + WidgetSolarTime.isNight( + unixMilliseconds: 1_710_929_103_999, + latitude: latitude, + longitude: longitude + ) + ) + } + + func testNextTransitionMatchesTaipeiSolarBoundaries() { + let latitude = 25.032188 + let longitude = 121.5183226 + + let cases: [(now: Int64, expected: Int64)] = [ + // one second before sunrise → today's sunrise + (1_710_885_457_000, 1_710_885_458), + + // exactly sunrise → today's sunset + (1_710_885_458_000, 1_710_929_103), + + // one second after sunrise → today's sunset + (1_710_885_459_000, 1_710_929_103), + + // one second before sunset → today's sunset + (1_710_929_102_000, 1_710_929_103), + + // exactly sunset → tomorrow's sunrise + (1_710_929_103_000, 1_710_971_796), + + // one second after sunset → tomorrow's sunrise + (1_710_929_104_000, 1_710_971_796), + ] + + for testCase in cases { + XCTAssertEqual( + WidgetSolarTime.nextDayNightTransition( + unixMilliseconds: testCase.now, + latitude: latitude, + longitude: longitude + ), + testCase.expected, + "Unexpected transition at \(testCase.now)" + ) + } + } + + func testNextTransitionCrossesYearBoundary() { + XCTAssertEqual( + WidgetSolarTime.nextDayNightTransition( + unixMilliseconds: 1_735_636_512_000, + latitude: 25.032188, + longitude: 121.5183226 + ), + 1_735_684_745 + ) + } +} From 7803a382eb2c16af893b366717418aafe1d4ebd9 Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Mon, 21 Sep 2026 01:53:14 +0800 Subject: [PATCH 05/18] chore(weather): add native widget snapshot generation Platform: ios --- .../CurrentWeatherSnapshotTime.swift | 12 + .../CurrentWeatherWidgetSnapshot.swift | 151 +++++- .../CurrentWeatherWidgetSnapshotFactory.swift | 40 ++ .../SavedWidgetLocationResolver.swift | 17 +- .../WidgetResolvedWeatherLocation.swift | 51 ++ ios/Runner.xcodeproj/project.pbxproj | 3 + .../CurrentWeatherWidgetTests.swift | 499 ++++++++++++++++++ .../WidgetLocationCatalogTests.swift | 4 +- 8 files changed, 761 insertions(+), 16 deletions(-) create mode 100644 ios/DPIPWidgets/CurrentWeatherSnapshotTime.swift create mode 100644 ios/DPIPWidgets/CurrentWeatherWidgetSnapshotFactory.swift create mode 100644 ios/DPIPWidgets/WidgetResolvedWeatherLocation.swift diff --git a/ios/DPIPWidgets/CurrentWeatherSnapshotTime.swift b/ios/DPIPWidgets/CurrentWeatherSnapshotTime.swift new file mode 100644 index 000000000..b1fa737c3 --- /dev/null +++ b/ios/DPIPWidgets/CurrentWeatherSnapshotTime.swift @@ -0,0 +1,12 @@ +git add \ + ios/DPIPWidgets/CurrentWeatherSnapshotTime.swift \ + ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift \ + ios/DPIPWidgets/CurrentWeatherWidgetSnapshotFactory.swift \ + ios/DPIPWidgets/SavedWidgetLocationResolver.swift \ + ios/DPIPWidgets/WidgetResolvedWeatherLocation.swift \ + ios/Runner.xcodeproj/project.pbxproj \ + ios/RunnerTests/CurrentWeatherWidgetTests.swift \ + ios/RunnerTests/WidgetLocationCatalogTests.swiftstruct CurrentWeatherSnapshotTime: Equatable, Sendable { + let calibratedNowUnixMilliseconds: Int64 + let calibratedTimeOffsetMilliseconds: Int +} diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift index 5b7436329..4440e4293 100644 --- a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift @@ -69,7 +69,7 @@ enum CurrentWeatherWidgetCondition: String, Decodable { } } -struct CurrentWeatherWidgetSnapshot: Decodable { +struct CurrentWeatherWidgetSnapshot: Codable { let schemaVersion: Int let sourceIdentifier: String? @@ -251,4 +251,153 @@ struct CurrentWeatherWidgetSnapshot: Decodable { forKey: .rain ) } + + func encode(to encoder: Encoder) throws { + guard schemaVersion == 5 else { + throw EncodingError.invalidValue( + schemaVersion, + .init( + codingPath: encoder.codingPath, + debugDescription: + "Only current-weather snapshot schema version 5 can be encoded." + ) + ) + } + + guard let sourceIdentifier else { + throw EncodingError.invalidValue( + sourceIdentifier as Any, + .init( + codingPath: encoder.codingPath, + debugDescription: + "Schema version 5 requires a source identifier." + ) + ) + } + + guard let address = CurrentWeatherSnapshotAddress( + sourceIdentifier: sourceIdentifier + ) else { + throw EncodingError.invalidValue( + sourceIdentifier, + .init( + codingPath: encoder.codingPath, + debugDescription: + "Invalid current-weather snapshot source identifier." + ) + ) + } + + guard CurrentWeatherSnapshotAddress( + sourceIdentifier: "region:\(regionCode)" + ) != nil else { + throw EncodingError.invalidValue( + regionCode, + .init( + codingPath: encoder.codingPath, + debugDescription: + "Current-weather snapshot region code must be exactly three ASCII digits." + ) + ) + } + + if case let .saved(addressRegionCode) = address { + guard addressRegionCode == regionCode else { + throw EncodingError.invalidValue( + regionCode, + .init( + codingPath: encoder.codingPath, + debugDescription: + "Saved snapshot source identifier and payload region code must match." + ) + ) + } + } + + var container = encoder.container( + keyedBy: CodingKeys.self + ) + + try container.encode( + schemaVersion, + forKey: .schemaVersion + ) + try container.encode( + sourceIdentifier, + forKey: .sourceIdentifier + ) + try container.encode( + regionCode, + forKey: .regionCode + ) + try container.encode( + regionName, + forKey: .regionName + ) + try container.encode( + observationTime, + forKey: .observationTime + ) + try container.encode( + stationName, + forKey: .stationName + ) + try container.encode( + weather, + forKey: .weather + ) + try container.encode( + weatherCode, + forKey: .weatherCode + ) + try container.encode( + condition.rawValue, + forKey: .condition + ) + try container.encode( + isNight, + forKey: .isNight + ) + try container.encode( + nextDayNightTransitionTime, + forKey: .nextDayNightTransitionTime + ) + try container.encode( + calibratedTimeOffsetMilliseconds, + forKey: .calibratedTimeOffsetMilliseconds + ) + + if let temperature { + try container.encode( + temperature, + forKey: .temperature + ) + } else { + try container.encodeNil( + forKey: .temperature + ) + } + + if let humidity { + try container.encode( + humidity, + forKey: .humidity + ) + } else { + try container.encodeNil( + forKey: .humidity + ) + } + + if let rain { + try container.encode( + rain, + forKey: .rain + ) + } else { + try container.encodeNil( + forKey: .rain + ) + } + } } diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotFactory.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotFactory.swift new file mode 100644 index 000000000..992533d64 --- /dev/null +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotFactory.swift @@ -0,0 +1,40 @@ +enum CurrentWeatherWidgetSnapshotFactory { + static func make( + observation: CurrentWeatherRemoteDTO, + location: WidgetResolvedWeatherLocation, + time: CurrentWeatherSnapshotTime + ) -> CurrentWeatherWidgetSnapshot { + let isNight = WidgetSolarTime.isNight( + unixMilliseconds: time.calibratedNowUnixMilliseconds, + latitude: location.latitude, + longitude: location.longitude + ) + + let nextTransition = + WidgetSolarTime.nextDayNightTransition( + unixMilliseconds: + time.calibratedNowUnixMilliseconds, + latitude: location.latitude, + longitude: location.longitude + ) + + return CurrentWeatherWidgetSnapshot( + schemaVersion: 5, + sourceIdentifier: location.address.sourceIdentifier, + regionCode: location.regionCode, + regionName: location.regionName, + observationTime: observation.time, + stationName: observation.stationName, + weather: observation.weather, + weatherCode: observation.weatherCode, + condition: observation.condition, + isNight: isNight, + nextDayNightTransitionTime: Int(nextTransition), + calibratedTimeOffsetMilliseconds: + time.calibratedTimeOffsetMilliseconds, + temperature: observation.temperature, + humidity: observation.humidity, + rain: observation.rain + ) + } +} diff --git a/ios/DPIPWidgets/SavedWidgetLocationResolver.swift b/ios/DPIPWidgets/SavedWidgetLocationResolver.swift index 979fe3a93..8b5f54e80 100644 --- a/ios/DPIPWidgets/SavedWidgetLocationResolver.swift +++ b/ios/DPIPWidgets/SavedWidgetLocationResolver.swift @@ -1,32 +1,23 @@ import Foundation -struct ResolvedSavedLocation: Equatable, Sendable { - let sourceIdentifier: String - let regionCode: String - let regionName: String - let latitude: Double - let longitude: Double -} - struct SavedWidgetLocationResolver: Sendable { let catalog: WidgetLocationCatalog? func resolve( target: WidgetLocationTarget - ) -> ResolvedSavedLocation? { + ) -> WidgetResolvedWeatherLocation? { guard case let .saved(regionCode) = target, let catalog, let location = catalog.locations.first(where: { $0.regionCode == regionCode - }), - let sourceIdentifier = target.sourceIdentifier + }) else { return nil } - return ResolvedSavedLocation( - sourceIdentifier: sourceIdentifier, + return WidgetResolvedWeatherLocation( + address: .saved(regionCode: regionCode), regionCode: location.regionCode, regionName: location.displayName, latitude: location.latitude, diff --git a/ios/DPIPWidgets/WidgetResolvedWeatherLocation.swift b/ios/DPIPWidgets/WidgetResolvedWeatherLocation.swift new file mode 100644 index 000000000..1dab277a6 --- /dev/null +++ b/ios/DPIPWidgets/WidgetResolvedWeatherLocation.swift @@ -0,0 +1,51 @@ +enum WidgetResolvedWeatherLocationValidation { + static func isValidRegionCode( + _ regionCode: String + ) -> Bool { + let bytes = regionCode.utf8 + + return bytes.count == 3 + && bytes.allSatisfy { byte in + byte >= 48 && byte <= 57 + } + } +} + +struct WidgetResolvedWeatherLocation: Equatable, Sendable { + let address: CurrentWeatherSnapshotAddress + let regionCode: String + let regionName: String + let latitude: Double + let longitude: Double + + init?( + address: CurrentWeatherSnapshotAddress, + regionCode: String, + regionName: String, + latitude: Double, + longitude: Double + ) { + guard + WidgetResolvedWeatherLocationValidation + .isValidRegionCode(regionCode), + latitude.isFinite, + longitude.isFinite, + (-90 ... 90).contains(latitude), + (-180 ... 180).contains(longitude) + else { + return nil + } + + if case let .saved(addressRegionCode) = address { + guard addressRegionCode == regionCode else { + return nil + } + } + + self.address = address + self.regionCode = regionCode + self.regionName = regionName + self.latitude = latitude + self.longitude = longitude + } +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 4783b4eca..3193764d2 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -229,8 +229,11 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( CurrentWeatherClient.swift, + CurrentWeatherSnapshotTime.swift, + CurrentWeatherWidgetSnapshotFactory.swift, SavedWidgetLocationResolver.swift, WidgetLocationTarget.swift, + WidgetResolvedWeatherLocation.swift, WidgetSolarTime.swift, ); target = 331C8080294A63A400263BE5 /* RunnerTests */; diff --git a/ios/RunnerTests/CurrentWeatherWidgetTests.swift b/ios/RunnerTests/CurrentWeatherWidgetTests.swift index dd1b10aea..b02009253 100644 --- a/ios/RunnerTests/CurrentWeatherWidgetTests.swift +++ b/ios/RunnerTests/CurrentWeatherWidgetTests.swift @@ -539,6 +539,311 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { from: Data(json.utf8) ) } + + private func makeSnapshot( + schemaVersion: Int = 5, + sourceIdentifier: String? = "region:100", + regionCode: String = "100", + condition: CurrentWeatherWidgetCondition = .clear, + temperature: Double? = 28.5, + humidity: Int? = 70, + rain: Double? = 0 + ) -> CurrentWeatherWidgetSnapshot { + CurrentWeatherWidgetSnapshot( + schemaVersion: schemaVersion, + sourceIdentifier: sourceIdentifier, + regionCode: regionCode, + regionName: "中正區", + observationTime: 1_710_900_000, + stationName: "臺北", + weather: "晴", + weatherCode: 100, + condition: condition, + isNight: false, + nextDayNightTransitionTime: 1_710_929_103, + calibratedTimeOffsetMilliseconds: -5_000, + temperature: temperature, + humidity: humidity, + rain: rain + ) + } + + func testEncodesSchemaVersionFiveWithAllFields() throws { + let snapshot = CurrentWeatherWidgetSnapshot( + schemaVersion: 5, + sourceIdentifier: "region:100", + regionCode: "100", + regionName: "中正區", + observationTime: 1_710_900_000, + stationName: "臺北", + weather: "晴", + weatherCode: 100, + condition: .clear, + isNight: false, + nextDayNightTransitionTime: 1_710_929_103, + calibratedTimeOffsetMilliseconds: -5_000, + temperature: 28.5, + humidity: 70, + rain: 0 + ) + + let data = try JSONEncoder().encode(snapshot) + + let json = try XCTUnwrap( + JSONSerialization.jsonObject( + with: data + ) as? [String: Any] + ) + + XCTAssertEqual(json.count, 15) + + XCTAssertEqual(json["schemaVersion"] as? Int, 5) + XCTAssertEqual( + json["sourceIdentifier"] as? String, + "region:100" + ) + XCTAssertEqual( + json["regionCode"] as? String, + "100" + ) + XCTAssertEqual( + json["regionName"] as? String, + "中正區" + ) + XCTAssertEqual( + json["observationTime"] as? Int, + 1_710_900_000 + ) + XCTAssertEqual( + json["stationName"] as? String, + "臺北" + ) + XCTAssertEqual( + json["weather"] as? String, + "晴" + ) + XCTAssertEqual( + json["weatherCode"] as? Int, + 100 + ) + XCTAssertEqual( + json["condition"] as? String, + "clear" + ) + XCTAssertEqual( + json["isNight"] as? Bool, + false + ) + XCTAssertEqual( + json["nextDayNightTransitionTime"] as? Int, + 1_710_929_103 + ) + XCTAssertEqual( + json["calibratedTimeOffsetMilliseconds"] as? Int, + -5_000 + ) + XCTAssertEqual( + json["temperature"] as? Double, + 28.5 + ) + XCTAssertEqual( + json["humidity"] as? Int, + 70 + ) + XCTAssertEqual( + json["rain"] as? Double, + 0 + ) + } + + func testEncodingPreservesExplicitNullWeatherValues() + throws + { + let snapshot = CurrentWeatherWidgetSnapshot( + schemaVersion: 5, + sourceIdentifier: "region:100", + regionCode: "100", + regionName: "中正區", + observationTime: 1_710_900_000, + stationName: "臺北", + weather: "晴", + weatherCode: 100, + condition: .clear, + isNight: false, + nextDayNightTransitionTime: 1_710_929_103, + calibratedTimeOffsetMilliseconds: 0, + temperature: nil, + humidity: nil, + rain: nil + ) + + let data = try JSONEncoder().encode(snapshot) + + let json = try XCTUnwrap( + JSONSerialization.jsonObject( + with: data + ) as? [String: Any] + ) + + XCTAssertEqual(json.count, 15) + + XCTAssertTrue(json["temperature"] is NSNull) + XCTAssertTrue(json["humidity"] is NSNull) + XCTAssertTrue(json["rain"] is NSNull) + } + + func testSchemaVersionFourCannotBeEncoded() { + let snapshot = makeSnapshot(schemaVersion: 4) + + XCTAssertThrowsError( + try JSONEncoder().encode(snapshot) + ) + } + + func testSchemaVersionFiveWithoutSourceIdentifierCannotBeEncoded() { + let snapshot = makeSnapshot(sourceIdentifier: nil) + + XCTAssertThrowsError( + try JSONEncoder().encode(snapshot) + ) + } + + func testSchemaVersionFiveRejectsMalformedSourceIdentifiers() { + let malformedSourceIdentifiers = [ + "", + "region:", + "region:10", + "region:1000", + "region:ABC", + "region:100", + "something-else", + ] + + for sourceIdentifier in malformedSourceIdentifiers { + let snapshot = makeSnapshot( + sourceIdentifier: sourceIdentifier + ) + + XCTAssertThrowsError( + try JSONEncoder().encode(snapshot), + sourceIdentifier + ) + } + } + + func testSavedSourceIdentifierMustMatchPayloadRegionCode() { + let snapshot = makeSnapshot( + sourceIdentifier: "region:100", + regionCode: "407" + ) + + XCTAssertThrowsError( + try JSONEncoder().encode(snapshot) + ) + } + + func testCurrentLocationWithResolvedRegionCodeCanBeEncoded() { + let snapshot = makeSnapshot( + sourceIdentifier: "current-location", + regionCode: "407" + ) + + XCTAssertNoThrow( + try JSONEncoder().encode(snapshot) + ) + } + + func testEncodedSchemaVersionFiveContainsExactlyContractKeys() throws { + let data = try JSONEncoder().encode(makeSnapshot()) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) + as? [String: Any] + ) + + XCTAssertEqual( + Set(json.keys), + Set([ + "schemaVersion", + "sourceIdentifier", + "regionCode", + "regionName", + "observationTime", + "stationName", + "weather", + "weatherCode", + "condition", + "isNight", + "nextDayNightTransitionTime", + "calibratedTimeOffsetMilliseconds", + "temperature", + "humidity", + "rain", + ]) + ) + } + + func testConditionEncodesUsingRawSchemaValue() throws { + let data = try JSONEncoder().encode( + makeSnapshot(condition: .thunderstorm) + ) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) + as? [String: Any] + ) + + XCTAssertEqual( + json["condition"] as? String, + "thunderstorm" + ) + } + + func testSchemaVersionFiveRoundTripPreservesAllFields() throws { + let original = CurrentWeatherWidgetSnapshot( + schemaVersion: 5, + sourceIdentifier: "region:407", + regionCode: "407", + regionName: "西屯區", + observationTime: 1_789_567_200, + stationName: "西屯", + weather: "雷雨", + weatherCode: 214, + condition: .thunderstorm, + isNight: true, + nextDayNightTransitionTime: 1_789_562_700, + calibratedTimeOffsetMilliseconds: -300_000, + temperature: 27.5, + humidity: 83, + rain: 12.5 + ) + + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + CurrentWeatherWidgetSnapshot.self, + from: encoded + ) + + XCTAssertEqual(decoded.schemaVersion, original.schemaVersion) + XCTAssertEqual(decoded.sourceIdentifier, original.sourceIdentifier) + XCTAssertEqual(decoded.regionCode, original.regionCode) + XCTAssertEqual(decoded.regionName, original.regionName) + XCTAssertEqual(decoded.observationTime, original.observationTime) + XCTAssertEqual(decoded.stationName, original.stationName) + XCTAssertEqual(decoded.weather, original.weather) + XCTAssertEqual(decoded.weatherCode, original.weatherCode) + XCTAssertEqual(decoded.condition, original.condition) + XCTAssertEqual(decoded.isNight, original.isNight) + XCTAssertEqual( + decoded.nextDayNightTransitionTime, + original.nextDayNightTransitionTime + ) + XCTAssertEqual( + decoded.calibratedTimeOffsetMilliseconds, + original.calibratedTimeOffsetMilliseconds + ) + XCTAssertEqual(decoded.temperature, original.temperature) + XCTAssertEqual(decoded.humidity, original.humidity) + XCTAssertEqual(decoded.rain, original.rain) + } } final class CurrentWeatherWidgetConditionTests: XCTestCase { @@ -1561,3 +1866,197 @@ final class WidgetSolarTimeTests: XCTestCase { ) } } + +final class WidgetResolvedWeatherLocationTests: XCTestCase { + func testAcceptsValidSavedLocation() { + let location = WidgetResolvedWeatherLocation( + address: .saved(regionCode: "407"), + regionCode: "407", + regionName: "西屯區", + latitude: 24.1658213, + longitude: 120.6336717 + ) + + XCTAssertEqual( + location?.address, + .saved(regionCode: "407") + ) + XCTAssertEqual(location?.regionCode, "407") + XCTAssertEqual(location?.regionName, "西屯區") + XCTAssertEqual(location?.latitude, 24.1658213) + XCTAssertEqual(location?.longitude, 120.6336717) + } + + func testRejectsSavedAddressRegionMismatch() { + XCTAssertNil( + WidgetResolvedWeatherLocation( + address: .saved(regionCode: "407"), + regionCode: "100", + regionName: "中正區", + latitude: 25.032188, + longitude: 121.5183226 + ) + ) + } + + func testAcceptsCurrentLocationWithResolvedRegion() { + let location = WidgetResolvedWeatherLocation( + address: .currentLocation, + regionCode: "407", + regionName: "西屯區", + latitude: 24.1658213, + longitude: 120.6336717 + ) + + XCTAssertEqual( + location?.address, + .currentLocation + ) + + XCTAssertEqual( + location?.regionCode, + "407" + ) + } + + func testRejectsInvalidRegionCode() { + XCTAssertNil( + WidgetResolvedWeatherLocation( + address: .currentLocation, + regionCode: "40A", + regionName: "西屯區", + latitude: 24.1658213, + longitude: 120.6336717 + ) + ) + } + + func testRejectsInvalidCoordinates() { + XCTAssertNil( + WidgetResolvedWeatherLocation( + address: .currentLocation, + regionCode: "407", + regionName: "西屯區", + latitude: 91, + longitude: 120.6336717 + ) + ) + + XCTAssertNil( + WidgetResolvedWeatherLocation( + address: .currentLocation, + regionCode: "407", + regionName: "西屯區", + latitude: 24.1658213, + longitude: .infinity + ) + ) + } +} + +final class CurrentWeatherSnapshotTimeTests: XCTestCase { + func testStoresCalibratedTimeSample() { + let time = CurrentWeatherSnapshotTime( + calibratedNowUnixMilliseconds: 1_789_567_200_123, + calibratedTimeOffsetMilliseconds: -5_000 + ) + + XCTAssertEqual( + time.calibratedNowUnixMilliseconds, + 1_789_567_200_123 + ) + XCTAssertEqual( + time.calibratedTimeOffsetMilliseconds, + -5_000 + ) + } +} + +final class CurrentWeatherWidgetSnapshotFactoryTests: XCTestCase { + func testCreatesSchemaFiveSnapshotFromResolvedInputs() throws { + let observation = try makeObservation() + + let location = try XCTUnwrap( + WidgetResolvedWeatherLocation( + address: .saved(regionCode: "100"), + regionCode: "100", + regionName: "中正區", + latitude: 25.032188, + longitude: 121.5183226 + ) + ) + + let time = CurrentWeatherSnapshotTime( + calibratedNowUnixMilliseconds: + 1_710_907_200_000, + calibratedTimeOffsetMilliseconds: + -5_000 + ) + + let snapshot = + CurrentWeatherWidgetSnapshotFactory.make( + observation: observation, + location: location, + time: time + ) + + XCTAssertEqual(snapshot.schemaVersion, 5) + XCTAssertEqual( + snapshot.sourceIdentifier, + "region:100" + ) + + XCTAssertEqual(snapshot.regionCode, "100") + XCTAssertEqual(snapshot.regionName, "中正區") + + XCTAssertEqual( + snapshot.observationTime, + 1_710_900_000 + ) + + XCTAssertEqual(snapshot.stationName, "臺北") + XCTAssertEqual(snapshot.weather, "晴") + XCTAssertEqual(snapshot.weatherCode, 100) + XCTAssertEqual(snapshot.condition, .clear) + + XCTAssertFalse(snapshot.isNight) + XCTAssertEqual( + snapshot.nextDayNightTransitionTime, + 1_710_929_103 + ) + + XCTAssertEqual( + snapshot.calibratedTimeOffsetMilliseconds, + -5_000 + ) + + XCTAssertEqual(snapshot.temperature, 28.5) + XCTAssertEqual(snapshot.humidity, 70) + XCTAssertEqual(snapshot.rain, 0) + } + + private func makeObservation() throws + -> CurrentWeatherRemoteDTO + { + let json = """ + { + "station": { + "name": "臺北" + }, + "time": 1710900000, + "data": { + "weather": "晴", + "weatherCode": 100, + "temperature": 28.5, + "humidity": 70, + "rain": 0 + } + } + """ + + return try JSONDecoder().decode( + CurrentWeatherRemoteDTO.self, + from: Data(json.utf8) + ) + } +} diff --git a/ios/RunnerTests/WidgetLocationCatalogTests.swift b/ios/RunnerTests/WidgetLocationCatalogTests.swift index 53a7b40b4..8225823a5 100644 --- a/ios/RunnerTests/WidgetLocationCatalogTests.swift +++ b/ios/RunnerTests/WidgetLocationCatalogTests.swift @@ -280,8 +280,8 @@ final class SavedWidgetLocationResolverTests: XCTestCase { resolver.resolve( target: WidgetLocationTarget(identifier: "region:242") ), - ResolvedSavedLocation( - sourceIdentifier: "region:242", + WidgetResolvedWeatherLocation( + address: .saved(regionCode: "242"), regionCode: "242", regionName: "新莊區", latitude: 25.0358303, From 7c41a7e64539135ed436ec1520776d12b4bc3596 Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Mon, 21 Sep 2026 02:16:36 +0800 Subject: [PATCH 06/18] chore(weather): add native widget snapshot pipeline Platform: ios --- .../CurrentWeatherSnapshotTime.swift | 10 +- .../CurrentWeatherWidgetSnapshotWriter.swift | 32 ++ ios/DPIPWidgets/WidgetSnapshotStore.swift | 13 +- ios/Runner.xcodeproj/project.pbxproj | 17 +- ios/Runner/WidgetSnapshotPlugin.swift | 32 +- ...rentWeatherWidgetSnapshotWriterTests.swift | 333 ++++++++++++++++++ .../CurrentWeatherSnapshotStorage.swift | 37 ++ 7 files changed, 445 insertions(+), 29 deletions(-) create mode 100644 ios/DPIPWidgets/CurrentWeatherWidgetSnapshotWriter.swift create mode 100644 ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift create mode 100644 ios/Shared/CurrentWeatherSnapshotStorage.swift diff --git a/ios/DPIPWidgets/CurrentWeatherSnapshotTime.swift b/ios/DPIPWidgets/CurrentWeatherSnapshotTime.swift index b1fa737c3..e489b77b5 100644 --- a/ios/DPIPWidgets/CurrentWeatherSnapshotTime.swift +++ b/ios/DPIPWidgets/CurrentWeatherSnapshotTime.swift @@ -1,12 +1,4 @@ -git add \ - ios/DPIPWidgets/CurrentWeatherSnapshotTime.swift \ - ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift \ - ios/DPIPWidgets/CurrentWeatherWidgetSnapshotFactory.swift \ - ios/DPIPWidgets/SavedWidgetLocationResolver.swift \ - ios/DPIPWidgets/WidgetResolvedWeatherLocation.swift \ - ios/Runner.xcodeproj/project.pbxproj \ - ios/RunnerTests/CurrentWeatherWidgetTests.swift \ - ios/RunnerTests/WidgetLocationCatalogTests.swiftstruct CurrentWeatherSnapshotTime: Equatable, Sendable { +struct CurrentWeatherSnapshotTime: Equatable, Sendable { let calibratedNowUnixMilliseconds: Int64 let calibratedTimeOffsetMilliseconds: Int } diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotWriter.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotWriter.swift new file mode 100644 index 000000000..07f38956a --- /dev/null +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotWriter.swift @@ -0,0 +1,32 @@ +import Foundation + +enum CurrentWeatherWidgetSnapshotWriterError: Error { + case invalidSourceIdentifier +} + +struct CurrentWeatherWidgetSnapshotWriter { + let containerURL: URL + + func write( + _ snapshot: CurrentWeatherWidgetSnapshot + ) throws { + let data = try JSONEncoder().encode(snapshot) + + guard + let sourceIdentifier = snapshot.sourceIdentifier, + let address = CurrentWeatherSnapshotAddress( + sourceIdentifier: sourceIdentifier + ) + else { + throw CurrentWeatherWidgetSnapshotWriterError + .invalidSourceIdentifier + } + + try CurrentWeatherSnapshotStorage( + containerURL: containerURL + ).replace( + data, + for: address + ) + } +} diff --git a/ios/DPIPWidgets/WidgetSnapshotStore.swift b/ios/DPIPWidgets/WidgetSnapshotStore.swift index 76cfa55cc..f0ed4e2c3 100644 --- a/ios/DPIPWidgets/WidgetSnapshotStore.swift +++ b/ios/DPIPWidgets/WidgetSnapshotStore.swift @@ -72,16 +72,9 @@ struct WidgetSnapshotStore { return nil } - return appGroupContainerURL - .appendingPathComponent( - "WidgetSnapshots", - isDirectory: true - ) - .appendingPathComponent( - "current-weather", - isDirectory: true - ) - .appendingPathComponent(address.filename) + return CurrentWeatherSnapshotStorage( + containerURL: appGroupContainerURL + ).snapshotURL(for: address) } func loadCurrentWeatherSnapshot( diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3193764d2..86c140045 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -48,6 +48,7 @@ D91A00000000000000000012 /* CurrentWeatherWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = D91A00000000000000000002 /* CurrentWeatherWidgetSnapshot.swift */; }; D91A00000000000000000013 /* CurrentWeatherWidgetTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = D91A00000000000000000003 /* CurrentWeatherWidgetTimeline.swift */; }; D91A00000000000000000014 /* CurrentWeatherRemoteDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D91A00000000000000000004 /* CurrentWeatherRemoteDTO.swift */; }; + E7A100000000000000000001 /* CurrentWeatherWidgetSnapshotWriterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A100000000000000000002 /* CurrentWeatherWidgetSnapshotWriterTests.swift */; }; DB8E84D957A6C2B12EEB0AB7 /* Sounds/report.aiff in Resources */ = {isa = PBXBuildFile; fileRef = FD769D73A7C4BE3619C1F9FB /* Sounds/report.aiff */; }; DP1PF1REBASE0001PL1ST010 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = DP1PF1REBASE0002PL1ST020 /* GoogleService-Info.plist */; }; E1EBD3F0B2991F7D60DF56C5 /* Sounds/weather.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 762B2690D1D999F97D84162C /* Sounds/weather.aiff */; }; @@ -159,6 +160,7 @@ D91A00000000000000000002 /* CurrentWeatherWidgetSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CurrentWeatherWidgetSnapshot.swift; path = ../DPIPWidgets/CurrentWeatherWidgetSnapshot.swift; sourceTree = ""; }; D91A00000000000000000003 /* CurrentWeatherWidgetTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CurrentWeatherWidgetTimeline.swift; path = ../DPIPWidgets/CurrentWeatherWidgetTimeline.swift; sourceTree = ""; }; D91A00000000000000000004 /* CurrentWeatherRemoteDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CurrentWeatherRemoteDTO.swift; path = ../DPIPWidgets/CurrentWeatherRemoteDTO.swift; sourceTree = ""; }; + E7A100000000000000000002 /* CurrentWeatherWidgetSnapshotWriterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentWeatherWidgetSnapshotWriterTests.swift; sourceTree = ""; }; DP1PF1REBASE0002PL1ST020 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; F9BCED8E5498E9FD7A454E8F /* Sounds/info.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/info.aiff; sourceTree = ""; }; FD769D73A7C4BE3619C1F9FB /* Sounds/report.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/report.aiff; sourceTree = ""; }; @@ -169,6 +171,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( CurrentWeatherSnapshotAddress.swift, + CurrentWeatherSnapshotStorage.swift, WidgetLocationCatalog.swift, ); target = 2715FCD430570C1C0014DC8A /* DPIPWidgetsExtension */; @@ -177,6 +180,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( CurrentWeatherSnapshotAddress.swift, + CurrentWeatherSnapshotStorage.swift, ); target = 97C146ED1CF9000F007C117D /* Runner */; }; @@ -184,6 +188,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( CurrentWeatherSnapshotAddress.swift, + CurrentWeatherSnapshotStorage.swift, WidgetLocationCatalog.swift, ); target = 331C8080294A63A400263BE5 /* RunnerTests */; @@ -231,6 +236,7 @@ CurrentWeatherClient.swift, CurrentWeatherSnapshotTime.swift, CurrentWeatherWidgetSnapshotFactory.swift, + CurrentWeatherWidgetSnapshotWriter.swift, SavedWidgetLocationResolver.swift, WidgetLocationTarget.swift, WidgetResolvedWeatherLocation.swift, @@ -238,10 +244,17 @@ ); target = 331C8080294A63A400263BE5 /* RunnerTests */; }; + E7A100000000000000000003 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + CurrentWeatherSnapshotStorage.swift, + ); + target = 27CACBA0305BD77F0046F79C /* DPIPWidgetIntentsExtension */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 270FCE9B305CD066003D1E62 /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (270FCEA1305CD07F003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 270FCEA3305CD1DD003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 270FCEA6305CD2D6003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = ""; }; + 270FCE9B305CD066003D1E62 /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (270FCEA1305CD07F003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 270FCEA3305CD1DD003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 270FCEA6305CD2D6003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, E7A100000000000000000003 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = ""; }; 2715FCDB30570C1C0014DC8A /* DPIPWidgets */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (2715FCEA30570C1D0014DC8A /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 27CACB9C305BD2A60046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 27CACD20305C6E2D0046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 27CACBB1305BD7E60046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = DPIPWidgets; sourceTree = ""; }; 27CACBA4305BD77F0046F79C /* DPIPWidgetIntentsExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (27CACC5E305C66560046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 27CACBAF305BD77F0046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = DPIPWidgetIntentsExtension; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -304,6 +317,7 @@ 27CACC7F305C67530046F79C /* WidgetLocationCatalogTests.swift */, 270FCEA4305CD2D6003D1E62 /* CurrentWeatherSnapshotAddressTests.swift */, 270FCEF4305D1EB2003D1E62 /* WidgetSnapshotFileTests.swift */, + E7A100000000000000000002 /* CurrentWeatherWidgetSnapshotWriterTests.swift */, ); path = RunnerTests; sourceTree = ""; @@ -641,6 +655,7 @@ 27CACC3E305C64AA0046F79C /* WidgetLocationIntentTests.swift in Sources */, D91A00000000000000000013 /* CurrentWeatherWidgetTimeline.swift in Sources */, D91A00000000000000000014 /* CurrentWeatherRemoteDTO.swift in Sources */, + E7A100000000000000000001 /* CurrentWeatherWidgetSnapshotWriterTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/WidgetSnapshotPlugin.swift b/ios/Runner/WidgetSnapshotPlugin.swift index 1f919160d..75916adf0 100644 --- a/ios/Runner/WidgetSnapshotPlugin.swift +++ b/ios/Runner/WidgetSnapshotPlugin.swift @@ -75,10 +75,27 @@ enum WidgetSnapshotFile { sourceIdentifier: String? = nil, in container: URL ) throws { - let destination: URL - do { - destination = try snapshotURL( + if kind == .currentWeather { + guard + let sourceIdentifier, + let address = CurrentWeatherSnapshotAddress( + sourceIdentifier: sourceIdentifier + ) + else { + throw WidgetSnapshotError.invalidPayload + } + + try CurrentWeatherSnapshotStorage( + containerURL: container + ).replace( + data, + for: address + ) + return + } + + let destination = try snapshotURL( kind: kind, sourceIdentifier: sourceIdentifier, in: container @@ -133,12 +150,9 @@ enum WidgetSnapshotFile { throw WidgetSnapshotError.invalidPayload } - return directory - .appendingPathComponent( - "current-weather", - isDirectory: true - ) - .appendingPathComponent(address.filename) + return CurrentWeatherSnapshotStorage( + containerURL: container + ).snapshotURL(for: address) case .weatherForecast: return directory.appendingPathComponent( diff --git a/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift b/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift new file mode 100644 index 000000000..cf8d24419 --- /dev/null +++ b/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift @@ -0,0 +1,333 @@ +import XCTest +@testable import Runner + +final class CurrentWeatherSnapshotStorageTests: XCTestCase { + private var containerURL: URL! + + override func setUpWithError() throws { + containerURL = FileManager.default.temporaryDirectory + .appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: containerURL) + containerURL = nil + } + + func testCurrentLocationResolvesCanonicalURL() { + let url = storage.snapshotURL(for: .currentLocation) + + XCTAssertEqual( + url, + containerURL + .appendingPathComponent( + "WidgetSnapshots", + isDirectory: true + ) + .appendingPathComponent( + "current-weather", + isDirectory: true + ) + .appendingPathComponent("current-location.json") + ) + } + + func testSavedRegionResolvesCanonicalURL() { + let url = storage.snapshotURL( + for: .saved(regionCode: "407") + ) + + XCTAssertEqual( + url, + containerURL + .appendingPathComponent( + "WidgetSnapshots", + isDirectory: true + ) + .appendingPathComponent( + "current-weather", + isDirectory: true + ) + .appendingPathComponent("region-407.json") + ) + } + + func testReplaceCreatesMissingIntermediateDirectories() + throws + { + let address = CurrentWeatherSnapshotAddress.currentLocation + + try storage.replace(Data("snapshot".utf8), for: address) + + var isDirectory: ObjCBool = false + let directoryExists = FileManager.default.fileExists( + atPath: storage.snapshotURL(for: address) + .deletingLastPathComponent().path, + isDirectory: &isDirectory + ) + + XCTAssertTrue(directoryExists) + XCTAssertTrue(isDirectory.boolValue) + } + + func testReplaceWritesExpectedBytes() throws { + let data = Data([0x00, 0x7F, 0x80, 0xFF]) + let address = CurrentWeatherSnapshotAddress.saved( + regionCode: "407" + ) + + try storage.replace(data, for: address) + + XCTAssertEqual( + try Data(contentsOf: storage.snapshotURL(for: address)), + data + ) + } + + private var storage: CurrentWeatherSnapshotStorage { + CurrentWeatherSnapshotStorage(containerURL: containerURL) + } +} + +final class CurrentWeatherWidgetSnapshotWriterTests: XCTestCase { + private var containerURL: URL! + + override func setUpWithError() throws { + containerURL = FileManager.default.temporaryDirectory + .appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: containerURL) + containerURL = nil + } + + func testValidSavedSnapshotWritesRegionCache() throws { + let snapshot = makeSnapshot( + sourceIdentifier: "region:407", + regionCode: "407" + ) + + try writer.write(snapshot) + + XCTAssertTrue( + FileManager.default.fileExists( + atPath: snapshotURL( + for: .saved(regionCode: "407") + ).path + ) + ) + } + + func testValidCurrentLocationSnapshotWritesCurrentCache() + throws + { + let snapshot = makeSnapshot( + sourceIdentifier: "current-location", + regionCode: "407" + ) + + try writer.write(snapshot) + + XCTAssertTrue( + FileManager.default.fileExists( + atPath: snapshotURL(for: .currentLocation).path + ) + ) + } + + func testWrittenJSONRoundTripsSchemaVersionFiveFields() + throws + { + let original = makeSnapshot( + sourceIdentifier: "region:407", + regionCode: "407" + ) + + try writer.write(original) + + let data = try Data( + contentsOf: snapshotURL( + for: .saved(regionCode: "407") + ) + ) + let decoded = try JSONDecoder().decode( + CurrentWeatherWidgetSnapshot.self, + from: data + ) + + XCTAssertEqual(decoded.schemaVersion, original.schemaVersion) + XCTAssertEqual( + decoded.sourceIdentifier, + original.sourceIdentifier + ) + XCTAssertEqual(decoded.regionCode, original.regionCode) + XCTAssertEqual(decoded.regionName, original.regionName) + XCTAssertEqual( + decoded.observationTime, + original.observationTime + ) + XCTAssertEqual(decoded.stationName, original.stationName) + XCTAssertEqual(decoded.weather, original.weather) + XCTAssertEqual(decoded.weatherCode, original.weatherCode) + XCTAssertEqual(decoded.condition, original.condition) + XCTAssertEqual(decoded.isNight, original.isNight) + XCTAssertEqual( + decoded.nextDayNightTransitionTime, + original.nextDayNightTransitionTime + ) + XCTAssertEqual( + decoded.calibratedTimeOffsetMilliseconds, + original.calibratedTimeOffsetMilliseconds + ) + XCTAssertEqual(decoded.temperature, original.temperature) + XCTAssertEqual(decoded.humidity, original.humidity) + XCTAssertEqual(decoded.rain, original.rain) + } + + func testSavedLocationCachesAreIndependent() throws { + let region407 = makeSnapshot( + sourceIdentifier: "region:407", + regionCode: "407", + regionName: "西屯區" + ) + let region100 = makeSnapshot( + sourceIdentifier: "region:100", + regionCode: "100", + regionName: "中正區" + ) + + try writer.write(region407) + let region407Bytes = try Data( + contentsOf: snapshotURL( + for: .saved(regionCode: "407") + ) + ) + try writer.write(region100) + + XCTAssertEqual( + try Data( + contentsOf: snapshotURL( + for: .saved(regionCode: "407") + ) + ), + region407Bytes + ) + XCTAssertTrue( + FileManager.default.fileExists( + atPath: snapshotURL( + for: .saved(regionCode: "100") + ).path + ) + ) + } + + func testCurrentLocationCacheIsIndependentFromSavedCache() + throws + { + let saved = makeSnapshot( + sourceIdentifier: "region:407", + regionCode: "407" + ) + let current = makeSnapshot( + sourceIdentifier: "current-location", + regionCode: "407" + ) + + try writer.write(saved) + let savedBytes = try Data( + contentsOf: snapshotURL( + for: .saved(regionCode: "407") + ) + ) + try writer.write(current) + + XCTAssertEqual( + try Data( + contentsOf: snapshotURL( + for: .saved(regionCode: "407") + ) + ), + savedBytes + ) + XCTAssertTrue( + FileManager.default.fileExists( + atPath: snapshotURL(for: .currentLocation).path + ) + ) + } + + func testEncodingFailurePreservesExistingCacheBytes() + throws + { + let address = CurrentWeatherSnapshotAddress.saved( + regionCode: "407" + ) + let valid = makeSnapshot( + sourceIdentifier: "region:407", + regionCode: "407" + ) + + try writer.write(valid) + let originalBytes = try Data( + contentsOf: snapshotURL(for: address) + ) + + let legacy = makeSnapshot( + schemaVersion: 4, + sourceIdentifier: "region:407", + regionCode: "407" + ) + + XCTAssertThrowsError(try writer.write(legacy)) + XCTAssertEqual( + try Data(contentsOf: snapshotURL(for: address)), + originalBytes + ) + } + + private var writer: CurrentWeatherWidgetSnapshotWriter { + CurrentWeatherWidgetSnapshotWriter( + containerURL: containerURL + ) + } + + private func snapshotURL( + for address: CurrentWeatherSnapshotAddress + ) -> URL { + CurrentWeatherSnapshotStorage( + containerURL: containerURL + ).snapshotURL(for: address) + } + + private func makeSnapshot( + schemaVersion: Int = 5, + sourceIdentifier: String, + regionCode: String, + regionName: String = "西屯區" + ) -> CurrentWeatherWidgetSnapshot { + CurrentWeatherWidgetSnapshot( + schemaVersion: schemaVersion, + sourceIdentifier: sourceIdentifier, + regionCode: regionCode, + regionName: regionName, + observationTime: 1_789_567_200, + stationName: "西屯", + weather: "雷雨", + weatherCode: 214, + condition: .thunderstorm, + isNight: true, + nextDayNightTransitionTime: 1_789_562_700, + calibratedTimeOffsetMilliseconds: -300_000, + temperature: 27.5, + humidity: 83, + rain: 12.5 + ) + } +} diff --git a/ios/Shared/CurrentWeatherSnapshotStorage.swift b/ios/Shared/CurrentWeatherSnapshotStorage.swift new file mode 100644 index 000000000..47d341643 --- /dev/null +++ b/ios/Shared/CurrentWeatherSnapshotStorage.swift @@ -0,0 +1,37 @@ +import Foundation + +struct CurrentWeatherSnapshotStorage { + let containerURL: URL + + func snapshotURL( + for address: CurrentWeatherSnapshotAddress + ) -> URL { + containerURL + .appendingPathComponent( + "WidgetSnapshots", + isDirectory: true + ) + .appendingPathComponent( + "current-weather", + isDirectory: true + ) + .appendingPathComponent(address.filename) + } + + func replace( + _ data: Data, + for address: CurrentWeatherSnapshotAddress + ) throws { + let destination = snapshotURL(for: address) + + try FileManager.default.createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + try data.write( + to: destination, + options: .atomic + ) + } +} From 9906ec512bda57317ca459e1aaa945fabb50e508 Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Mon, 21 Sep 2026 02:40:12 +0800 Subject: [PATCH 07/18] chore(weather): add native widget calibrated clock Platform: ios --- ios/DPIPWidgets/WidgetSNTPClient.swift | 386 +++++++++++++++++++ ios/DPIPWidgets/WidgetServerClock.swift | 150 +++++++ ios/Runner.xcodeproj/project.pbxproj | 10 + ios/RunnerTests/WidgetSNTPClientTests.swift | 247 ++++++++++++ ios/RunnerTests/WidgetServerClockTests.swift | 377 ++++++++++++++++++ 5 files changed, 1170 insertions(+) create mode 100644 ios/DPIPWidgets/WidgetSNTPClient.swift create mode 100644 ios/DPIPWidgets/WidgetServerClock.swift create mode 100644 ios/RunnerTests/WidgetSNTPClientTests.swift create mode 100644 ios/RunnerTests/WidgetServerClockTests.swift diff --git a/ios/DPIPWidgets/WidgetSNTPClient.swift b/ios/DPIPWidgets/WidgetSNTPClient.swift new file mode 100644 index 000000000..1d3f2262c --- /dev/null +++ b/ios/DPIPWidgets/WidgetSNTPClient.swift @@ -0,0 +1,386 @@ +import Foundation +import Network + +protocol WidgetWallTimeSource: Sendable { + func now() -> Date +} + +struct WidgetSystemWallTimeSource: WidgetWallTimeSource { + func now() -> Date { + Date() + } +} + +struct WidgetSNTPExchange: Sendable { + let response: Data + let clientTransmitTime: Date + let clientReceiveTime: Date +} + +protocol WidgetSNTPHostQuerying: Sendable { + func query( + host: String, + timeout: TimeInterval + ) async throws -> WidgetSNTPExchange +} + +protocol WidgetServerTimeSource: Sendable { + func serverTimeUnixMilliseconds() async throws -> Int64 +} + +enum WidgetSNTPError: Error, Equatable { + case allHostsFailed + case cancelled + case connectionFailed + case invalidResponse + case timedOut +} + +enum WidgetNTPPacket { + static let length = 48 + static let unixEpochDelta: TimeInterval = 2_208_988_800 + private static let eraSeconds: TimeInterval = 4_294_967_296 + + static func request(transmitTime: Date) -> Data { + var packet = Data(repeating: 0, count: length) + // Leap indicator 0, NTP version 3, client mode 3. + packet[0] = 0x1B + writeTimestamp( + unixTime: transmitTime.timeIntervalSince1970, + to: &packet, + at: 40 + ) + return packet + } + + static func correctedUnixMilliseconds( + exchange: WidgetSNTPExchange + ) throws -> Int64 { + let response = exchange.response + guard response.count >= length else { + throw WidgetSNTPError.invalidResponse + } + + let firstByte = response[0] + let leapIndicator = firstByte >> 6 + let version = (firstByte >> 3) & 0x07 + let mode = firstByte & 0x07 + let stratum = response[1] + + guard leapIndicator != 3, + version == 3 || version == 4, + mode == 4, + (1...15).contains(stratum) + else { + throw WidgetSNTPError.invalidResponse + } + + guard !timestampIsZero(in: response, at: 32), + !timestampIsZero(in: response, at: 40) + else { + throw WidgetSNTPError.invalidResponse + } + + let serverReceiveTime = try unixTime( + from: response, + at: 32, + near: exchange.clientReceiveTime.timeIntervalSince1970 + ) + let serverTransmitTime = try unixTime( + from: response, + at: 40, + near: exchange.clientReceiveTime.timeIntervalSince1970 + ) + + guard serverTransmitTime >= serverReceiveTime + else { + throw WidgetSNTPError.invalidResponse + } + + let offset = offsetSeconds( + clientTransmitTime: + exchange.clientTransmitTime.timeIntervalSince1970, + serverReceiveTime: serverReceiveTime, + serverTransmitTime: serverTransmitTime, + clientReceiveTime: + exchange.clientReceiveTime.timeIntervalSince1970 + ) + let correctedTime = + exchange.clientReceiveTime.timeIntervalSince1970 + offset + + guard correctedTime.isFinite, + correctedTime >= 0, + correctedTime <= Double(Int64.max) / 1_000 + else { + throw WidgetSNTPError.invalidResponse + } + + return Int64((correctedTime * 1_000).rounded()) + } + + static func offsetSeconds( + clientTransmitTime: TimeInterval, + serverReceiveTime: TimeInterval, + serverTransmitTime: TimeInterval, + clientReceiveTime: TimeInterval + ) -> TimeInterval { + ( + (serverReceiveTime - clientTransmitTime) + + (serverTransmitTime - clientReceiveTime) + ) / 2 + } + + static func unixTime( + from packet: Data, + at offset: Int, + near referenceUnixTime: TimeInterval + ) throws -> TimeInterval { + guard offset >= 0, packet.count >= offset + 8 else { + throw WidgetSNTPError.invalidResponse + } + + let seconds = UInt32(packet[offset]) << 24 + | UInt32(packet[offset + 1]) << 16 + | UInt32(packet[offset + 2]) << 8 + | UInt32(packet[offset + 3]) + let fraction = UInt32(packet[offset + 4]) << 24 + | UInt32(packet[offset + 5]) << 16 + | UInt32(packet[offset + 6]) << 8 + | UInt32(packet[offset + 7]) + + let eraZeroUnixTime = TimeInterval(seconds) - unixEpochDelta + // The wire format carries no era number. Select the era nearest T4, + // as required to unfold timestamps after the February 2036 rollover. + let era = ( + (referenceUnixTime - eraZeroUnixTime) / eraSeconds + ).rounded() + return eraZeroUnixTime + era * eraSeconds + + TimeInterval(fraction) / 4_294_967_296 + } + + static func writeTimestamp( + unixTime: TimeInterval, + to packet: inout Data, + at offset: Int + ) { + let ntpTime = unixTime + unixEpochDelta + let wholeSeconds = ntpTime.rounded(.down) + var eraSeconds = wholeSeconds.truncatingRemainder( + dividingBy: self.eraSeconds + ) + if eraSeconds < 0 { + eraSeconds += self.eraSeconds + } + let seconds = UInt32(eraSeconds) + let fractionalSeconds = ntpTime - wholeSeconds + let fraction = UInt32( + (fractionalSeconds * 4_294_967_296).rounded(.down) + ) + + write(seconds, to: &packet, at: offset) + write(fraction, to: &packet, at: offset + 4) + } + + private static func write( + _ value: UInt32, + to packet: inout Data, + at offset: Int + ) { + packet[offset] = UInt8((value >> 24) & 0xFF) + packet[offset + 1] = UInt8((value >> 16) & 0xFF) + packet[offset + 2] = UInt8((value >> 8) & 0xFF) + packet[offset + 3] = UInt8(value & 0xFF) + } + + private static func timestampIsZero( + in packet: Data, + at offset: Int + ) -> Bool { + packet[offset..<(offset + 8)].allSatisfy { $0 == 0 } + } +} + +struct WidgetSNTPClient: WidgetServerTimeSource { + static let primaryHost = "time.exptech.com.tw" + static let backupHost = "time.apple.com" + + private let hosts: [String] + private let hostTimeout: TimeInterval + private let hostQuery: any WidgetSNTPHostQuerying + + init( + hosts: [String] = [primaryHost, backupHost], + hostTimeout: TimeInterval = 3, + hostQuery: any WidgetSNTPHostQuerying = WidgetNetworkSNTPHostQuery() + ) { + self.hosts = hosts + self.hostTimeout = hostTimeout + self.hostQuery = hostQuery + } + + func serverTimeUnixMilliseconds() async throws -> Int64 { + for host in hosts { + do { + let exchange = try await hostQuery.query( + host: host, + timeout: hostTimeout + ) + return try WidgetNTPPacket.correctedUnixMilliseconds( + exchange: exchange + ) + } catch is CancellationError { + throw CancellationError() + } catch WidgetSNTPError.cancelled { + throw CancellationError() + } catch { + continue + } + } + throw WidgetSNTPError.allHostsFailed + } +} + +struct WidgetNetworkSNTPHostQuery: WidgetSNTPHostQuerying { + private let wallClock: any WidgetWallTimeSource + + init( + wallClock: any WidgetWallTimeSource = WidgetSystemWallTimeSource() + ) { + self.wallClock = wallClock + } + + func query( + host: String, + timeout: TimeInterval + ) async throws -> WidgetSNTPExchange { + try Task.checkCancellation() + + let connection = NWConnection( + host: NWEndpoint.Host(host), + port: NWEndpoint.Port(rawValue: 123)!, + using: .udp + ) + let context = WidgetSNTPQueryContext() + let queue = DispatchQueue( + label: "com.exptech.dpip.widget-sntp.\(UUID().uuidString)" + ) + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + context.install( + connection: connection, + continuation: continuation + ) + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + let transmitTime = wallClock.now() + let request = WidgetNTPPacket.request( + transmitTime: transmitTime + ) + connection.send( + content: request, + completion: .contentProcessed { error in + if error != nil { + context.finish( + .failure( + WidgetSNTPError.connectionFailed + ) + ) + return + } + + connection.receiveMessage { + data, + _, + _, + error in + let receiveTime = wallClock.now() + guard error == nil, let data else { + context.finish( + .failure( + WidgetSNTPError + .connectionFailed + ) + ) + return + } + context.finish( + .success( + WidgetSNTPExchange( + response: data, + clientTransmitTime: + transmitTime, + clientReceiveTime: + receiveTime + ) + ) + ) + } + } + ) + case .failed: + context.finish( + .failure(WidgetSNTPError.connectionFailed) + ) + case .cancelled: + context.finish( + .failure(WidgetSNTPError.cancelled) + ) + default: + break + } + } + + queue.asyncAfter(deadline: .now() + timeout) { + context.finish(.failure(WidgetSNTPError.timedOut)) + } + connection.start(queue: queue) + } + } onCancel: { + context.finish(.failure(WidgetSNTPError.cancelled)) + } + } +} + +private final class WidgetSNTPQueryContext: @unchecked Sendable { + private let lock = NSLock() + private var connection: NWConnection? + private var continuation: + CheckedContinuation? + private var pendingResult: Result? + + func install( + connection: NWConnection, + continuation: CheckedContinuation + ) { + lock.lock() + if let pendingResult { + lock.unlock() + connection.cancel() + continuation.resume(with: pendingResult) + return + } + self.connection = connection + self.continuation = continuation + lock.unlock() + } + + func finish(_ result: Result) { + lock.lock() + guard pendingResult == nil else { + lock.unlock() + return + } + pendingResult = result + let connection = connection + let continuation = continuation + self.connection = nil + self.continuation = nil + lock.unlock() + + connection?.cancel() + continuation?.resume(with: result) + } +} diff --git a/ios/DPIPWidgets/WidgetServerClock.swift b/ios/DPIPWidgets/WidgetServerClock.swift new file mode 100644 index 000000000..8ccdfeaea --- /dev/null +++ b/ios/DPIPWidgets/WidgetServerClock.swift @@ -0,0 +1,150 @@ +import Foundation + +protocol WidgetMonotonicTimeSource: Sendable { + func elapsedMilliseconds() -> Int64 +} + +struct WidgetSystemMonotonicTimeSource: WidgetMonotonicTimeSource { + func elapsedMilliseconds() -> Int64 { + Int64(ProcessInfo.processInfo.systemUptime * 1_000) + } +} + +protocol WidgetServerClockTimeoutRunning: Sendable { + func serverTimeUnixMilliseconds( + from source: any WidgetServerTimeSource, + timeout: TimeInterval + ) async throws -> Int64 +} + +enum WidgetServerClockError: Error, Equatable { + case timedOut +} + +struct WidgetTaskServerClockTimeout: WidgetServerClockTimeoutRunning { + func serverTimeUnixMilliseconds( + from source: any WidgetServerTimeSource, + timeout: TimeInterval + ) async throws -> Int64 { + try await withThrowingTaskGroup( + of: Int64.self, + returning: Int64.self + ) { group in + group.addTask { + try await source.serverTimeUnixMilliseconds() + } + group.addTask { + let nanoseconds = UInt64(timeout * 1_000_000_000) + try await Task.sleep(nanoseconds: nanoseconds) + throw WidgetServerClockError.timedOut + } + + defer { + group.cancelAll() + } + guard let result = try await group.next() else { + throw WidgetServerClockError.timedOut + } + return result + } + } +} + +actor WidgetServerClock { + private let deviceClock: any WidgetWallTimeSource + private let monotonicClock: any WidgetMonotonicTimeSource + private let serverTimeSource: any WidgetServerTimeSource + private let timeoutRunner: any WidgetServerClockTimeoutRunning + private let synchronizationTimeout: TimeInterval + + private var anchorServerUnixMilliseconds: Int64? + private var anchorMonotonicMilliseconds: Int64? + private var synchronizationTask: Task? + + init( + deviceClock: any WidgetWallTimeSource = WidgetSystemWallTimeSource(), + monotonicClock: any WidgetMonotonicTimeSource = + WidgetSystemMonotonicTimeSource(), + serverTimeSource: any WidgetServerTimeSource = WidgetSNTPClient(), + timeoutRunner: any WidgetServerClockTimeoutRunning = + WidgetTaskServerClockTimeout(), + synchronizationTimeout: TimeInterval = 8 + ) { + self.deviceClock = deviceClock + self.monotonicClock = monotonicClock + self.serverTimeSource = serverTimeSource + self.timeoutRunner = timeoutRunner + self.synchronizationTimeout = synchronizationTimeout + } + + var hasSynchronized: Bool { + anchorServerUnixMilliseconds != nil + && anchorMonotonicMilliseconds != nil + } + + @discardableResult + func synchronize() async -> Bool { + if let synchronizationTask { + return await synchronizationTask.value + } + + let task = Task { () -> Bool in + do { + let serverTime = try await timeoutRunner + .serverTimeUnixMilliseconds( + from: serverTimeSource, + timeout: synchronizationTimeout + ) + anchorServerUnixMilliseconds = serverTime + anchorMonotonicMilliseconds = + monotonicClock.elapsedMilliseconds() + synchronizationTask = nil + return true + } catch { + synchronizationTask = nil + return false + } + } + synchronizationTask = task + return await task.value + } + + func calibratedNowUnixMilliseconds() -> Int64 { + guard let anchorServerUnixMilliseconds, + let anchorMonotonicMilliseconds + else { + return deviceUnixMilliseconds() + } + + return anchorServerUnixMilliseconds + + monotonicClock.elapsedMilliseconds() + - anchorMonotonicMilliseconds + } + + func currentWeatherSnapshotTime() -> CurrentWeatherSnapshotTime { + let deviceNow = deviceUnixMilliseconds() + guard let anchorServerUnixMilliseconds, + let anchorMonotonicMilliseconds + else { + return CurrentWeatherSnapshotTime( + calibratedNowUnixMilliseconds: deviceNow, + calibratedTimeOffsetMilliseconds: 0 + ) + } + + let calibratedNow = anchorServerUnixMilliseconds + + monotonicClock.elapsedMilliseconds() + - anchorMonotonicMilliseconds + return CurrentWeatherSnapshotTime( + calibratedNowUnixMilliseconds: calibratedNow, + calibratedTimeOffsetMilliseconds: + Int(calibratedNow - deviceNow) + ) + } + + private func deviceUnixMilliseconds() -> Int64 { + Int64( + (deviceClock.now().timeIntervalSince1970 * 1_000).rounded() + ) + } +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 86c140045..03dbf9084 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -21,6 +21,8 @@ 27CACBAA305BD77F0046F79C /* DPIPWidgetIntentsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 27CACBA1305BD77F0046F79C /* DPIPWidgetIntentsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 27CACC3E305C64AA0046F79C /* WidgetLocationIntentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27CACC3D305C64AA0046F79C /* WidgetLocationIntentTests.swift */; }; 27CACC80305C67530046F79C /* WidgetLocationCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27CACC7F305C67530046F79C /* WidgetLocationCatalogTests.swift */; }; + 28A000000000000000000003 /* WidgetSNTPClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28A000000000000000000001 /* WidgetSNTPClientTests.swift */; }; + 28A000000000000000000004 /* WidgetServerClockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28A000000000000000000002 /* WidgetServerClockTests.swift */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 522508B9301F863A006148C2 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 522508B7301F863A006148C2 /* InfoPlist.strings */; }; @@ -117,6 +119,8 @@ 27CACBA2305BD77F0046F79C /* Intents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Intents.framework; path = System/Library/Frameworks/Intents.framework; sourceTree = SDKROOT; }; 27CACC3D305C64AA0046F79C /* WidgetLocationIntentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetLocationIntentTests.swift; sourceTree = ""; }; 27CACC7F305C67530046F79C /* WidgetLocationCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetLocationCatalogTests.swift; sourceTree = ""; }; + 28A000000000000000000001 /* WidgetSNTPClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSNTPClientTests.swift; sourceTree = ""; }; + 28A000000000000000000002 /* WidgetServerClockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetServerClockTests.swift; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3AE87ED82FDB896B2B5C5F1B /* Sounds/warn.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/warn.aiff; sourceTree = ""; }; @@ -238,6 +242,8 @@ CurrentWeatherWidgetSnapshotFactory.swift, CurrentWeatherWidgetSnapshotWriter.swift, SavedWidgetLocationResolver.swift, + WidgetServerClock.swift, + WidgetSNTPClient.swift, WidgetLocationTarget.swift, WidgetResolvedWeatherLocation.swift, WidgetSolarTime.swift, @@ -309,6 +315,8 @@ isa = PBXGroup; children = ( 331C807B294A618700263BE5 /* RunnerTests.swift */, + 28A000000000000000000001 /* WidgetSNTPClientTests.swift */, + 28A000000000000000000002 /* WidgetServerClockTests.swift */, D91A00000000000000000001 /* CurrentWeatherWidgetTests.swift */, D91A00000000000000000002 /* CurrentWeatherWidgetSnapshot.swift */, D91A00000000000000000003 /* CurrentWeatherWidgetTimeline.swift */, @@ -647,6 +655,8 @@ buildActionMask = 2147483647; files = ( 270FCEA5305CD2D6003D1E62 /* CurrentWeatherSnapshotAddressTests.swift in Sources */, + 28A000000000000000000003 /* WidgetSNTPClientTests.swift in Sources */, + 28A000000000000000000004 /* WidgetServerClockTests.swift in Sources */, 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, D91A00000000000000000011 /* CurrentWeatherWidgetTests.swift in Sources */, D91A00000000000000000012 /* CurrentWeatherWidgetSnapshot.swift in Sources */, diff --git a/ios/RunnerTests/WidgetSNTPClientTests.swift b/ios/RunnerTests/WidgetSNTPClientTests.swift new file mode 100644 index 000000000..3f0a06516 --- /dev/null +++ b/ios/RunnerTests/WidgetSNTPClientTests.swift @@ -0,0 +1,247 @@ +import Foundation +import XCTest + +final class WidgetSNTPClientTests: XCTestCase { + func testPrimarySuccessDoesNotQueryBackup() async throws { + let query = ScriptedSNTPHostQuery( + results: [.success(makeExchange(offsetMilliseconds: 250))] + ) + let client = WidgetSNTPClient(hostQuery: query) + + let result = try await client.serverTimeUnixMilliseconds() + + XCTAssertEqual(result, 10_250) + let calls = await query.calls + XCTAssertEqual(calls.map(\.host), [WidgetSNTPClient.primaryHost]) + XCTAssertEqual(calls.map(\.timeout), [3]) + } + + func testPrimaryFailureFallsBackToBackup() async throws { + let query = ScriptedSNTPHostQuery( + results: [ + .failure(WidgetSNTPError.connectionFailed), + .success(makeExchange(offsetMilliseconds: -400)), + ] + ) + let client = WidgetSNTPClient(hostQuery: query) + + let result = try await client.serverTimeUnixMilliseconds() + + XCTAssertEqual(result, 9_600) + let calls = await query.calls + XCTAssertEqual( + calls.map(\.host), + [WidgetSNTPClient.primaryHost, WidgetSNTPClient.backupHost] + ) + XCTAssertEqual(calls.map(\.timeout), [3, 3]) + } + + func testPrimaryTimeoutFallsBackWithoutRealDelay() async throws { + let query = ScriptedSNTPHostQuery( + results: [ + .failure(WidgetSNTPError.timedOut), + .success(makeExchange(offsetMilliseconds: 0)), + ] + ) + let client = WidgetSNTPClient(hostQuery: query) + + let result = try await client.serverTimeUnixMilliseconds() + + XCTAssertEqual(result, 10_000) + let calls = await query.calls + XCTAssertEqual(calls.map(\.timeout), [3, 3]) + } + + func testBothHostsFail() async { + let query = ScriptedSNTPHostQuery( + results: [ + .failure(WidgetSNTPError.connectionFailed), + .failure(WidgetSNTPError.timedOut), + ] + ) + let client = WidgetSNTPClient(hostQuery: query) + + do { + _ = try await client.serverTimeUnixMilliseconds() + XCTFail("Expected all hosts to fail") + } catch let error as WidgetSNTPError { + XCTAssertEqual(error, .allHostsFailed) + } catch { + XCTFail("Unexpected error: \(error)") + } + + let calls = await query.calls + XCTAssertEqual( + calls.map(\.host), + [WidgetSNTPClient.primaryHost, WidgetSNTPClient.backupHost] + ) + } + + func testMalformedAndUndersizedResponsesAreRejected() { + let undersized = WidgetSNTPExchange( + response: Data(repeating: 0, count: 47), + clientTransmitTime: date(milliseconds: 9_000), + clientReceiveTime: date(milliseconds: 10_000) + ) + XCTAssertThrowsError( + try WidgetNTPPacket.correctedUnixMilliseconds( + exchange: undersized + ) + ) { error in + XCTAssertEqual(error as? WidgetSNTPError, .invalidResponse) + } + + var malformedPacket = makeServerPacket( + serverReceiveMilliseconds: 9_500, + serverTransmitMilliseconds: 9_600 + ) + malformedPacket[0] = 0x23 + let malformed = WidgetSNTPExchange( + response: malformedPacket, + clientTransmitTime: date(milliseconds: 9_000), + clientReceiveTime: date(milliseconds: 10_000) + ) + XCTAssertThrowsError( + try WidgetNTPPacket.correctedUnixMilliseconds( + exchange: malformed + ) + ) { error in + XCTAssertEqual(error as? WidgetSNTPError, .invalidResponse) + } + } + + func testNTPTimeConvertsToUnixEpochWithFraction() throws { + var packet = Data(repeating: 0, count: WidgetNTPPacket.length) + WidgetNTPPacket.writeTimestamp( + unixTime: 0.5, + to: &packet, + at: 32 + ) + + let unixTime = try WidgetNTPPacket.unixTime( + from: packet, + at: 32, + near: 0.5 + ) + + XCTAssertEqual(unixTime, 0.5, accuracy: 0.000_001) + } + + func testNTPTimeUnfoldsEraAfter2036Rollover() throws { + let unixTimeIn2040: TimeInterval = 2_208_988_800 + var packet = Data(repeating: 0, count: WidgetNTPPacket.length) + WidgetNTPPacket.writeTimestamp( + unixTime: unixTimeIn2040, + to: &packet, + at: 32 + ) + + let decoded = try WidgetNTPPacket.unixTime( + from: packet, + at: 32, + near: unixTimeIn2040 + ) + + XCTAssertEqual(decoded, unixTimeIn2040, accuracy: 0.000_001) + } + + func testOffsetFormulaSupportsPositiveAndNegativeOffsets() { + let positive = WidgetNTPPacket.offsetSeconds( + clientTransmitTime: 100, + serverReceiveTime: 106, + serverTransmitTime: 107, + clientReceiveTime: 103 + ) + let negative = WidgetNTPPacket.offsetSeconds( + clientTransmitTime: 100, + serverReceiveTime: 96, + serverTransmitTime: 97, + clientReceiveTime: 103 + ) + + XCTAssertEqual(positive, 5) + XCTAssertEqual(negative, -5) + } + + func testRequestIsStandardFortyEightByteClientPacket() { + let request = WidgetNTPPacket.request( + transmitTime: date(milliseconds: 10_000) + ) + + XCTAssertEqual(request.count, 48) + XCTAssertEqual(request[0], 0x1B) + } + + private func makeExchange( + offsetMilliseconds: Int64 + ) -> WidgetSNTPExchange { + let clientTransmitMilliseconds: Int64 = 9_000 + let clientReceiveMilliseconds: Int64 = 10_000 + let serverReceiveMilliseconds = + clientTransmitMilliseconds + offsetMilliseconds + 100 + let serverTransmitMilliseconds = + clientReceiveMilliseconds + offsetMilliseconds - 100 + + return WidgetSNTPExchange( + response: makeServerPacket( + serverReceiveMilliseconds: serverReceiveMilliseconds, + serverTransmitMilliseconds: serverTransmitMilliseconds + ), + clientTransmitTime: date( + milliseconds: clientTransmitMilliseconds + ), + clientReceiveTime: date( + milliseconds: clientReceiveMilliseconds + ) + ) + } + + private func makeServerPacket( + serverReceiveMilliseconds: Int64, + serverTransmitMilliseconds: Int64 + ) -> Data { + var packet = Data(repeating: 0, count: WidgetNTPPacket.length) + packet[0] = 0x24 + packet[1] = 1 + WidgetNTPPacket.writeTimestamp( + unixTime: TimeInterval(serverReceiveMilliseconds) / 1_000, + to: &packet, + at: 32 + ) + WidgetNTPPacket.writeTimestamp( + unixTime: TimeInterval(serverTransmitMilliseconds) / 1_000, + to: &packet, + at: 40 + ) + return packet + } + + private func date(milliseconds: Int64) -> Date { + Date(timeIntervalSince1970: TimeInterval(milliseconds) / 1_000) + } +} + +private actor ScriptedSNTPHostQuery: WidgetSNTPHostQuerying { + struct Call: Sendable { + let host: String + let timeout: TimeInterval + } + + private(set) var calls: [Call] = [] + private var results: [Result] + + init(results: [Result]) { + self.results = results + } + + func query( + host: String, + timeout: TimeInterval + ) async throws -> WidgetSNTPExchange { + calls.append(Call(host: host, timeout: timeout)) + guard !results.isEmpty else { + throw WidgetSNTPError.connectionFailed + } + return try results.removeFirst().get() + } +} diff --git a/ios/RunnerTests/WidgetServerClockTests.swift b/ios/RunnerTests/WidgetServerClockTests.swift new file mode 100644 index 000000000..e420026d6 --- /dev/null +++ b/ios/RunnerTests/WidgetServerClockTests.swift @@ -0,0 +1,377 @@ +import Foundation +import XCTest + +final class WidgetServerClockTests: XCTestCase { + func testStartsUnsynchronized() async { + let clock = makeClock() + + let hasSynchronized = await clock.hasSynchronized + + XCTAssertFalse(hasSynchronized) + } + + func testSuccessfulSyncCreatesAnchor() async { + let monotonic = TestMonotonicClock(milliseconds: 400) + let clock = makeClock( + monotonic: monotonic, + source: ScriptedServerTimeSource(results: [.success(10_000)]) + ) + + let succeeded = await clock.synchronize() + let hasSynchronized = await clock.hasSynchronized + let calibratedNow = await clock.calibratedNowUnixMilliseconds() + + XCTAssertTrue(succeeded) + XCTAssertTrue(hasSynchronized) + XCTAssertEqual(calibratedNow, 10_000) + } + + func testCalibratedNowAdvancesWithMonotonicElapsedTime() async { + let monotonic = TestMonotonicClock(milliseconds: 400) + let clock = makeClock( + monotonic: monotonic, + source: ScriptedServerTimeSource(results: [.success(10_000)]) + ) + let synchronized = await clock.synchronize() + XCTAssertTrue(synchronized) + + monotonic.set(milliseconds: 1_900) + let calibratedNow = await clock.calibratedNowUnixMilliseconds() + + XCTAssertEqual(calibratedNow, 11_500) + } + + func testDeviceWallClockJumpDoesNotMoveCalibratedNow() async { + let wallClock = TestWallClock(milliseconds: 10_000) + let monotonic = TestMonotonicClock(milliseconds: 400) + let clock = makeClock( + wallClock: wallClock, + monotonic: monotonic, + source: ScriptedServerTimeSource(results: [.success(20_000)]) + ) + let synchronized = await clock.synchronize() + XCTAssertTrue(synchronized) + monotonic.set(milliseconds: 900) + let beforeJump = await clock.calibratedNowUnixMilliseconds() + + wallClock.set(milliseconds: 9_999_999) + let afterJump = await clock.calibratedNowUnixMilliseconds() + + XCTAssertEqual(beforeJump, 20_500) + XCTAssertEqual(afterJump, beforeJump) + } + + func testFailedLaterSyncPreservesPreviousAnchor() async { + let monotonic = TestMonotonicClock(milliseconds: 100) + let source = ScriptedServerTimeSource( + results: [ + .success(10_000), + .failure(WidgetSNTPError.allHostsFailed), + ] + ) + let clock = makeClock(monotonic: monotonic, source: source) + let firstSyncSucceeded = await clock.synchronize() + XCTAssertTrue(firstSyncSucceeded) + monotonic.set(milliseconds: 600) + + let secondSyncSucceeded = await clock.synchronize() + let hasSynchronized = await clock.hasSynchronized + let calibratedNow = await clock.calibratedNowUnixMilliseconds() + + XCTAssertFalse(secondSyncSucceeded) + XCTAssertTrue(hasSynchronized) + XCTAssertEqual(calibratedNow, 10_500) + } + + func testZeroOffsetIsStillSynchronized() async { + let wallClock = TestWallClock(milliseconds: 10_000) + let clock = makeClock( + wallClock: wallClock, + source: ScriptedServerTimeSource(results: [.success(10_000)]) + ) + + let synchronized = await clock.synchronize() + XCTAssertTrue(synchronized) + let sample = await clock.currentWeatherSnapshotTime() + let hasSynchronized = await clock.hasSynchronized + + XCTAssertTrue(hasSynchronized) + XCTAssertEqual(sample.calibratedTimeOffsetMilliseconds, 0) + } + + func testSnapshotOffsetIsCalibratedMinusDevice() async { + let aheadClock = makeClock( + wallClock: TestWallClock(milliseconds: 15_000), + source: ScriptedServerTimeSource(results: [.success(10_000)]) + ) + let aheadSynchronized = await aheadClock.synchronize() + XCTAssertTrue(aheadSynchronized) + + let behindClock = makeClock( + wallClock: TestWallClock(milliseconds: 5_000), + source: ScriptedServerTimeSource(results: [.success(10_000)]) + ) + let behindSynchronized = await behindClock.synchronize() + XCTAssertTrue(behindSynchronized) + + let aheadSample = await aheadClock.currentWeatherSnapshotTime() + let behindSample = await behindClock.currentWeatherSnapshotTime() + XCTAssertEqual(aheadSample.calibratedTimeOffsetMilliseconds, -5_000) + XCTAssertEqual(behindSample.calibratedTimeOffsetMilliseconds, 5_000) + } + + func testSnapshotTimeUsesOneLogicalClockSample() async { + let wallClock = TestWallClock(milliseconds: 8_000) + let monotonic = TestMonotonicClock(milliseconds: 100) + let clock = makeClock( + wallClock: wallClock, + monotonic: monotonic, + source: ScriptedServerTimeSource(results: [.success(10_000)]) + ) + let synchronized = await clock.synchronize() + XCTAssertTrue(synchronized) + monotonic.set(milliseconds: 350) + wallClock.resetReadCount() + monotonic.resetReadCount() + + let sample = await clock.currentWeatherSnapshotTime() + + XCTAssertEqual(sample.calibratedNowUnixMilliseconds, 10_250) + XCTAssertEqual(sample.calibratedTimeOffsetMilliseconds, 2_250) + XCTAssertEqual(wallClock.readCount, 1) + XCTAssertEqual(monotonic.readCount, 1) + } + + func testConcurrentSyncCallsShareUnderlyingWork() async { + let source = GatedServerTimeSource() + let clock = makeClock(source: source) + + let first = Task { await clock.synchronize() } + while await source.callCount == 0 { + await Task.yield() + } + let second = Task { await clock.synchronize() } + for _ in 0..<20 { + await Task.yield() + } + + let callsBeforeCompletion = await source.callCount + XCTAssertEqual(callsBeforeCompletion, 1) + await source.succeed(with: 10_000) + let firstResult = await first.value + let secondResult = await second.value + let finalCallCount = await source.callCount + XCTAssertTrue(firstResult) + XCTAssertTrue(secondResult) + XCTAssertEqual(finalCallCount, 1) + } + + func testOuterTimeoutLeavesClockUnsynchronizedWithoutWaiting() async { + let source = ScriptedServerTimeSource(results: [.success(10_000)]) + let timeoutRunner = ScriptedTimeoutRunner( + results: [.failure(WidgetServerClockError.timedOut)] + ) + let clock = WidgetServerClock( + deviceClock: TestWallClock(milliseconds: 4_000), + monotonicClock: TestMonotonicClock(milliseconds: 0), + serverTimeSource: source, + timeoutRunner: timeoutRunner + ) + + let succeeded = await clock.synchronize() + let hasSynchronized = await clock.hasSynchronized + let callCount = await source.callCount + let timeouts = await timeoutRunner.timeouts + + XCTAssertFalse(succeeded) + XCTAssertFalse(hasSynchronized) + XCTAssertEqual(callCount, 0) + XCTAssertEqual(timeouts, [8]) + } + + func testOuterTimeoutPreservesExistingAnchor() async { + let monotonic = TestMonotonicClock(milliseconds: 100) + let timeoutRunner = ScriptedTimeoutRunner( + results: [ + .success(10_000), + .failure(WidgetServerClockError.timedOut), + ] + ) + let clock = WidgetServerClock( + deviceClock: TestWallClock(milliseconds: 4_000), + monotonicClock: monotonic, + serverTimeSource: + ScriptedServerTimeSource(results: [.success(99_999)]), + timeoutRunner: timeoutRunner + ) + let firstSucceeded = await clock.synchronize() + monotonic.set(milliseconds: 600) + + let secondSucceeded = await clock.synchronize() + let hasSynchronized = await clock.hasSynchronized + let calibratedNow = await clock.calibratedNowUnixMilliseconds() + + XCTAssertTrue(firstSucceeded) + XCTAssertFalse(secondSucceeded) + XCTAssertTrue(hasSynchronized) + XCTAssertEqual(calibratedNow, 10_500) + } + + private func makeClock( + wallClock: TestWallClock = TestWallClock(milliseconds: 1_000), + monotonic: TestMonotonicClock = + TestMonotonicClock(milliseconds: 0), + source: any WidgetServerTimeSource = + ScriptedServerTimeSource(results: [.success(2_000)]) + ) -> WidgetServerClock { + WidgetServerClock( + deviceClock: wallClock, + monotonicClock: monotonic, + serverTimeSource: source, + timeoutRunner: PassthroughTimeoutRunner() + ) + } +} + +private final class TestWallClock: WidgetWallTimeSource, + @unchecked Sendable +{ + private let lock = NSLock() + private var milliseconds: Int64 + private var _readCount = 0 + + init(milliseconds: Int64) { + self.milliseconds = milliseconds + } + + var readCount: Int { + lock.lock() + defer { lock.unlock() } + return _readCount + } + + func now() -> Date { + lock.lock() + _readCount += 1 + let milliseconds = milliseconds + lock.unlock() + return Date( + timeIntervalSince1970: TimeInterval(milliseconds) / 1_000 + ) + } + + func set(milliseconds: Int64) { + lock.lock() + self.milliseconds = milliseconds + lock.unlock() + } + + func resetReadCount() { + lock.lock() + _readCount = 0 + lock.unlock() + } +} + +private final class TestMonotonicClock: WidgetMonotonicTimeSource, + @unchecked Sendable +{ + private let lock = NSLock() + private var milliseconds: Int64 + private var _readCount = 0 + + init(milliseconds: Int64) { + self.milliseconds = milliseconds + } + + var readCount: Int { + lock.lock() + defer { lock.unlock() } + return _readCount + } + + func elapsedMilliseconds() -> Int64 { + lock.lock() + _readCount += 1 + let milliseconds = milliseconds + lock.unlock() + return milliseconds + } + + func set(milliseconds: Int64) { + lock.lock() + self.milliseconds = milliseconds + lock.unlock() + } + + func resetReadCount() { + lock.lock() + _readCount = 0 + lock.unlock() + } +} + +private actor ScriptedServerTimeSource: WidgetServerTimeSource { + private(set) var callCount = 0 + private var results: [Result] + + init(results: [Result]) { + self.results = results + } + + func serverTimeUnixMilliseconds() async throws -> Int64 { + callCount += 1 + guard !results.isEmpty else { + throw WidgetSNTPError.allHostsFailed + } + return try results.removeFirst().get() + } +} + +private actor GatedServerTimeSource: WidgetServerTimeSource { + private(set) var callCount = 0 + private var continuation: CheckedContinuation? + + func serverTimeUnixMilliseconds() async throws -> Int64 { + callCount += 1 + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + } + } + + func succeed(with value: Int64) { + continuation?.resume(returning: value) + continuation = nil + } +} + +private struct PassthroughTimeoutRunner: + WidgetServerClockTimeoutRunning +{ + func serverTimeUnixMilliseconds( + from source: any WidgetServerTimeSource, + timeout: TimeInterval + ) async throws -> Int64 { + try await source.serverTimeUnixMilliseconds() + } +} + +private actor ScriptedTimeoutRunner: WidgetServerClockTimeoutRunning { + private(set) var timeouts: [TimeInterval] = [] + private var results: [Result] + + init(results: [Result]) { + self.results = results + } + + func serverTimeUnixMilliseconds( + from source: any WidgetServerTimeSource, + timeout: TimeInterval + ) async throws -> Int64 { + timeouts.append(timeout) + guard !results.isEmpty else { + throw WidgetServerClockError.timedOut + } + return try results.removeFirst().get() + } +} From 4978f2e511f13661c82f3ac042a5a5ccbc135eef Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Mon, 21 Sep 2026 02:55:45 +0800 Subject: [PATCH 08/18] chore(weather): add saved widget weather refresh service Platform: ios --- ios/DPIPWidgets/CurrentWeatherClient.swift | 2 +- .../CurrentWeatherWidgetSnapshotWriter.swift | 2 +- ...edCurrentWeatherWidgetRefreshService.swift | 117 +++ ios/Runner.xcodeproj/project.pbxproj | 5 + ...rentWeatherWidgetRefreshServiceTests.swift | 716 ++++++++++++++++++ 5 files changed, 840 insertions(+), 2 deletions(-) create mode 100644 ios/DPIPWidgets/SavedCurrentWeatherWidgetRefreshService.swift create mode 100644 ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift diff --git a/ios/DPIPWidgets/CurrentWeatherClient.swift b/ios/DPIPWidgets/CurrentWeatherClient.swift index b5619caed..21107d93d 100644 --- a/ios/DPIPWidgets/CurrentWeatherClient.swift +++ b/ios/DPIPWidgets/CurrentWeatherClient.swift @@ -8,7 +8,7 @@ enum CurrentWeatherClientError: Error, Equatable { case responseTooLarge } -struct CurrentWeatherClient { +struct CurrentWeatherClient: Sendable { private static let scheme = "https" private static let host = "api.core-tnn1.exptech.dev" private static let realtimePath = "/api/v5/meteor/weather/realtime" diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotWriter.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotWriter.swift index 07f38956a..33778c01e 100644 --- a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotWriter.swift +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotWriter.swift @@ -4,7 +4,7 @@ enum CurrentWeatherWidgetSnapshotWriterError: Error { case invalidSourceIdentifier } -struct CurrentWeatherWidgetSnapshotWriter { +struct CurrentWeatherWidgetSnapshotWriter: Sendable { let containerURL: URL func write( diff --git a/ios/DPIPWidgets/SavedCurrentWeatherWidgetRefreshService.swift b/ios/DPIPWidgets/SavedCurrentWeatherWidgetRefreshService.swift new file mode 100644 index 000000000..d9c42cc5a --- /dev/null +++ b/ios/DPIPWidgets/SavedCurrentWeatherWidgetRefreshService.swift @@ -0,0 +1,117 @@ +enum SavedCurrentWeatherWidgetRefreshResult: Equatable, Sendable { + case refreshed + case noObservation + case unavailable + case failed +} + +struct SavedCurrentWeatherWidgetRefreshService: Sendable { + typealias ResolveLocation = @Sendable ( + WidgetLocationTarget + ) -> WidgetResolvedWeatherLocation? + typealias FetchWeather = @Sendable ( + Double, + Double + ) async throws -> CurrentWeatherRemoteDTO? + typealias SynchronizeClock = @Sendable () async + -> CurrentWeatherSnapshotTime? + typealias WriteSnapshot = @Sendable ( + CurrentWeatherWidgetSnapshot + ) throws -> Void + + private let resolveLocation: ResolveLocation + private let fetchWeather: FetchWeather + private let synchronizeClock: SynchronizeClock + private let writeSnapshot: WriteSnapshot + + init( + resolver: SavedWidgetLocationResolver, + weatherClient: CurrentWeatherClient, + clock: WidgetServerClock, + writer: CurrentWeatherWidgetSnapshotWriter + ) { + self.init( + resolveLocation: { target in + resolver.resolve(target: target) + }, + fetchWeather: { latitude, longitude in + try await weatherClient.fetch( + latitude: latitude, + longitude: longitude + ) + }, + clock: clock, + writeSnapshot: { snapshot in + try writer.write(snapshot) + } + ) + } + + init( + resolveLocation: @escaping ResolveLocation, + fetchWeather: @escaping FetchWeather, + clock: WidgetServerClock, + writeSnapshot: @escaping WriteSnapshot + ) { + self.init( + resolveLocation: resolveLocation, + fetchWeather: fetchWeather, + synchronizeClock: { + _ = await clock.synchronize() + guard await clock.hasSynchronized else { + return nil + } + return await clock.currentWeatherSnapshotTime() + }, + writeSnapshot: writeSnapshot + ) + } + + init( + resolveLocation: @escaping ResolveLocation, + fetchWeather: @escaping FetchWeather, + synchronizeClock: @escaping SynchronizeClock, + writeSnapshot: @escaping WriteSnapshot + ) { + self.resolveLocation = resolveLocation + self.fetchWeather = fetchWeather + self.synchronizeClock = synchronizeClock + self.writeSnapshot = writeSnapshot + } + + func refresh( + target: WidgetLocationTarget + ) async -> SavedCurrentWeatherWidgetRefreshResult { + guard let location = resolveLocation(target) else { + return .unavailable + } + + async let observation = fetchWeather( + location.latitude, + location.longitude + ) + async let snapshotTime = synchronizeClock() + + do { + let (resolvedObservation, resolvedSnapshotTime) = + try await (observation, snapshotTime) + + guard let resolvedObservation else { + return .noObservation + } + guard let resolvedSnapshotTime else { + return .failed + } + + let snapshot = CurrentWeatherWidgetSnapshotFactory.make( + observation: resolvedObservation, + location: location, + time: resolvedSnapshotTime + ) + try writeSnapshot(snapshot) + return .refreshed + } catch { + return .failed + } + } +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 03dbf9084..6f3317aa1 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -51,6 +51,7 @@ D91A00000000000000000013 /* CurrentWeatherWidgetTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = D91A00000000000000000003 /* CurrentWeatherWidgetTimeline.swift */; }; D91A00000000000000000014 /* CurrentWeatherRemoteDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D91A00000000000000000004 /* CurrentWeatherRemoteDTO.swift */; }; E7A100000000000000000001 /* CurrentWeatherWidgetSnapshotWriterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A100000000000000000002 /* CurrentWeatherWidgetSnapshotWriterTests.swift */; }; + E7A200000000000000000001 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A200000000000000000002 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift */; }; DB8E84D957A6C2B12EEB0AB7 /* Sounds/report.aiff in Resources */ = {isa = PBXBuildFile; fileRef = FD769D73A7C4BE3619C1F9FB /* Sounds/report.aiff */; }; DP1PF1REBASE0001PL1ST010 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = DP1PF1REBASE0002PL1ST020 /* GoogleService-Info.plist */; }; E1EBD3F0B2991F7D60DF56C5 /* Sounds/weather.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 762B2690D1D999F97D84162C /* Sounds/weather.aiff */; }; @@ -165,6 +166,7 @@ D91A00000000000000000003 /* CurrentWeatherWidgetTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CurrentWeatherWidgetTimeline.swift; path = ../DPIPWidgets/CurrentWeatherWidgetTimeline.swift; sourceTree = ""; }; D91A00000000000000000004 /* CurrentWeatherRemoteDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CurrentWeatherRemoteDTO.swift; path = ../DPIPWidgets/CurrentWeatherRemoteDTO.swift; sourceTree = ""; }; E7A100000000000000000002 /* CurrentWeatherWidgetSnapshotWriterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentWeatherWidgetSnapshotWriterTests.swift; sourceTree = ""; }; + E7A200000000000000000002 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedCurrentWeatherWidgetRefreshServiceTests.swift; sourceTree = ""; }; DP1PF1REBASE0002PL1ST020 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; F9BCED8E5498E9FD7A454E8F /* Sounds/info.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/info.aiff; sourceTree = ""; }; FD769D73A7C4BE3619C1F9FB /* Sounds/report.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/report.aiff; sourceTree = ""; }; @@ -241,6 +243,7 @@ CurrentWeatherSnapshotTime.swift, CurrentWeatherWidgetSnapshotFactory.swift, CurrentWeatherWidgetSnapshotWriter.swift, + SavedCurrentWeatherWidgetRefreshService.swift, SavedWidgetLocationResolver.swift, WidgetServerClock.swift, WidgetSNTPClient.swift, @@ -326,6 +329,7 @@ 270FCEA4305CD2D6003D1E62 /* CurrentWeatherSnapshotAddressTests.swift */, 270FCEF4305D1EB2003D1E62 /* WidgetSnapshotFileTests.swift */, E7A100000000000000000002 /* CurrentWeatherWidgetSnapshotWriterTests.swift */, + E7A200000000000000000002 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift */, ); path = RunnerTests; sourceTree = ""; @@ -666,6 +670,7 @@ D91A00000000000000000013 /* CurrentWeatherWidgetTimeline.swift in Sources */, D91A00000000000000000014 /* CurrentWeatherRemoteDTO.swift in Sources */, E7A100000000000000000001 /* CurrentWeatherWidgetSnapshotWriterTests.swift in Sources */, + E7A200000000000000000001 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift b/ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift new file mode 100644 index 000000000..ba409939f --- /dev/null +++ b/ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift @@ -0,0 +1,716 @@ +import Foundation +import XCTest + +final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { + private var containerURL: URL! + + override func setUpWithError() throws { + containerURL = FileManager.default.temporaryDirectory + .appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: containerURL) + containerURL = nil + } + + func testSavedTargetWritesCorrectRegionCache() async throws { + let weather = ScriptedRefreshWeather( + result: .success(try makeObservation()) + ) + let writer = CurrentWeatherWidgetSnapshotWriter( + containerURL: containerURL + ) + let service = try makeService( + codes: ["242", "433"], + weather: weather, + writeSnapshot: writer.write + ) + + let result = await service.refresh( + target: .saved(regionCode: "242") + ) + + XCTAssertEqual(result, .refreshed) + let storage = CurrentWeatherSnapshotStorage( + containerURL: containerURL + ) + let savedURL = storage.snapshotURL( + for: .saved(regionCode: "242") + ) + XCTAssertTrue(FileManager.default.fileExists(atPath: savedURL.path)) + XCTAssertFalse( + FileManager.default.fileExists( + atPath: storage.snapshotURL( + for: .saved(regionCode: "433") + ).path + ) + ) + } + + func testCurrentLocationTargetIsUnavailableWithoutStartingWork() async throws { + let weather = ScriptedRefreshWeather( + result: .success(try makeObservation()) + ) + let clock = ScriptedRefreshClock( + sample: makeSnapshotTime() + ) + let writer = RefreshSnapshotWriterSpy() + let service = try makeService( + codes: ["242"], + weather: weather, + synchronizeClock: { + await clock.synchronizeAndSample() + }, + writeSnapshot: writer.write + ) + + let result = await service.refresh(target: .currentLocation) + + XCTAssertEqual(result, .unavailable) + let weatherCallCount = await weather.callCount + let clockCallCount = await clock.callCount + XCTAssertEqual(weatherCallCount, 0) + XCTAssertEqual(clockCallCount, 0) + XCTAssertEqual(writer.writeCount, 0) + } + + func testInvalidTargetIsUnavailableWithoutStartingWork() async throws { + let weather = ScriptedRefreshWeather( + result: .success(try makeObservation()) + ) + let clock = ScriptedRefreshClock( + sample: makeSnapshotTime() + ) + let writer = RefreshSnapshotWriterSpy() + let service = try makeService( + codes: ["242"], + weather: weather, + synchronizeClock: { + await clock.synchronizeAndSample() + }, + writeSnapshot: writer.write + ) + + let result = await service.refresh( + target: .invalid(identifier: "region:242") + ) + + XCTAssertEqual(result, .unavailable) + let weatherCallCount = await weather.callCount + let clockCallCount = await clock.callCount + XCTAssertEqual(weatherCallCount, 0) + XCTAssertEqual(clockCallCount, 0) + XCTAssertEqual(writer.writeCount, 0) + } + + func testMissingSavedRegionIsUnavailableWithoutStartingWork() async throws { + let weather = ScriptedRefreshWeather( + result: .success(try makeObservation()) + ) + let clock = ScriptedRefreshClock( + sample: makeSnapshotTime() + ) + let writer = RefreshSnapshotWriterSpy() + let service = try makeService( + codes: ["242"], + weather: weather, + synchronizeClock: { + await clock.synchronizeAndSample() + }, + writeSnapshot: writer.write + ) + + let result = await service.refresh( + target: .saved(regionCode: "433") + ) + + XCTAssertEqual(result, .unavailable) + let weatherCallCount = await weather.callCount + let clockCallCount = await clock.callCount + XCTAssertEqual(weatherCallCount, 0) + XCTAssertEqual(clockCallCount, 0) + XCTAssertEqual(writer.writeCount, 0) + } + + func testSuccessfulRefreshWritesSchemaFiveResolvedSnapshot() async throws { + let observation = try makeObservation() + let weather = ScriptedRefreshWeather( + result: .success(observation) + ) + let clock = makeClock( + serverResults: [.success(10_000)] + ) + let writer = RefreshSnapshotWriterSpy() + let service = try makeService( + codes: ["242"], + weather: weather, + clock: clock, + writeSnapshot: writer.write + ) + + let result = await service.refresh( + target: .saved(regionCode: "242") + ) + + XCTAssertEqual(result, .refreshed) + let snapshot = try XCTUnwrap(writer.snapshots.first) + XCTAssertEqual(snapshot.schemaVersion, 5) + XCTAssertEqual(snapshot.sourceIdentifier, "region:242") + XCTAssertEqual(snapshot.regionCode, "242") + XCTAssertEqual(snapshot.regionName, "新莊區") + XCTAssertEqual(snapshot.observationTime, observation.time) + XCTAssertEqual(snapshot.stationName, observation.stationName) + XCTAssertEqual(snapshot.weather, observation.weather) + XCTAssertEqual(snapshot.weatherCode, observation.weatherCode) + XCTAssertEqual(snapshot.temperature, observation.temperature) + XCTAssertEqual(snapshot.humidity, observation.humidity) + XCTAssertEqual(snapshot.rain, observation.rain) + XCTAssertEqual(snapshot.calibratedTimeOffsetMilliseconds, 5_000) + let coordinates = await weather.coordinates + XCTAssertEqual(coordinates.count, 1) + XCTAssertEqual(coordinates.first?.latitude, 25.0358303) + XCTAssertEqual(coordinates.first?.longitude, 121.4500307) + } + + func testFirstClockSyncFailureDoesNotWrite() async throws { + let weather = ScriptedRefreshWeather( + result: .success(try makeObservation()) + ) + let writer = RefreshSnapshotWriterSpy() + let clock = makeClock( + serverResults: [.failure(.scripted)] + ) + let service = try makeService( + codes: ["242"], + weather: weather, + clock: clock, + writeSnapshot: writer.write + ) + + let result = await service.refresh( + target: .saved(regionCode: "242") + ) + + XCTAssertEqual(result, .failed) + let hasSynchronized = await clock.hasSynchronized + let weatherCallCount = await weather.callCount + XCTAssertFalse(hasSynchronized) + XCTAssertEqual(weatherCallCount, 1) + XCTAssertEqual(writer.writeCount, 0) + } + + func testLaterClockSyncFailureUsesRetainedAnchor() async throws { + let wallClock = RefreshTestWallClock(milliseconds: 5_000) + let monotonicClock = RefreshTestMonotonicClock(milliseconds: 100) + let clock = makeClock( + wallClock: wallClock, + monotonicClock: monotonicClock, + serverResults: [ + .success(10_000), + .failure(.scripted), + ] + ) + let firstSyncSucceeded = await clock.synchronize() + XCTAssertTrue(firstSyncSucceeded) + monotonicClock.set(milliseconds: 600) + + let weather = ScriptedRefreshWeather( + result: .success(try makeObservation()) + ) + let writer = RefreshSnapshotWriterSpy() + let service = try makeService( + codes: ["242"], + weather: weather, + clock: clock, + writeSnapshot: writer.write + ) + + let result = await service.refresh( + target: .saved(regionCode: "242") + ) + + XCTAssertEqual(result, .refreshed) + let hasSynchronized = await clock.hasSynchronized + XCTAssertTrue(hasSynchronized) + XCTAssertEqual(writer.writeCount, 1) + XCTAssertEqual( + writer.snapshots.first?.calibratedTimeOffsetMilliseconds, + 5_500 + ) + } + + func testWeatherFailureKeepsExistingCache() async throws { + let oldData = try seedExistingSnapshot(regionCode: "242") + let weather = ScriptedRefreshWeather( + result: .failure(.scripted) + ) + let writer = CurrentWeatherWidgetSnapshotWriter( + containerURL: containerURL + ) + let service = try makeService( + codes: ["242"], + weather: weather, + writeSnapshot: writer.write + ) + + let result = await service.refresh( + target: .saved(regionCode: "242") + ) + + XCTAssertEqual(result, .failed) + XCTAssertEqual(try cachedData(regionCode: "242"), oldData) + } + + func testNoObservationKeepsExistingCache() async throws { + let oldData = try seedExistingSnapshot(regionCode: "242") + let weather = ScriptedRefreshWeather(result: .success(nil)) + let writer = CurrentWeatherWidgetSnapshotWriter( + containerURL: containerURL + ) + let service = try makeService( + codes: ["242"], + weather: weather, + writeSnapshot: writer.write + ) + + let result = await service.refresh( + target: .saved(regionCode: "242") + ) + + XCTAssertEqual(result, .noObservation) + XCTAssertEqual(try cachedData(regionCode: "242"), oldData) + } + + func testWriterFailureKeepsExistingCache() async throws { + let oldData = try seedExistingSnapshot(regionCode: "242") + let weather = ScriptedRefreshWeather( + result: .success(try makeObservation()) + ) + let writer = RefreshSnapshotWriterSpy(error: .scripted) + let service = try makeService( + codes: ["242"], + weather: weather, + writeSnapshot: writer.write + ) + + let result = await service.refresh( + target: .saved(regionCode: "242") + ) + + XCTAssertEqual(result, .failed) + XCTAssertEqual(writer.writeCount, 1) + XCTAssertEqual(try cachedData(regionCode: "242"), oldData) + } + + func testRegionARefreshNeverOverwritesRegionBCache() async throws { + let regionBData = try seedExistingSnapshot(regionCode: "433") + let weather = ScriptedRefreshWeather( + result: .success(try makeObservation()) + ) + let writer = CurrentWeatherWidgetSnapshotWriter( + containerURL: containerURL + ) + let service = try makeService( + codes: ["242", "433"], + weather: weather, + writeSnapshot: writer.write + ) + + let result = await service.refresh( + target: .saved(regionCode: "242") + ) + + XCTAssertEqual(result, .refreshed) + XCTAssertEqual(try cachedData(regionCode: "433"), regionBData) + XCTAssertNotNil(try? cachedData(regionCode: "242")) + } + + func testWeatherAndClockStartBeforeEitherCompletes() async throws { + let weatherGate = RefreshOperationGate() + let clockGate = RefreshOperationGate() + let observation = try makeObservation() + let writer = RefreshSnapshotWriterSpy() + let resolver = try makeResolver(codes: ["242"]) + let service = SavedCurrentWeatherWidgetRefreshService( + resolveLocation: resolver.resolve, + fetchWeather: { _, _ in + await weatherGate.wait() + return observation + }, + synchronizeClock: { + await clockGate.wait() + return self.makeSnapshotTime() + }, + writeSnapshot: writer.write + ) + + let refresh = Task { + await service.refresh( + target: .saved(regionCode: "242") + ) + } + for _ in 0..<200 { + if await weatherGate.hasStarted, + await clockGate.hasStarted { + break + } + await Task.yield() + } + + let weatherStarted = await weatherGate.hasStarted + let clockStarted = await clockGate.hasStarted + XCTAssertTrue(weatherStarted) + XCTAssertTrue(clockStarted) + await weatherGate.open() + await clockGate.open() + let result = await refresh.value + XCTAssertEqual(result, .refreshed) + } + + private func makeService( + codes: [String], + weather: ScriptedRefreshWeather, + synchronizeClock: @escaping + SavedCurrentWeatherWidgetRefreshService.SynchronizeClock = { + CurrentWeatherSnapshotTime( + calibratedNowUnixMilliseconds: 1_710_907_200_000, + calibratedTimeOffsetMilliseconds: 0 + ) + }, + writeSnapshot: @escaping + SavedCurrentWeatherWidgetRefreshService.WriteSnapshot + ) throws -> SavedCurrentWeatherWidgetRefreshService { + let resolver = try makeResolver(codes: codes) + return SavedCurrentWeatherWidgetRefreshService( + resolveLocation: resolver.resolve, + fetchWeather: { latitude, longitude in + try await weather.fetch( + latitude: latitude, + longitude: longitude + ) + }, + synchronizeClock: synchronizeClock, + writeSnapshot: writeSnapshot + ) + } + + private func makeService( + codes: [String], + weather: ScriptedRefreshWeather, + clock: WidgetServerClock, + writeSnapshot: @escaping + SavedCurrentWeatherWidgetRefreshService.WriteSnapshot + ) throws -> SavedCurrentWeatherWidgetRefreshService { + let resolver = try makeResolver(codes: codes) + return SavedCurrentWeatherWidgetRefreshService( + resolveLocation: resolver.resolve, + fetchWeather: { latitude, longitude in + try await weather.fetch( + latitude: latitude, + longitude: longitude + ) + }, + clock: clock, + writeSnapshot: writeSnapshot + ) + } + + private func makeResolver( + codes: [String] + ) throws -> SavedWidgetLocationResolver { + let locations = try codes.map { code in + try XCTUnwrap(makeCatalogLocation(regionCode: code)) + } + let catalog = try XCTUnwrap( + WidgetLocationCatalog( + schemaVersion: 1, + locations: locations + ) + ) + return SavedWidgetLocationResolver(catalog: catalog) + } + + private func makeCatalogLocation( + regionCode: String + ) -> WidgetLocationCatalogLocation? { + switch regionCode { + case "242": + return WidgetLocationCatalogLocation( + regionCode: "242", + displayName: "新莊區", + administrativeAreaName: "新北市", + latitude: 25.0358303, + longitude: 121.4500307 + ) + case "433": + return WidgetLocationCatalogLocation( + regionCode: "433", + displayName: "沙鹿區", + administrativeAreaName: "臺中市", + latitude: 24.2338622, + longitude: 120.565703 + ) + default: + return nil + } + } + + private func makeObservation() throws -> CurrentWeatherRemoteDTO { + let data = Data( + """ + { + "station": {"name": "板橋"}, + "time": 1710900000, + "data": { + "weather": "晴", + "weatherCode": 100, + "temperature": 28.5, + "humidity": 70, + "rain": 0 + } + } + """.utf8 + ) + return try JSONDecoder().decode( + CurrentWeatherRemoteDTO.self, + from: data + ) + } + + private func makeSnapshotTime() -> CurrentWeatherSnapshotTime { + CurrentWeatherSnapshotTime( + calibratedNowUnixMilliseconds: 1_710_907_200_000, + calibratedTimeOffsetMilliseconds: 0 + ) + } + + private func makeClock( + wallClock: RefreshTestWallClock = + RefreshTestWallClock(milliseconds: 5_000), + monotonicClock: RefreshTestMonotonicClock = + RefreshTestMonotonicClock(milliseconds: 100), + serverResults: [Result] + ) -> WidgetServerClock { + WidgetServerClock( + deviceClock: wallClock, + monotonicClock: monotonicClock, + serverTimeSource: RefreshScriptedServerTimeSource( + results: serverResults + ), + timeoutRunner: RefreshPassthroughTimeoutRunner() + ) + } + + @discardableResult + private func seedExistingSnapshot( + regionCode: String + ) throws -> Data { + let writer = CurrentWeatherWidgetSnapshotWriter( + containerURL: containerURL + ) + try writer.write( + CurrentWeatherWidgetSnapshot( + schemaVersion: 5, + sourceIdentifier: "region:\(regionCode)", + regionCode: regionCode, + regionName: "舊快取", + observationTime: 1_700_000_000, + stationName: "舊測站", + weather: "陰", + weatherCode: 300, + condition: .overcast, + isNight: false, + nextDayNightTransitionTime: 1_700_010_000, + calibratedTimeOffsetMilliseconds: 123, + temperature: 20, + humidity: 60, + rain: 1 + ) + ) + return try cachedData(regionCode: regionCode) + } + + private func cachedData(regionCode: String) throws -> Data { + let url = CurrentWeatherSnapshotStorage( + containerURL: containerURL + ).snapshotURL(for: .saved(regionCode: regionCode)) + return try Data(contentsOf: url) + } +} + +private enum RefreshTestError: Error, Sendable { + case scripted +} + +private actor ScriptedRefreshWeather { + struct Coordinates: Sendable { + let latitude: Double + let longitude: Double + } + + private(set) var callCount = 0 + private(set) var coordinates: [Coordinates] = [] + private let result: Result + + init(result: Result) { + self.result = result + } + + func fetch( + latitude: Double, + longitude: Double + ) throws -> CurrentWeatherRemoteDTO? { + callCount += 1 + coordinates.append( + Coordinates(latitude: latitude, longitude: longitude) + ) + return try result.get() + } +} + +private actor ScriptedRefreshClock { + private(set) var callCount = 0 + private let sample: CurrentWeatherSnapshotTime? + + init(sample: CurrentWeatherSnapshotTime?) { + self.sample = sample + } + + func synchronizeAndSample() -> CurrentWeatherSnapshotTime? { + callCount += 1 + return sample + } +} + +private final class RefreshSnapshotWriterSpy: @unchecked Sendable { + private let lock = NSLock() + private let error: RefreshTestError? + private var _snapshots: [CurrentWeatherWidgetSnapshot] = [] + private var _writeCount = 0 + + init(error: RefreshTestError? = nil) { + self.error = error + } + + var snapshots: [CurrentWeatherWidgetSnapshot] { + lock.lock() + defer { lock.unlock() } + return _snapshots + } + + var writeCount: Int { + lock.lock() + defer { lock.unlock() } + return _writeCount + } + + func write(_ snapshot: CurrentWeatherWidgetSnapshot) throws { + lock.lock() + _writeCount += 1 + let error = error + if error == nil { + _snapshots.append(snapshot) + } + lock.unlock() + + if let error { + throw error + } + } +} + +private actor RefreshOperationGate { + private(set) var hasStarted = false + private var isOpen = false + private var continuation: CheckedContinuation? + + func wait() async { + hasStarted = true + guard !isOpen else { + return + } + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func open() { + isOpen = true + continuation?.resume() + continuation = nil + } +} + +private final class RefreshTestWallClock: WidgetWallTimeSource, + @unchecked Sendable +{ + private let lock = NSLock() + private var milliseconds: Int64 + + init(milliseconds: Int64) { + self.milliseconds = milliseconds + } + + func now() -> Date { + lock.lock() + let milliseconds = milliseconds + lock.unlock() + return Date( + timeIntervalSince1970: TimeInterval(milliseconds) / 1_000 + ) + } +} + +private final class RefreshTestMonotonicClock: WidgetMonotonicTimeSource, + @unchecked Sendable +{ + private let lock = NSLock() + private var milliseconds: Int64 + + init(milliseconds: Int64) { + self.milliseconds = milliseconds + } + + func elapsedMilliseconds() -> Int64 { + lock.lock() + let milliseconds = milliseconds + lock.unlock() + return milliseconds + } + + func set(milliseconds: Int64) { + lock.lock() + self.milliseconds = milliseconds + lock.unlock() + } +} + +private actor RefreshScriptedServerTimeSource: WidgetServerTimeSource { + private var results: [Result] + + init(results: [Result]) { + self.results = results + } + + func serverTimeUnixMilliseconds() throws -> Int64 { + guard !results.isEmpty else { + throw RefreshTestError.scripted + } + return try results.removeFirst().get() + } +} + +private struct RefreshPassthroughTimeoutRunner: + WidgetServerClockTimeoutRunning +{ + func serverTimeUnixMilliseconds( + from source: any WidgetServerTimeSource, + timeout: TimeInterval + ) async throws -> Int64 { + try await source.serverTimeUnixMilliseconds() + } +} From f7b20a908c35b59f5a3eb19348bde1861940c0a6 Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Mon, 21 Sep 2026 03:11:22 +0800 Subject: [PATCH 09/18] chore(weather): enable saved widget self refresh Platform: ios --- .../DPIPWidgetProviderSupport.swift | 150 +++++++ ios/DPIPWidgets/DPIPWidgets.swift | 44 +- ios/DPIPWidgets/WidgetSnapshotStore.swift | 22 +- ios/Runner.xcodeproj/project.pbxproj | 16 +- ios/RunnerTests/DPIPWidgetProviderTests.swift | 411 ++++++++++++++++++ ios/Shared/WidgetLocationCatalog.swift | 20 +- 6 files changed, 617 insertions(+), 46 deletions(-) create mode 100644 ios/DPIPWidgets/DPIPWidgetProviderSupport.swift create mode 100644 ios/RunnerTests/DPIPWidgetProviderTests.swift diff --git a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift new file mode 100644 index 000000000..dc2a6b9ce --- /dev/null +++ b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift @@ -0,0 +1,150 @@ +import Foundation + +struct DPIPWidgetTimelinePlan: Sendable { + let snapshot: CurrentWeatherWidgetSnapshot? + let states: [CurrentWeatherWidgetTimelineState] + let reloadDate: Date +} + +struct DPIPWidgetTimelinePlanner: Sendable { + typealias LoadSnapshot = @Sendable ( + WidgetLocationTarget + ) -> CurrentWeatherWidgetSnapshot? + typealias RefreshSaved = @Sendable ( + WidgetLocationTarget + ) async -> SavedCurrentWeatherWidgetRefreshResult + typealias Now = @Sendable () -> Date + + private let staleAfter: TimeInterval + private let refreshInterval: TimeInterval + private let loadSnapshot: LoadSnapshot + private let refreshSaved: RefreshSaved + private let now: Now + + init( + staleAfter: TimeInterval, + refreshInterval: TimeInterval, + loadSnapshot: @escaping LoadSnapshot, + refreshSaved: @escaping RefreshSaved, + now: @escaping Now + ) { + self.staleAfter = staleAfter + self.refreshInterval = refreshInterval + self.loadSnapshot = loadSnapshot + self.refreshSaved = refreshSaved + self.now = now + } + + func plan( + for target: WidgetLocationTarget + ) async -> DPIPWidgetTimelinePlan { + if case .saved = target { + _ = await refreshSaved(target) + } + + // Always reload after the refresh attempt. Failed refreshes leave the + // same-location cache untouched, so this also provides SWR behavior. + let snapshot = loadSnapshot(target) + let deviceNow = now() + let states = CurrentWeatherWidgetTimeline.states( + snapshot: snapshot, + deviceNow: deviceNow, + staleAfter: staleAfter + ) + + return DPIPWidgetTimelinePlan( + snapshot: snapshot, + states: states, + reloadDate: deviceNow.addingTimeInterval(refreshInterval) + ) + } +} + +struct SavedCurrentWeatherWidgetRefreshRuntime: Sendable { + typealias RefreshWithClock = @Sendable ( + WidgetServerClock, + WidgetLocationTarget + ) async -> SavedCurrentWeatherWidgetRefreshResult + + private let serverClock: WidgetServerClock + private let refreshWithClock: RefreshWithClock + + init( + serverClock: WidgetServerClock, + refreshWithClock: @escaping RefreshWithClock + ) { + self.serverClock = serverClock + self.refreshWithClock = refreshWithClock + } + + func refresh( + target: WidgetLocationTarget + ) async -> SavedCurrentWeatherWidgetRefreshResult { + await refreshWithClock(serverClock, target) + } +} + +struct DPIPWidgetProviderDependencies: Sendable { + typealias LoadSnapshot = DPIPWidgetTimelinePlanner.LoadSnapshot + + let loadSnapshot: LoadSnapshot + let timelinePlanner: DPIPWidgetTimelinePlanner +} + +enum DPIPWidgetProviderRuntime { + private static let appGroupIdentifier = + "group.com.exptech.dpip.dpip.widgets" + private static let staleAfter: TimeInterval = 30 * 60 + + // This process-scoped dependency graph retains one clock for every + // getTimeline call handled by the current extension process. + static let shared: DPIPWidgetProviderDependencies = { + let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) + let snapshotStore = WidgetSnapshotStore( + containerURL: containerURL + ) + let loadSnapshot: DPIPWidgetTimelinePlanner.LoadSnapshot = { + target in + snapshotStore.loadCurrentWeatherSnapshot(for: target) + } + + let weatherClient = CurrentWeatherClient() + let refreshRuntime = SavedCurrentWeatherWidgetRefreshRuntime( + serverClock: WidgetServerClock(), + refreshWithClock: { clock, target in + guard let containerURL else { + return .unavailable + } + + let catalog = WidgetLocationCatalogStore( + containerURL: containerURL + ).load() + let service = SavedCurrentWeatherWidgetRefreshService( + resolver: SavedWidgetLocationResolver( + catalog: catalog + ), + weatherClient: weatherClient, + clock: clock, + writer: CurrentWeatherWidgetSnapshotWriter( + containerURL: containerURL + ) + ) + + return await service.refresh(target: target) + } + ) + + return DPIPWidgetProviderDependencies( + loadSnapshot: loadSnapshot, + timelinePlanner: DPIPWidgetTimelinePlanner( + staleAfter: staleAfter, + refreshInterval: staleAfter, + loadSnapshot: loadSnapshot, + refreshSaved: refreshRuntime.refresh, + now: { Date.now } + ) + ) + }() +} diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index 51b3bc3ea..c2d1a5148 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -5,7 +5,14 @@ import Intents struct DPIPWidgetProvider: IntentTimelineProvider { typealias Intent = WeatherWidgetConfigurationIntent private let staleAfter: TimeInterval = 30 * 60 - private let snapshotStore = WidgetSnapshotStore() + private let dependencies: DPIPWidgetProviderDependencies + + init( + dependencies: DPIPWidgetProviderDependencies = + DPIPWidgetProviderRuntime.shared + ) { + self.dependencies = dependencies + } private func snapshot( for configuration: WeatherWidgetConfigurationIntent @@ -14,9 +21,7 @@ struct DPIPWidgetProvider: IntentTimelineProvider { identifier: configuration.location?.identifier ) - return snapshotStore.loadCurrentWeatherSnapshot( - for: target - ) + return dependencies.loadSnapshot(target) } func placeholder(in context: Context) -> DPIPWidgetEntry { @@ -57,31 +62,30 @@ struct DPIPWidgetProvider: IntentTimelineProvider { in context: Context, completion: @escaping (Timeline) -> Void ) { - let deviceNow = Date() - let snapshot = snapshot(for: configuration) - - let entries = CurrentWeatherWidgetTimeline.states( - snapshot: snapshot, - deviceNow: deviceNow, - staleAfter: staleAfter + let target = WidgetLocationTarget( + identifier: configuration.location?.identifier ) - .map { state in + + Task { + let plan = await dependencies.timelinePlanner.plan( + for: target + ) + let entries = plan.states.map { state in DPIPWidgetEntry( date: state.date, - snapshot: snapshot, + snapshot: plan.snapshot, isStale: state.isStale, isNight: state.isNight ) } - completion( - Timeline( - entries: entries, - // The app owns refreshes. This timeline projects only the - // stale deadline and one solar transition in the snapshot. - policy: .never + completion( + Timeline( + entries: entries, + policy: .after(plan.reloadDate) + ) ) - ) + } } } diff --git a/ios/DPIPWidgets/WidgetSnapshotStore.swift b/ios/DPIPWidgets/WidgetSnapshotStore.swift index f0ed4e2c3..2542a1609 100644 --- a/ios/DPIPWidgets/WidgetSnapshotStore.swift +++ b/ios/DPIPWidgets/WidgetSnapshotStore.swift @@ -14,16 +14,15 @@ enum WidgetSnapshotKind { } } -struct WidgetSnapshotStore { - private let appGroupIdentifier = - "group.com.exptech.dpip.dpip.widgets" +struct WidgetSnapshotStore: Sendable { + private let appGroupContainerURL: URL? + + init(containerURL: URL?) { + appGroupContainerURL = containerURL + } func snapshotURL(for kind: WidgetSnapshotKind) -> URL? { - guard let appGroupContainerURL = - FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: appGroupIdentifier - ) - else { + guard let appGroupContainerURL else { return nil } @@ -63,12 +62,7 @@ struct WidgetSnapshotStore { return nil } - guard let appGroupContainerURL = - FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: - appGroupIdentifier - ) - else { + guard let appGroupContainerURL else { return nil } diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 6f3317aa1..a176dfb49 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -50,11 +50,12 @@ D91A00000000000000000012 /* CurrentWeatherWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = D91A00000000000000000002 /* CurrentWeatherWidgetSnapshot.swift */; }; D91A00000000000000000013 /* CurrentWeatherWidgetTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = D91A00000000000000000003 /* CurrentWeatherWidgetTimeline.swift */; }; D91A00000000000000000014 /* CurrentWeatherRemoteDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D91A00000000000000000004 /* CurrentWeatherRemoteDTO.swift */; }; - E7A100000000000000000001 /* CurrentWeatherWidgetSnapshotWriterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A100000000000000000002 /* CurrentWeatherWidgetSnapshotWriterTests.swift */; }; - E7A200000000000000000001 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A200000000000000000002 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift */; }; DB8E84D957A6C2B12EEB0AB7 /* Sounds/report.aiff in Resources */ = {isa = PBXBuildFile; fileRef = FD769D73A7C4BE3619C1F9FB /* Sounds/report.aiff */; }; DP1PF1REBASE0001PL1ST010 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = DP1PF1REBASE0002PL1ST020 /* GoogleService-Info.plist */; }; E1EBD3F0B2991F7D60DF56C5 /* Sounds/weather.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 762B2690D1D999F97D84162C /* Sounds/weather.aiff */; }; + E7A100000000000000000001 /* CurrentWeatherWidgetSnapshotWriterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A100000000000000000002 /* CurrentWeatherWidgetSnapshotWriterTests.swift */; }; + E7A200000000000000000001 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A200000000000000000002 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift */; }; + F4E200000000000000000001 /* DPIPWidgetProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E200000000000000000002 /* DPIPWidgetProviderTests.swift */; }; F56D5186166227E46D96919D /* Sounds/info.aiff in Resources */ = {isa = PBXBuildFile; fileRef = F9BCED8E5498E9FD7A454E8F /* Sounds/info.aiff */; }; /* End PBXBuildFile section */ @@ -165,9 +166,10 @@ D91A00000000000000000002 /* CurrentWeatherWidgetSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CurrentWeatherWidgetSnapshot.swift; path = ../DPIPWidgets/CurrentWeatherWidgetSnapshot.swift; sourceTree = ""; }; D91A00000000000000000003 /* CurrentWeatherWidgetTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CurrentWeatherWidgetTimeline.swift; path = ../DPIPWidgets/CurrentWeatherWidgetTimeline.swift; sourceTree = ""; }; D91A00000000000000000004 /* CurrentWeatherRemoteDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CurrentWeatherRemoteDTO.swift; path = ../DPIPWidgets/CurrentWeatherRemoteDTO.swift; sourceTree = ""; }; + DP1PF1REBASE0002PL1ST020 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; E7A100000000000000000002 /* CurrentWeatherWidgetSnapshotWriterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentWeatherWidgetSnapshotWriterTests.swift; sourceTree = ""; }; E7A200000000000000000002 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedCurrentWeatherWidgetRefreshServiceTests.swift; sourceTree = ""; }; - DP1PF1REBASE0002PL1ST020 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; + F4E200000000000000000002 /* DPIPWidgetProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DPIPWidgetProviderTests.swift; sourceTree = ""; }; F9BCED8E5498E9FD7A454E8F /* Sounds/info.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/info.aiff; sourceTree = ""; }; FD769D73A7C4BE3619C1F9FB /* Sounds/report.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/report.aiff; sourceTree = ""; }; /* End PBXFileReference section */ @@ -243,12 +245,14 @@ CurrentWeatherSnapshotTime.swift, CurrentWeatherWidgetSnapshotFactory.swift, CurrentWeatherWidgetSnapshotWriter.swift, + DPIPWidgetProviderSupport.swift, SavedCurrentWeatherWidgetRefreshService.swift, SavedWidgetLocationResolver.swift, - WidgetServerClock.swift, - WidgetSNTPClient.swift, WidgetLocationTarget.swift, WidgetResolvedWeatherLocation.swift, + WidgetServerClock.swift, + WidgetSNTPClient.swift, + WidgetSnapshotStore.swift, WidgetSolarTime.swift, ); target = 331C8080294A63A400263BE5 /* RunnerTests */; @@ -330,6 +334,7 @@ 270FCEF4305D1EB2003D1E62 /* WidgetSnapshotFileTests.swift */, E7A100000000000000000002 /* CurrentWeatherWidgetSnapshotWriterTests.swift */, E7A200000000000000000002 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift */, + F4E200000000000000000002 /* DPIPWidgetProviderTests.swift */, ); path = RunnerTests; sourceTree = ""; @@ -671,6 +676,7 @@ D91A00000000000000000014 /* CurrentWeatherRemoteDTO.swift in Sources */, E7A100000000000000000001 /* CurrentWeatherWidgetSnapshotWriterTests.swift in Sources */, E7A200000000000000000001 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift in Sources */, + F4E200000000000000000001 /* DPIPWidgetProviderTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/RunnerTests/DPIPWidgetProviderTests.swift b/ios/RunnerTests/DPIPWidgetProviderTests.swift new file mode 100644 index 000000000..73306f2de --- /dev/null +++ b/ios/RunnerTests/DPIPWidgetProviderTests.swift @@ -0,0 +1,411 @@ +import Foundation +import XCTest + +final class DPIPWidgetProviderTests: XCTestCase { + private let staleAfter: TimeInterval = 30 * 60 + private let now = Date(timeIntervalSince1970: 10_000) + + func testSavedTargetInvokesRefresh() async { + let target = WidgetLocationTarget.saved(regionCode: "407") + let store = ProviderTimelineTestStore( + refreshResult: .failed, + snapshots: [(target, snapshot(for: target))] + ) + + _ = await planner(store: store).plan(for: target) + + XCTAssertEqual(store.refreshTargets, [target]) + } + + func testSavedTargetReloadsCacheAfterSuccessfulRefresh() async { + let target = WidgetLocationTarget.saved(regionCode: "407") + let oldSnapshot = snapshot( + for: target, + stationName: "old-station" + ) + let refreshedSnapshot = snapshot( + for: target, + stationName: "refreshed-station" + ) + let store = ProviderTimelineTestStore( + refreshResult: .refreshed, + snapshots: [(target, oldSnapshot)], + refreshedSnapshot: refreshedSnapshot + ) + + let plan = await planner(store: store).plan(for: target) + + XCTAssertEqual( + store.events, + [.refresh(target), .load(target)] + ) + XCTAssertEqual(plan.snapshot?.stationName, "refreshed-station") + } + + func testSavedRefreshFailureKeepsCachedSnapshot() async { + let target = WidgetLocationTarget.saved(regionCode: "407") + let store = ProviderTimelineTestStore( + refreshResult: .failed, + snapshots: [ + (target, snapshot( + for: target, + stationName: "cached-station" + )), + ] + ) + + let plan = await planner(store: store).plan(for: target) + + XCTAssertEqual(plan.snapshot?.stationName, "cached-station") + XCTAssertEqual(store.cachedSnapshot(for: target)?.regionCode, "407") + } + + func testSavedNoObservationKeepsCachedSnapshot() async { + let target = WidgetLocationTarget.saved(regionCode: "407") + let store = ProviderTimelineTestStore( + refreshResult: .noObservation, + snapshots: [ + (target, snapshot( + for: target, + stationName: "cached-station" + )), + ] + ) + + let plan = await planner(store: store).plan(for: target) + + XCTAssertEqual(plan.snapshot?.stationName, "cached-station") + XCTAssertEqual(store.cachedSnapshot(for: target)?.regionCode, "407") + } + + func testSavedUnavailableKeepsSameLocationCache() async { + let target = WidgetLocationTarget.saved(regionCode: "407") + let otherTarget = WidgetLocationTarget.saved(regionCode: "242") + let store = ProviderTimelineTestStore( + refreshResult: .unavailable, + snapshots: [ + (target, snapshot( + for: target, + stationName: "region-407" + )), + (otherTarget, snapshot( + for: otherTarget, + stationName: "region-242" + )), + ] + ) + + let plan = await planner(store: store).plan(for: target) + + XCTAssertEqual(plan.snapshot?.stationName, "region-407") + XCTAssertEqual(store.loadTargets, [target]) + } + + func testCurrentLocationDoesNotInvokeSavedRefresh() async { + let target = WidgetLocationTarget.currentLocation + let store = ProviderTimelineTestStore( + refreshResult: .refreshed, + snapshots: [(target, snapshot(for: target))] + ) + + _ = await planner(store: store).plan(for: target) + + XCTAssertTrue(store.refreshTargets.isEmpty) + } + + func testCurrentLocationDisplaysExistingCache() async { + let target = WidgetLocationTarget.currentLocation + let store = ProviderTimelineTestStore( + refreshResult: .failed, + snapshots: [ + (target, snapshot( + for: target, + stationName: "current-location-cache" + )), + ] + ) + + let plan = await planner(store: store).plan(for: target) + + XCTAssertEqual( + plan.snapshot?.stationName, + "current-location-cache" + ) + } + + func testInvalidTargetDoesNotInvokeRefreshOrUseOtherCache() async { + let target = WidgetLocationTarget.invalid( + identifier: "region:invalid" + ) + let savedTarget = WidgetLocationTarget.saved(regionCode: "407") + let store = ProviderTimelineTestStore( + refreshResult: .refreshed, + snapshots: [(savedTarget, snapshot(for: savedTarget))] + ) + + let plan = await planner(store: store).plan(for: target) + + XCTAssertTrue(store.refreshTargets.isEmpty) + XCTAssertNil(plan.snapshot) + XCTAssertEqual(store.loadTargets, [target]) + } + + func testRefreshFailureDoesNotMutateOrDeleteCache() async { + let target = WidgetLocationTarget.saved(regionCode: "407") + let cachedSnapshot = snapshot( + for: target, + stationName: "unchanged-cache" + ) + let store = ProviderTimelineTestStore( + refreshResult: .failed, + snapshots: [(target, cachedSnapshot)] + ) + + _ = await planner(store: store).plan(for: target) + + XCTAssertEqual(store.cacheMutationCount, 0) + XCTAssertEqual( + store.cachedSnapshot(for: target)?.stationName, + "unchanged-cache" + ) + } + + func testTimelineRequestsFutureRefreshEveryThirtyMinutes() async { + let target = WidgetLocationTarget.currentLocation + let store = ProviderTimelineTestStore( + refreshResult: .failed, + snapshots: [] + ) + + let plan = await planner(store: store).plan(for: target) + + XCTAssertGreaterThan(plan.reloadDate, now) + XCTAssertEqual( + plan.reloadDate, + now.addingTimeInterval(staleAfter) + ) + } + + func testTimelineProjectionDelegatesToCurrentWeatherTimeline() async { + let target = WidgetLocationTarget.currentLocation + let cachedSnapshot = snapshot( + for: target, + observationTime: 9_000, + isNight: false, + transitionTime: 10_500 + ) + let store = ProviderTimelineTestStore( + refreshResult: .failed, + snapshots: [(target, cachedSnapshot)] + ) + + let plan = await planner(store: store).plan(for: target) + + XCTAssertEqual( + plan.states, + CurrentWeatherWidgetTimeline.states( + snapshot: cachedSnapshot, + deviceNow: now, + staleAfter: staleAfter + ) + ) + } + + func testRefreshRuntimeReusesOneServerClock() async { + let recorder = ProviderClockIdentityRecorder() + let runtime = SavedCurrentWeatherWidgetRefreshRuntime( + serverClock: WidgetServerClock(), + refreshWithClock: { clock, _ in + recorder.record(clock) + return .failed + } + ) + + _ = await runtime.refresh( + target: .saved(regionCode: "407") + ) + _ = await runtime.refresh( + target: .saved(regionCode: "242") + ) + + XCTAssertEqual(recorder.identities.count, 2) + XCTAssertEqual(Set(recorder.identities).count, 1) + } + + private func planner( + store: ProviderTimelineTestStore + ) -> DPIPWidgetTimelinePlanner { + let fixedNow = now + return DPIPWidgetTimelinePlanner( + staleAfter: staleAfter, + refreshInterval: staleAfter, + loadSnapshot: store.load, + refreshSaved: store.refresh, + now: { fixedNow } + ) + } + + private func snapshot( + for target: WidgetLocationTarget, + stationName: String = "station", + observationTime: Int = 10_000, + isNight: Bool = false, + transitionTime: Int = 0 + ) -> CurrentWeatherWidgetSnapshot { + let regionCode: String + switch target { + case .saved(let savedRegionCode): + regionCode = savedRegionCode + case .currentLocation, .invalid: + regionCode = "407" + } + + return CurrentWeatherWidgetSnapshot( + schemaVersion: 5, + sourceIdentifier: target.sourceIdentifier, + regionCode: regionCode, + regionName: "測試地區", + observationTime: observationTime, + stationName: stationName, + weather: "晴", + weatherCode: 100, + condition: .clear, + isNight: isNight, + nextDayNightTransitionTime: transitionTime, + calibratedTimeOffsetMilliseconds: 0, + temperature: 28, + humidity: 70, + rain: 0 + ) + } +} + +private enum ProviderTimelineEvent: Equatable { + case refresh(WidgetLocationTarget) + case load(WidgetLocationTarget) +} + +private final class ProviderTimelineTestStore: @unchecked Sendable { + private let lock = NSLock() + private let refreshResult: SavedCurrentWeatherWidgetRefreshResult + private let refreshedSnapshot: CurrentWeatherWidgetSnapshot? + + private var storedSnapshots: [String: CurrentWeatherWidgetSnapshot] + private var storedEvents: [ProviderTimelineEvent] = [] + private var storedCacheMutationCount = 0 + + init( + refreshResult: SavedCurrentWeatherWidgetRefreshResult, + snapshots: [( + WidgetLocationTarget, + CurrentWeatherWidgetSnapshot + )], + refreshedSnapshot: CurrentWeatherWidgetSnapshot? = nil + ) { + self.refreshResult = refreshResult + self.refreshedSnapshot = refreshedSnapshot + storedSnapshots = Dictionary( + uniqueKeysWithValues: snapshots.map { + (Self.key(for: $0.0), $0.1) + } + ) + } + + func refresh( + target: WidgetLocationTarget + ) async -> SavedCurrentWeatherWidgetRefreshResult { + recordRefresh(target) + return refreshResult + } + + func load( + target: WidgetLocationTarget + ) -> CurrentWeatherWidgetSnapshot? { + lock.lock() + defer { lock.unlock() } + storedEvents.append(.load(target)) + return storedSnapshots[Self.key(for: target)] + } + + func cachedSnapshot( + for target: WidgetLocationTarget + ) -> CurrentWeatherWidgetSnapshot? { + lock.lock() + defer { lock.unlock() } + return storedSnapshots[Self.key(for: target)] + } + + var events: [ProviderTimelineEvent] { + lock.lock() + defer { lock.unlock() } + return storedEvents + } + + var refreshTargets: [WidgetLocationTarget] { + events.compactMap { event in + guard case let .refresh(target) = event else { + return nil + } + return target + } + } + + var loadTargets: [WidgetLocationTarget] { + events.compactMap { event in + guard case let .load(target) = event else { + return nil + } + return target + } + } + + var cacheMutationCount: Int { + lock.lock() + defer { lock.unlock() } + return storedCacheMutationCount + } + + private func recordRefresh( + _ target: WidgetLocationTarget + ) { + lock.lock() + defer { lock.unlock() } + storedEvents.append(.refresh(target)) + + if refreshResult == .refreshed, + let refreshedSnapshot { + storedSnapshots[Self.key(for: target)] = refreshedSnapshot + storedCacheMutationCount += 1 + } + } + + private static func key( + for target: WidgetLocationTarget + ) -> String { + switch target { + case .currentLocation: + return "current-location" + case .saved(let regionCode): + return "region:\(regionCode)" + case .invalid(let identifier): + return "invalid:\(identifier)" + } + } +} + +private final class ProviderClockIdentityRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storedIdentities: [ObjectIdentifier] = [] + + func record(_ clock: WidgetServerClock) { + lock.lock() + defer { lock.unlock() } + storedIdentities.append(ObjectIdentifier(clock)) + } + + var identities: [ObjectIdentifier] { + lock.lock() + defer { lock.unlock() } + return storedIdentities + } +} diff --git a/ios/Shared/WidgetLocationCatalog.swift b/ios/Shared/WidgetLocationCatalog.swift index 1a2e36ea9..f0924c8ed 100644 --- a/ios/Shared/WidgetLocationCatalog.swift +++ b/ios/Shared/WidgetLocationCatalog.swift @@ -159,15 +159,21 @@ extension WidgetLocationCatalogLocation: Decodable { } struct WidgetLocationCatalogStore: Sendable { - private let appGroupIdentifier = - "group.com.exptech.dpip.dpip.widgets" + private let containerURL: URL? + + init() { + containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: + "group.com.exptech.dpip.dpip.widgets" + ) + } + + init(containerURL: URL) { + self.containerURL = containerURL + } func load() -> WidgetLocationCatalog? { - guard let containerURL = - FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: appGroupIdentifier - ) - else { + guard let containerURL else { return nil } From fdcc4e2628c108599215ad81b43a2ab368da981f Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Mon, 21 Sep 2026 11:00:18 +0800 Subject: [PATCH 10/18] chore(weather): add widget refresh diagnostics Platform: ios --- .../DPIPWidgetProviderSupport.swift | 134 +++++++++++++++++- ios/DPIPWidgets/DPIPWidgets.swift | 20 +++ ...edCurrentWeatherWidgetRefreshService.swift | 124 ++++++++++++++-- ios/RunnerTests/DPIPWidgetProviderTests.swift | 34 ++++- 4 files changed, 291 insertions(+), 21 deletions(-) diff --git a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift index dc2a6b9ce..d4f335325 100644 --- a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift +++ b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift @@ -1,4 +1,41 @@ import Foundation +#if DEBUG +import OSLog + +enum WidgetWeatherRefreshDiagnostics { + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.exptech.dpip", + category: "WidgetWeatherRefresh" + ) + + static func log(_ message: String) { + logger.debug("\(message, privacy: .public)") + } + + static func targetIdentifier(_ target: WidgetLocationTarget) -> String { + switch target { + case .currentLocation: + return "current-location" + case .saved(let regionCode): + return "region:\(regionCode)" + case .invalid: + return "invalid" + } + } + + static func snapshotSummary( + _ snapshot: CurrentWeatherWidgetSnapshot? + ) -> String { + guard let snapshot else { + return "unavailable" + } + + return "sourceIdentifier=\(snapshot.sourceIdentifier ?? "none") " + + "regionCode=\(snapshot.regionCode) " + + "observationTime=\(snapshot.observationTime)" + } +} +#endif struct DPIPWidgetTimelinePlan: Sendable { let snapshot: CurrentWeatherWidgetSnapshot? @@ -38,8 +75,37 @@ struct DPIPWidgetTimelinePlanner: Sendable { func plan( for target: WidgetLocationTarget ) async -> DPIPWidgetTimelinePlan { + #if DEBUG + let snapshotBeforeRefresh: CurrentWeatherWidgetSnapshot? + if case .saved = target { + snapshotBeforeRefresh = loadSnapshot(target) + WidgetWeatherRefreshDiagnostics.log( + "cacheBefore " + + WidgetWeatherRefreshDiagnostics.snapshotSummary( + snapshotBeforeRefresh + ) + ) + } else { + snapshotBeforeRefresh = nil + } + #endif + if case .saved = target { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "refresh began target=" + + WidgetWeatherRefreshDiagnostics.targetIdentifier(target) + ) + #endif + + #if DEBUG + let refreshResult = await refreshSaved(target) + WidgetWeatherRefreshDiagnostics.log( + "refresh result=\(refreshResult.diagnosticName)" + ) + #else _ = await refreshSaved(target) + #endif } // Always reload after the refresh attempt. Failed refreshes leave the @@ -51,11 +117,32 @@ struct DPIPWidgetTimelinePlanner: Sendable { deviceNow: deviceNow, staleAfter: staleAfter ) + let reloadDate = deviceNow.addingTimeInterval(refreshInterval) + + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "cacheAfter " + + WidgetWeatherRefreshDiagnostics.snapshotSummary(snapshot) + ) + if case .saved = target { + let cacheChanged = snapshotBeforeRefresh?.observationTime + != snapshot?.observationTime + WidgetWeatherRefreshDiagnostics.log( + "cacheChanged=\(cacheChanged)" + ) + } + let stale = states.first?.isStale ?? false + WidgetWeatherRefreshDiagnostics.log( + "timeline entries=\(states.count) stale=\(stale) " + + "reloadIntervalSeconds=\(Int(refreshInterval)) " + + "reloadDateUnix=\(Int(reloadDate.timeIntervalSince1970))" + ) + #endif return DPIPWidgetTimelinePlan( snapshot: snapshot, states: states, - reloadDate: deviceNow.addingTimeInterval(refreshInterval) + reloadDate: reloadDate ) } } @@ -96,6 +183,12 @@ enum DPIPWidgetProviderRuntime { "group.com.exptech.dpip.dpip.widgets" private static let staleAfter: TimeInterval = 30 * 60 + #if DEBUG + static let refreshInterval: TimeInterval = 60 + #else + static let refreshInterval: TimeInterval = 30 * 60 + #endif + // This process-scoped dependency graph retains one clock for every // getTimeline call handled by the current extension process. static let shared: DPIPWidgetProviderDependencies = { @@ -115,12 +208,32 @@ enum DPIPWidgetProviderRuntime { serverClock: WidgetServerClock(), refreshWithClock: { clock, target in guard let containerURL else { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "catalog unavailable appGroupContainer=false" + ) + #endif return .unavailable } + #if DEBUG + WidgetWeatherRefreshDiagnostics.log("catalog load started") + #endif let catalog = WidgetLocationCatalogStore( containerURL: containerURL ).load() + #if DEBUG + if let catalog { + WidgetWeatherRefreshDiagnostics.log( + "catalog load succeeded locations=" + + "\(catalog.locations.count)" + ) + } else { + WidgetWeatherRefreshDiagnostics.log( + "catalog load failed" + ) + } + #endif let service = SavedCurrentWeatherWidgetRefreshService( resolver: SavedWidgetLocationResolver( catalog: catalog @@ -140,7 +253,7 @@ enum DPIPWidgetProviderRuntime { loadSnapshot: loadSnapshot, timelinePlanner: DPIPWidgetTimelinePlanner( staleAfter: staleAfter, - refreshInterval: staleAfter, + refreshInterval: refreshInterval, loadSnapshot: loadSnapshot, refreshSaved: refreshRuntime.refresh, now: { Date.now } @@ -148,3 +261,20 @@ enum DPIPWidgetProviderRuntime { ) }() } + +#if DEBUG +private extension SavedCurrentWeatherWidgetRefreshResult { + var diagnosticName: String { + switch self { + case .refreshed: + return "refreshed" + case .noObservation: + return "noObservation" + case .unavailable: + return "unavailable" + case .failed: + return "failed" + } + } +} +#endif diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index c2d1a5148..7abb24f1d 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -66,6 +66,26 @@ struct DPIPWidgetProvider: IntentTimelineProvider { identifier: configuration.location?.identifier ) + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "getTimeline target=" + + WidgetWeatherRefreshDiagnostics.targetIdentifier(target) + ) + switch target { + case .saved(let regionCode): + WidgetWeatherRefreshDiagnostics.log( + "target=saved region=\(regionCode)" + ) + case .currentLocation: + WidgetWeatherRefreshDiagnostics.log( + "target=current-location nativeRefresh=not-implemented " + + "cacheLoad=enabled" + ) + case .invalid: + WidgetWeatherRefreshDiagnostics.log("target=invalid") + } + #endif + Task { let plan = await dependencies.timelinePlanner.plan( for: target diff --git a/ios/DPIPWidgets/SavedCurrentWeatherWidgetRefreshService.swift b/ios/DPIPWidgets/SavedCurrentWeatherWidgetRefreshService.swift index d9c42cc5a..f6f4b5b5f 100644 --- a/ios/DPIPWidgets/SavedCurrentWeatherWidgetRefreshService.swift +++ b/ios/DPIPWidgets/SavedCurrentWeatherWidgetRefreshService.swift @@ -57,8 +57,33 @@ struct SavedCurrentWeatherWidgetRefreshService: Sendable { resolveLocation: resolveLocation, fetchWeather: fetchWeather, synchronizeClock: { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "clock synchronization started" + ) + #endif + #if DEBUG + let synchronized = await clock.synchronize() + #else _ = await clock.synchronize() - guard await clock.hasSynchronized else { + #endif + let hasSynchronized = await clock.hasSynchronized + #if DEBUG + if synchronized { + WidgetWeatherRefreshDiagnostics.log( + "clock synchronized" + ) + } else if hasSynchronized { + WidgetWeatherRefreshDiagnostics.log( + "clock failed retainedPreviousAnchor=true" + ) + } else { + WidgetWeatherRefreshDiagnostics.log( + "clock failed retainedPreviousAnchor=false" + ) + } + #endif + guard hasSynchronized else { return nil } return await clock.currentWeatherSnapshotTime() @@ -83,35 +108,106 @@ struct SavedCurrentWeatherWidgetRefreshService: Sendable { target: WidgetLocationTarget ) async -> SavedCurrentWeatherWidgetRefreshResult { guard let location = resolveLocation(target) else { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "location resolution unavailable target=" + + WidgetWeatherRefreshDiagnostics.targetIdentifier(target) + ) + #endif return .unavailable } + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "location resolved sourceIdentifier=" + + location.address.sourceIdentifier + + " regionCode=\(location.regionCode)" + ) + #endif + + #if DEBUG + async let observation = fetchWeatherWithDiagnostics(location) + #else async let observation = fetchWeather( location.latitude, location.longitude ) + #endif async let snapshotTime = synchronizeClock() + let resolvedObservation: CurrentWeatherRemoteDTO? + let resolvedSnapshotTime: CurrentWeatherSnapshotTime? do { - let (resolvedObservation, resolvedSnapshotTime) = - try await (observation, snapshotTime) + (resolvedObservation, resolvedSnapshotTime) = try await ( + observation, + snapshotTime + ) + } catch { + return .failed + } - guard let resolvedObservation else { - return .noObservation - } - guard let resolvedSnapshotTime else { - return .failed - } + guard let resolvedObservation else { + return .noObservation + } + guard let resolvedSnapshotTime else { + return .failed + } - let snapshot = CurrentWeatherWidgetSnapshotFactory.make( - observation: resolvedObservation, - location: location, - time: resolvedSnapshotTime - ) + let snapshot = CurrentWeatherWidgetSnapshotFactory.make( + observation: resolvedObservation, + location: location, + time: resolvedSnapshotTime + ) + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "snapshot write attempted sourceIdentifier=" + + (snapshot.sourceIdentifier ?? "none") + + " regionCode=\(snapshot.regionCode) " + + "observationTime=\(snapshot.observationTime)" + ) + #endif + do { try writeSnapshot(snapshot) + #if DEBUG + WidgetWeatherRefreshDiagnostics.log("snapshot write succeeded") + #endif return .refreshed } catch { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log("snapshot write failed") + #endif return .failed } } + + #if DEBUG + private func fetchWeatherWithDiagnostics( + _ location: WidgetResolvedWeatherLocation + ) async throws -> CurrentWeatherRemoteDTO? { + WidgetWeatherRefreshDiagnostics.log( + "weather started sourceIdentifier=" + + location.address.sourceIdentifier + ) + do { + let observation = try await fetchWeather( + location.latitude, + location.longitude + ) + if let observation { + WidgetWeatherRefreshDiagnostics.log( + "weather observation received observationTime=" + + "\(observation.time)" + ) + } else { + WidgetWeatherRefreshDiagnostics.log( + "weather no observation" + ) + } + return observation + } catch { + WidgetWeatherRefreshDiagnostics.log("weather failed") + throw error + } + } + #endif } diff --git a/ios/RunnerTests/DPIPWidgetProviderTests.swift b/ios/RunnerTests/DPIPWidgetProviderTests.swift index 73306f2de..6d14b9535 100644 --- a/ios/RunnerTests/DPIPWidgetProviderTests.swift +++ b/ios/RunnerTests/DPIPWidgetProviderTests.swift @@ -37,7 +37,7 @@ final class DPIPWidgetProviderTests: XCTestCase { XCTAssertEqual( store.events, - [.refresh(target), .load(target)] + [.load(target), .refresh(target), .load(target)] ) XCTAssertEqual(plan.snapshot?.stationName, "refreshed-station") } @@ -98,7 +98,7 @@ final class DPIPWidgetProviderTests: XCTestCase { let plan = await planner(store: store).plan(for: target) XCTAssertEqual(plan.snapshot?.stationName, "region-407") - XCTAssertEqual(store.loadTargets, [target]) + XCTAssertEqual(store.loadTargets, [target, target]) } func testCurrentLocationDoesNotInvokeSavedRefresh() async { @@ -170,7 +170,25 @@ final class DPIPWidgetProviderTests: XCTestCase { ) } - func testTimelineRequestsFutureRefreshEveryThirtyMinutes() async { + #if DEBUG + func testDebugTimelineRequestsRefreshAfterSixtySeconds() async { + let target = WidgetLocationTarget.currentLocation + let store = ProviderTimelineTestStore( + refreshResult: .failed, + snapshots: [] + ) + + let plan = await planner(store: store).plan(for: target) + + XCTAssertEqual(DPIPWidgetProviderRuntime.refreshInterval, 60) + XCTAssertGreaterThan(plan.reloadDate, now) + XCTAssertEqual( + plan.reloadDate, + now.addingTimeInterval(60) + ) + } + #else + func testReleaseTimelineRequestsRefreshAfterThirtyMinutes() async { let target = WidgetLocationTarget.currentLocation let store = ProviderTimelineTestStore( refreshResult: .failed, @@ -179,12 +197,18 @@ final class DPIPWidgetProviderTests: XCTestCase { let plan = await planner(store: store).plan(for: target) + XCTAssertEqual(DPIPWidgetProviderRuntime.refreshInterval, 30 * 60) XCTAssertGreaterThan(plan.reloadDate, now) XCTAssertEqual( plan.reloadDate, - now.addingTimeInterval(staleAfter) + now.addingTimeInterval(30 * 60) ) } + #endif + + func testRuntimeKeepsThirtyMinuteStaleInterval() { + XCTAssertEqual(staleAfter, 30 * 60) + } func testTimelineProjectionDelegatesToCurrentWeatherTimeline() async { let target = WidgetLocationTarget.currentLocation @@ -238,7 +262,7 @@ final class DPIPWidgetProviderTests: XCTestCase { let fixedNow = now return DPIPWidgetTimelinePlanner( staleAfter: staleAfter, - refreshInterval: staleAfter, + refreshInterval: DPIPWidgetProviderRuntime.refreshInterval, loadSnapshot: store.load, refreshSaved: store.refresh, now: { fixedNow } From 45a086060fb3fb6013a99b8e7da7be64f16fdaf4 Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Mon, 21 Sep 2026 11:46:31 +0800 Subject: [PATCH 11/18] chore(weather): tune widget refresh interval Platform: ios --- ios/DPIPWidgets/DPIPWidgetProviderSupport.swift | 2 +- ios/RunnerTests/DPIPWidgetProviderTests.swift | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift index d4f335325..c727168c0 100644 --- a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift +++ b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift @@ -186,7 +186,7 @@ enum DPIPWidgetProviderRuntime { #if DEBUG static let refreshInterval: TimeInterval = 60 #else - static let refreshInterval: TimeInterval = 30 * 60 + static let refreshInterval: TimeInterval = 20 * 60 #endif // This process-scoped dependency graph retains one clock for every diff --git a/ios/RunnerTests/DPIPWidgetProviderTests.swift b/ios/RunnerTests/DPIPWidgetProviderTests.swift index 6d14b9535..115aca24f 100644 --- a/ios/RunnerTests/DPIPWidgetProviderTests.swift +++ b/ios/RunnerTests/DPIPWidgetProviderTests.swift @@ -188,7 +188,7 @@ final class DPIPWidgetProviderTests: XCTestCase { ) } #else - func testReleaseTimelineRequestsRefreshAfterThirtyMinutes() async { + func testReleaseTimelineRequestsRefreshAfterTwentyMinutes() async { let target = WidgetLocationTarget.currentLocation let store = ProviderTimelineTestStore( refreshResult: .failed, @@ -197,11 +197,11 @@ final class DPIPWidgetProviderTests: XCTestCase { let plan = await planner(store: store).plan(for: target) - XCTAssertEqual(DPIPWidgetProviderRuntime.refreshInterval, 30 * 60) + XCTAssertEqual(DPIPWidgetProviderRuntime.refreshInterval, 20 * 60) XCTAssertGreaterThan(plan.reloadDate, now) XCTAssertEqual( plan.reloadDate, - now.addingTimeInterval(30 * 60) + now.addingTimeInterval(20 * 60) ) } #endif From 9e537b985d9eef7db5d496be748c506bbd81547b Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Mon, 21 Sep 2026 13:40:57 +0800 Subject: [PATCH 12/18] chore(weather): add widget current location client Platform: ios --- ios/DPIPWidgets/Info.plist | 2 + .../WidgetCurrentLocationClient.swift | 432 ++++++++++++++++++ ios/Runner.xcodeproj/project.pbxproj | 5 + .../WidgetCurrentLocationClientTests.swift | 403 ++++++++++++++++ 4 files changed, 842 insertions(+) create mode 100644 ios/DPIPWidgets/WidgetCurrentLocationClient.swift create mode 100644 ios/RunnerTests/WidgetCurrentLocationClientTests.swift diff --git a/ios/DPIPWidgets/Info.plist b/ios/DPIPWidgets/Info.plist index 0f118fb75..d9f22de03 100644 --- a/ios/DPIPWidgets/Info.plist +++ b/ios/DPIPWidgets/Info.plist @@ -2,6 +2,8 @@ + NSWidgetWantsLocation + NSExtension NSExtensionPointIdentifier diff --git a/ios/DPIPWidgets/WidgetCurrentLocationClient.swift b/ios/DPIPWidgets/WidgetCurrentLocationClient.swift new file mode 100644 index 000000000..397b529af --- /dev/null +++ b/ios/DPIPWidgets/WidgetCurrentLocationClient.swift @@ -0,0 +1,432 @@ +import CoreLocation +import Foundation + +struct WidgetCurrentLocation: Equatable, Sendable { + let latitude: Double + let longitude: Double + + init?(latitude: Double, longitude: Double) { + guard latitude.isFinite, + longitude.isFinite, + (-90...90).contains(latitude), + (-180...180).contains(longitude) + else { + return nil + } + + self.latitude = latitude + self.longitude = longitude + } +} + +enum WidgetCurrentLocationResult: Equatable, Sendable { + case acquired(WidgetCurrentLocation) + case unavailable + case timedOut + case failed +} + +enum WidgetLocationAuthorization: Equatable, Sendable { + case notDetermined + case restricted + case denied + case authorizedAlways + case authorizedWhenInUse + case unknown + + init(_ status: CLAuthorizationStatus) { + switch status { + case .notDetermined: + self = .notDetermined + case .restricted: + self = .restricted + case .denied: + self = .denied + case .authorizedAlways: + self = .authorizedAlways + case .authorizedWhenInUse: + self = .authorizedWhenInUse + @unknown default: + self = .unknown + } + } + + var permitsLocationRequest: Bool { + self == .authorizedAlways || self == .authorizedWhenInUse + } + + #if DEBUG + var diagnosticName: String { + switch self { + case .notDetermined: + return "not-determined" + case .restricted: + return "restricted" + case .denied: + return "denied" + case .authorizedAlways: + return "authorized-always" + case .authorizedWhenInUse: + return "authorized-when-in-use" + case .unknown: + return "unknown" + } + } + #endif +} + +struct WidgetLocationSample: Equatable, Sendable { + let latitude: Double + let longitude: Double + let timestamp: Date +} + +@MainActor +protocol WidgetLocationManagerDelegate: AnyObject { + func widgetLocationManager( + _ manager: any WidgetLocationManaging, + didUpdate samples: [WidgetLocationSample] + ) + + func widgetLocationManagerDidFail( + _ manager: any WidgetLocationManaging + ) +} + +@MainActor +protocol WidgetLocationManaging: AnyObject { + var delegate: (any WidgetLocationManagerDelegate)? { get set } + var authorization: WidgetLocationAuthorization { get } + var isAuthorizedForWidgetUpdates: Bool { get } + var desiredAccuracy: Double { get set } + + func requestLocation() + func stopUpdatingLocation() +} + +@MainActor +protocol WidgetLocationTimeoutCancellable: AnyObject { + func cancel() +} + +@MainActor +protocol WidgetLocationTimeoutScheduling: AnyObject { + func schedule( + after interval: TimeInterval, + action: @escaping @MainActor () -> Void + ) -> any WidgetLocationTimeoutCancellable +} + +@MainActor +final class WidgetCurrentLocationClient { + typealias ServicesEnabled = @MainActor () -> Bool + typealias MakeManager = @MainActor () -> any WidgetLocationManaging + typealias Now = @MainActor () -> Date + + nonisolated static let defaultTimeout: TimeInterval = 10 + nonisolated static let defaultMaximumAge: TimeInterval = 10 * 60 + nonisolated static let defaultDesiredAccuracy: Double = + kCLLocationAccuracyKilometer + + private let servicesEnabled: ServicesEnabled + private let makeManager: MakeManager + private let timeoutScheduler: any WidgetLocationTimeoutScheduling + private let timeout: TimeInterval + private let maximumAge: TimeInterval + private let desiredAccuracy: Double + private let now: Now + private var activeRequests: [UUID: WidgetCurrentLocationRequest] = [:] + + init( + servicesEnabled: @escaping ServicesEnabled = { + CLLocationManager.locationServicesEnabled() + }, + makeManager: @escaping MakeManager = { + CoreLocationWidgetLocationManager() + }, + timeoutScheduler: (any WidgetLocationTimeoutScheduling)? = nil, + timeout: TimeInterval = defaultTimeout, + maximumAge: TimeInterval = defaultMaximumAge, + desiredAccuracy: Double = defaultDesiredAccuracy, + now: @escaping Now = Date.init + ) { + self.servicesEnabled = servicesEnabled + self.makeManager = makeManager + self.timeoutScheduler = timeoutScheduler + ?? WidgetLocationDispatchTimeoutScheduler() + self.timeout = timeout + self.maximumAge = maximumAge + self.desiredAccuracy = desiredAccuracy + self.now = now + } + + func acquireLocation() async -> WidgetCurrentLocationResult { + guard servicesEnabled() else { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "current-location authorizationState=services-disabled " + + "widgetUpdatesAuthorized=false" + ) + WidgetWeatherRefreshDiagnostics.log("current-location unavailable") + #endif + return .unavailable + } + + let manager = makeManager() + let authorization = manager.authorization + let widgetUpdatesAuthorized = + manager.isAuthorizedForWidgetUpdates + + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "current-location authorizationState=" + + authorization.diagnosticName + + " widgetUpdatesAuthorized=\(widgetUpdatesAuthorized)" + ) + #endif + + guard authorization.permitsLocationRequest, + widgetUpdatesAuthorized + else { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log("current-location unavailable") + #endif + return .unavailable + } + + return await withCheckedContinuation { continuation in + let requestID = UUID() + let request = WidgetCurrentLocationRequest( + manager: manager, + timeoutScheduler: timeoutScheduler, + timeout: timeout, + maximumAge: maximumAge, + desiredAccuracy: desiredAccuracy, + now: now + ) { [weak self] result in + self?.activeRequests[requestID] = nil + continuation.resume(returning: result) + } + activeRequests[requestID] = request + request.start() + } + } +} + +@MainActor +private final class WidgetCurrentLocationRequest: + WidgetLocationManagerDelegate +{ + typealias Completion = @MainActor (WidgetCurrentLocationResult) -> Void + + private let manager: any WidgetLocationManaging + private let timeoutScheduler: any WidgetLocationTimeoutScheduling + private let timeout: TimeInterval + private let maximumAge: TimeInterval + private let desiredAccuracy: Double + private let now: WidgetCurrentLocationClient.Now + private var completion: Completion? + private var timeoutCancellation: ( + any WidgetLocationTimeoutCancellable + )? + private var hasFinished = false + + init( + manager: any WidgetLocationManaging, + timeoutScheduler: any WidgetLocationTimeoutScheduling, + timeout: TimeInterval, + maximumAge: TimeInterval, + desiredAccuracy: Double, + now: @escaping WidgetCurrentLocationClient.Now, + completion: @escaping Completion + ) { + self.manager = manager + self.timeoutScheduler = timeoutScheduler + self.timeout = timeout + self.maximumAge = maximumAge + self.desiredAccuracy = desiredAccuracy + self.now = now + self.completion = completion + } + + func start() { + manager.delegate = self + manager.desiredAccuracy = desiredAccuracy + timeoutCancellation = timeoutScheduler.schedule( + after: timeout + ) { [weak self] in + self?.finish(.timedOut) + } + + #if DEBUG + WidgetWeatherRefreshDiagnostics.log("current-location request started") + #endif + manager.requestLocation() + } + + func widgetLocationManager( + _ manager: any WidgetLocationManaging, + didUpdate samples: [WidgetLocationSample] + ) { + guard !hasFinished else { + return + } + + let requestNow = now() + let location: WidgetCurrentLocation? = samples.reversed().compactMap { + sample -> WidgetCurrentLocation? in + let age = requestNow.timeIntervalSince(sample.timestamp) + guard age >= 0, age <= maximumAge else { + return nil + } + return WidgetCurrentLocation( + latitude: sample.latitude, + longitude: sample.longitude + ) + }.first + + guard let location else { + finish(.failed) + return + } + finish(.acquired(location)) + } + + func widgetLocationManagerDidFail( + _ manager: any WidgetLocationManaging + ) { + finish(.failed) + } + + private func finish(_ result: WidgetCurrentLocationResult) { + guard !hasFinished else { + return + } + hasFinished = true + + timeoutCancellation?.cancel() + timeoutCancellation = nil + manager.stopUpdatingLocation() + manager.delegate = nil + + #if DEBUG + switch result { + case .acquired: + WidgetWeatherRefreshDiagnostics.log("current-location acquired") + case .unavailable: + WidgetWeatherRefreshDiagnostics.log("current-location unavailable") + case .timedOut: + WidgetWeatherRefreshDiagnostics.log("current-location timeout") + case .failed: + WidgetWeatherRefreshDiagnostics.log("current-location failed") + } + #endif + + let completion = completion + self.completion = nil + completion?(result) + } +} + +@MainActor +private final class CoreLocationWidgetLocationManager: NSObject, + WidgetLocationManaging, + CLLocationManagerDelegate +{ + weak var delegate: (any WidgetLocationManagerDelegate)? + + var authorization: WidgetLocationAuthorization { + WidgetLocationAuthorization(manager.authorizationStatus) + } + + var isAuthorizedForWidgetUpdates: Bool { + manager.isAuthorizedForWidgetUpdates + } + + var desiredAccuracy: Double { + get { manager.desiredAccuracy } + set { manager.desiredAccuracy = newValue } + } + + private let manager: CLLocationManager + + override init() { + manager = CLLocationManager() + super.init() + manager.delegate = self + } + + func requestLocation() { + manager.requestLocation() + } + + func stopUpdatingLocation() { + manager.stopUpdatingLocation() + } + + nonisolated func locationManager( + _ manager: CLLocationManager, + didUpdateLocations locations: [CLLocation] + ) { + let samples = locations.map { location in + WidgetLocationSample( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude, + timestamp: location.timestamp + ) + } + Task { @MainActor [weak self] in + guard let self else { + return + } + delegate?.widgetLocationManager(self, didUpdate: samples) + } + } + + nonisolated func locationManager( + _ manager: CLLocationManager, + didFailWithError error: any Error + ) { + Task { @MainActor [weak self] in + guard let self else { + return + } + delegate?.widgetLocationManagerDidFail(self) + } + } +} + +@MainActor +private final class WidgetLocationDispatchTimeoutScheduler: + WidgetLocationTimeoutScheduling +{ + func schedule( + after interval: TimeInterval, + action: @escaping @MainActor () -> Void + ) -> any WidgetLocationTimeoutCancellable { + let workItem = DispatchWorkItem { + action() + } + DispatchQueue.main.asyncAfter( + deadline: .now() + interval, + execute: workItem + ) + return WidgetLocationDispatchTimeoutCancellation(workItem: workItem) + } +} + +@MainActor +private final class WidgetLocationDispatchTimeoutCancellation: + WidgetLocationTimeoutCancellable +{ + private var workItem: DispatchWorkItem? + + init(workItem: DispatchWorkItem) { + self.workItem = workItem + } + + func cancel() { + workItem?.cancel() + workItem = nil + } +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index a176dfb49..36973c341 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 27CACC80305C67530046F79C /* WidgetLocationCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27CACC7F305C67530046F79C /* WidgetLocationCatalogTests.swift */; }; 28A000000000000000000003 /* WidgetSNTPClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28A000000000000000000001 /* WidgetSNTPClientTests.swift */; }; 28A000000000000000000004 /* WidgetServerClockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28A000000000000000000002 /* WidgetServerClockTests.swift */; }; + 4F1000000000000000000002 /* WidgetCurrentLocationClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F1000000000000000000001 /* WidgetCurrentLocationClientTests.swift */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 522508B9301F863A006148C2 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 522508B7301F863A006148C2 /* InfoPlist.strings */; }; @@ -123,6 +124,7 @@ 27CACC7F305C67530046F79C /* WidgetLocationCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetLocationCatalogTests.swift; sourceTree = ""; }; 28A000000000000000000001 /* WidgetSNTPClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSNTPClientTests.swift; sourceTree = ""; }; 28A000000000000000000002 /* WidgetServerClockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetServerClockTests.swift; sourceTree = ""; }; + 4F1000000000000000000001 /* WidgetCurrentLocationClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCurrentLocationClientTests.swift; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3AE87ED82FDB896B2B5C5F1B /* Sounds/warn.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/warn.aiff; sourceTree = ""; }; @@ -248,6 +250,7 @@ DPIPWidgetProviderSupport.swift, SavedCurrentWeatherWidgetRefreshService.swift, SavedWidgetLocationResolver.swift, + WidgetCurrentLocationClient.swift, WidgetLocationTarget.swift, WidgetResolvedWeatherLocation.swift, WidgetServerClock.swift, @@ -324,6 +327,7 @@ 331C807B294A618700263BE5 /* RunnerTests.swift */, 28A000000000000000000001 /* WidgetSNTPClientTests.swift */, 28A000000000000000000002 /* WidgetServerClockTests.swift */, + 4F1000000000000000000001 /* WidgetCurrentLocationClientTests.swift */, D91A00000000000000000001 /* CurrentWeatherWidgetTests.swift */, D91A00000000000000000002 /* CurrentWeatherWidgetSnapshot.swift */, D91A00000000000000000003 /* CurrentWeatherWidgetTimeline.swift */, @@ -666,6 +670,7 @@ 270FCEA5305CD2D6003D1E62 /* CurrentWeatherSnapshotAddressTests.swift in Sources */, 28A000000000000000000003 /* WidgetSNTPClientTests.swift in Sources */, 28A000000000000000000004 /* WidgetServerClockTests.swift in Sources */, + 4F1000000000000000000002 /* WidgetCurrentLocationClientTests.swift in Sources */, 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, D91A00000000000000000011 /* CurrentWeatherWidgetTests.swift in Sources */, D91A00000000000000000012 /* CurrentWeatherWidgetSnapshot.swift in Sources */, diff --git a/ios/RunnerTests/WidgetCurrentLocationClientTests.swift b/ios/RunnerTests/WidgetCurrentLocationClientTests.swift new file mode 100644 index 000000000..770d9c893 --- /dev/null +++ b/ios/RunnerTests/WidgetCurrentLocationClientTests.swift @@ -0,0 +1,403 @@ +import Foundation +import XCTest + +@MainActor +final class WidgetCurrentLocationClientTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_000_000) + + func testServicesDisabledReturnsUnavailable() async { + let manager = FakeWidgetLocationManager() + var managerWasCreated = false + let client = makeClient( + servicesEnabled: false, + manager: manager, + onMakeManager: { managerWasCreated = true } + ) + + let result = await client.acquireLocation() + + XCTAssertEqual(result, .unavailable) + XCTAssertFalse(managerWasCreated) + XCTAssertEqual(manager.requestCount, 0) + } + + func testNotDeterminedReturnsUnavailable() async { + let manager = FakeWidgetLocationManager( + authorization: .notDetermined, + widgetUpdatesAuthorized: true + ) + + let result = await makeClient(manager: manager).acquireLocation() + + XCTAssertEqual(result, .unavailable) + XCTAssertEqual(manager.requestCount, 0) + } + + func testDeniedReturnsUnavailable() async { + let manager = FakeWidgetLocationManager( + authorization: .denied, + widgetUpdatesAuthorized: true + ) + + let result = await makeClient(manager: manager).acquireLocation() + + XCTAssertEqual(result, .unavailable) + XCTAssertEqual(manager.requestCount, 0) + } + + func testRestrictedReturnsUnavailable() async { + let manager = FakeWidgetLocationManager( + authorization: .restricted, + widgetUpdatesAuthorized: true + ) + + let result = await makeClient(manager: manager).acquireLocation() + + XCTAssertEqual(result, .unavailable) + XCTAssertEqual(manager.requestCount, 0) + } + + func testWidgetAuthorizationFalseReturnsUnavailable() async { + let manager = FakeWidgetLocationManager( + authorization: .authorizedWhenInUse, + widgetUpdatesAuthorized: false + ) + + let result = await makeClient(manager: manager).acquireLocation() + + XCTAssertEqual(result, .unavailable) + XCTAssertEqual(manager.requestCount, 0) + } + + func testAuthorizedWhenInUseMayAcquireLocation() async { + let expected = WidgetCurrentLocation( + latitude: 25.033, + longitude: 121.5654 + )! + let manager = successfulManager( + authorization: .authorizedWhenInUse, + location: expected + ) + + let result = await makeClient(manager: manager).acquireLocation() + + XCTAssertEqual(result, .acquired(expected)) + XCTAssertEqual(manager.requestCount, 1) + } + + func testAuthorizedAlwaysMayAcquireLocation() async { + let expected = WidgetCurrentLocation( + latitude: 22.6273, + longitude: 120.3014 + )! + let manager = successfulManager( + authorization: .authorizedAlways, + location: expected + ) + + let result = await makeClient(manager: manager).acquireLocation() + + XCTAssertEqual(result, .acquired(expected)) + XCTAssertEqual(manager.requestCount, 1) + } + + func testValidFreshLocationIsReturned() async { + let manager = FakeWidgetLocationManager( + event: .samples([ + WidgetLocationSample( + latitude: 24.1477, + longitude: 120.6736, + timestamp: now.addingTimeInterval(-599) + ), + ]) + ) + + let result = await makeClient(manager: manager).acquireLocation() + + XCTAssertEqual( + result, + .acquired( + WidgetCurrentLocation( + latitude: 24.1477, + longitude: 120.6736 + )! + ) + ) + } + + func testStaleLocationIsRejected() async { + let manager = FakeWidgetLocationManager( + event: .samples([ + WidgetLocationSample( + latitude: 25.033, + longitude: 121.5654, + timestamp: now.addingTimeInterval(-601) + ), + ]) + ) + + let result = await makeClient(manager: manager).acquireLocation() + + XCTAssertEqual(result, .failed) + } + + func testInvalidAndNonFiniteCoordinatesAreRejected() async { + let manager = FakeWidgetLocationManager( + event: .samples([ + WidgetLocationSample( + latitude: 91, + longitude: 121.5654, + timestamp: now + ), + WidgetLocationSample( + latitude: .nan, + longitude: 121.5654, + timestamp: now + ), + ]) + ) + + let result = await makeClient(manager: manager).acquireLocation() + + XCTAssertEqual(result, .failed) + } + + func testCoreLocationFailureReturnsFailure() async { + let manager = FakeWidgetLocationManager(event: .failure) + + let result = await makeClient(manager: manager).acquireLocation() + + XCTAssertEqual(result, .failed) + } + + func testTimeoutIgnoresLateLocationAndCompletesOnlyOnce() async { + let manager = FakeWidgetLocationManager() + let scheduler = FakeWidgetLocationTimeoutScheduler() + let client = makeClient(manager: manager, scheduler: scheduler) + let task = Task { @MainActor in + await client.acquireLocation() + } + await waitForRequest(on: manager) + + scheduler.fireLast() + let result = await task.value + manager.send( + samples: [ + WidgetLocationSample( + latitude: 25.033, + longitude: 121.5654, + timestamp: now + ), + ] + ) + + XCTAssertEqual(result, .timedOut) + XCTAssertEqual(manager.stopCount, 1) + XCTAssertNil(manager.delegate) + XCTAssertEqual(scheduler.lastCancellation?.cancelCount, 1) + } + + func testSuccessCancelsTimeoutAndCompletesOnlyOnce() async { + let expected = WidgetCurrentLocation( + latitude: 25.033, + longitude: 121.5654 + )! + let manager = successfulManager( + authorization: .authorizedWhenInUse, + location: expected + ) + let scheduler = FakeWidgetLocationTimeoutScheduler() + let client = makeClient(manager: manager, scheduler: scheduler) + + let result = await client.acquireLocation() + scheduler.fireLast() + + XCTAssertEqual(result, .acquired(expected)) + XCTAssertEqual(manager.stopCount, 1) + XCTAssertNil(manager.delegate) + XCTAssertEqual(scheduler.lastCancellation?.cancelCount, 1) + } + + func testUnavailableDoesNotRequestFallbackLocation() async { + let manager = FakeWidgetLocationManager( + authorization: .authorizedWhenInUse, + widgetUpdatesAuthorized: false, + event: .samples([ + WidgetLocationSample( + latitude: 25.033, + longitude: 121.5654, + timestamp: now + ), + ]) + ) + + let result = await makeClient(manager: manager).acquireLocation() + + XCTAssertEqual(result, .unavailable) + XCTAssertEqual(manager.requestCount, 0) + XCTAssertEqual(manager.stopCount, 0) + } + + func testRequestUsesTownshipAppropriateAccuracyAndTenSecondTimeout() async { + let expected = WidgetCurrentLocation( + latitude: 25.033, + longitude: 121.5654 + )! + let manager = successfulManager( + authorization: .authorizedWhenInUse, + location: expected + ) + let scheduler = FakeWidgetLocationTimeoutScheduler() + + _ = await makeClient( + manager: manager, + scheduler: scheduler + ).acquireLocation() + + XCTAssertEqual( + manager.desiredAccuracy, + WidgetCurrentLocationClient.defaultDesiredAccuracy + ) + XCTAssertEqual( + scheduler.scheduledIntervals, + [WidgetCurrentLocationClient.defaultTimeout] + ) + } + + private func makeClient( + servicesEnabled: Bool = true, + manager: FakeWidgetLocationManager, + scheduler: FakeWidgetLocationTimeoutScheduler? = nil, + onMakeManager: @escaping @MainActor () -> Void = {} + ) -> WidgetCurrentLocationClient { + WidgetCurrentLocationClient( + servicesEnabled: { servicesEnabled }, + makeManager: { + onMakeManager() + return manager + }, + timeoutScheduler: scheduler + ?? FakeWidgetLocationTimeoutScheduler(), + now: { self.now } + ) + } + + private func successfulManager( + authorization: WidgetLocationAuthorization, + location: WidgetCurrentLocation + ) -> FakeWidgetLocationManager { + FakeWidgetLocationManager( + authorization: authorization, + event: .samples([ + WidgetLocationSample( + latitude: location.latitude, + longitude: location.longitude, + timestamp: now + ), + ]) + ) + } + + private func waitForRequest( + on manager: FakeWidgetLocationManager + ) async { + for _ in 0..<100 where manager.requestCount == 0 { + await Task.yield() + } + XCTAssertEqual(manager.requestCount, 1) + } +} + +@MainActor +private final class FakeWidgetLocationManager: WidgetLocationManaging { + enum Event { + case none + case samples([WidgetLocationSample]) + case failure + } + + weak var delegate: (any WidgetLocationManagerDelegate)? + let authorization: WidgetLocationAuthorization + let isAuthorizedForWidgetUpdates: Bool + var desiredAccuracy: Double = 0 + private(set) var requestCount = 0 + private(set) var stopCount = 0 + private let event: Event + + init( + authorization: WidgetLocationAuthorization = .authorizedWhenInUse, + widgetUpdatesAuthorized: Bool = true, + event: Event = .none + ) { + self.authorization = authorization + self.isAuthorizedForWidgetUpdates = widgetUpdatesAuthorized + self.event = event + } + + func requestLocation() { + requestCount += 1 + switch event { + case .none: + break + case .samples(let samples): + send(samples: samples) + case .failure: + delegate?.widgetLocationManagerDidFail(self) + } + } + + func stopUpdatingLocation() { + stopCount += 1 + } + + func send(samples: [WidgetLocationSample]) { + delegate?.widgetLocationManager(self, didUpdate: samples) + } +} + +@MainActor +private final class FakeWidgetLocationTimeoutScheduler: + WidgetLocationTimeoutScheduling +{ + private(set) var scheduledIntervals: [TimeInterval] = [] + private(set) var cancellations: [FakeWidgetLocationTimeoutCancellation] = [] + + var lastCancellation: FakeWidgetLocationTimeoutCancellation? { + cancellations.last + } + + func schedule( + after interval: TimeInterval, + action: @escaping @MainActor () -> Void + ) -> any WidgetLocationTimeoutCancellable { + scheduledIntervals.append(interval) + let cancellation = FakeWidgetLocationTimeoutCancellation(action: action) + cancellations.append(cancellation) + return cancellation + } + + func fireLast() { + lastCancellation?.fire() + } +} + +@MainActor +private final class FakeWidgetLocationTimeoutCancellation: + WidgetLocationTimeoutCancellable +{ + private var action: (@MainActor () -> Void)? + private(set) var cancelCount = 0 + + init(action: @escaping @MainActor () -> Void) { + self.action = action + } + + func cancel() { + cancelCount += 1 + action = nil + } + + func fire() { + action?() + } +} From 4392d85d509b9afc9e26df1d9c004840ae25a7b0 Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Mon, 21 Sep 2026 13:57:29 +0800 Subject: [PATCH 13/18] chore(weather): add widget township resolver Platform: ios --- .github/workflows/ci.yml | 3 + ios/DPIPWidgets/WidgetTownshipBoundaries.bin | Bin 0 -> 709387 bytes ios/DPIPWidgets/WidgetTownshipDirectory.json | 1 + ios/DPIPWidgets/WidgetTownshipResolver.swift | 661 ++++++++++++++++++ ios/Runner.xcodeproj/project.pbxproj | 7 + .../WidgetTownshipResolverTests.swift | 406 +++++++++++ tool/check.sh | 3 +- tool/check/widget_township_resources.sh | 6 + tool/gen/widget_township_resources.py | 106 +++ 9 files changed, 1192 insertions(+), 1 deletion(-) create mode 100644 ios/DPIPWidgets/WidgetTownshipBoundaries.bin create mode 100644 ios/DPIPWidgets/WidgetTownshipDirectory.json create mode 100644 ios/DPIPWidgets/WidgetTownshipResolver.swift create mode 100644 ios/RunnerTests/WidgetTownshipResolverTests.swift create mode 100755 tool/check/widget_township_resources.sh create mode 100644 tool/gen/widget_township_resources.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 221af5dcc..e65c9aacb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,6 +161,9 @@ jobs: - name: Build info run: bash tool/check/build_info.sh + - name: Widget township resources + run: bash tool/check/widget_township_resources.sh + - name: Codegen is up to date run: | bash tool/dev/codegen.sh diff --git a/ios/DPIPWidgets/WidgetTownshipBoundaries.bin b/ios/DPIPWidgets/WidgetTownshipBoundaries.bin new file mode 100644 index 0000000000000000000000000000000000000000..e32490c7868300fc05095f02753f9e61ef3c6aff GIT binary patch literal 709387 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
PW=jJO!6wHn11G*3fVM4q@f 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�fpuG% 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 Self { + let raw: RawDirectory + do { + raw = try JSONDecoder().decode(RawDirectory.self, from: data) + } catch { + throw WidgetTownshipResourceError.invalidDirectory + } + + guard raw.schemaVersion == supportedSchemaVersion else { + throw WidgetTownshipResourceError.unsupportedDirectorySchema( + raw.schemaVersion + ) + } + + let townships = try raw.townships.map { rawTownship in + guard let township = WidgetTownship( + regionCode: rawTownship.regionCode, + displayName: rawTownship.displayName, + administrativeAreaName: rawTownship.administrativeAreaName, + latitude: rawTownship.latitude, + longitude: rawTownship.longitude + ) else { + throw WidgetTownshipResourceError.invalidDirectory + } + return township + } + return try Self(townships: townships) + } + + var regionCodes: Set { + Set(byCode.keys) + } + + func township(regionCode: String) -> WidgetTownship? { + byCode[regionCode] + } + + /// Matches Dart TownDirectory.nearest: one cosine for the query latitude, + /// source-directory order for ties, and strict `<` replacement. + func nearest(latitude: Double, longitude: Double) -> WidgetTownship? { + let cosine = cos(latitude * .pi / 180) + var best: WidgetTownship? + var bestSquaredDistance = Double.infinity + + for township in townships { + let latitudeDelta = latitude - township.latitude + let longitudeDelta = (longitude - township.longitude) * cosine + let squaredDistance = latitudeDelta * latitudeDelta + + longitudeDelta * longitudeDelta + if squaredDistance < bestSquaredDistance { + bestSquaredDistance = squaredDistance + best = township + } + } + return best + } +} + +private struct RawDirectory: Decodable { + let schemaVersion: Int + let townships: [RawTownship] +} + +private struct RawTownship: Decodable { + let regionCode: String + let displayName: String + let administrativeAreaName: String + let latitude: Double + let longitude: Double +} + +struct WidgetTownshipCoordinate: Equatable, Sendable { + let longitude: Double + let latitude: Double +} + +struct WidgetTownshipPolygon: Sendable { + /// First ring is the outer ring; subsequent rings are holes. + let rings: [[WidgetTownshipCoordinate]] + + init(rings: [[WidgetTownshipCoordinate]]) throws { + guard !rings.isEmpty, rings.allSatisfy({ $0.count >= 3 }) else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + self.rings = rings + } +} + +struct WidgetTownshipShape: Sendable { + let regionCode: String + let polygons: [WidgetTownshipPolygon] + let minLongitude: Double + let minLatitude: Double + let maxLongitude: Double + let maxLatitude: Double + + init( + regionCode: String, + polygons: [WidgetTownshipPolygon] + ) throws { + guard + WidgetResolvedWeatherLocationValidation + .isValidRegionCode(regionCode), + !polygons.isEmpty + else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + + var minLongitude = Double.infinity + var minLatitude = Double.infinity + var maxLongitude = -Double.infinity + var maxLatitude = -Double.infinity + for polygon in polygons { + for ring in polygon.rings { + for point in ring { + guard + point.longitude.isFinite, + point.latitude.isFinite, + (-180 ... 180).contains(point.longitude), + (-90 ... 90).contains(point.latitude) + else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + minLongitude = min(minLongitude, point.longitude) + minLatitude = min(minLatitude, point.latitude) + maxLongitude = max(maxLongitude, point.longitude) + maxLatitude = max(maxLatitude, point.latitude) + } + } + } + + self.regionCode = regionCode + self.polygons = polygons + self.minLongitude = minLongitude + self.minLatitude = minLatitude + self.maxLongitude = maxLongitude + self.maxLatitude = maxLatitude + } + + func contains(latitude: Double, longitude: Double) -> Bool { + guard + longitude >= minLongitude, + longitude <= maxLongitude, + latitude >= minLatitude, + latitude <= maxLatitude + else { + return false + } + + for polygon in polygons { + guard Self.isInside( + ring: polygon.rings[0], + latitude: latitude, + longitude: longitude + ) else { + continue + } + + let isInsideHole = polygon.rings.dropFirst().contains { ring in + Self.isInside( + ring: ring, + latitude: latitude, + longitude: longitude + ) + } + if !isInsideHole { + return true + } + } + return false + } + + /// Ray casting with the same comparisons and axis order as Dart. + private static func isInside( + ring: [WidgetTownshipCoordinate], + latitude: Double, + longitude: Double + ) -> Bool { + var inside = false + var previous = ring[ring.count - 1] + + for current in ring { + if (current.latitude > latitude) + != (previous.latitude > latitude), + longitude + < (previous.longitude - current.longitude) + * (latitude - current.latitude) + / (previous.latitude - current.latitude) + + current.longitude + { + inside.toggle() + } + previous = current + } + return inside + } +} + +struct WidgetTownshipBoundaryTable: Sendable { + private let shapesByCode: [String: WidgetTownshipShape] + private let grid: WidgetTownshipGrid + + init(shapes: [WidgetTownshipShape]) throws { + guard !shapes.isEmpty else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + + let byCode = Dictionary( + shapes.map { ($0.regionCode, $0) }, + uniquingKeysWith: { first, _ in first } + ) + guard byCode.count == shapes.count else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + + shapesByCode = byCode + grid = WidgetTownshipGrid(shapes: shapes) + } + + static func decode(_ data: Data) throws -> Self { + do { + var reader = WidgetTownshipBinaryReader(data: data) + let townCount = try reader.readCount(maximum: 10_000) + guard townCount > 0 else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + + var shapes: [WidgetTownshipShape] = [] + shapes.reserveCapacity(townCount) + for _ in 0 ..< townCount { + let codeLength = try reader.readCount(maximum: 16) + let codeBytes = try reader.readBytes(count: codeLength) + guard let regionCode = String( + bytes: codeBytes, + encoding: .ascii + ) else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + + let polygonCount = try reader.readCount(maximum: 100_000) + guard polygonCount > 0 else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + var polygons: [WidgetTownshipPolygon] = [] + polygons.reserveCapacity(polygonCount) + + for _ in 0 ..< polygonCount { + let ringCount = try reader.readCount(maximum: 100_000) + guard ringCount > 0 else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + var rings: [[WidgetTownshipCoordinate]] = [] + rings.reserveCapacity(ringCount) + + for _ in 0 ..< ringCount { + let pointCount = try reader.readCount( + maximum: 2_000_000 + ) + guard pointCount >= 3 else { + throw WidgetTownshipResourceError + .invalidBoundaryData + } + + var points: [WidgetTownshipCoordinate] = [] + points.reserveCapacity(pointCount) + var longitudeInteger = 0 + var latitudeInteger = 0 + + for _ in 0 ..< pointCount { + longitudeInteger = try Self.add( + Self.unzigzag(try reader.readVarint()), + to: longitudeInteger + ) + latitudeInteger = try Self.add( + Self.unzigzag(try reader.readVarint()), + to: latitudeInteger + ) + points.append( + WidgetTownshipCoordinate( + longitude: + Double(longitudeInteger) / 10_000, + latitude: + Double(latitudeInteger) / 10_000 + ) + ) + } + rings.append(points) + } + polygons.append( + try WidgetTownshipPolygon(rings: rings) + ) + } + shapes.append( + try WidgetTownshipShape( + regionCode: regionCode, + polygons: polygons + ) + ) + } + + guard reader.isAtEnd else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + return try Self(shapes: shapes) + } catch let error as WidgetTownshipResourceError { + throw error + } catch { + throw WidgetTownshipResourceError.invalidBoundaryData + } + } + + var regionCodes: Set { + Set(shapesByCode.keys) + } + + func codeAt(latitude: Double, longitude: Double) -> String? { + for regionCode in grid.candidates( + latitude: latitude, + longitude: longitude + ) { + if shapesByCode[regionCode]?.contains( + latitude: latitude, + longitude: longitude + ) == true { + return regionCode + } + } + return nil + } + + private static func unzigzag(_ value: UInt64) throws -> Int { + guard value >> 1 <= UInt64(Int.max) else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + let magnitude = Int(value >> 1) + return value & 1 == 0 ? magnitude : -magnitude - 1 + } + + private static func add(_ delta: Int, to value: Int) throws -> Int { + let (sum, overflow) = value.addingReportingOverflow(delta) + guard !overflow else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + return sum + } +} + +private struct WidgetTownshipGrid: Sendable { + private static let cellSize = 0.05 + + private let minLongitude: Double + private let minLatitude: Double + private let columnCount: Int + private let rowCount: Int + private let cells: [[String]?] + + init(shapes: [WidgetTownshipShape]) { + let minLongitude = shapes.map(\.minLongitude).min()! + let minLatitude = shapes.map(\.minLatitude).min()! + let maxLongitude = shapes.map(\.maxLongitude).max()! + let maxLatitude = shapes.map(\.maxLatitude).max()! + let columnCount = Int( + ceil((maxLongitude - minLongitude) / Self.cellSize) + ) + 1 + let rowCount = Int( + ceil((maxLatitude - minLatitude) / Self.cellSize) + ) + 1 + var cells = Array<[String]?>( + repeating: nil, + count: columnCount * rowCount + ) + + for shape in shapes { + let firstColumn = Int(floor( + (shape.minLongitude - minLongitude) / Self.cellSize + )) + let lastColumn = Int(floor( + (shape.maxLongitude - minLongitude) / Self.cellSize + )) + let firstRow = Int(floor( + (shape.minLatitude - minLatitude) / Self.cellSize + )) + let lastRow = Int(floor( + (shape.maxLatitude - minLatitude) / Self.cellSize + )) + for row in firstRow ... lastRow { + for column in firstColumn ... lastColumn { + let index = row * columnCount + column + if cells[index] == nil { + cells[index] = [] + } + cells[index]?.append(shape.regionCode) + } + } + } + + self.minLongitude = minLongitude + self.minLatitude = minLatitude + self.columnCount = columnCount + self.rowCount = rowCount + self.cells = cells + } + + func candidates(latitude: Double, longitude: Double) -> [String] { + let column = Int(floor( + (longitude - minLongitude) / Self.cellSize + )) + let row = Int(floor((latitude - minLatitude) / Self.cellSize)) + guard + column >= 0, + column < columnCount, + row >= 0, + row < rowCount + else { + return [] + } + return cells[row * columnCount + column] ?? [] + } +} + +private struct WidgetTownshipBinaryReader { + private let bytes: [UInt8] + private var position = 0 + + init(data: Data) { + bytes = Array(data) + } + + var isAtEnd: Bool { + position == bytes.count + } + + mutating func readBytes(count: Int) throws -> ArraySlice { + guard + count >= 0, + position <= bytes.count, + count <= bytes.count - position + else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + defer { position += count } + return bytes[position ..< position + count] + } + + mutating func readCount(maximum: Int) throws -> Int { + let value = try readVarint() + guard value <= UInt64(maximum) else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + return Int(value) + } + + mutating func readVarint() throws -> UInt64 { + var value: UInt64 = 0 + var shift: UInt64 = 0 + + for _ in 0 ..< 10 { + guard position < bytes.count else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + let byte = bytes[position] + position += 1 + let payload = UInt64(byte & 0x7F) + guard shift < 64, payload <= UInt64.max >> shift else { + throw WidgetTownshipResourceError.invalidBoundaryData + } + value |= payload << shift + if byte & 0x80 == 0 { + return value + } + shift += 7 + } + throw WidgetTownshipResourceError.invalidBoundaryData + } +} + +struct WidgetTownshipResolver: Sendable { + let boundaries: WidgetTownshipBoundaryTable + let directory: WidgetTownshipDirectory + + init( + boundaries: WidgetTownshipBoundaryTable, + directory: WidgetTownshipDirectory + ) throws { + guard boundaries.regionCodes == directory.regionCodes else { + throw WidgetTownshipResourceError.incompatibleCodeSets + } + self.boundaries = boundaries + self.directory = directory + } + + /// Exact polygon first; nearest authoritative centroid only when no polygon + /// contains the coordinate. Resource failures happen before this value can + /// be constructed, so corrupt boundaries never degrade to nearest-only. + func resolve( + _ currentLocation: WidgetCurrentLocation + ) -> WidgetResolvedWeatherLocation? { + let exactCode = boundaries.codeAt( + latitude: currentLocation.latitude, + longitude: currentLocation.longitude + ) + let township: WidgetTownship? + if let exactCode { + township = directory.township(regionCode: exactCode) + } else { + township = directory.nearest( + latitude: currentLocation.latitude, + longitude: currentLocation.longitude + ) + } + + guard let township else { + return nil + } + return WidgetResolvedWeatherLocation( + address: .currentLocation, + regionCode: township.regionCode, + regionName: township.displayName, + latitude: township.latitude, + longitude: township.longitude + ) + } +} + +struct WidgetTownshipResourceLoader: Sendable { + private let directoryName: String + private let boundaryName: String + + init( + directoryName: String = "WidgetTownshipDirectory", + boundaryName: String = "WidgetTownshipBoundaries" + ) { + self.directoryName = directoryName + self.boundaryName = boundaryName + } + + func load(bundle: Bundle) throws -> WidgetTownshipResolver { + guard let directoryURL = bundle.url( + forResource: directoryName, + withExtension: "json" + ) else { + throw WidgetTownshipResourceError.missingResource( + "\(directoryName).json" + ) + } + guard let boundaryURL = bundle.url( + forResource: boundaryName, + withExtension: "bin" + ) else { + throw WidgetTownshipResourceError.missingResource( + "\(boundaryName).bin" + ) + } + + let directoryData: Data + let boundaryData: Data + do { + directoryData = try Data(contentsOf: directoryURL) + boundaryData = try Data(contentsOf: boundaryURL) + } catch { + throw WidgetTownshipResourceError.missingResource( + "Widget township resource" + ) + } + return try load( + directoryData: directoryData, + boundaryData: boundaryData + ) + } + + func load( + directoryData: Data, + boundaryData: Data + ) throws -> WidgetTownshipResolver { + let directory = try WidgetTownshipDirectory.decode(directoryData) + let boundaries = try WidgetTownshipBoundaryTable.decode(boundaryData) + return try WidgetTownshipResolver( + boundaries: boundaries, + directory: directory + ) + } +} + +/// Swift static initialization is lazy and once-only. The immutable resolver +/// is therefore decoded at most once per Widget Extension process. +enum WidgetTownshipResolverRuntime { + static let shared: WidgetTownshipResolver? = try? + WidgetTownshipResourceLoader().load(bundle: .main) +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 36973c341..65cee9be4 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -57,6 +57,7 @@ E7A100000000000000000001 /* CurrentWeatherWidgetSnapshotWriterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A100000000000000000002 /* CurrentWeatherWidgetSnapshotWriterTests.swift */; }; E7A200000000000000000001 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A200000000000000000002 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift */; }; F4E200000000000000000001 /* DPIPWidgetProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E200000000000000000002 /* DPIPWidgetProviderTests.swift */; }; + F4F200000000000000000001 /* WidgetTownshipResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F200000000000000000002 /* WidgetTownshipResolverTests.swift */; }; F56D5186166227E46D96919D /* Sounds/info.aiff in Resources */ = {isa = PBXBuildFile; fileRef = F9BCED8E5498E9FD7A454E8F /* Sounds/info.aiff */; }; /* End PBXBuildFile section */ @@ -172,6 +173,7 @@ E7A100000000000000000002 /* CurrentWeatherWidgetSnapshotWriterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentWeatherWidgetSnapshotWriterTests.swift; sourceTree = ""; }; E7A200000000000000000002 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedCurrentWeatherWidgetRefreshServiceTests.swift; sourceTree = ""; }; F4E200000000000000000002 /* DPIPWidgetProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DPIPWidgetProviderTests.swift; sourceTree = ""; }; + F4F200000000000000000002 /* WidgetTownshipResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetTownshipResolverTests.swift; sourceTree = ""; }; F9BCED8E5498E9FD7A454E8F /* Sounds/info.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/info.aiff; sourceTree = ""; }; FD769D73A7C4BE3619C1F9FB /* Sounds/report.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/report.aiff; sourceTree = ""; }; /* End PBXFileReference section */ @@ -257,6 +259,9 @@ WidgetSNTPClient.swift, WidgetSnapshotStore.swift, WidgetSolarTime.swift, + WidgetTownshipBoundaries.bin, + WidgetTownshipDirectory.json, + WidgetTownshipResolver.swift, ); target = 331C8080294A63A400263BE5 /* RunnerTests */; }; @@ -339,6 +344,7 @@ E7A100000000000000000002 /* CurrentWeatherWidgetSnapshotWriterTests.swift */, E7A200000000000000000002 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift */, F4E200000000000000000002 /* DPIPWidgetProviderTests.swift */, + F4F200000000000000000002 /* WidgetTownshipResolverTests.swift */, ); path = RunnerTests; sourceTree = ""; @@ -682,6 +688,7 @@ E7A100000000000000000001 /* CurrentWeatherWidgetSnapshotWriterTests.swift in Sources */, E7A200000000000000000001 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift in Sources */, F4E200000000000000000001 /* DPIPWidgetProviderTests.swift in Sources */, + F4F200000000000000000001 /* WidgetTownshipResolverTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/RunnerTests/WidgetTownshipResolverTests.swift b/ios/RunnerTests/WidgetTownshipResolverTests.swift new file mode 100644 index 000000000..00c9e90c6 --- /dev/null +++ b/ios/RunnerTests/WidgetTownshipResolverTests.swift @@ -0,0 +1,406 @@ +import Foundation +import XCTest + +final class WidgetTownshipResolverTests: XCTestCase { + func testAdjacentTownshipsAndAsymmetricCoordinatesResolveExactly() throws { + let boundaries = try makeSyntheticBoundaries() + + XCTAssertEqual( + boundaries.codeAt(latitude: 24.05, longitude: 120.05), + "100" + ) + XCTAssertEqual( + boundaries.codeAt(latitude: 24.05, longitude: 120.15), + "101" + ) + } + + func testPolygonHoleDoesNotResolveAsContained() throws { + let boundaries = try makeSyntheticBoundaries() + + XCTAssertEqual( + boundaries.codeAt(latitude: 24.21, longitude: 120.01), + "102" + ) + XCTAssertNil( + boundaries.codeAt(latitude: 24.25, longitude: 120.05) + ) + } + + func testMultipartTownshipMatchesEitherPart() throws { + let boundaries = try makeSyntheticBoundaries() + + XCTAssertEqual( + boundaries.codeAt(latitude: 24.21, longitude: 120.21), + "103" + ) + XCTAssertEqual( + boundaries.codeAt(latitude: 24.21, longitude: 120.26), + "103" + ) + } + + func testPointOutsideEveryTownshipReturnsNil() throws { + let boundaries = try makeSyntheticBoundaries() + + XCTAssertNil( + boundaries.codeAt(latitude: 24.5, longitude: 120.5) + ) + XCTAssertNil( + boundaries.codeAt(latitude: 24.15, longitude: 120.05) + ) + } + + func testResolverFallsBackToNearestCentroid() throws { + let resolver = try WidgetTownshipResolver( + boundaries: makeSyntheticBoundaries(), + directory: makeSyntheticDirectory() + ) + let preciseLocation = try XCTUnwrap( + WidgetCurrentLocation(latitude: 24.18, longitude: 120.05) + ) + + let resolved = try XCTUnwrap(resolver.resolve(preciseLocation)) + + XCTAssertEqual(resolved.address, .currentLocation) + XCTAssertEqual(resolved.regionCode, "102") + XCTAssertEqual(resolved.regionName, "Hole區") + XCTAssertEqual(resolved.latitude, 24.25) + XCTAssertEqual(resolved.longitude, 120.05) + XCTAssertNotEqual(resolved.latitude, preciseLocation.latitude) + } + + func testIncompatibleBoundaryAndDirectoryCodesFailClosed() throws { + let boundary = try WidgetTownshipBoundaryTable( + shapes: [ + makeShape( + code: "100", + polygons: [ + [[ + (120.0, 24.0), + (120.1, 24.0), + (120.1, 24.1), + (120.0, 24.1), + (120.0, 24.0), + ]], + ] + ), + ] + ) + + XCTAssertThrowsError( + try WidgetTownshipResolver( + boundaries: boundary, + directory: makeSyntheticDirectory() + ) + ) { error in + XCTAssertEqual( + error as? WidgetTownshipResourceError, + .incompatibleCodeSets + ) + } + } + + func testMalformedAndTruncatedBoundariesFailCleanly() { + XCTAssertThrowsError( + try WidgetTownshipBoundaryTable.decode(Data([0x80])) + ) { error in + XCTAssertEqual( + error as? WidgetTownshipResourceError, + .invalidBoundaryData + ) + } + } + + func testMalformedAndUnsupportedDirectoriesFailCleanly() { + XCTAssertThrowsError( + try WidgetTownshipDirectory.decode(Data("{}".utf8)) + ) { error in + XCTAssertEqual( + error as? WidgetTownshipResourceError, + .invalidDirectory + ) + } + + let unsupported = Data( + #"{"schemaVersion":2,"townships":[]}"#.utf8 + ) + XCTAssertThrowsError( + try WidgetTownshipDirectory.decode(unsupported) + ) { error in + XCTAssertEqual( + error as? WidgetTownshipResourceError, + .unsupportedDirectorySchema(2) + ) + } + } + + func testMissingBundleResourcesFailClosed() { + let loader = WidgetTownshipResourceLoader( + directoryName: "missing-directory", + boundaryName: "missing-boundaries" + ) + + XCTAssertThrowsError(try loader.load(bundle: productionBundle)) { + error in + XCTAssertEqual( + error as? WidgetTownshipResourceError, + .missingResource("missing-directory.json") + ) + } + } + + func testProductionResourceCodeSetsAndDirectoryOrderMatch() throws { + let resolver = try loadProductionResolver() + + XCTAssertEqual(resolver.directory.townships.count, 368) + XCTAssertEqual(resolver.boundaries.regionCodes.count, 368) + XCTAssertEqual( + resolver.boundaries.regionCodes, + resolver.directory.regionCodes + ) + XCTAssertEqual( + resolver.directory.townships.prefix(3).map(\.regionCode), + ["100", "103", "104"] + ) + XCTAssertEqual( + resolver.directory.townships.suffix(3).map(\.regionCode), + ["981", "982", "983"] + ) + XCTAssertTrue( + resolver.directory.regionCodes.allSatisfy { code in + code.utf8.count == 3 + && code.utf8.allSatisfy { $0 >= 48 && $0 <= 57 } + } + ) + } + + func testProductionGoldenCoordinatesResolveToTownshipCentroids() throws { + let resolver = try loadProductionResolver() + let goldens: [( + latitude: Double, + longitude: Double, + code: String, + name: String, + centroidLatitude: Double, + centroidLongitude: Double + )] = [ + (25.0330, 121.5645, "110", "信義區", 25.0377271, 121.5818185), + (24.1616, 120.6478, "407", "西屯區", 24.1658213, 120.6336717), + (22.6210, 120.3120, "802", "苓雅區", 22.621759, 120.312194), + (24.1000, 121.6000, "972", "秀林鄉", 24.1185835, 121.6248326), + ] + + for golden in goldens { + XCTAssertEqual( + resolver.boundaries.codeAt( + latitude: golden.latitude, + longitude: golden.longitude + ), + golden.code + ) + let input = try XCTUnwrap( + WidgetCurrentLocation( + latitude: golden.latitude, + longitude: golden.longitude + ) + ) + let resolved = try XCTUnwrap(resolver.resolve(input)) + XCTAssertEqual(resolved.address, .currentLocation) + XCTAssertEqual(resolved.regionCode, golden.code) + XCTAssertEqual(resolved.regionName, golden.name) + XCTAssertEqual(resolved.latitude, golden.centroidLatitude) + XCTAssertEqual(resolved.longitude, golden.centroidLongitude) + XCTAssertTrue( + resolved.latitude != input.latitude + || resolved.longitude != input.longitude + ) + } + } + + func testProductionSeaPointUsesDartNearestCentroidResult() throws { + let resolver = try loadProductionResolver() + let seaLocation = try XCTUnwrap( + WidgetCurrentLocation(latitude: 24.0, longitude: 120.0) + ) + + XCTAssertNil( + resolver.boundaries.codeAt( + latitude: seaLocation.latitude, + longitude: seaLocation.longitude + ) + ) + let resolved = try XCTUnwrap(resolver.resolve(seaLocation)) + XCTAssertEqual(resolved.regionCode, "528") + XCTAssertEqual(resolved.regionName, "芳苑鄉") + XCTAssertEqual(resolved.latitude, 23.924354) + XCTAssertEqual(resolved.longitude, 120.320389) + } + + func testTruncatedProductionBoundaryCannotDowngradeToNearest() throws { + let directoryData = try productionResourceData( + name: "WidgetTownshipDirectory", + extension: "json" + ) + var boundaryData = try productionResourceData( + name: "WidgetTownshipBoundaries", + extension: "bin" + ) + boundaryData.removeLast() + + XCTAssertThrowsError( + try WidgetTownshipResourceLoader().load( + directoryData: directoryData, + boundaryData: boundaryData + ) + ) { error in + XCTAssertEqual( + error as? WidgetTownshipResourceError, + .invalidBoundaryData + ) + } + } + + private var productionBundle: Bundle { + Bundle(for: Self.self) + } + + private func loadProductionResolver() throws -> WidgetTownshipResolver { + try WidgetTownshipResourceLoader().load(bundle: productionBundle) + } + + private func productionResourceData( + name: String, + extension pathExtension: String + ) throws -> Data { + let url = try XCTUnwrap( + productionBundle.url( + forResource: name, + withExtension: pathExtension + ) + ) + return try Data(contentsOf: url) + } + + private func makeSyntheticDirectory() throws -> WidgetTownshipDirectory { + try WidgetTownshipDirectory( + townships: [ + try makeTownship("100", "Alpha區", 24.05, 120.05), + try makeTownship("101", "Beta區", 24.05, 120.15), + try makeTownship("102", "Hole區", 24.25, 120.05), + try makeTownship("103", "Multipart區", 24.21, 120.235), + ] + ) + } + + private func makeTownship( + _ code: String, + _ name: String, + _ latitude: Double, + _ longitude: Double + ) throws -> WidgetTownship { + try XCTUnwrap( + WidgetTownship( + regionCode: code, + displayName: name, + administrativeAreaName: "測試市", + latitude: latitude, + longitude: longitude + ) + ) + } + + private func makeSyntheticBoundaries() throws + -> WidgetTownshipBoundaryTable + { + try WidgetTownshipBoundaryTable( + shapes: [ + makeShape( + code: "100", + polygons: [ + [[ + (120.0, 24.0), + (120.1, 24.0), + (120.1, 24.1), + (120.0, 24.1), + (120.0, 24.0), + ]], + ] + ), + makeShape( + code: "101", + polygons: [ + [[ + (120.1, 24.0), + (120.2, 24.0), + (120.2, 24.1), + (120.1, 24.1), + (120.1, 24.0), + ]], + ] + ), + makeShape( + code: "102", + polygons: [ + [ + [ + (120.0, 24.2), + (120.1, 24.2), + (120.1, 24.3), + (120.0, 24.3), + (120.0, 24.2), + ], + [ + (120.03, 24.23), + (120.07, 24.23), + (120.07, 24.27), + (120.03, 24.27), + (120.03, 24.23), + ], + ], + ] + ), + makeShape( + code: "103", + polygons: [ + [[ + (120.2, 24.2), + (120.22, 24.2), + (120.22, 24.22), + (120.2, 24.22), + (120.2, 24.2), + ]], + [[ + (120.25, 24.2), + (120.27, 24.2), + (120.27, 24.22), + (120.25, 24.22), + (120.25, 24.2), + ]], + ] + ), + ] + ) + } + + private func makeShape( + code: String, + polygons: [[[(Double, Double)]]] + ) throws -> WidgetTownshipShape { + try WidgetTownshipShape( + regionCode: code, + polygons: try polygons.map { rings in + try WidgetTownshipPolygon( + rings: rings.map { ring in + ring.map { longitude, latitude in + WidgetTownshipCoordinate( + longitude: longitude, + latitude: latitude + ) + } + } + ) + } + ) + } +} diff --git a/tool/check.sh b/tool/check.sh index b1e521399..69fb5539f 100755 --- a/tool/check.sh +++ b/tool/check.sh @@ -36,7 +36,8 @@ cached analyze "$(cache_key "${CODE_INPUTS[@]}")" tool/dev/analyze.sh # Five of these finish in under a quarter of a second and are run every time: # hashing their inputs would cost as much as running them, and a cache that # saves nothing is a cache that can only be wrong. -for gate in tooling l10n pubspec_lock notification_sounds build_info; do +for gate in tooling l10n pubspec_lock notification_sounds build_info \ + widget_township_resources; do step "check/$gate" "tool/check/$gate.sh" done diff --git a/tool/check/widget_township_resources.sh b/tool/check/widget_township_resources.sh new file mode 100755 index 000000000..1bf230485 --- /dev/null +++ b/tool/check/widget_township_resources.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Generated Widget township resources must match the authoritative Dart assets. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/../.." + +python3 tool/gen/widget_township_resources.py --check diff --git a/tool/gen/widget_township_resources.py b/tool/gen/widget_township_resources.py new file mode 100644 index 000000000..ba5f5aba8 --- /dev/null +++ b/tool/gen/widget_township_resources.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Generate the iOS Widget township resources from the Dart assets. + +The Dart assets remain the only geographic source of truth. The boundary +payload is gzip-inflated without re-encoding, while the directory is reduced to +the fields the Widget Extension needs for resolution and weather queries. + +Run: + python3 tool/gen/widget_township_resources.py + python3 tool/gen/widget_township_resources.py --check +""" + +from __future__ import annotations + +import argparse +import gzip +import json +from pathlib import Path +import sys +from typing import Any + + +REPO = Path(__file__).resolve().parents[2] +DIRECTORY_SOURCE = REPO / "assets/location.json.gz" +BOUNDARY_SOURCE = REPO / "assets/map/town_boundaries.bin.gz" +DIRECTORY_OUTPUT = REPO / "ios/DPIPWidgets/WidgetTownshipDirectory.json" +BOUNDARY_OUTPUT = REPO / "ios/DPIPWidgets/WidgetTownshipBoundaries.bin" +SCHEMA_VERSION = 1 + + +def generated_outputs() -> dict[Path, bytes]: + with gzip.open(DIRECTORY_SOURCE, "rt", encoding="utf-8") as source: + source_directory: dict[str, dict[str, Any]] = json.load(source) + + townships = [] + for region_code, town in source_directory.items(): + townships.append( + { + "regionCode": region_code, + "displayName": town["town"] + town["townLevel"], + "administrativeAreaName": town["city"] + town["cityLevel"], + "latitude": town["lat"], + "longitude": town["lng"], + } + ) + + directory = { + "schemaVersion": SCHEMA_VERSION, + "townships": townships, + } + directory_bytes = ( + json.dumps( + directory, + ensure_ascii=False, + separators=(",", ":"), + ) + + "\n" + ).encode("utf-8") + boundary_bytes = gzip.decompress(BOUNDARY_SOURCE.read_bytes()) + + return { + DIRECTORY_OUTPUT: directory_bytes, + BOUNDARY_OUTPUT: boundary_bytes, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--check", + action="store_true", + help="fail when committed outputs differ from a fresh generation", + ) + args = parser.parse_args() + + outputs = generated_outputs() + if args.check: + stale = [ + path + for path, expected in outputs.items() + if not path.is_file() or path.read_bytes() != expected + ] + if stale: + for path in stale: + print( + f"stale generated resource: {path.relative_to(REPO)}", + file=sys.stderr, + ) + print( + "run: python3 tool/gen/widget_township_resources.py", + file=sys.stderr, + ) + return 1 + print("Widget township resources are up to date.") + return 0 + + for path, contents in outputs.items(): + path.write_bytes(contents) + print( + f"generated {path.relative_to(REPO)} ({len(contents)} bytes)" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 1ce3655e1984b7b73c09b63c37c929e69d9ec153 Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Mon, 21 Sep 2026 18:42:40 +0800 Subject: [PATCH 14/18] chore(weather): enable current location widget refresh Platform: ios --- .../IntentHandler.swift | 29 +- .../WidgetLocationOptions.swift | 12 +- ...onCurrentWeatherWidgetRefreshService.swift | 236 +++++ .../DPIPWidgetProviderSupport.swift | 166 +++- ios/DPIPWidgets/DPIPWidgets.swift | 6 +- .../WidgetCurrentLocationClient.swift | 45 +- ios/Runner.xcodeproj/project.pbxproj | 11 +- ...rentWeatherWidgetRefreshServiceTests.swift | 810 ++++++++++++++++++ ios/RunnerTests/DPIPWidgetProviderTests.swift | 167 +++- .../WidgetCurrentLocationClientTests.swift | 170 +++- .../WidgetLocationIntentTests.swift | 9 + 11 files changed, 1579 insertions(+), 82 deletions(-) create mode 100644 ios/DPIPWidgets/CurrentLocationCurrentWeatherWidgetRefreshService.swift create mode 100644 ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift diff --git a/ios/DPIPWidgetIntentsExtension/IntentHandler.swift b/ios/DPIPWidgetIntentsExtension/IntentHandler.swift index e35aebd9f..61270f1a4 100644 --- a/ios/DPIPWidgetIntentsExtension/IntentHandler.swift +++ b/ios/DPIPWidgetIntentsExtension/IntentHandler.swift @@ -7,6 +7,12 @@ final class IntentHandler: INExtension, return self } + func defaultLocation( + for intent: WeatherWidgetConfigurationIntent + ) -> WidgetLocation? { + makeCurrentWidgetLocation() + } + func provideLocationOptionsCollection( for intent: WeatherWidgetConfigurationIntent, with completion: @escaping ( @@ -15,11 +21,6 @@ final class IntentHandler: INExtension, ) -> Void ) { let catalog = WidgetLocationCatalogStore().load() - let currentLocationDisplayString = String( - localized: "intent.current_location", - bundle: .main, - comment: "Current-location option in weather widget configuration." - ) let locations = makeWidgetLocationOptions( from: catalog, @@ -36,4 +37,22 @@ final class IntentHandler: INExtension, nil ) } + + private func makeCurrentWidgetLocation() -> WidgetLocation { + let option = makeCurrentWidgetLocationOption( + displayString: currentLocationDisplayString + ) + return WidgetLocation( + identifier: option.identifier, + display: option.displayString + ) + } + + private var currentLocationDisplayString: String { + String( + localized: "intent.current_location", + bundle: .main, + comment: "Current-location option in weather widget configuration." + ) + } } diff --git a/ios/DPIPWidgetIntentsExtension/WidgetLocationOptions.swift b/ios/DPIPWidgetIntentsExtension/WidgetLocationOptions.swift index 74ffbdf5f..28ba24315 100644 --- a/ios/DPIPWidgetIntentsExtension/WidgetLocationOptions.swift +++ b/ios/DPIPWidgetIntentsExtension/WidgetLocationOptions.swift @@ -5,13 +5,21 @@ struct WidgetLocationOption: Equatable { let displayString: String } +func makeCurrentWidgetLocationOption( + displayString: String +) -> WidgetLocationOption { + WidgetLocationOption( + identifier: "current-location", + displayString: displayString + ) +} + func makeWidgetLocationOptions( from catalog: WidgetLocationCatalog?, currentLocationDisplayString: String ) -> [WidgetLocationOption] { var options = [ - WidgetLocationOption( - identifier: "current-location", + makeCurrentWidgetLocationOption( displayString: currentLocationDisplayString ) ] diff --git a/ios/DPIPWidgets/CurrentLocationCurrentWeatherWidgetRefreshService.swift b/ios/DPIPWidgets/CurrentLocationCurrentWeatherWidgetRefreshService.swift new file mode 100644 index 000000000..ca20ace7d --- /dev/null +++ b/ios/DPIPWidgets/CurrentLocationCurrentWeatherWidgetRefreshService.swift @@ -0,0 +1,236 @@ +enum CurrentLocationCurrentWeatherWidgetRefreshResult: Equatable, Sendable { + case refreshed + case noObservation + case unavailable + case failed +} + +struct CurrentLocationCurrentWeatherWidgetRefreshService: Sendable { + typealias AcquireLocation = @MainActor @Sendable () async + -> WidgetCurrentLocationResult + typealias ResolveTownship = @Sendable ( + WidgetCurrentLocation + ) -> WidgetResolvedWeatherLocation? + typealias FetchWeather = @Sendable ( + Double, + Double + ) async throws -> CurrentWeatherRemoteDTO? + typealias SynchronizeClock = @Sendable () async + -> CurrentWeatherSnapshotTime? + typealias WriteSnapshot = @Sendable ( + CurrentWeatherWidgetSnapshot + ) throws -> Void + + private let acquireLocation: AcquireLocation + private let resolveTownship: ResolveTownship + private let fetchWeather: FetchWeather + private let synchronizeClock: SynchronizeClock + private let writeSnapshot: WriteSnapshot + + init( + acquireLocation: @escaping AcquireLocation, + resolver: WidgetTownshipResolver, + weatherClient: CurrentWeatherClient, + clock: WidgetServerClock, + writer: CurrentWeatherWidgetSnapshotWriter + ) { + self.init( + acquireLocation: acquireLocation, + resolveTownship: { currentLocation in + resolver.resolve(currentLocation) + }, + fetchWeather: { latitude, longitude in + try await weatherClient.fetch( + latitude: latitude, + longitude: longitude + ) + }, + clock: clock, + writeSnapshot: { snapshot in + try writer.write(snapshot) + } + ) + } + + init( + acquireLocation: @escaping AcquireLocation, + resolveTownship: @escaping ResolveTownship, + fetchWeather: @escaping FetchWeather, + clock: WidgetServerClock, + writeSnapshot: @escaping WriteSnapshot + ) { + self.init( + acquireLocation: acquireLocation, + resolveTownship: resolveTownship, + fetchWeather: fetchWeather, + synchronizeClock: { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "clock synchronization started" + ) + let synchronized = await clock.synchronize() + #else + _ = await clock.synchronize() + #endif + let hasSynchronized = await clock.hasSynchronized + #if DEBUG + if synchronized { + WidgetWeatherRefreshDiagnostics.log( + "clock synchronized" + ) + } else if hasSynchronized { + WidgetWeatherRefreshDiagnostics.log( + "clock failed retainedPreviousAnchor=true" + ) + } else { + WidgetWeatherRefreshDiagnostics.log( + "clock failed retainedPreviousAnchor=false" + ) + } + #endif + guard hasSynchronized else { + return nil + } + return await clock.currentWeatherSnapshotTime() + }, + writeSnapshot: writeSnapshot + ) + } + + init( + acquireLocation: @escaping AcquireLocation, + resolveTownship: @escaping ResolveTownship, + fetchWeather: @escaping FetchWeather, + synchronizeClock: @escaping SynchronizeClock, + writeSnapshot: @escaping WriteSnapshot + ) { + self.acquireLocation = acquireLocation + self.resolveTownship = resolveTownship + self.fetchWeather = fetchWeather + self.synchronizeClock = synchronizeClock + self.writeSnapshot = writeSnapshot + } + + func refresh() async -> CurrentLocationCurrentWeatherWidgetRefreshResult { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "current location acquisition started" + ) + #endif + + let currentLocationResult = await acquireLocation() + let currentLocation: WidgetCurrentLocation + switch currentLocationResult { + case .acquired(let acquiredLocation): + currentLocation = acquiredLocation + case .unavailable, .timedOut: + return .unavailable + case .failed: + return .failed + } + + #if DEBUG + WidgetWeatherRefreshDiagnostics.log("township resolution started") + #endif + guard let location = resolveTownship(currentLocation), + location.address == .currentLocation + else { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "township resolution unavailable" + ) + #endif + return .unavailable + } + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "township resolved regionCode=\(location.regionCode)" + ) + #endif + + // Once a township exists, weather and clock synchronization are + // independent. Starting them together bounds this phase to the slower + // operation without doing network work for unavailable locations. + #if DEBUG + async let observation = fetchWeatherWithDiagnostics(location) + #else + async let observation = fetchWeather( + location.latitude, + location.longitude + ) + #endif + async let snapshotTime = synchronizeClock() + + let resolvedObservation: CurrentWeatherRemoteDTO? + let resolvedSnapshotTime: CurrentWeatherSnapshotTime? + do { + (resolvedObservation, resolvedSnapshotTime) = try await ( + observation, + snapshotTime + ) + } catch { + return .failed + } + + guard let resolvedObservation else { + return .noObservation + } + guard let resolvedSnapshotTime else { + return .failed + } + + let snapshot = CurrentWeatherWidgetSnapshotFactory.make( + observation: resolvedObservation, + location: location, + time: resolvedSnapshotTime + ) + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "writer attempted sourceIdentifier=" + + (snapshot.sourceIdentifier ?? "none") + + " regionCode=\(snapshot.regionCode) " + + "observationTime=\(snapshot.observationTime)" + ) + #endif + do { + try writeSnapshot(snapshot) + #if DEBUG + WidgetWeatherRefreshDiagnostics.log("writer succeeded") + #endif + return .refreshed + } catch { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log("writer failed") + #endif + return .failed + } + } + + #if DEBUG + private func fetchWeatherWithDiagnostics( + _ location: WidgetResolvedWeatherLocation + ) async throws -> CurrentWeatherRemoteDTO? { + WidgetWeatherRefreshDiagnostics.log("weather started") + do { + let observation = try await fetchWeather( + location.latitude, + location.longitude + ) + if let observation { + WidgetWeatherRefreshDiagnostics.log( + "weather observation received observationTime=" + + "\(observation.time)" + ) + } else { + WidgetWeatherRefreshDiagnostics.log( + "weather no observation" + ) + } + return observation + } catch { + WidgetWeatherRefreshDiagnostics.log("weather failed") + throw error + } + } + #endif +} diff --git a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift index c727168c0..912e7637b 100644 --- a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift +++ b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift @@ -34,6 +34,33 @@ enum WidgetWeatherRefreshDiagnostics { + "regionCode=\(snapshot.regionCode) " + "observationTime=\(snapshot.observationTime)" } + + static func cacheChanged( + from previous: CurrentWeatherWidgetSnapshot?, + to current: CurrentWeatherWidgetSnapshot? + ) -> Bool { + guard let previous, let current else { + return (previous == nil) != (current == nil) + } + + return previous.schemaVersion != current.schemaVersion + || previous.sourceIdentifier != current.sourceIdentifier + || previous.regionCode != current.regionCode + || previous.regionName != current.regionName + || previous.observationTime != current.observationTime + || previous.stationName != current.stationName + || previous.weather != current.weather + || previous.weatherCode != current.weatherCode + || previous.condition != current.condition + || previous.isNight != current.isNight + || previous.nextDayNightTransitionTime + != current.nextDayNightTransitionTime + || previous.calibratedTimeOffsetMilliseconds + != current.calibratedTimeOffsetMilliseconds + || previous.temperature != current.temperature + || previous.humidity != current.humidity + || previous.rain != current.rain + } } #endif @@ -50,12 +77,15 @@ struct DPIPWidgetTimelinePlanner: Sendable { typealias RefreshSaved = @Sendable ( WidgetLocationTarget ) async -> SavedCurrentWeatherWidgetRefreshResult + typealias RefreshCurrent = @Sendable () async + -> CurrentLocationCurrentWeatherWidgetRefreshResult typealias Now = @Sendable () -> Date private let staleAfter: TimeInterval private let refreshInterval: TimeInterval private let loadSnapshot: LoadSnapshot private let refreshSaved: RefreshSaved + private let refreshCurrent: RefreshCurrent private let now: Now init( @@ -63,12 +93,14 @@ struct DPIPWidgetTimelinePlanner: Sendable { refreshInterval: TimeInterval, loadSnapshot: @escaping LoadSnapshot, refreshSaved: @escaping RefreshSaved, + refreshCurrent: @escaping RefreshCurrent, now: @escaping Now ) { self.staleAfter = staleAfter self.refreshInterval = refreshInterval self.loadSnapshot = loadSnapshot self.refreshSaved = refreshSaved + self.refreshCurrent = refreshCurrent self.now = now } @@ -77,7 +109,8 @@ struct DPIPWidgetTimelinePlanner: Sendable { ) async -> DPIPWidgetTimelinePlan { #if DEBUG let snapshotBeforeRefresh: CurrentWeatherWidgetSnapshot? - if case .saved = target { + switch target { + case .saved, .currentLocation: snapshotBeforeRefresh = loadSnapshot(target) WidgetWeatherRefreshDiagnostics.log( "cacheBefore " @@ -85,12 +118,13 @@ struct DPIPWidgetTimelinePlanner: Sendable { snapshotBeforeRefresh ) ) - } else { + case .invalid: snapshotBeforeRefresh = nil } #endif - if case .saved = target { + switch target { + case .saved: #if DEBUG WidgetWeatherRefreshDiagnostics.log( "refresh began target=" @@ -106,6 +140,20 @@ struct DPIPWidgetTimelinePlanner: Sendable { #else _ = await refreshSaved(target) #endif + case .currentLocation: + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "refresh began target=current-location" + ) + let refreshResult = await refreshCurrent() + WidgetWeatherRefreshDiagnostics.log( + "refresh result=\(refreshResult.diagnosticName)" + ) + #else + _ = await refreshCurrent() + #endif + case .invalid: + break } // Always reload after the refresh attempt. Failed refreshes leave the @@ -124,12 +172,17 @@ struct DPIPWidgetTimelinePlanner: Sendable { "cacheAfter " + WidgetWeatherRefreshDiagnostics.snapshotSummary(snapshot) ) - if case .saved = target { - let cacheChanged = snapshotBeforeRefresh?.observationTime - != snapshot?.observationTime + switch target { + case .saved, .currentLocation: + let cacheChanged = WidgetWeatherRefreshDiagnostics.cacheChanged( + from: snapshotBeforeRefresh, + to: snapshot + ) WidgetWeatherRefreshDiagnostics.log( "cacheChanged=\(cacheChanged)" ) + case .invalid: + break } let stale = states.first?.isStale ?? false WidgetWeatherRefreshDiagnostics.log( @@ -171,11 +224,38 @@ struct SavedCurrentWeatherWidgetRefreshRuntime: Sendable { } } +struct CurrentLocationCurrentWeatherWidgetRefreshRuntime: Sendable { + typealias RefreshWithClock = @Sendable ( + WidgetServerClock + ) async -> CurrentLocationCurrentWeatherWidgetRefreshResult + + private let serverClock: WidgetServerClock + private let refreshWithClock: RefreshWithClock + + init( + serverClock: WidgetServerClock, + refreshWithClock: @escaping RefreshWithClock + ) { + self.serverClock = serverClock + self.refreshWithClock = refreshWithClock + } + + func refresh() async -> CurrentLocationCurrentWeatherWidgetRefreshResult { + await refreshWithClock(serverClock) + } +} + struct DPIPWidgetProviderDependencies: Sendable { typealias LoadSnapshot = DPIPWidgetTimelinePlanner.LoadSnapshot let loadSnapshot: LoadSnapshot let timelinePlanner: DPIPWidgetTimelinePlanner + + func snapshot( + for target: WidgetLocationTarget + ) -> CurrentWeatherWidgetSnapshot? { + loadSnapshot(target) + } } enum DPIPWidgetProviderRuntime { @@ -204,10 +284,14 @@ enum DPIPWidgetProviderRuntime { } let weatherClient = CurrentWeatherClient() - let refreshRuntime = SavedCurrentWeatherWidgetRefreshRuntime( - serverClock: WidgetServerClock(), + let serverClock = WidgetServerClock() + let writer = containerURL.map { + CurrentWeatherWidgetSnapshotWriter(containerURL: $0) + } + let savedRefreshRuntime = SavedCurrentWeatherWidgetRefreshRuntime( + serverClock: serverClock, refreshWithClock: { clock, target in - guard let containerURL else { + guard let containerURL, let writer else { #if DEBUG WidgetWeatherRefreshDiagnostics.log( "catalog unavailable appGroupContainer=false" @@ -240,14 +324,50 @@ enum DPIPWidgetProviderRuntime { ), weatherClient: weatherClient, clock: clock, - writer: CurrentWeatherWidgetSnapshotWriter( - containerURL: containerURL - ) + writer: writer ) return await service.refresh(target: target) } ) + let currentRefreshRuntime = + CurrentLocationCurrentWeatherWidgetRefreshRuntime( + serverClock: serverClock, + refreshWithClock: { clock in + guard let writer else { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "current location refresh unavailable " + + "appGroupContainer=false" + ) + #endif + return .unavailable + } + guard let resolver = + WidgetTownshipResolverRuntime.shared + else { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "township resolver unavailable" + ) + #endif + return .unavailable + } + + let service = + CurrentLocationCurrentWeatherWidgetRefreshService( + acquireLocation: { @MainActor in + await WidgetCurrentLocationClient() + .acquireLocation() + }, + resolver: resolver, + weatherClient: weatherClient, + clock: clock, + writer: writer + ) + return await service.refresh() + } + ) return DPIPWidgetProviderDependencies( loadSnapshot: loadSnapshot, @@ -255,7 +375,12 @@ enum DPIPWidgetProviderRuntime { staleAfter: staleAfter, refreshInterval: refreshInterval, loadSnapshot: loadSnapshot, - refreshSaved: refreshRuntime.refresh, + refreshSaved: { target in + await savedRefreshRuntime.refresh(target: target) + }, + refreshCurrent: { + await currentRefreshRuntime.refresh() + }, now: { Date.now } ) ) @@ -277,4 +402,19 @@ private extension SavedCurrentWeatherWidgetRefreshResult { } } } + +private extension CurrentLocationCurrentWeatherWidgetRefreshResult { + var diagnosticName: String { + switch self { + case .refreshed: + return "refreshed" + case .noObservation: + return "noObservation" + case .unavailable: + return "unavailable" + case .failed: + return "failed" + } + } +} #endif diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index 7abb24f1d..effd35026 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -21,7 +21,7 @@ struct DPIPWidgetProvider: IntentTimelineProvider { identifier: configuration.location?.identifier ) - return dependencies.loadSnapshot(target) + return dependencies.snapshot(for: target) } func placeholder(in context: Context) -> DPIPWidgetEntry { @@ -78,8 +78,8 @@ struct DPIPWidgetProvider: IntentTimelineProvider { ) case .currentLocation: WidgetWeatherRefreshDiagnostics.log( - "target=current-location nativeRefresh=not-implemented " - + "cacheLoad=enabled" + "target=current-location nativeRefresh=enabled " + + "cacheReload=enabled" ) case .invalid: WidgetWeatherRefreshDiagnostics.log("target=invalid") diff --git a/ios/DPIPWidgets/WidgetCurrentLocationClient.swift b/ios/DPIPWidgets/WidgetCurrentLocationClient.swift index 397b529af..53496ffb6 100644 --- a/ios/DPIPWidgets/WidgetCurrentLocationClient.swift +++ b/ios/DPIPWidgets/WidgetCurrentLocationClient.swift @@ -100,7 +100,7 @@ protocol WidgetLocationManaging: AnyObject { var isAuthorizedForWidgetUpdates: Bool { get } var desiredAccuracy: Double { get set } - func requestLocation() + func startUpdatingLocation() func stopUpdatingLocation() } @@ -125,6 +125,7 @@ final class WidgetCurrentLocationClient { nonisolated static let defaultTimeout: TimeInterval = 10 nonisolated static let defaultMaximumAge: TimeInterval = 10 * 60 + nonisolated static let defaultRequestStartTolerance: TimeInterval = 1 nonisolated static let defaultDesiredAccuracy: Double = kCLLocationAccuracyKilometer @@ -133,6 +134,7 @@ final class WidgetCurrentLocationClient { private let timeoutScheduler: any WidgetLocationTimeoutScheduling private let timeout: TimeInterval private let maximumAge: TimeInterval + private let requestStartTolerance: TimeInterval private let desiredAccuracy: Double private let now: Now private var activeRequests: [UUID: WidgetCurrentLocationRequest] = [:] @@ -147,6 +149,7 @@ final class WidgetCurrentLocationClient { timeoutScheduler: (any WidgetLocationTimeoutScheduling)? = nil, timeout: TimeInterval = defaultTimeout, maximumAge: TimeInterval = defaultMaximumAge, + requestStartTolerance: TimeInterval = defaultRequestStartTolerance, desiredAccuracy: Double = defaultDesiredAccuracy, now: @escaping Now = Date.init ) { @@ -156,6 +159,7 @@ final class WidgetCurrentLocationClient { ?? WidgetLocationDispatchTimeoutScheduler() self.timeout = timeout self.maximumAge = maximumAge + self.requestStartTolerance = requestStartTolerance self.desiredAccuracy = desiredAccuracy self.now = now } @@ -167,7 +171,7 @@ final class WidgetCurrentLocationClient { "current-location authorizationState=services-disabled " + "widgetUpdatesAuthorized=false" ) - WidgetWeatherRefreshDiagnostics.log("current-location unavailable") + WidgetWeatherRefreshDiagnostics.log("current location unavailable") #endif return .unavailable } @@ -189,7 +193,7 @@ final class WidgetCurrentLocationClient { widgetUpdatesAuthorized else { #if DEBUG - WidgetWeatherRefreshDiagnostics.log("current-location unavailable") + WidgetWeatherRefreshDiagnostics.log("current location unavailable") #endif return .unavailable } @@ -201,6 +205,7 @@ final class WidgetCurrentLocationClient { timeoutScheduler: timeoutScheduler, timeout: timeout, maximumAge: maximumAge, + requestStartTolerance: requestStartTolerance, desiredAccuracy: desiredAccuracy, now: now ) { [weak self] result in @@ -223,12 +228,14 @@ private final class WidgetCurrentLocationRequest: private let timeoutScheduler: any WidgetLocationTimeoutScheduling private let timeout: TimeInterval private let maximumAge: TimeInterval + private let requestStartTolerance: TimeInterval private let desiredAccuracy: Double private let now: WidgetCurrentLocationClient.Now private var completion: Completion? private var timeoutCancellation: ( any WidgetLocationTimeoutCancellable )? + private var startedAt: Date? private var hasFinished = false init( @@ -236,6 +243,7 @@ private final class WidgetCurrentLocationRequest: timeoutScheduler: any WidgetLocationTimeoutScheduling, timeout: TimeInterval, maximumAge: TimeInterval, + requestStartTolerance: TimeInterval, desiredAccuracy: Double, now: @escaping WidgetCurrentLocationClient.Now, completion: @escaping Completion @@ -244,6 +252,7 @@ private final class WidgetCurrentLocationRequest: self.timeoutScheduler = timeoutScheduler self.timeout = timeout self.maximumAge = maximumAge + self.requestStartTolerance = requestStartTolerance self.desiredAccuracy = desiredAccuracy self.now = now self.completion = completion @@ -252,6 +261,7 @@ private final class WidgetCurrentLocationRequest: func start() { manager.delegate = self manager.desiredAccuracy = desiredAccuracy + startedAt = now() timeoutCancellation = timeoutScheduler.schedule( after: timeout ) { [weak self] in @@ -259,9 +269,9 @@ private final class WidgetCurrentLocationRequest: } #if DEBUG - WidgetWeatherRefreshDiagnostics.log("current-location request started") + WidgetWeatherRefreshDiagnostics.log("current location request started") #endif - manager.requestLocation() + manager.startUpdatingLocation() } func widgetLocationManager( @@ -272,11 +282,21 @@ private final class WidgetCurrentLocationRequest: return } + guard let startedAt else { + return + } + let requestNow = now() + let earliestAcceptedTimestamp = startedAt.addingTimeInterval( + -requestStartTolerance + ) let location: WidgetCurrentLocation? = samples.reversed().compactMap { sample -> WidgetCurrentLocation? in let age = requestNow.timeIntervalSince(sample.timestamp) - guard age >= 0, age <= maximumAge else { + guard age >= -requestStartTolerance, + age <= maximumAge, + sample.timestamp >= earliestAcceptedTimestamp + else { return nil } return WidgetCurrentLocation( @@ -286,7 +306,6 @@ private final class WidgetCurrentLocationRequest: }.first guard let location else { - finish(.failed) return } finish(.acquired(location)) @@ -312,13 +331,13 @@ private final class WidgetCurrentLocationRequest: #if DEBUG switch result { case .acquired: - WidgetWeatherRefreshDiagnostics.log("current-location acquired") + WidgetWeatherRefreshDiagnostics.log("current location acquired") case .unavailable: - WidgetWeatherRefreshDiagnostics.log("current-location unavailable") + WidgetWeatherRefreshDiagnostics.log("current location unavailable") case .timedOut: - WidgetWeatherRefreshDiagnostics.log("current-location timeout") + WidgetWeatherRefreshDiagnostics.log("current location timeout") case .failed: - WidgetWeatherRefreshDiagnostics.log("current-location failed") + WidgetWeatherRefreshDiagnostics.log("current location failed") } #endif @@ -356,8 +375,8 @@ private final class CoreLocationWidgetLocationManager: NSObject, manager.delegate = self } - func requestLocation() { - manager.requestLocation() + func startUpdatingLocation() { + manager.startUpdatingLocation() } func stopUpdatingLocation() { diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 65cee9be4..1ddd123a9 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -23,9 +23,10 @@ 27CACC80305C67530046F79C /* WidgetLocationCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27CACC7F305C67530046F79C /* WidgetLocationCatalogTests.swift */; }; 28A000000000000000000003 /* WidgetSNTPClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28A000000000000000000001 /* WidgetSNTPClientTests.swift */; }; 28A000000000000000000004 /* WidgetServerClockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28A000000000000000000002 /* WidgetServerClockTests.swift */; }; - 4F1000000000000000000002 /* WidgetCurrentLocationClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F1000000000000000000001 /* WidgetCurrentLocationClientTests.swift */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 4F1000000000000000000002 /* WidgetCurrentLocationClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F1000000000000000000001 /* WidgetCurrentLocationClientTests.swift */; }; + 4F3000000000000000000002 /* CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F3000000000000000000001 /* CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift */; }; 522508B9301F863A006148C2 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 522508B7301F863A006148C2 /* InfoPlist.strings */; }; 72E4CBC23930C168D057AC64 /* Sounds/warn.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 3AE87ED82FDB896B2B5C5F1B /* Sounds/warn.aiff */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; @@ -125,12 +126,13 @@ 27CACC7F305C67530046F79C /* WidgetLocationCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetLocationCatalogTests.swift; sourceTree = ""; }; 28A000000000000000000001 /* WidgetSNTPClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSNTPClientTests.swift; sourceTree = ""; }; 28A000000000000000000002 /* WidgetServerClockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetServerClockTests.swift; sourceTree = ""; }; - 4F1000000000000000000001 /* WidgetCurrentLocationClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCurrentLocationClientTests.swift; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3AE87ED82FDB896B2B5C5F1B /* Sounds/warn.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/warn.aiff; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 4D00D2962CF4598B61D0C722 /* Sounds/eew_alert.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/eew_alert.aiff; sourceTree = ""; }; + 4F1000000000000000000001 /* WidgetCurrentLocationClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCurrentLocationClientTests.swift; sourceTree = ""; }; + 4F3000000000000000000001 /* CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift; sourceTree = ""; }; 522508B8301F863A006148C2 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/InfoPlist.strings; sourceTree = ""; }; 522508BA301F8687006148C2 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/InfoPlist.strings"; sourceTree = ""; }; 522508BB301F868B006148C2 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -246,6 +248,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( CurrentWeatherClient.swift, + CurrentLocationCurrentWeatherWidgetRefreshService.swift, CurrentWeatherSnapshotTime.swift, CurrentWeatherWidgetSnapshotFactory.swift, CurrentWeatherWidgetSnapshotWriter.swift, @@ -256,8 +259,8 @@ WidgetLocationTarget.swift, WidgetResolvedWeatherLocation.swift, WidgetServerClock.swift, - WidgetSNTPClient.swift, WidgetSnapshotStore.swift, + WidgetSNTPClient.swift, WidgetSolarTime.swift, WidgetTownshipBoundaries.bin, WidgetTownshipDirectory.json, @@ -333,6 +336,7 @@ 28A000000000000000000001 /* WidgetSNTPClientTests.swift */, 28A000000000000000000002 /* WidgetServerClockTests.swift */, 4F1000000000000000000001 /* WidgetCurrentLocationClientTests.swift */, + 4F3000000000000000000001 /* CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift */, D91A00000000000000000001 /* CurrentWeatherWidgetTests.swift */, D91A00000000000000000002 /* CurrentWeatherWidgetSnapshot.swift */, D91A00000000000000000003 /* CurrentWeatherWidgetTimeline.swift */, @@ -677,6 +681,7 @@ 28A000000000000000000003 /* WidgetSNTPClientTests.swift in Sources */, 28A000000000000000000004 /* WidgetServerClockTests.swift in Sources */, 4F1000000000000000000002 /* WidgetCurrentLocationClientTests.swift in Sources */, + 4F3000000000000000000002 /* CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift in Sources */, 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, D91A00000000000000000011 /* CurrentWeatherWidgetTests.swift in Sources */, D91A00000000000000000012 /* CurrentWeatherWidgetSnapshot.swift in Sources */, diff --git a/ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift b/ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift new file mode 100644 index 000000000..07eec5c13 --- /dev/null +++ b/ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift @@ -0,0 +1,810 @@ +import Foundation +import XCTest + +@MainActor +final class CurrentLocationCurrentWeatherWidgetRefreshServiceTests: + XCTestCase +{ + private var containerURL: URL! + private let preciseLocation = WidgetCurrentLocation( + latitude: 24.181234, + longitude: 120.612345 + )! + + override func setUpWithError() throws { + containerURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: containerURL) + containerURL = nil + } + + func testSuccessfulRefreshRunsPipelineAndWritesResolvedSnapshot() + async throws + { + let recorder = CurrentLocationRefreshEventRecorder() + let weather = CurrentLocationScriptedWeather( + result: .success(try makeObservation()), + recorder: recorder + ) + let writer = CurrentLocationSnapshotWriterSpy(recorder: recorder) + let service = makeService( + recorder: recorder, + weather: weather, + writeSnapshot: writer.write + ) + + let result = await service.refresh() + + XCTAssertEqual(result, .refreshed) + let snapshot = try XCTUnwrap(writer.snapshots.first) + XCTAssertEqual(snapshot.schemaVersion, 5) + XCTAssertEqual(snapshot.sourceIdentifier, "current-location") + XCTAssertEqual(snapshot.regionCode, "407") + XCTAssertEqual(snapshot.regionName, "西屯區") + XCTAssertEqual(snapshot.stationName, "西屯測站") + XCTAssertEqual(snapshot.calibratedTimeOffsetMilliseconds, 321) + + let events = recorder.events + XCTAssertLessThan( + try XCTUnwrap(events.firstIndex(of: .acquireLocation)), + try XCTUnwrap(events.firstIndex(of: .resolveTownship)) + ) + XCTAssertLessThan( + try XCTUnwrap(events.firstIndex(of: .resolveTownship)), + try XCTUnwrap(events.firstIndex(of: .fetchWeather)) + ) + XCTAssertLessThan( + try XCTUnwrap(events.firstIndex(of: .resolveTownship)), + try XCTUnwrap(events.firstIndex(of: .synchronizeClock)) + ) + XCTAssertGreaterThan( + try XCTUnwrap(events.firstIndex(of: .writeSnapshot)), + try XCTUnwrap(events.firstIndex(of: .fetchWeather)) + ) + XCTAssertGreaterThan( + try XCTUnwrap(events.firstIndex(of: .writeSnapshot)), + try XCTUnwrap(events.firstIndex(of: .synchronizeClock)) + ) + } + + func testWeatherUsesTownshipCentroidInsteadOfPreciseLocation() + async throws + { + let weather = CurrentLocationScriptedWeather( + result: .success(try makeObservation()) + ) + let service = makeService( + weather: weather, + writeSnapshot: CurrentLocationSnapshotWriterSpy().write + ) + + let result = await service.refresh() + XCTAssertEqual(result, .refreshed) + + let coordinates = await weather.coordinates + XCTAssertEqual(coordinates.count, 1) + XCTAssertEqual(coordinates.first?.latitude, 24.1813400) + XCTAssertEqual(coordinates.first?.longitude, 120.6466200) + XCTAssertNotEqual( + coordinates.first?.latitude, + preciseLocation.latitude + ) + XCTAssertNotEqual( + coordinates.first?.longitude, + preciseLocation.longitude + ) + } + + func testLocationUnavailableDoesNotStartNetworkOrWrite() async throws { + let weather = CurrentLocationScriptedWeather( + result: .success(try makeObservation()) + ) + let clock = CurrentLocationScriptedClock(sample: makeSnapshotTime()) + let writer = CurrentLocationSnapshotWriterSpy() + let service = makeService( + locationResult: .unavailable, + weather: weather, + synchronizeClock: { + await clock.synchronizeAndSample() + }, + writeSnapshot: writer.write + ) + + let result = await service.refresh() + let weatherCallCount = await weather.callCount + let clockCallCount = await clock.callCount + XCTAssertEqual(result, .unavailable) + XCTAssertEqual(weatherCallCount, 0) + XCTAssertEqual(clockCallCount, 0) + XCTAssertEqual(writer.writeCount, 0) + } + + func testLocationTimeoutIsUnavailableWithoutWrite() async throws { + let weather = CurrentLocationScriptedWeather( + result: .success(try makeObservation()) + ) + let writer = CurrentLocationSnapshotWriterSpy() + let service = makeService( + locationResult: .timedOut, + weather: weather, + writeSnapshot: writer.write + ) + + let result = await service.refresh() + let weatherCallCount = await weather.callCount + XCTAssertEqual(result, .unavailable) + XCTAssertEqual(weatherCallCount, 0) + XCTAssertEqual(writer.writeCount, 0) + } + + func testLocationAcquisitionFailureFailsWithoutWrite() async throws { + let weather = CurrentLocationScriptedWeather( + result: .success(try makeObservation()) + ) + let writer = CurrentLocationSnapshotWriterSpy() + let service = makeService( + locationResult: .failed, + weather: weather, + writeSnapshot: writer.write + ) + + let result = await service.refresh() + let weatherCallCount = await weather.callCount + XCTAssertEqual(result, .failed) + XCTAssertEqual(weatherCallCount, 0) + XCTAssertEqual(writer.writeCount, 0) + } + + func testTownshipResolutionFailureDoesNotStartNetworkOrWrite() + async throws + { + let weather = CurrentLocationScriptedWeather( + result: .success(try makeObservation()) + ) + let clock = CurrentLocationScriptedClock(sample: makeSnapshotTime()) + let writer = CurrentLocationSnapshotWriterSpy() + let service = makeService( + shouldResolveTownship: false, + weather: weather, + synchronizeClock: { + await clock.synchronizeAndSample() + }, + writeSnapshot: writer.write + ) + + let result = await service.refresh() + let weatherCallCount = await weather.callCount + let clockCallCount = await clock.callCount + XCTAssertEqual(result, .unavailable) + XCTAssertEqual(weatherCallCount, 0) + XCTAssertEqual(clockCallCount, 0) + XCTAssertEqual(writer.writeCount, 0) + } + + func testResolvedSavedAddressIsRejectedWithoutFallback() async throws { + let weather = CurrentLocationScriptedWeather( + result: .success(try makeObservation()) + ) + let writer = CurrentLocationSnapshotWriterSpy() + let savedLocation = try XCTUnwrap( + WidgetResolvedWeatherLocation( + address: .saved(regionCode: "407"), + regionCode: "407", + regionName: "西屯區", + latitude: 24.1813400, + longitude: 120.6466200 + ) + ) + let service = makeService( + resolvedLocation: savedLocation, + weather: weather, + writeSnapshot: writer.write + ) + + let result = await service.refresh() + let weatherCallCount = await weather.callCount + XCTAssertEqual(result, .unavailable) + XCTAssertEqual(weatherCallCount, 0) + XCTAssertEqual(writer.writeCount, 0) + } + + func testNoObservationLeavesExistingCurrentLocationCacheUntouched() + async throws + { + let oldData = try seedExistingSnapshot() + let weather = CurrentLocationScriptedWeather(result: .success(nil)) + let writer = CurrentWeatherWidgetSnapshotWriter( + containerURL: containerURL + ) + let service = makeService( + weather: weather, + writeSnapshot: writer.write + ) + + let result = await service.refresh() + XCTAssertEqual(result, .noObservation) + XCTAssertEqual(try cachedData(), oldData) + } + + func testWeatherFailureLeavesExistingCurrentLocationCacheUntouched() + async throws + { + let oldData = try seedExistingSnapshot() + let weather = CurrentLocationScriptedWeather( + result: .failure(.scripted) + ) + let writer = CurrentWeatherWidgetSnapshotWriter( + containerURL: containerURL + ) + let service = makeService( + weather: weather, + writeSnapshot: writer.write + ) + + let result = await service.refresh() + XCTAssertEqual(result, .failed) + XCTAssertEqual(try cachedData(), oldData) + } + + func testFirstClockSyncFailureWithoutAnchorDoesNotWrite() async throws { + let weather = CurrentLocationScriptedWeather( + result: .success(try makeObservation()) + ) + let writer = CurrentLocationSnapshotWriterSpy() + let clock = makeClock(serverResults: [.failure(.scripted)]) + let service = makeService( + weather: weather, + clock: clock, + writeSnapshot: writer.write + ) + + let result = await service.refresh() + let hasSynchronized = await clock.hasSynchronized + XCTAssertEqual(result, .failed) + XCTAssertFalse(hasSynchronized) + XCTAssertEqual(writer.writeCount, 0) + } + + func testLaterClockSyncFailureUsesRetainedAnchor() async throws { + let monotonicClock = CurrentLocationTestMonotonicClock( + milliseconds: 100 + ) + let clock = makeClock( + monotonicClock: monotonicClock, + serverResults: [ + .success(10_000), + .failure(.scripted), + ] + ) + let firstSyncSucceeded = await clock.synchronize() + XCTAssertTrue(firstSyncSucceeded) + monotonicClock.set(milliseconds: 600) + let weather = CurrentLocationScriptedWeather( + result: .success(try makeObservation()) + ) + let writer = CurrentLocationSnapshotWriterSpy() + let service = makeService( + weather: weather, + clock: clock, + writeSnapshot: writer.write + ) + + let result = await service.refresh() + XCTAssertEqual(result, .refreshed) + XCTAssertEqual(writer.writeCount, 1) + XCTAssertEqual( + writer.snapshots.first?.calibratedTimeOffsetMilliseconds, + 5_500 + ) + } + + func testWriterFailureLeavesExistingCurrentLocationCacheUntouched() + async throws + { + let oldData = try seedExistingSnapshot() + let weather = CurrentLocationScriptedWeather( + result: .success(try makeObservation()) + ) + let writer = CurrentLocationSnapshotWriterSpy(error: .scripted) + let service = makeService( + weather: weather, + writeSnapshot: writer.write + ) + + let result = await service.refresh() + XCTAssertEqual(result, .failed) + XCTAssertEqual(writer.writeCount, 1) + XCTAssertEqual(try cachedData(), oldData) + } + + func testTownshipChangeReplacesOnlyCanonicalCurrentLocationFile() + async throws + { + let writer = CurrentWeatherWidgetSnapshotWriter( + containerURL: containerURL + ) + let firstService = makeService( + resolvedLocation: resolvedLocation( + regionCode: "407", + regionName: "西屯區", + latitude: 24.1813400, + longitude: 120.6466200 + ), + weather: CurrentLocationScriptedWeather( + result: .success(try makeObservation()) + ), + writeSnapshot: writer.write + ) + let secondService = makeService( + resolvedLocation: resolvedLocation( + regionCode: "110", + regionName: "信義區", + latitude: 25.0333200, + longitude: 121.5701000 + ), + weather: CurrentLocationScriptedWeather( + result: .success(try makeObservation()) + ), + writeSnapshot: writer.write + ) + + let firstResult = await firstService.refresh() + let secondResult = await secondService.refresh() + XCTAssertEqual(firstResult, .refreshed) + XCTAssertEqual(secondResult, .refreshed) + + let snapshot = try JSONDecoder().decode( + CurrentWeatherWidgetSnapshot.self, + from: cachedData() + ) + XCTAssertEqual(snapshot.sourceIdentifier, "current-location") + XCTAssertEqual(snapshot.regionCode, "110") + XCTAssertEqual(snapshot.regionName, "信義區") + let directory = CurrentWeatherSnapshotStorage( + containerURL: containerURL + ).snapshotURL(for: .currentLocation) + .deletingLastPathComponent() + XCTAssertEqual( + try FileManager.default.contentsOfDirectory(atPath: directory.path), + ["current-location.json"] + ) + } + + func testWeatherAndClockRunConcurrentlyAfterTownshipResolution() + async throws + { + let weatherGate = CurrentLocationRefreshOperationGate() + let clockGate = CurrentLocationRefreshOperationGate() + let observation = try makeObservation() + let resolvedLocation = resolvedLocation() + let snapshotTime = makeSnapshotTime() + let writer = CurrentLocationSnapshotWriterSpy() + let service = CurrentLocationCurrentWeatherWidgetRefreshService( + acquireLocation: { @MainActor [preciseLocation] in + .acquired(preciseLocation) + }, + resolveTownship: { _ in + resolvedLocation + }, + fetchWeather: { _, _ in + await weatherGate.wait() + return observation + }, + synchronizeClock: { + await clockGate.wait() + return snapshotTime + }, + writeSnapshot: writer.write + ) + + let refresh = Task { await service.refresh() } + for _ in 0..<200 { + if await weatherGate.hasStarted, + await clockGate.hasStarted { + break + } + await Task.yield() + } + + let weatherStarted = await weatherGate.hasStarted + let clockStarted = await clockGate.hasStarted + XCTAssertTrue(weatherStarted) + XCTAssertTrue(clockStarted) + await weatherGate.open() + await clockGate.open() + let result = await refresh.value + XCTAssertEqual(result, .refreshed) + } + + private func makeService( + locationResult: WidgetCurrentLocationResult? = nil, + resolvedLocation: WidgetResolvedWeatherLocation? = nil, + shouldResolveTownship: Bool = true, + recorder: CurrentLocationRefreshEventRecorder? = nil, + weather: CurrentLocationScriptedWeather, + synchronizeClock: @escaping + CurrentLocationCurrentWeatherWidgetRefreshService + .SynchronizeClock = { + CurrentWeatherSnapshotTime( + calibratedNowUnixMilliseconds: 1_710_907_200_000, + calibratedTimeOffsetMilliseconds: 321 + ) + }, + writeSnapshot: @escaping + CurrentLocationCurrentWeatherWidgetRefreshService.WriteSnapshot + ) -> CurrentLocationCurrentWeatherWidgetRefreshService { + let preciseLocation = preciseLocation + let defaultResolvedLocation = self.resolvedLocation() + return CurrentLocationCurrentWeatherWidgetRefreshService( + acquireLocation: { @MainActor in + recorder?.record(.acquireLocation) + return locationResult ?? .acquired(preciseLocation) + }, + resolveTownship: { location in + recorder?.record(.resolveTownship) + guard shouldResolveTownship, + location == preciseLocation + else { + return nil + } + if let resolvedLocation { + return resolvedLocation + } + return defaultResolvedLocation + }, + fetchWeather: { latitude, longitude in + try await weather.fetch( + latitude: latitude, + longitude: longitude + ) + }, + synchronizeClock: { + recorder?.record(.synchronizeClock) + return await synchronizeClock() + }, + writeSnapshot: writeSnapshot + ) + } + + private func makeService( + weather: CurrentLocationScriptedWeather, + clock: WidgetServerClock, + writeSnapshot: @escaping + CurrentLocationCurrentWeatherWidgetRefreshService.WriteSnapshot + ) -> CurrentLocationCurrentWeatherWidgetRefreshService { + let preciseLocation = preciseLocation + let resolvedLocation = resolvedLocation() + return CurrentLocationCurrentWeatherWidgetRefreshService( + acquireLocation: { @MainActor in + .acquired(preciseLocation) + }, + resolveTownship: { location in + guard location == preciseLocation else { + return nil + } + return resolvedLocation + }, + fetchWeather: { latitude, longitude in + try await weather.fetch( + latitude: latitude, + longitude: longitude + ) + }, + clock: clock, + writeSnapshot: writeSnapshot + ) + } + + private func resolvedLocation( + regionCode: String = "407", + regionName: String = "西屯區", + latitude: Double = 24.1813400, + longitude: Double = 120.6466200 + ) -> WidgetResolvedWeatherLocation { + WidgetResolvedWeatherLocation( + address: .currentLocation, + regionCode: regionCode, + regionName: regionName, + latitude: latitude, + longitude: longitude + )! + } + + private func makeObservation() throws -> CurrentWeatherRemoteDTO { + try JSONDecoder().decode( + CurrentWeatherRemoteDTO.self, + from: Data( + """ + { + "station": {"name": "西屯測站"}, + "time": 1710900000, + "data": { + "weather": "晴", + "weatherCode": 100, + "temperature": 28.5, + "humidity": 70, + "rain": 0 + } + } + """.utf8 + ) + ) + } + + private func makeSnapshotTime() -> CurrentWeatherSnapshotTime { + CurrentWeatherSnapshotTime( + calibratedNowUnixMilliseconds: 1_710_907_200_000, + calibratedTimeOffsetMilliseconds: 321 + ) + } + + private func makeClock( + monotonicClock: CurrentLocationTestMonotonicClock = + CurrentLocationTestMonotonicClock(milliseconds: 100), + serverResults: [Result] + ) -> WidgetServerClock { + WidgetServerClock( + deviceClock: CurrentLocationTestWallClock(milliseconds: 5_000), + monotonicClock: monotonicClock, + serverTimeSource: CurrentLocationScriptedServerTimeSource( + results: serverResults + ), + timeoutRunner: CurrentLocationPassthroughTimeoutRunner() + ) + } + + @discardableResult + private func seedExistingSnapshot() throws -> Data { + try CurrentWeatherWidgetSnapshotWriter( + containerURL: containerURL + ).write( + CurrentWeatherWidgetSnapshot( + schemaVersion: 5, + sourceIdentifier: "current-location", + regionCode: "407", + regionName: "舊快取", + observationTime: 1_700_000_000, + stationName: "舊測站", + weather: "陰", + weatherCode: 300, + condition: .overcast, + isNight: false, + nextDayNightTransitionTime: 1_700_010_000, + calibratedTimeOffsetMilliseconds: 123, + temperature: 20, + humidity: 60, + rain: 1 + ) + ) + return try cachedData() + } + + private func cachedData() throws -> Data { + try Data( + contentsOf: CurrentWeatherSnapshotStorage( + containerURL: containerURL + ).snapshotURL(for: .currentLocation) + ) + } +} + +private enum CurrentLocationRefreshTestError: Error, Sendable { + case scripted +} + +private enum CurrentLocationRefreshEvent: Equatable { + case acquireLocation + case resolveTownship + case fetchWeather + case synchronizeClock + case writeSnapshot +} + +private final class CurrentLocationRefreshEventRecorder: + @unchecked Sendable +{ + private let lock = NSLock() + private var storedEvents: [CurrentLocationRefreshEvent] = [] + + var events: [CurrentLocationRefreshEvent] { + lock.lock() + defer { lock.unlock() } + return storedEvents + } + + func record(_ event: CurrentLocationRefreshEvent) { + lock.lock() + storedEvents.append(event) + lock.unlock() + } +} + +private actor CurrentLocationScriptedWeather { + struct Coordinates: Sendable { + let latitude: Double + let longitude: Double + } + + private(set) var callCount = 0 + private(set) var coordinates: [Coordinates] = [] + private let result: Result< + CurrentWeatherRemoteDTO?, + CurrentLocationRefreshTestError + > + private let recorder: CurrentLocationRefreshEventRecorder? + + init( + result: Result< + CurrentWeatherRemoteDTO?, + CurrentLocationRefreshTestError + >, + recorder: CurrentLocationRefreshEventRecorder? = nil + ) { + self.result = result + self.recorder = recorder + } + + func fetch( + latitude: Double, + longitude: Double + ) throws -> CurrentWeatherRemoteDTO? { + recorder?.record(.fetchWeather) + callCount += 1 + coordinates.append( + Coordinates(latitude: latitude, longitude: longitude) + ) + return try result.get() + } +} + +private actor CurrentLocationScriptedClock { + private(set) var callCount = 0 + private let sample: CurrentWeatherSnapshotTime? + + init(sample: CurrentWeatherSnapshotTime?) { + self.sample = sample + } + + func synchronizeAndSample() -> CurrentWeatherSnapshotTime? { + callCount += 1 + return sample + } +} + +private final class CurrentLocationSnapshotWriterSpy: @unchecked Sendable { + private let lock = NSLock() + private let error: CurrentLocationRefreshTestError? + private let recorder: CurrentLocationRefreshEventRecorder? + private var storedSnapshots: [CurrentWeatherWidgetSnapshot] = [] + private var storedWriteCount = 0 + + init( + error: CurrentLocationRefreshTestError? = nil, + recorder: CurrentLocationRefreshEventRecorder? = nil + ) { + self.error = error + self.recorder = recorder + } + + var snapshots: [CurrentWeatherWidgetSnapshot] { + lock.lock() + defer { lock.unlock() } + return storedSnapshots + } + + var writeCount: Int { + lock.lock() + defer { lock.unlock() } + return storedWriteCount + } + + func write(_ snapshot: CurrentWeatherWidgetSnapshot) throws { + recorder?.record(.writeSnapshot) + lock.lock() + storedWriteCount += 1 + let error = error + if error == nil { + storedSnapshots.append(snapshot) + } + lock.unlock() + + if let error { + throw error + } + } +} + +private actor CurrentLocationRefreshOperationGate { + private(set) var hasStarted = false + private var isOpen = false + private var continuation: CheckedContinuation? + + func wait() async { + hasStarted = true + guard !isOpen else { + return + } + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func open() { + isOpen = true + continuation?.resume() + continuation = nil + } +} + +private final class CurrentLocationTestWallClock: WidgetWallTimeSource, + @unchecked Sendable +{ + private let milliseconds: Int64 + + init(milliseconds: Int64) { + self.milliseconds = milliseconds + } + + func now() -> Date { + Date( + timeIntervalSince1970: TimeInterval(milliseconds) / 1_000 + ) + } +} + +private final class CurrentLocationTestMonotonicClock: + WidgetMonotonicTimeSource, + @unchecked Sendable +{ + private let lock = NSLock() + private var milliseconds: Int64 + + init(milliseconds: Int64) { + self.milliseconds = milliseconds + } + + func elapsedMilliseconds() -> Int64 { + lock.lock() + defer { lock.unlock() } + return milliseconds + } + + func set(milliseconds: Int64) { + lock.lock() + self.milliseconds = milliseconds + lock.unlock() + } +} + +private actor CurrentLocationScriptedServerTimeSource: + WidgetServerTimeSource +{ + private var results: [ + Result + ] + + init(results: [Result]) { + self.results = results + } + + func serverTimeUnixMilliseconds() throws -> Int64 { + guard !results.isEmpty else { + throw CurrentLocationRefreshTestError.scripted + } + return try results.removeFirst().get() + } +} + +private struct CurrentLocationPassthroughTimeoutRunner: + WidgetServerClockTimeoutRunning +{ + func serverTimeUnixMilliseconds( + from source: any WidgetServerTimeSource, + timeout: TimeInterval + ) async throws -> Int64 { + try await source.serverTimeUnixMilliseconds() + } +} diff --git a/ios/RunnerTests/DPIPWidgetProviderTests.swift b/ios/RunnerTests/DPIPWidgetProviderTests.swift index 115aca24f..a4ec88f40 100644 --- a/ios/RunnerTests/DPIPWidgetProviderTests.swift +++ b/ios/RunnerTests/DPIPWidgetProviderTests.swift @@ -14,7 +14,8 @@ final class DPIPWidgetProviderTests: XCTestCase { _ = await planner(store: store).plan(for: target) - XCTAssertEqual(store.refreshTargets, [target]) + XCTAssertEqual(store.savedRefreshTargets, [target]) + XCTAssertEqual(store.currentRefreshCount, 0) } func testSavedTargetReloadsCacheAfterSuccessfulRefresh() async { @@ -37,7 +38,7 @@ final class DPIPWidgetProviderTests: XCTestCase { XCTAssertEqual( store.events, - [.load(target), .refresh(target), .load(target)] + [.load(target), .refreshSaved(target), .load(target)] ) XCTAssertEqual(plan.snapshot?.stationName, "refreshed-station") } @@ -101,22 +102,50 @@ final class DPIPWidgetProviderTests: XCTestCase { XCTAssertEqual(store.loadTargets, [target, target]) } - func testCurrentLocationDoesNotInvokeSavedRefresh() async { + func testCurrentLocationInvokesOnlyCurrentRefresh() async { let target = WidgetLocationTarget.currentLocation let store = ProviderTimelineTestStore( refreshResult: .refreshed, + currentRefreshResult: .refreshed, snapshots: [(target, snapshot(for: target))] ) _ = await planner(store: store).plan(for: target) - XCTAssertTrue(store.refreshTargets.isEmpty) + XCTAssertTrue(store.savedRefreshTargets.isEmpty) + XCTAssertEqual(store.currentRefreshCount, 1) } - func testCurrentLocationDisplaysExistingCache() async { + func testCurrentLocationSuccessReloadsCurrentLocationCache() async { + let target = WidgetLocationTarget.currentLocation + let refreshedSnapshot = snapshot( + for: target, + stationName: "refreshed-current-location" + ) + let store = ProviderTimelineTestStore( + refreshResult: .failed, + currentRefreshResult: .refreshed, + snapshots: [(target, snapshot(for: target))], + refreshedSnapshot: refreshedSnapshot + ) + + let plan = await planner(store: store).plan(for: target) + + XCTAssertEqual( + store.events, + [.load(target), .refreshCurrent, .load(target)] + ) + XCTAssertEqual( + plan.snapshot?.stationName, + "refreshed-current-location" + ) + } + + func testCurrentLocationFailureReloadsStaleCache() async { let target = WidgetLocationTarget.currentLocation let store = ProviderTimelineTestStore( refreshResult: .failed, + currentRefreshResult: .failed, snapshots: [ (target, snapshot( for: target, @@ -131,6 +160,28 @@ final class DPIPWidgetProviderTests: XCTestCase { plan.snapshot?.stationName, "current-location-cache" ) + XCTAssertEqual( + store.events, + [.load(target), .refreshCurrent, .load(target)] + ) + } + + func testCurrentLocationFailureWithoutCacheKeepsNoDataBehavior() async { + let target = WidgetLocationTarget.currentLocation + let store = ProviderTimelineTestStore( + refreshResult: .failed, + currentRefreshResult: .failed, + snapshots: [] + ) + + let plan = await planner(store: store).plan(for: target) + + XCTAssertNil(plan.snapshot) + XCTAssertEqual(plan.states.count, 1) + XCTAssertEqual( + store.events, + [.load(target), .refreshCurrent, .load(target)] + ) } func testInvalidTargetDoesNotInvokeRefreshOrUseOtherCache() async { @@ -145,7 +196,8 @@ final class DPIPWidgetProviderTests: XCTestCase { let plan = await planner(store: store).plan(for: target) - XCTAssertTrue(store.refreshTargets.isEmpty) + XCTAssertTrue(store.savedRefreshTargets.isEmpty) + XCTAssertEqual(store.currentRefreshCount, 0) XCTAssertNil(plan.snapshot) XCTAssertEqual(store.loadTargets, [target]) } @@ -256,6 +308,54 @@ final class DPIPWidgetProviderTests: XCTestCase { XCTAssertEqual(Set(recorder.identities).count, 1) } + func testSavedAndCurrentRefreshRuntimesShareOneServerClock() async { + let recorder = ProviderClockIdentityRecorder() + let clock = WidgetServerClock() + let savedRuntime = SavedCurrentWeatherWidgetRefreshRuntime( + serverClock: clock, + refreshWithClock: { receivedClock, _ in + recorder.record(receivedClock) + return .failed + } + ) + let currentRuntime = + CurrentLocationCurrentWeatherWidgetRefreshRuntime( + serverClock: clock, + refreshWithClock: { receivedClock in + recorder.record(receivedClock) + return .failed + } + ) + + _ = await savedRuntime.refresh( + target: .saved(regionCode: "407") + ) + _ = await currentRuntime.refresh() + + XCTAssertEqual(recorder.identities.count, 2) + XCTAssertEqual(Set(recorder.identities).count, 1) + } + + func testSnapshotPathIsCacheOnlyAndDoesNotInvokeRefresh() { + let target = WidgetLocationTarget.currentLocation + let store = ProviderTimelineTestStore( + refreshResult: .refreshed, + currentRefreshResult: .refreshed, + snapshots: [(target, snapshot(for: target))] + ) + let dependencies = DPIPWidgetProviderDependencies( + loadSnapshot: store.load, + timelinePlanner: planner(store: store) + ) + + let loaded = dependencies.snapshot(for: target) + + XCTAssertEqual(loaded?.sourceIdentifier, "current-location") + XCTAssertEqual(store.loadTargets, [target]) + XCTAssertTrue(store.savedRefreshTargets.isEmpty) + XCTAssertEqual(store.currentRefreshCount, 0) + } + private func planner( store: ProviderTimelineTestStore ) -> DPIPWidgetTimelinePlanner { @@ -264,7 +364,8 @@ final class DPIPWidgetProviderTests: XCTestCase { staleAfter: staleAfter, refreshInterval: DPIPWidgetProviderRuntime.refreshInterval, loadSnapshot: store.load, - refreshSaved: store.refresh, + refreshSaved: store.refreshSaved, + refreshCurrent: store.refreshCurrent, now: { fixedNow } ) } @@ -305,13 +406,16 @@ final class DPIPWidgetProviderTests: XCTestCase { } private enum ProviderTimelineEvent: Equatable { - case refresh(WidgetLocationTarget) + case refreshSaved(WidgetLocationTarget) + case refreshCurrent case load(WidgetLocationTarget) } private final class ProviderTimelineTestStore: @unchecked Sendable { private let lock = NSLock() - private let refreshResult: SavedCurrentWeatherWidgetRefreshResult + private let savedRefreshResult: SavedCurrentWeatherWidgetRefreshResult + private let currentRefreshResult: + CurrentLocationCurrentWeatherWidgetRefreshResult private let refreshedSnapshot: CurrentWeatherWidgetSnapshot? private var storedSnapshots: [String: CurrentWeatherWidgetSnapshot] @@ -320,13 +424,16 @@ private final class ProviderTimelineTestStore: @unchecked Sendable { init( refreshResult: SavedCurrentWeatherWidgetRefreshResult, + currentRefreshResult: + CurrentLocationCurrentWeatherWidgetRefreshResult = .failed, snapshots: [( WidgetLocationTarget, CurrentWeatherWidgetSnapshot )], refreshedSnapshot: CurrentWeatherWidgetSnapshot? = nil ) { - self.refreshResult = refreshResult + savedRefreshResult = refreshResult + self.currentRefreshResult = currentRefreshResult self.refreshedSnapshot = refreshedSnapshot storedSnapshots = Dictionary( uniqueKeysWithValues: snapshots.map { @@ -335,11 +442,18 @@ private final class ProviderTimelineTestStore: @unchecked Sendable { ) } - func refresh( + func refreshSaved( target: WidgetLocationTarget ) async -> SavedCurrentWeatherWidgetRefreshResult { - recordRefresh(target) - return refreshResult + recordSavedRefresh(target) + return savedRefreshResult + } + + func refreshCurrent() async + -> CurrentLocationCurrentWeatherWidgetRefreshResult + { + recordCurrentRefresh() + return currentRefreshResult } func load( @@ -365,15 +479,19 @@ private final class ProviderTimelineTestStore: @unchecked Sendable { return storedEvents } - var refreshTargets: [WidgetLocationTarget] { + var savedRefreshTargets: [WidgetLocationTarget] { events.compactMap { event in - guard case let .refresh(target) = event else { + guard case let .refreshSaved(target) = event else { return nil } return target } } + var currentRefreshCount: Int { + events.filter { $0 == .refreshCurrent }.count + } + var loadTargets: [WidgetLocationTarget] { events.compactMap { event in guard case let .load(target) = event else { @@ -389,14 +507,27 @@ private final class ProviderTimelineTestStore: @unchecked Sendable { return storedCacheMutationCount } - private func recordRefresh( + private func recordSavedRefresh( _ target: WidgetLocationTarget ) { lock.lock() defer { lock.unlock() } - storedEvents.append(.refresh(target)) + storedEvents.append(.refreshSaved(target)) + + if savedRefreshResult == .refreshed, + let refreshedSnapshot { + storedSnapshots[Self.key(for: target)] = refreshedSnapshot + storedCacheMutationCount += 1 + } + } + + private func recordCurrentRefresh() { + lock.lock() + defer { lock.unlock() } + let target = WidgetLocationTarget.currentLocation + storedEvents.append(.refreshCurrent) - if refreshResult == .refreshed, + if currentRefreshResult == .refreshed, let refreshedSnapshot { storedSnapshots[Self.key(for: target)] = refreshedSnapshot storedCacheMutationCount += 1 diff --git a/ios/RunnerTests/WidgetCurrentLocationClientTests.swift b/ios/RunnerTests/WidgetCurrentLocationClientTests.swift index 770d9c893..43edab029 100644 --- a/ios/RunnerTests/WidgetCurrentLocationClientTests.swift +++ b/ios/RunnerTests/WidgetCurrentLocationClientTests.swift @@ -18,7 +18,7 @@ final class WidgetCurrentLocationClientTests: XCTestCase { XCTAssertEqual(result, .unavailable) XCTAssertFalse(managerWasCreated) - XCTAssertEqual(manager.requestCount, 0) + XCTAssertEqual(manager.startCount, 0) } func testNotDeterminedReturnsUnavailable() async { @@ -30,7 +30,7 @@ final class WidgetCurrentLocationClientTests: XCTestCase { let result = await makeClient(manager: manager).acquireLocation() XCTAssertEqual(result, .unavailable) - XCTAssertEqual(manager.requestCount, 0) + XCTAssertEqual(manager.startCount, 0) } func testDeniedReturnsUnavailable() async { @@ -42,7 +42,7 @@ final class WidgetCurrentLocationClientTests: XCTestCase { let result = await makeClient(manager: manager).acquireLocation() XCTAssertEqual(result, .unavailable) - XCTAssertEqual(manager.requestCount, 0) + XCTAssertEqual(manager.startCount, 0) } func testRestrictedReturnsUnavailable() async { @@ -54,7 +54,7 @@ final class WidgetCurrentLocationClientTests: XCTestCase { let result = await makeClient(manager: manager).acquireLocation() XCTAssertEqual(result, .unavailable) - XCTAssertEqual(manager.requestCount, 0) + XCTAssertEqual(manager.startCount, 0) } func testWidgetAuthorizationFalseReturnsUnavailable() async { @@ -66,7 +66,7 @@ final class WidgetCurrentLocationClientTests: XCTestCase { let result = await makeClient(manager: manager).acquireLocation() XCTAssertEqual(result, .unavailable) - XCTAssertEqual(manager.requestCount, 0) + XCTAssertEqual(manager.startCount, 0) } func testAuthorizedWhenInUseMayAcquireLocation() async { @@ -82,7 +82,7 @@ final class WidgetCurrentLocationClientTests: XCTestCase { let result = await makeClient(manager: manager).acquireLocation() XCTAssertEqual(result, .acquired(expected)) - XCTAssertEqual(manager.requestCount, 1) + XCTAssertEqual(manager.startCount, 1) } func testAuthorizedAlwaysMayAcquireLocation() async { @@ -98,16 +98,16 @@ final class WidgetCurrentLocationClientTests: XCTestCase { let result = await makeClient(manager: manager).acquireLocation() XCTAssertEqual(result, .acquired(expected)) - XCTAssertEqual(manager.requestCount, 1) + XCTAssertEqual(manager.startCount, 1) } - func testValidFreshLocationIsReturned() async { + func testSampleGeneratedDuringActiveRequestIsAccepted() async { let manager = FakeWidgetLocationManager( event: .samples([ WidgetLocationSample( latitude: 24.1477, longitude: 120.6736, - timestamp: now.addingTimeInterval(-599) + timestamp: now ), ]) ) @@ -125,25 +125,141 @@ final class WidgetCurrentLocationClientTests: XCTestCase { ) } - func testStaleLocationIsRejected() async { + func testClearlyPreRequestCachedSampleWaitsUntilTimeout() async { + let manager = FakeWidgetLocationManager() + let scheduler = FakeWidgetLocationTimeoutScheduler() + let client = makeClient(manager: manager, scheduler: scheduler) + let task = Task { @MainActor in + await client.acquireLocation() + } + await waitForRequest(on: manager) + + manager.send( + samples: [ + WidgetLocationSample( + latitude: 25.033, + longitude: 121.5654, + timestamp: now.addingTimeInterval(-2) + ), + ] + ) + await Task.yield() + + XCTAssertEqual(manager.stopCount, 0) + XCTAssertNotNil(manager.delegate) + XCTAssertEqual(scheduler.lastCancellation?.cancelCount, 0) + + scheduler.fireLast() + let result = await task.value + + XCTAssertEqual(result, .timedOut) + XCTAssertEqual(manager.stopCount, 1) + XCTAssertNil(manager.delegate) + } + + func testCurrentSampleWinsAfterPreRequestCachedSample() async { + let manager = FakeWidgetLocationManager() + let scheduler = FakeWidgetLocationTimeoutScheduler() + let client = makeClient(manager: manager, scheduler: scheduler) + let task = Task { @MainActor in + await client.acquireLocation() + } + await waitForRequest(on: manager) + + manager.send( + samples: [ + WidgetLocationSample( + latitude: 24.1477, + longitude: 120.6736, + timestamp: now.addingTimeInterval(-30) + ), + ] + ) + manager.send( + samples: [ + WidgetLocationSample( + latitude: 25.033, + longitude: 121.5654, + timestamp: now + ), + ] + ) + + let result = await task.value + + XCTAssertEqual( + result, + .acquired( + WidgetCurrentLocation( + latitude: 25.033, + longitude: 121.5654 + )! + ) + ) + XCTAssertEqual(manager.stopCount, 1) + XCTAssertNil(manager.delegate) + } + + func testRequestStartToleranceAcceptsLegitimateNearStartSample() async { + let expected = WidgetCurrentLocation( + latitude: 25.033, + longitude: 121.5654 + )! let manager = FakeWidgetLocationManager( event: .samples([ + WidgetLocationSample( + latitude: expected.latitude, + longitude: expected.longitude, + timestamp: now.addingTimeInterval(-0.5) + ), + ]) + ) + + let result = await makeClient(manager: manager).acquireLocation() + + XCTAssertEqual(result, .acquired(expected)) + } + + func testTenMinuteAbsoluteFreshnessLimitStillRejectsStaleSample() async { + let manager = FakeWidgetLocationManager() + let scheduler = FakeWidgetLocationTimeoutScheduler() + let client = makeClient( + manager: manager, + scheduler: scheduler, + requestStartTolerance: 700 + ) + let task = Task { @MainActor in + await client.acquireLocation() + } + await waitForRequest(on: manager) + + manager.send( + samples: [ WidgetLocationSample( latitude: 25.033, longitude: 121.5654, timestamp: now.addingTimeInterval(-601) ), - ]) + ] ) + scheduler.fireLast() - let result = await makeClient(manager: manager).acquireLocation() + let result = await task.value - XCTAssertEqual(result, .failed) + XCTAssertEqual(result, .timedOut) } func testInvalidAndNonFiniteCoordinatesAreRejected() async { - let manager = FakeWidgetLocationManager( - event: .samples([ + let manager = FakeWidgetLocationManager() + let scheduler = FakeWidgetLocationTimeoutScheduler() + let client = makeClient(manager: manager, scheduler: scheduler) + let task = Task { @MainActor in + await client.acquireLocation() + } + await waitForRequest(on: manager) + + manager.send( + samples: [ WidgetLocationSample( latitude: 91, longitude: 121.5654, @@ -154,12 +270,13 @@ final class WidgetCurrentLocationClientTests: XCTestCase { longitude: 121.5654, timestamp: now ), - ]) + ] ) + scheduler.fireLast() - let result = await makeClient(manager: manager).acquireLocation() + let result = await task.value - XCTAssertEqual(result, .failed) + XCTAssertEqual(result, .timedOut) } func testCoreLocationFailureReturnsFailure() async { @@ -234,11 +351,11 @@ final class WidgetCurrentLocationClientTests: XCTestCase { let result = await makeClient(manager: manager).acquireLocation() XCTAssertEqual(result, .unavailable) - XCTAssertEqual(manager.requestCount, 0) + XCTAssertEqual(manager.startCount, 0) XCTAssertEqual(manager.stopCount, 0) } - func testRequestUsesTownshipAppropriateAccuracyAndTenSecondTimeout() async { + func testAcquisitionUsesTownshipAppropriateAccuracyAndTenSecondTimeout() async { let expected = WidgetCurrentLocation( latitude: 25.033, longitude: 121.5654 @@ -268,6 +385,8 @@ final class WidgetCurrentLocationClientTests: XCTestCase { servicesEnabled: Bool = true, manager: FakeWidgetLocationManager, scheduler: FakeWidgetLocationTimeoutScheduler? = nil, + requestStartTolerance: TimeInterval = + WidgetCurrentLocationClient.defaultRequestStartTolerance, onMakeManager: @escaping @MainActor () -> Void = {} ) -> WidgetCurrentLocationClient { WidgetCurrentLocationClient( @@ -278,6 +397,7 @@ final class WidgetCurrentLocationClientTests: XCTestCase { }, timeoutScheduler: scheduler ?? FakeWidgetLocationTimeoutScheduler(), + requestStartTolerance: requestStartTolerance, now: { self.now } ) } @@ -301,10 +421,10 @@ final class WidgetCurrentLocationClientTests: XCTestCase { private func waitForRequest( on manager: FakeWidgetLocationManager ) async { - for _ in 0..<100 where manager.requestCount == 0 { + for _ in 0..<100 where manager.startCount == 0 { await Task.yield() } - XCTAssertEqual(manager.requestCount, 1) + XCTAssertEqual(manager.startCount, 1) } } @@ -320,7 +440,7 @@ private final class FakeWidgetLocationManager: WidgetLocationManaging { let authorization: WidgetLocationAuthorization let isAuthorizedForWidgetUpdates: Bool var desiredAccuracy: Double = 0 - private(set) var requestCount = 0 + private(set) var startCount = 0 private(set) var stopCount = 0 private let event: Event @@ -334,8 +454,8 @@ private final class FakeWidgetLocationManager: WidgetLocationManaging { self.event = event } - func requestLocation() { - requestCount += 1 + func startUpdatingLocation() { + startCount += 1 switch event { case .none: break diff --git a/ios/RunnerTests/WidgetLocationIntentTests.swift b/ios/RunnerTests/WidgetLocationIntentTests.swift index b00b59496..cba75caae 100644 --- a/ios/RunnerTests/WidgetLocationIntentTests.swift +++ b/ios/RunnerTests/WidgetLocationIntentTests.swift @@ -53,6 +53,15 @@ final class WidgetLocationIntentTests: XCTestCase { XCTAssertEqual(options.first?.displayString, "所在地") } + func testDefaultLocationOptionIsExplicitCurrentLocation() { + let option = makeCurrentWidgetLocationOption( + displayString: "所在地" + ) + + XCTAssertEqual(option.identifier, "current-location") + XCTAssertEqual(option.displayString, "所在地") + } + func testCatalogLocationsPreserveSavedOrder() throws { let firstLocation = try XCTUnwrap( WidgetLocationCatalogLocation( From 7160ca16c790121045fcef89b47d9bb4847633fe Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Tue, 22 Sep 2026 00:56:56 +0800 Subject: [PATCH 15/18] fix(weather): prevent stale widget snapshot replacement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios Fix(zh-Hant): 修正 iOS 天氣小工具偶爾顯示先前所在地的問題 Fix(en-US): fix iOS weather widgets occasionally showing a previous location --- ...onCurrentWeatherWidgetRefreshService.swift | 130 ++- .../CurrentWeatherWidgetSnapshotWriter.swift | 41 +- .../DPIPWidgetProviderSupport.swift | 4 + ...edCurrentWeatherWidgetRefreshService.swift | 130 ++- ios/Runner/WidgetSnapshotPlugin.swift | 87 +- ...rentWeatherWidgetRefreshServiceTests.swift | 39 + ...rentWeatherWidgetSnapshotWriterTests.swift | 972 +++++++++++++++++- ios/RunnerTests/RunnerTests.swift | 56 +- ...rentWeatherWidgetRefreshServiceTests.swift | 35 + .../CurrentWeatherSnapshotStorage.swift | 579 ++++++++++- 10 files changed, 1957 insertions(+), 116 deletions(-) diff --git a/ios/DPIPWidgets/CurrentLocationCurrentWeatherWidgetRefreshService.swift b/ios/DPIPWidgets/CurrentLocationCurrentWeatherWidgetRefreshService.swift index ca20ace7d..890d26bac 100644 --- a/ios/DPIPWidgets/CurrentLocationCurrentWeatherWidgetRefreshService.swift +++ b/ios/DPIPWidgets/CurrentLocationCurrentWeatherWidgetRefreshService.swift @@ -1,5 +1,6 @@ enum CurrentLocationCurrentWeatherWidgetRefreshResult: Equatable, Sendable { case refreshed + case superseded case noObservation case unavailable case failed @@ -20,12 +21,20 @@ struct CurrentLocationCurrentWeatherWidgetRefreshService: Sendable { typealias WriteSnapshot = @Sendable ( CurrentWeatherWidgetSnapshot ) throws -> Void + typealias BeginWrite = @Sendable ( + CurrentWeatherSnapshotAddress + ) throws -> CurrentWeatherSnapshotWriteToken + typealias CommitSnapshot = @Sendable ( + CurrentWeatherWidgetSnapshot, + CurrentWeatherSnapshotWriteToken + ) throws -> CurrentWeatherSnapshotWriteResult private let acquireLocation: AcquireLocation private let resolveTownship: ResolveTownship private let fetchWeather: FetchWeather private let synchronizeClock: SynchronizeClock - private let writeSnapshot: WriteSnapshot + private let beginWrite: BeginWrite + private let commitSnapshot: CommitSnapshot init( acquireLocation: @escaping AcquireLocation, @@ -45,9 +54,14 @@ struct CurrentLocationCurrentWeatherWidgetRefreshService: Sendable { longitude: longitude ) }, - clock: clock, - writeSnapshot: { snapshot in - try writer.write(snapshot) + synchronizeClock: { + await Self.synchronizedSnapshotTime(clock: clock) + }, + beginWrite: { address in + try writer.beginWrite(for: address) + }, + commitSnapshot: { snapshot, token in + try writer.write(snapshot, using: token) } ) } @@ -64,34 +78,7 @@ struct CurrentLocationCurrentWeatherWidgetRefreshService: Sendable { resolveTownship: resolveTownship, fetchWeather: fetchWeather, synchronizeClock: { - #if DEBUG - WidgetWeatherRefreshDiagnostics.log( - "clock synchronization started" - ) - let synchronized = await clock.synchronize() - #else - _ = await clock.synchronize() - #endif - let hasSynchronized = await clock.hasSynchronized - #if DEBUG - if synchronized { - WidgetWeatherRefreshDiagnostics.log( - "clock synchronized" - ) - } else if hasSynchronized { - WidgetWeatherRefreshDiagnostics.log( - "clock failed retainedPreviousAnchor=true" - ) - } else { - WidgetWeatherRefreshDiagnostics.log( - "clock failed retainedPreviousAnchor=false" - ) - } - #endif - guard hasSynchronized else { - return nil - } - return await clock.currentWeatherSnapshotTime() + await Self.synchronizedSnapshotTime(clock: clock) }, writeSnapshot: writeSnapshot ) @@ -103,15 +90,49 @@ struct CurrentLocationCurrentWeatherWidgetRefreshService: Sendable { fetchWeather: @escaping FetchWeather, synchronizeClock: @escaping SynchronizeClock, writeSnapshot: @escaping WriteSnapshot + ) { + self.init( + acquireLocation: acquireLocation, + resolveTownship: resolveTownship, + fetchWeather: fetchWeather, + synchronizeClock: synchronizeClock, + beginWrite: { address in + CurrentWeatherSnapshotWriteToken( + address: address, + generation: 1 + ) + }, + commitSnapshot: { snapshot, _ in + try writeSnapshot(snapshot) + return .written + } + ) + } + + init( + acquireLocation: @escaping AcquireLocation, + resolveTownship: @escaping ResolveTownship, + fetchWeather: @escaping FetchWeather, + synchronizeClock: @escaping SynchronizeClock, + beginWrite: @escaping BeginWrite, + commitSnapshot: @escaping CommitSnapshot ) { self.acquireLocation = acquireLocation self.resolveTownship = resolveTownship self.fetchWeather = fetchWeather self.synchronizeClock = synchronizeClock - self.writeSnapshot = writeSnapshot + self.beginWrite = beginWrite + self.commitSnapshot = commitSnapshot } func refresh() async -> CurrentLocationCurrentWeatherWidgetRefreshResult { + let writeToken: CurrentWeatherSnapshotWriteToken + do { + writeToken = try beginWrite(.currentLocation) + } catch { + return .failed + } + #if DEBUG WidgetWeatherRefreshDiagnostics.log( "current location acquisition started" @@ -193,11 +214,15 @@ struct CurrentLocationCurrentWeatherWidgetRefreshService: Sendable { ) #endif do { - try writeSnapshot(snapshot) + let writeResult = try commitSnapshot(snapshot, writeToken) #if DEBUG - WidgetWeatherRefreshDiagnostics.log("writer succeeded") + WidgetWeatherRefreshDiagnostics.log( + writeResult == .written + ? "writer succeeded" + : "writer superseded" + ) #endif - return .refreshed + return writeResult == .written ? .refreshed : .superseded } catch { #if DEBUG WidgetWeatherRefreshDiagnostics.log("writer failed") @@ -206,6 +231,39 @@ struct CurrentLocationCurrentWeatherWidgetRefreshService: Sendable { } } + private static func synchronizedSnapshotTime( + clock: WidgetServerClock + ) async -> CurrentWeatherSnapshotTime? { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "clock synchronization started" + ) + let synchronized = await clock.synchronize() + #else + _ = await clock.synchronize() + #endif + let hasSynchronized = await clock.hasSynchronized + #if DEBUG + if synchronized { + WidgetWeatherRefreshDiagnostics.log( + "clock synchronized" + ) + } else if hasSynchronized { + WidgetWeatherRefreshDiagnostics.log( + "clock failed retainedPreviousAnchor=true" + ) + } else { + WidgetWeatherRefreshDiagnostics.log( + "clock failed retainedPreviousAnchor=false" + ) + } + #endif + guard hasSynchronized else { + return nil + } + return await clock.currentWeatherSnapshotTime() + } + #if DEBUG private func fetchWeatherWithDiagnostics( _ location: WidgetResolvedWeatherLocation diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotWriter.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotWriter.swift index 33778c01e..5bf58b50f 100644 --- a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotWriter.swift +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotWriter.swift @@ -7,11 +7,44 @@ enum CurrentWeatherWidgetSnapshotWriterError: Error { struct CurrentWeatherWidgetSnapshotWriter: Sendable { let containerURL: URL + func beginWrite( + for address: CurrentWeatherSnapshotAddress + ) throws -> CurrentWeatherSnapshotWriteToken { + try CurrentWeatherSnapshotStorage( + containerURL: containerURL + ).beginWrite(for: address) + } + func write( _ snapshot: CurrentWeatherWidgetSnapshot ) throws { + let address = try address(for: snapshot) + let token = try beginWrite(for: address) + _ = try write(snapshot, using: token) + } + + func write( + _ snapshot: CurrentWeatherWidgetSnapshot, + using token: CurrentWeatherSnapshotWriteToken + ) throws -> CurrentWeatherSnapshotWriteResult { let data = try JSONEncoder().encode(snapshot) + let address = try address(for: snapshot) + guard address == token.address else { + throw CurrentWeatherSnapshotStorageError.invalidWriteToken + } + + return try CurrentWeatherSnapshotStorage( + containerURL: containerURL + ).replace( + data, + using: token + ) + } + + private func address( + for snapshot: CurrentWeatherWidgetSnapshot + ) throws -> CurrentWeatherSnapshotAddress { guard let sourceIdentifier = snapshot.sourceIdentifier, let address = CurrentWeatherSnapshotAddress( @@ -21,12 +54,6 @@ struct CurrentWeatherWidgetSnapshotWriter: Sendable { throw CurrentWeatherWidgetSnapshotWriterError .invalidSourceIdentifier } - - try CurrentWeatherSnapshotStorage( - containerURL: containerURL - ).replace( - data, - for: address - ) + return address } } diff --git a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift index 912e7637b..0a7a29fc9 100644 --- a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift +++ b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift @@ -393,6 +393,8 @@ private extension SavedCurrentWeatherWidgetRefreshResult { switch self { case .refreshed: return "refreshed" + case .superseded: + return "superseded" case .noObservation: return "noObservation" case .unavailable: @@ -408,6 +410,8 @@ private extension CurrentLocationCurrentWeatherWidgetRefreshResult { switch self { case .refreshed: return "refreshed" + case .superseded: + return "superseded" case .noObservation: return "noObservation" case .unavailable: diff --git a/ios/DPIPWidgets/SavedCurrentWeatherWidgetRefreshService.swift b/ios/DPIPWidgets/SavedCurrentWeatherWidgetRefreshService.swift index f6f4b5b5f..e61e83b4d 100644 --- a/ios/DPIPWidgets/SavedCurrentWeatherWidgetRefreshService.swift +++ b/ios/DPIPWidgets/SavedCurrentWeatherWidgetRefreshService.swift @@ -1,5 +1,6 @@ enum SavedCurrentWeatherWidgetRefreshResult: Equatable, Sendable { case refreshed + case superseded case noObservation case unavailable case failed @@ -18,11 +19,19 @@ struct SavedCurrentWeatherWidgetRefreshService: Sendable { typealias WriteSnapshot = @Sendable ( CurrentWeatherWidgetSnapshot ) throws -> Void + typealias BeginWrite = @Sendable ( + CurrentWeatherSnapshotAddress + ) throws -> CurrentWeatherSnapshotWriteToken + typealias CommitSnapshot = @Sendable ( + CurrentWeatherWidgetSnapshot, + CurrentWeatherSnapshotWriteToken + ) throws -> CurrentWeatherSnapshotWriteResult private let resolveLocation: ResolveLocation private let fetchWeather: FetchWeather private let synchronizeClock: SynchronizeClock - private let writeSnapshot: WriteSnapshot + private let beginWrite: BeginWrite + private let commitSnapshot: CommitSnapshot init( resolver: SavedWidgetLocationResolver, @@ -40,9 +49,14 @@ struct SavedCurrentWeatherWidgetRefreshService: Sendable { longitude: longitude ) }, - clock: clock, - writeSnapshot: { snapshot in - try writer.write(snapshot) + synchronizeClock: { + await Self.synchronizedSnapshotTime(clock: clock) + }, + beginWrite: { address in + try writer.beginWrite(for: address) + }, + commitSnapshot: { snapshot, token in + try writer.write(snapshot, using: token) } ) } @@ -57,36 +71,7 @@ struct SavedCurrentWeatherWidgetRefreshService: Sendable { resolveLocation: resolveLocation, fetchWeather: fetchWeather, synchronizeClock: { - #if DEBUG - WidgetWeatherRefreshDiagnostics.log( - "clock synchronization started" - ) - #endif - #if DEBUG - let synchronized = await clock.synchronize() - #else - _ = await clock.synchronize() - #endif - let hasSynchronized = await clock.hasSynchronized - #if DEBUG - if synchronized { - WidgetWeatherRefreshDiagnostics.log( - "clock synchronized" - ) - } else if hasSynchronized { - WidgetWeatherRefreshDiagnostics.log( - "clock failed retainedPreviousAnchor=true" - ) - } else { - WidgetWeatherRefreshDiagnostics.log( - "clock failed retainedPreviousAnchor=false" - ) - } - #endif - guard hasSynchronized else { - return nil - } - return await clock.currentWeatherSnapshotTime() + await Self.synchronizedSnapshotTime(clock: clock) }, writeSnapshot: writeSnapshot ) @@ -97,11 +82,36 @@ struct SavedCurrentWeatherWidgetRefreshService: Sendable { fetchWeather: @escaping FetchWeather, synchronizeClock: @escaping SynchronizeClock, writeSnapshot: @escaping WriteSnapshot + ) { + self.init( + resolveLocation: resolveLocation, + fetchWeather: fetchWeather, + synchronizeClock: synchronizeClock, + beginWrite: { address in + CurrentWeatherSnapshotWriteToken( + address: address, + generation: 1 + ) + }, + commitSnapshot: { snapshot, _ in + try writeSnapshot(snapshot) + return .written + } + ) + } + + init( + resolveLocation: @escaping ResolveLocation, + fetchWeather: @escaping FetchWeather, + synchronizeClock: @escaping SynchronizeClock, + beginWrite: @escaping BeginWrite, + commitSnapshot: @escaping CommitSnapshot ) { self.resolveLocation = resolveLocation self.fetchWeather = fetchWeather self.synchronizeClock = synchronizeClock - self.writeSnapshot = writeSnapshot + self.beginWrite = beginWrite + self.commitSnapshot = commitSnapshot } func refresh( @@ -125,6 +135,13 @@ struct SavedCurrentWeatherWidgetRefreshService: Sendable { ) #endif + let writeToken: CurrentWeatherSnapshotWriteToken + do { + writeToken = try beginWrite(location.address) + } catch { + return .failed + } + #if DEBUG async let observation = fetchWeatherWithDiagnostics(location) #else @@ -167,11 +184,15 @@ struct SavedCurrentWeatherWidgetRefreshService: Sendable { ) #endif do { - try writeSnapshot(snapshot) + let writeResult = try commitSnapshot(snapshot, writeToken) #if DEBUG - WidgetWeatherRefreshDiagnostics.log("snapshot write succeeded") + WidgetWeatherRefreshDiagnostics.log( + writeResult == .written + ? "snapshot write succeeded" + : "snapshot write superseded" + ) #endif - return .refreshed + return writeResult == .written ? .refreshed : .superseded } catch { #if DEBUG WidgetWeatherRefreshDiagnostics.log("snapshot write failed") @@ -180,6 +201,39 @@ struct SavedCurrentWeatherWidgetRefreshService: Sendable { } } + private static func synchronizedSnapshotTime( + clock: WidgetServerClock + ) async -> CurrentWeatherSnapshotTime? { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "clock synchronization started" + ) + let synchronized = await clock.synchronize() + #else + _ = await clock.synchronize() + #endif + let hasSynchronized = await clock.hasSynchronized + #if DEBUG + if synchronized { + WidgetWeatherRefreshDiagnostics.log( + "clock synchronized" + ) + } else if hasSynchronized { + WidgetWeatherRefreshDiagnostics.log( + "clock failed retainedPreviousAnchor=true" + ) + } else { + WidgetWeatherRefreshDiagnostics.log( + "clock failed retainedPreviousAnchor=false" + ) + } + #endif + guard hasSynchronized else { + return nil + } + return await clock.currentWeatherSnapshotTime() + } + #if DEBUG private func fetchWeatherWithDiagnostics( _ location: WidgetResolvedWeatherLocation diff --git a/ios/Runner/WidgetSnapshotPlugin.swift b/ios/Runner/WidgetSnapshotPlugin.swift index 75916adf0..4300336a0 100644 --- a/ios/Runner/WidgetSnapshotPlugin.swift +++ b/ios/Runner/WidgetSnapshotPlugin.swift @@ -69,12 +69,14 @@ enum WidgetSnapshotFile { return data } + @discardableResult static func replace( _ data: Data, kind: WidgetSnapshotKind, sourceIdentifier: String? = nil, - in container: URL - ) throws { + in container: URL, + currentWeatherWriteToken: CurrentWeatherSnapshotWriteToken? = nil + ) throws -> CurrentWeatherSnapshotWriteResult { do { if kind == .currentWeather { guard @@ -86,13 +88,18 @@ enum WidgetSnapshotFile { throw WidgetSnapshotError.invalidPayload } - try CurrentWeatherSnapshotStorage( + let storage = CurrentWeatherSnapshotStorage( containerURL: container - ).replace( + ) + let token = try currentWeatherWriteToken + ?? storage.beginWrite(for: address) + guard token.address == address else { + throw WidgetSnapshotError.invalidPayload + } + return try storage.replace( data, - for: address + using: token ) - return } let destination = try snapshotURL( @@ -110,6 +117,30 @@ enum WidgetSnapshotFile { to: destination, options: .atomic ) + return .written + } catch let error as WidgetSnapshotError { + throw error + } catch { + throw WidgetSnapshotError.writeFailed + } + } + + static func beginCurrentWeatherWrite( + sourceIdentifier: String?, + in container: URL + ) throws -> CurrentWeatherSnapshotWriteToken { + do { + guard + let sourceIdentifier, + let address = CurrentWeatherSnapshotAddress( + sourceIdentifier: sourceIdentifier + ) + else { + throw WidgetSnapshotError.invalidPayload + } + return try CurrentWeatherSnapshotStorage( + containerURL: container + ).beginWrite(for: address) } catch let error as WidgetSnapshotError { throw error } catch { @@ -212,24 +243,44 @@ public final class WidgetSnapshotPlugin: NSObject, FlutterPlugin { return } - writeQueue.async { - // An absent key or a profile without this entitlement must fail closed. - guard let group = Bundle.main.object(forInfoDictionaryKey: "DPIPWidgetAppGroupIdentifier") as? String, - let container = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: group) - else { - DispatchQueue.main.async { result(self.flutterError(.appGroupUnavailable)) } - return - } + // Reserve current-weather ordering at MethodChannel request entry, before + // the Runner write queue can delay this request behind unrelated files. + guard let group = Bundle.main.object( + forInfoDictionaryKey: "DPIPWidgetAppGroupIdentifier" + ) as? String, + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: group) + else { + result(flutterError(.appGroupUnavailable)) + return + } + let currentWeatherWriteToken: CurrentWeatherSnapshotWriteToken? + do { + currentWeatherWriteToken = kind == .currentWeather + ? try WidgetSnapshotFile.beginCurrentWeatherWrite( + sourceIdentifier: sourceIdentifier, + in: container + ) + : nil + } catch let error as WidgetSnapshotError { + result(flutterError(error)) + return + } catch { + result(flutterError(.writeFailed)) + return + } + + writeQueue.async { do { - try WidgetSnapshotFile.replace( + let writeResult = try WidgetSnapshotFile.replace( data, kind: kind, sourceIdentifier: sourceIdentifier, - in: container + in: container, + currentWeatherWriteToken: currentWeatherWriteToken ) - if let widgetKind = kind.widgetKind { + if writeResult == .written, let widgetKind = kind.widgetKind { WidgetCenter.shared.reloadTimelines(ofKind: widgetKind) } DispatchQueue.main.async { result(nil) } diff --git a/ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift b/ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift index 07eec5c13..88850420d 100644 --- a/ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift +++ b/ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift @@ -419,6 +419,45 @@ final class CurrentLocationCurrentWeatherWidgetRefreshServiceTests: XCTAssertEqual(result, .refreshed) } + func testRejectedWriteReturnsSuperseded() async throws { + let expectedToken = CurrentWeatherSnapshotWriteToken( + address: .currentLocation, + generation: 9 + ) + let weather = CurrentLocationScriptedWeather( + result: .success(try makeObservation()) + ) + let preciseLocation = preciseLocation + let resolvedLocation = resolvedLocation() + let service = CurrentLocationCurrentWeatherWidgetRefreshService( + acquireLocation: { @MainActor in + .acquired(preciseLocation) + }, + resolveTownship: { _ in + resolvedLocation + }, + fetchWeather: { latitude, longitude in + try await weather.fetch( + latitude: latitude, + longitude: longitude + ) + }, + synchronizeClock: makeSnapshotTime, + beginWrite: { address in + XCTAssertEqual(address, expectedToken.address) + return expectedToken + }, + commitSnapshot: { _, token in + XCTAssertEqual(token, expectedToken) + return .rejected + } + ) + + let result = await service.refresh() + + XCTAssertEqual(result, .superseded) + } + private func makeService( locationResult: WidgetCurrentLocationResult? = nil, resolvedLocation: WidgetResolvedWeatherLocation? = nil, diff --git a/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift b/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift index cf8d24419..0b6e3c2a3 100644 --- a/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift +++ b/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift @@ -59,8 +59,19 @@ final class CurrentWeatherSnapshotStorageTests: XCTestCase { throws { let address = CurrentWeatherSnapshotAddress.currentLocation + let token = try storage.beginWrite(for: address) - try storage.replace(Data("snapshot".utf8), for: address) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + using: token + ), + .written + ) var isDirectory: ObjCBool = false let directoryExists = FileManager.default.fileExists( @@ -73,23 +84,932 @@ final class CurrentWeatherSnapshotStorageTests: XCTestCase { XCTAssertTrue(isDirectory.boolValue) } - func testReplaceWritesExpectedBytes() throws { - let data = Data([0x00, 0x7F, 0x80, 0xFF]) + func testNewerObservationTimeWins() throws { + let address = CurrentWeatherSnapshotAddress.saved( + regionCode: "407" + ) + let first = try storage.beginWrite(for: address) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "region:407", + regionCode: "407", + observationTime: 100 + ), + using: first + ), + .written + ) + let second = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "region:407", + regionCode: "407", + observationTime: 200 + ), + using: second + ), + .written + ) + XCTAssertEqual(try cachedSnapshot(for: address).observationTime, 200) + } + + func testOlderObservationArrivingLaterIsRejected() throws { + let address = CurrentWeatherSnapshotAddress.saved( + regionCode: "407" + ) + let olderRefresh = try storage.beginWrite(for: address) + let newerRefresh = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "region:407", + regionCode: "407", + observationTime: 200 + ), + using: newerRefresh + ), + .written + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "region:407", + regionCode: "407", + observationTime: 100 + ), + using: olderRefresh + ), + .rejected + ) + XCTAssertEqual(try cachedSnapshot(for: address).observationTime, 200) + } + + func testSavedRegionOlderGenerationWithNewerObservationWins() + throws + { + let address = CurrentWeatherSnapshotAddress.saved( + regionCode: "407" + ) + let olderRefresh = try storage.beginWrite(for: address) + let newerRefresh = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "region:407", + regionCode: "407", + observationTime: 100 + ), + using: newerRefresh + ), + .written + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "region:407", + regionCode: "407", + observationTime: 200 + ), + using: olderRefresh + ), + .written + ) + XCTAssertEqual(try cachedSnapshot(for: address).observationTime, 200) + } + + func testSameObservationNewerCurrentLocationTownshipWins() + throws + { + let address = CurrentWeatherSnapshotAddress.currentLocation + let region407 = try storage.beginWrite(for: address) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + using: region407 + ), + .written + ) + let region110 = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 100 + ), + using: region110 + ), + .written + ) + XCTAssertEqual(try cachedSnapshot(for: address).regionCode, "110") + } + + func testSameObservationOlderCurrentLocationCannotRevertTownship() + throws + { + let address = CurrentWeatherSnapshotAddress.currentLocation + let older407 = try storage.beginWrite(for: address) + let newer110 = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 100 + ), + using: newer110 + ), + .written + ) + let bytesAfterNewerWrite = try Data( + contentsOf: storage.snapshotURL(for: address) + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + using: older407 + ), + .rejected + ) + + XCTAssertEqual( + try Data(contentsOf: storage.snapshotURL(for: address)), + bytesAfterNewerWrite + ) + XCTAssertEqual(try cachedSnapshot(for: address).regionCode, "110") + } + + func testOlderCurrentLocationWithNewerObservationCannotRevertTownship() + throws + { + let address = CurrentWeatherSnapshotAddress.currentLocation + let older407 = try storage.beginWrite(for: address) + let newer110 = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 100 + ), + using: newer110 + ), + .written + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 200 + ), + using: older407 + ), + .rejected + ) + + let cached = try cachedSnapshot(for: address) + XCTAssertEqual(cached.regionCode, "110") + XCTAssertEqual(cached.observationTime, 100) + } + + func testSameCurrentLocationRegionUsesObservationBeforeGeneration() + throws + { + let address = CurrentWeatherSnapshotAddress.currentLocation + let older = try storage.beginWrite(for: address) + let newer = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + using: newer + ), + .written + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 200 + ), + using: older + ), + .written + ) + XCTAssertEqual(try cachedSnapshot(for: address).observationTime, 200) + } + + func testSameRegionWeatherWriteRetainsNewerLocationFence() throws { + let address = CurrentWeatherSnapshotAddress.currentLocation + let olderWeather = try storage.beginWrite(for: address) + let intermediateTownship = try storage.beginWrite(for: address) + let newestSameTownship = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + using: newestSameTownship + ), + .written + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 200 + ), + using: olderWeather + ), + .written + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 300 + ), + using: intermediateTownship + ), + .rejected + ) + + let cached = try cachedSnapshot(for: address) + XCTAssertEqual(cached.regionCode, "407") + XCTAssertEqual(cached.observationTime, 200) + } + + func testRejectedWeatherStillAdvancesCurrentLocationFence() + throws + { + let address = CurrentWeatherSnapshotAddress.currentLocation + let initial = try storage.beginWrite(for: address) + let intermediateTownship = try storage.beginWrite(for: address) + let newestSameTownship = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 200 + ), + using: initial + ), + .written + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + using: newestSameTownship + ), + .rejected + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 300 + ), + using: intermediateTownship + ), + .rejected + ) + + let cached = try cachedSnapshot(for: address) + XCTAssertEqual(cached.regionCode, "407") + XCTAssertEqual(cached.observationTime, 200) + } + + func testSavedRegionUsesGenerationForSameObservation() throws { let address = CurrentWeatherSnapshotAddress.saved( regionCode: "407" ) + let older = try storage.beginWrite(for: address) + let newer = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "region:407", + regionCode: "407", + regionName: "newer", + observationTime: 100 + ), + using: newer + ), + .written + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "region:407", + regionCode: "407", + regionName: "older", + observationTime: 100 + ), + using: older + ), + .rejected + ) + XCTAssertEqual(try cachedSnapshot(for: address).regionName, "newer") + } + + func testDifferentTargetsHaveIndependentGenerationsAndCaches() + throws + { + let current = CurrentWeatherSnapshotAddress.currentLocation + let saved = CurrentWeatherSnapshotAddress.saved(regionCode: "407") + let currentToken = try storage.beginWrite(for: current) + let savedToken = try storage.beginWrite(for: saved) + + XCTAssertEqual(currentToken.generation, 1) + XCTAssertEqual(savedToken.generation, 1) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 100 + ), + using: currentToken + ), + .written + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "region:407", + regionCode: "407", + observationTime: 100 + ), + using: savedToken + ), + .written + ) + XCTAssertEqual(try cachedSnapshot(for: current).regionCode, "110") + XCTAssertEqual(try cachedSnapshot(for: saved).regionCode, "407") + } + + func testPreFixSchemaFiveCacheAcceptsFirstEqualObservationWrite() + throws + { + let address = CurrentWeatherSnapshotAddress.currentLocation + try seedUncoordinatedSnapshot( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + for: address + ) + let firstCoordinatedRefresh = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 100 + ), + using: firstCoordinatedRefresh + ), + .written + ) + XCTAssertEqual(try cachedSnapshot(for: address).regionCode, "110") + } + + func testLegacyInjectedGenerationMigratesToSidecarOnly() throws { + let address = CurrentWeatherSnapshotAddress.currentLocation + var legacySnapshot = try XCTUnwrap( + JSONSerialization.jsonObject( + with: snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ) + ) as? [String: Any] + ) + legacySnapshot["_dpipStorageWriteGeneration"] = 3 + try seedUncoordinatedSnapshot( + JSONSerialization.data(withJSONObject: legacySnapshot), + for: address + ) + let stateURL = orderingStateURL(for: address) + try FileManager.default.createDirectory( + at: stateURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data( + "{\"schemaVersion\":1,\"lastIssuedGeneration\":5}".utf8 + ).write(to: stateURL, options: .atomic) + + let migrated = try storage.beginWrite(for: address) + XCTAssertEqual(migrated.generation, 6) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 100 + ), + using: migrated + ), + .written + ) + + let canonical = try XCTUnwrap( + JSONSerialization.jsonObject( + with: Data(contentsOf: storage.snapshotURL(for: address)) + ) as? [String: Any] + ) + XCTAssertNil(canonical["_dpipStorageWriteGeneration"]) + let sidecar = try XCTUnwrap( + JSONSerialization.jsonObject( + with: Data(contentsOf: stateURL) + ) as? [String: Any] + ) + XCTAssertEqual(sidecar["schemaVersion"] as? Int, 2) + let committed = try XCTUnwrap( + sidecar["committedSnapshot"] as? [String: Any] + ) + XCTAssertEqual(committed["snapshotGeneration"] as? Int, 6) + } + + func testLegacyCacheAcceptsFirstValidCoordinatedWrite() throws { + let address = CurrentWeatherSnapshotAddress.currentLocation + var legacy = try XCTUnwrap( + JSONSerialization.jsonObject( + with: snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ) + ) as? [String: Any] + ) + legacy["schemaVersion"] = 4 + legacy.removeValue(forKey: "sourceIdentifier") + try seedUncoordinatedSnapshot( + JSONSerialization.data(withJSONObject: legacy), + for: address + ) + let firstCoordinatedRefresh = try storage.beginWrite(for: address) + + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 100 + ), + using: firstCoordinatedRefresh + ), + .written + ) + XCTAssertEqual(try cachedSnapshot(for: address).regionCode, "110") + } + + func testDeterministicInterleavingRejectsLateOlderRefresh() + throws + { + let address = CurrentWeatherSnapshotAddress.currentLocation + let older = try storage.beginWrite(for: address) + let newer = try storage.beginWrite(for: address) + let firstAccessBlocked = expectation( + description: "older write is blocked before compare" + ) + let olderFinished = expectation(description: "older write finished") + let newerFinished = expectation(description: "newer write finished") + let coordinator = BlockingFirstSnapshotCoordinator( + firstAccessBlocked: firstAccessBlocked + ) + let interleavedStorage = CurrentWeatherSnapshotStorage( + containerURL: containerURL, + coordinator: coordinator + ) + let olderResult = SnapshotWriteResultBox() + let newerResult = SnapshotWriteResultBox() + + DispatchQueue.global().async { + olderResult.set( + Result { + try interleavedStorage.replace( + self.snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + using: older + ) + } + ) + olderFinished.fulfill() + } + wait(for: [firstAccessBlocked], timeout: 2) + DispatchQueue.global().async { + newerResult.set( + Result { + try interleavedStorage.replace( + self.snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 100 + ), + using: newer + ) + } + ) + newerFinished.fulfill() + } + wait(for: [newerFinished], timeout: 2) + coordinator.releaseFirstAccess() + wait(for: [olderFinished], timeout: 2) + + XCTAssertEqual(try newerResult.get().get(), .written) + XCTAssertEqual(try olderResult.get().get(), .rejected) + XCTAssertEqual(try cachedSnapshot(for: address).regionCode, "110") + } + + func testCoordinationFailurePreservesExistingCache() throws { + let address = CurrentWeatherSnapshotAddress.currentLocation + let token = try storage.beginWrite(for: address) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + using: token + ), + .written + ) + let original = try Data(contentsOf: storage.snapshotURL(for: address)) + let nextToken = try storage.beginWrite(for: address) + let failingStorage = CurrentWeatherSnapshotStorage( + containerURL: containerURL, + coordinator: FailingSnapshotCoordinator() + ) + + XCTAssertThrowsError( + try failingStorage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 100 + ), + using: nextToken + ) + ) + XCTAssertEqual( + try Data(contentsOf: storage.snapshotURL(for: address)), + original + ) + } + + func testCrashBeforeSnapshotReplacementFencesAllocatedTokens() + throws + { + let address = CurrentWeatherSnapshotAddress.currentLocation + let initial = try storage.beginWrite(for: address) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + using: initial + ), + .written + ) + let olderInFlight = try storage.beginWrite(for: address) + let interrupted = try storage.beginWrite(for: address) + let failingStorage = CurrentWeatherSnapshotStorage( + containerURL: containerURL, + persister: FailingNthSnapshotPersister(failAt: 2) + ) + + XCTAssertThrowsError( + try failingStorage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 50 + ), + using: interrupted + ) + ) + XCTAssertEqual(try cachedSnapshot(for: address).regionCode, "407") + + let recovered = try storage.beginWrite(for: address) + XCTAssertEqual(recovered.generation, 4) + XCTAssertThrowsError( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 300 + ), + using: olderInFlight + ) + ) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 50 + ), + using: recovered + ), + .written + ) + XCTAssertEqual(try cachedSnapshot(for: address).regionCode, "110") + } + + func testCrashAfterSnapshotReplacementRecoversCommittedOrdering() + throws + { + let address = CurrentWeatherSnapshotAddress.currentLocation + let initial = try storage.beginWrite(for: address) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + using: initial + ), + .written + ) + let olderInFlight = try storage.beginWrite(for: address) + let interrupted = try storage.beginWrite(for: address) + let failingStorage = CurrentWeatherSnapshotStorage( + containerURL: containerURL, + persister: FailingNthSnapshotPersister(failAt: 3) + ) + + XCTAssertThrowsError( + try failingStorage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 50 + ), + using: interrupted + ) + ) + XCTAssertEqual(try cachedSnapshot(for: address).regionCode, "110") - try storage.replace(data, for: address) + let recovered = try storage.beginWrite(for: address) + XCTAssertEqual(recovered.generation, 4) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 300 + ), + using: olderInFlight + ), + .rejected + ) + XCTAssertEqual(try cachedSnapshot(for: address).regionCode, "110") + } + + func testSidecarSnapshotMismatchFailsClosed() throws { + let address = CurrentWeatherSnapshotAddress.currentLocation + let initial = try storage.beginWrite(for: address) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 100 + ), + using: initial + ), + .written + ) + let inFlight = try storage.beginWrite(for: address) + let mismatched = snapshotData( + sourceIdentifier: "current-location", + regionCode: "110", + observationTime: 200 + ) + try seedUncoordinatedSnapshot(mismatched, for: address) + XCTAssertThrowsError( + try storage.replace( + snapshotData( + sourceIdentifier: "current-location", + regionCode: "407", + observationTime: 300 + ), + using: inFlight + ) + ) XCTAssertEqual( try Data(contentsOf: storage.snapshotURL(for: address)), - data + mismatched ) + XCTAssertThrowsError(try storage.beginWrite(for: address)) } private var storage: CurrentWeatherSnapshotStorage { CurrentWeatherSnapshotStorage(containerURL: containerURL) } + + private func snapshotData( + sourceIdentifier: String, + regionCode: String, + regionName: String = "西屯區", + observationTime: Int + ) -> Data { + try! JSONEncoder().encode( + CurrentWeatherWidgetSnapshot( + schemaVersion: 5, + sourceIdentifier: sourceIdentifier, + regionCode: regionCode, + regionName: regionName, + observationTime: observationTime, + stationName: "測站", + weather: "晴", + weatherCode: 100, + condition: .clear, + isNight: false, + nextDayNightTransitionTime: 200, + calibratedTimeOffsetMilliseconds: 0, + temperature: 25, + humidity: 60, + rain: 0 + ) + ) + } + + private func cachedSnapshot( + for address: CurrentWeatherSnapshotAddress + ) throws -> CurrentWeatherWidgetSnapshot { + try JSONDecoder().decode( + CurrentWeatherWidgetSnapshot.self, + from: Data(contentsOf: storage.snapshotURL(for: address)) + ) + } + + private func seedUncoordinatedSnapshot( + _ data: Data, + for address: CurrentWeatherSnapshotAddress + ) throws { + let url = storage.snapshotURL(for: address) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: url, options: .atomic) + } + + private func orderingStateURL( + for address: CurrentWeatherSnapshotAddress + ) -> URL { + containerURL + .appendingPathComponent("WidgetSnapshots", isDirectory: true) + .appendingPathComponent( + "current-weather-ordering", + isDirectory: true + ) + .appendingPathComponent(address.filename + ".json") + } +} + +private enum SnapshotCoordinationTestError: Error { + case failed + case missingResult +} + +private struct FailingSnapshotCoordinator: + CurrentWeatherSnapshotCoordinating +{ + func coordinate( + writingItemAt url: URL, + _ accessor: (URL) throws -> T + ) throws -> T { + throw SnapshotCoordinationTestError.failed + } +} + +private final class BlockingFirstSnapshotCoordinator: + CurrentWeatherSnapshotCoordinating, + @unchecked Sendable +{ + private let lock = NSLock() + private let firstAccessBlocked: XCTestExpectation + private let firstAccessGate = DispatchSemaphore(value: 0) + private var accessCount = 0 + + init(firstAccessBlocked: XCTestExpectation) { + self.firstAccessBlocked = firstAccessBlocked + } + + func coordinate( + writingItemAt url: URL, + _ accessor: (URL) throws -> T + ) throws -> T { + lock.lock() + accessCount += 1 + let shouldBlock = accessCount == 1 + lock.unlock() + + if shouldBlock { + firstAccessBlocked.fulfill() + firstAccessGate.wait() + } + return try accessor(url) + } + + func releaseFirstAccess() { + firstAccessGate.signal() + } +} + +private final class SnapshotWriteResultBox: @unchecked Sendable { + private let lock = NSLock() + private var result: Result? + + func set( + _ result: Result + ) { + lock.lock() + self.result = result + lock.unlock() + } + + func get() throws -> Result { + lock.lock() + defer { lock.unlock() } + guard let result else { + throw SnapshotCoordinationTestError.missingResult + } + return result + } +} + +private final class FailingNthSnapshotPersister: + CurrentWeatherSnapshotPersisting, + @unchecked Sendable +{ + private let lock = NSLock() + private let failAt: Int + private var writeCount = 0 + + init(failAt: Int) { + self.failAt = failAt + } + + func write(_ data: Data, to url: URL) throws { + lock.lock() + writeCount += 1 + let shouldFail = writeCount == failAt + lock.unlock() + + if shouldFail { + throw SnapshotCoordinationTestError.failed + } + try data.write(to: url, options: .atomic) + } } final class CurrentWeatherWidgetSnapshotWriterTests: XCTestCase { @@ -191,6 +1111,48 @@ final class CurrentWeatherWidgetSnapshotWriterTests: XCTestCase { XCTAssertEqual(decoded.rain, original.rain) } + func testWrittenJSONContainsExactlyCanonicalSchemaVersionFiveKeys() + throws + { + try writer.write( + makeSnapshot( + sourceIdentifier: "region:407", + regionCode: "407" + ) + ) + + let data = try Data( + contentsOf: snapshotURL( + for: .saved(regionCode: "407") + ) + ) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) + as? [String: Any] + ) + + XCTAssertEqual( + Set(json.keys), + Set([ + "schemaVersion", + "sourceIdentifier", + "regionCode", + "regionName", + "observationTime", + "stationName", + "weather", + "weatherCode", + "condition", + "isNight", + "nextDayNightTransitionTime", + "calibratedTimeOffsetMilliseconds", + "temperature", + "humidity", + "rain", + ]) + ) + } + func testSavedLocationCachesAreIndependent() throws { let region407 = makeSnapshot( sourceIdentifier: "region:407", diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index 435d33bf8..76ba46e0e 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -50,11 +50,65 @@ final class RunnerTests: XCTestCase { XCTAssertEqual(try Data(contentsOf: target), second) } + func testCurrentWeatherRequestTokenPreservesArrivalOrder() throws { + let container = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: container) } + let kind = try WidgetSnapshotFile.kind("currentWeather") + let older = try WidgetSnapshotFile.beginCurrentWeatherWrite( + sourceIdentifier: "current-location", + in: container + ) + let newer = try WidgetSnapshotFile.beginCurrentWeatherWrite( + sourceIdentifier: "current-location", + in: container + ) + let olderData = try WidgetSnapshotFile.payload( + "{\"schemaVersion\":5,\"observationTime\":100,\"regionCode\":\"407\"}" + ) + let newerData = try WidgetSnapshotFile.payload( + "{\"schemaVersion\":5,\"observationTime\":100,\"regionCode\":\"110\"}" + ) + + XCTAssertEqual( + try WidgetSnapshotFile.replace( + newerData, + kind: kind, + sourceIdentifier: "current-location", + in: container, + currentWeatherWriteToken: newer + ), + .written + ) + XCTAssertEqual( + try WidgetSnapshotFile.replace( + olderData, + kind: kind, + sourceIdentifier: "current-location", + in: container, + currentWeatherWriteToken: older + ), + .rejected + ) + + let target = try WidgetSnapshotFile.snapshotURL( + kind: kind, + sourceIdentifier: "current-location", + in: container + ) + let stored = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(contentsOf: target)) + as? [String: Any] + ) + XCTAssertEqual(stored["regionCode"] as? String, "110") + } + func testSnapshotClearIsIdempotent() throws { let container = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: container) } let kind = try WidgetSnapshotFile.kind("currentWeather") - let data = try WidgetSnapshotFile.payload("{\"schemaVersion\":1}") + let data = try WidgetSnapshotFile.payload( + "{\"schemaVersion\":5,\"observationTime\":100,\"regionCode\":\"220\"}" + ) let directory = container.appendingPathComponent("WidgetSnapshots") let legacyTarget = directory.appendingPathComponent("current-weather.json") let perLocationTarget = try WidgetSnapshotFile.snapshotURL( diff --git a/ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift b/ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift index ba409939f..2f6f7be6c 100644 --- a/ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift +++ b/ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift @@ -371,6 +371,41 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { XCTAssertEqual(result, .refreshed) } + func testRejectedWriteReturnsSuperseded() async throws { + let resolver = try makeResolver(codes: ["242"]) + let weather = ScriptedRefreshWeather( + result: .success(try makeObservation()) + ) + let expectedToken = CurrentWeatherSnapshotWriteToken( + address: .saved(regionCode: "242"), + generation: 7 + ) + let service = SavedCurrentWeatherWidgetRefreshService( + resolveLocation: resolver.resolve, + fetchWeather: { latitude, longitude in + try await weather.fetch( + latitude: latitude, + longitude: longitude + ) + }, + synchronizeClock: makeSnapshotTime, + beginWrite: { address in + XCTAssertEqual(address, expectedToken.address) + return expectedToken + }, + commitSnapshot: { _, token in + XCTAssertEqual(token, expectedToken) + return .rejected + } + ) + + let result = await service.refresh( + target: .saved(regionCode: "242") + ) + + XCTAssertEqual(result, .superseded) + } + private func makeService( codes: [String], weather: ScriptedRefreshWeather, diff --git a/ios/Shared/CurrentWeatherSnapshotStorage.swift b/ios/Shared/CurrentWeatherSnapshotStorage.swift index 47d341643..72de70b41 100644 --- a/ios/Shared/CurrentWeatherSnapshotStorage.swift +++ b/ios/Shared/CurrentWeatherSnapshotStorage.swift @@ -1,11 +1,229 @@ +import CryptoKit import Foundation -struct CurrentWeatherSnapshotStorage { +enum CurrentWeatherSnapshotWriteResult: Equatable, Sendable { + case written + case rejected +} + +struct CurrentWeatherSnapshotWriteToken: Equatable, Sendable { + let address: CurrentWeatherSnapshotAddress + let generation: Int64 +} + +enum CurrentWeatherSnapshotStorageError: Error { + case coordinationFailed + case invalidOrderingState + case invalidSnapshot + case generationExhausted + case invalidWriteToken +} + +protocol CurrentWeatherSnapshotCoordinating: Sendable { + func coordinate( + writingItemAt url: URL, + _ accessor: (URL) throws -> T + ) throws -> T +} + +struct CurrentWeatherSnapshotFileCoordinator: + CurrentWeatherSnapshotCoordinating +{ + func coordinate( + writingItemAt url: URL, + _ accessor: (URL) throws -> T + ) throws -> T { + let coordinator = NSFileCoordinator(filePresenter: nil) + var coordinationError: NSError? + var accessorResult: Result? + + coordinator.coordinate( + writingItemAt: url, + options: .forReplacing, + error: &coordinationError + ) { coordinatedURL in + accessorResult = Result { + try accessor(coordinatedURL) + } + } + + if let accessorResult { + return try accessorResult.get() + } + if let coordinationError { + throw coordinationError + } + throw CurrentWeatherSnapshotStorageError.coordinationFailed + } +} + +protocol CurrentWeatherSnapshotPersisting: Sendable { + func write(_ data: Data, to url: URL) throws +} + +struct CurrentWeatherSnapshotAtomicPersister: + CurrentWeatherSnapshotPersisting +{ + func write(_ data: Data, to url: URL) throws { + try data.write(to: url, options: .atomic) + } +} + +struct CurrentWeatherSnapshotStorage: Sendable { let containerURL: URL + private let coordinator: any CurrentWeatherSnapshotCoordinating + private let persister: any CurrentWeatherSnapshotPersisting + + init( + containerURL: URL, + coordinator: any CurrentWeatherSnapshotCoordinating = + CurrentWeatherSnapshotFileCoordinator(), + persister: any CurrentWeatherSnapshotPersisting = + CurrentWeatherSnapshotAtomicPersister() + ) { + self.containerURL = containerURL + self.coordinator = coordinator + self.persister = persister + } func snapshotURL( for address: CurrentWeatherSnapshotAddress ) -> URL { + snapshotDirectoryURL + .appendingPathComponent(address.filename) + } + + /// Reserves a per-target generation when a logical refresh begins. + /// + /// The persisted counter, rather than a wall or monotonic clock, gives + /// Runner and Widget Extension processes one comparable ordering domain. + func beginWrite( + for address: CurrentWeatherSnapshotAddress + ) throws -> CurrentWeatherSnapshotWriteToken { + try createDirectories() + let destination = snapshotURL(for: address) + + return try coordinator.coordinate( + writingItemAt: destination + ) { coordinatedDestination in + let stateURL = orderingStateURL( + coordinatedSnapshotURL: coordinatedDestination + ) + let snapshotData = try loadSnapshotData( + at: coordinatedDestination + ) + var state = try reconcile( + loadedState: try loadOrderingState(at: stateURL), + snapshotData: snapshotData, + stateURL: stateURL, + mayAdoptSnapshotWithoutState: true + ) + + guard state.lastIssuedGeneration < Int64.max else { + throw CurrentWeatherSnapshotStorageError + .generationExhausted + } + + let generation = state.lastIssuedGeneration + 1 + state.lastIssuedGeneration = generation + try persist(state, to: stateURL) + + return CurrentWeatherSnapshotWriteToken( + address: address, + generation: generation + ) + } + } + + /// Compares and atomically replaces one canonical target snapshot. + /// + /// The sidecar is first marked with a pending commit, then the canonical + /// snapshot is replaced, and finally the sidecar is committed. Recovery + /// accepts only the old or pending snapshot fingerprint; any other pairing + /// fails closed. The snapshot bytes themselves remain the exact schema-v5 + /// payload produced by Dart or Swift. + func replace( + _ data: Data, + using token: CurrentWeatherSnapshotWriteToken + ) throws -> CurrentWeatherSnapshotWriteResult { + try createDirectories() + let destination = snapshotURL(for: token.address) + let candidateOrdering = try decodeSnapshotOrdering(from: data) + + return try coordinator.coordinate( + writingItemAt: destination + ) { coordinatedDestination in + let stateURL = orderingStateURL( + coordinatedSnapshotURL: coordinatedDestination + ) + guard let loadedState = try loadOrderingState(at: stateURL) else { + throw CurrentWeatherSnapshotStorageError.invalidWriteToken + } + let snapshotData = try loadSnapshotData( + at: coordinatedDestination + ) + var state = try reconcile( + loadedState: loadedState, + snapshotData: snapshotData, + stateURL: stateURL, + mayAdoptSnapshotWithoutState: false + ) + + guard + token.generation > state.rejectedThroughGeneration, + token.generation <= state.lastIssuedGeneration + else { + throw CurrentWeatherSnapshotStorageError.invalidWriteToken + } + + if let existing = state.committedSnapshot, + !shouldReplace( + address: token.address, + existing: existing, + candidate: candidateOrdering, + candidateGeneration: token.generation + ) + { + // A newer same-township acquisition still advances the + // Current Location identity fence even when its older weather + // observation cannot replace the canonical snapshot. + if token.address == .currentLocation, + candidateOrdering.regionCode == existing.regionCode, + token.generation > existing.locationGeneration + { + state.committedSnapshot = existing.withLocationGeneration( + token.generation + ) + try persist(state, to: stateURL) + } + return .rejected + } + + let locationGeneration = locationGeneration( + address: token.address, + existing: state.committedSnapshot, + candidate: candidateOrdering, + candidateGeneration: token.generation + ) + let candidateCommit = StoredSnapshotOrdering( + snapshotGeneration: token.generation, + locationGeneration: locationGeneration, + observationTime: candidateOrdering.observationTime, + regionCode: candidateOrdering.regionCode, + snapshotSHA256: sha256(data) + ) + + state.pendingCommit = candidateCommit + try persist(state, to: stateURL) + try persister.write(data, to: coordinatedDestination) + state.committedSnapshot = candidateCommit + state.pendingCommit = nil + try persist(state, to: stateURL) + return .written + } + } + + private var snapshotDirectoryURL: URL { containerURL .appendingPathComponent( "WidgetSnapshots", @@ -15,23 +233,362 @@ struct CurrentWeatherSnapshotStorage { "current-weather", isDirectory: true ) - .appendingPathComponent(address.filename) } - func replace( - _ data: Data, - for address: CurrentWeatherSnapshotAddress - ) throws { - let destination = snapshotURL(for: address) + private var orderingDirectoryURL: URL { + containerURL + .appendingPathComponent( + "WidgetSnapshots", + isDirectory: true + ) + .appendingPathComponent( + "current-weather-ordering", + isDirectory: true + ) + } + private func createDirectories() throws { + try FileManager.default.createDirectory( + at: snapshotDirectoryURL, + withIntermediateDirectories: true + ) try FileManager.default.createDirectory( - at: destination.deletingLastPathComponent(), + at: orderingDirectoryURL, withIntermediateDirectories: true ) + } + + private func orderingStateURL( + coordinatedSnapshotURL: URL + ) -> URL { + // Keep the coordinator-provided filename in case Foundation redirects + // the coordinated item, while storing ordering outside the canonical + // cache directory consumed by WidgetSnapshotStore. + orderingDirectoryURL.appendingPathComponent( + coordinatedSnapshotURL.lastPathComponent + ".json" + ) + } + + private func loadSnapshotData(at url: URL) throws -> Data? { + guard FileManager.default.fileExists(atPath: url.path) else { + return nil + } + return try Data(contentsOf: url) + } + + private func loadOrderingState( + at url: URL + ) throws -> LoadedOrderingState? { + guard FileManager.default.fileExists(atPath: url.path) else { + return nil + } + + let data = try Data(contentsOf: url) + let schemaVersion: Int + do { + schemaVersion = try JSONDecoder().decode( + OrderingStateVersion.self, + from: data + ).schemaVersion + } catch { + throw CurrentWeatherSnapshotStorageError.invalidOrderingState + } + + do { + switch schemaVersion { + case 1: + let legacy = try JSONDecoder().decode( + LegacyOrderingState.self, + from: data + ) + guard legacy.lastIssuedGeneration > 0 else { + throw CurrentWeatherSnapshotStorageError + .invalidOrderingState + } + return .legacy(legacy) + case 2: + let state = try JSONDecoder().decode( + OrderingState.self, + from: data + ) + try validate(state) + return .current(state) + default: + throw CurrentWeatherSnapshotStorageError + .invalidOrderingState + } + } catch let error as CurrentWeatherSnapshotStorageError { + throw error + } catch { + throw CurrentWeatherSnapshotStorageError.invalidOrderingState + } + } + + private func validate(_ state: OrderingState) throws { + guard + state.schemaVersion == 2, + state.lastIssuedGeneration >= 0, + state.rejectedThroughGeneration >= 0, + state.rejectedThroughGeneration <= state.lastIssuedGeneration + else { + throw CurrentWeatherSnapshotStorageError.invalidOrderingState + } + + for ordering in [ + state.committedSnapshot, + state.pendingCommit, + ].compactMap({ $0 }) { + guard + ordering.snapshotGeneration >= 0, + ordering.snapshotGeneration <= state.lastIssuedGeneration, + ordering.locationGeneration >= ordering.snapshotGeneration, + ordering.locationGeneration <= state.lastIssuedGeneration, + ordering.snapshotSHA256.count == 64, + ordering.snapshotSHA256.allSatisfy({ + $0.isHexDigit && !$0.isUppercase + }) + else { + throw CurrentWeatherSnapshotStorageError + .invalidOrderingState + } + } + } + + private func reconcile( + loadedState: LoadedOrderingState?, + snapshotData: Data?, + stateURL: URL, + mayAdoptSnapshotWithoutState: Bool + ) throws -> OrderingState { + switch loadedState { + case nil: + guard mayAdoptSnapshotWithoutState else { + throw CurrentWeatherSnapshotStorageError.invalidOrderingState + } + let committed = try snapshotData.map { + try adoptedOrdering(from: $0) + } + return OrderingState( + schemaVersion: 2, + lastIssuedGeneration: + committed?.snapshotGeneration ?? 0, + rejectedThroughGeneration: 0, + committedSnapshot: committed, + pendingCommit: nil + ) + + case .legacy(let legacy): + let committed = try snapshotData.map { + try adoptedOrdering(from: $0) + } + guard + (committed?.snapshotGeneration ?? 0) + <= legacy.lastIssuedGeneration + else { + throw CurrentWeatherSnapshotStorageError.invalidOrderingState + } + let migrated = OrderingState( + schemaVersion: 2, + lastIssuedGeneration: legacy.lastIssuedGeneration, + // Tokens issued by the old two-file protocol cannot be proven + // safe after migration, so only a newly allocated token may + // commit. + rejectedThroughGeneration: legacy.lastIssuedGeneration, + committedSnapshot: committed, + pendingCommit: nil + ) + try persist(migrated, to: stateURL) + return migrated + + case .current(var state): + if let pending = state.pendingCommit { + if try snapshotDataMatches(snapshotData, pending) { + // The snapshot replacement completed but the final sidecar + // update did not. Finish that commit during recovery. + state.committedSnapshot = pending + state.pendingCommit = nil + try persist(state, to: stateURL) + } else if try snapshotDataMatches( + snapshotData, + state.committedSnapshot + ) { + // The pending marker landed but the snapshot replacement + // did not. Fence every already-issued token through that + // generation before allowing a fresh acquisition. + state.rejectedThroughGeneration = max( + state.rejectedThroughGeneration, + pending.snapshotGeneration + ) + state.pendingCommit = nil + try persist(state, to: stateURL) + } else { + throw CurrentWeatherSnapshotStorageError + .invalidOrderingState + } + } else if try !snapshotDataMatches( + snapshotData, + state.committedSnapshot + ) { + throw CurrentWeatherSnapshotStorageError.invalidOrderingState + } + return state + } + } + + private func snapshotDataMatches( + _ data: Data?, + _ ordering: StoredSnapshotOrdering? + ) throws -> Bool { + switch (data, ordering) { + case (nil, nil): + return true + case (.some(let data), .some(let ordering)): + guard sha256(data) == ordering.snapshotSHA256 else { + return false + } + let decoded = try decodeSnapshotOrdering(from: data) + return decoded.observationTime == ordering.observationTime + && decoded.regionCode == ordering.regionCode + case (.none, .some), (.some, .none): + return false + } + } - try data.write( - to: destination, - options: .atomic + private func adoptedOrdering( + from data: Data + ) throws -> StoredSnapshotOrdering { + let decoded = try decodeSnapshotOrdering(from: data) + let generation = decoded.legacyGeneration ?? 0 + guard generation >= 0 else { + throw CurrentWeatherSnapshotStorageError.invalidOrderingState + } + return StoredSnapshotOrdering( + snapshotGeneration: generation, + locationGeneration: generation, + observationTime: decoded.observationTime, + regionCode: decoded.regionCode, + snapshotSHA256: sha256(data) ) } + + private func persist( + _ state: OrderingState, + to url: URL + ) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + try persister.write(encoder.encode(state), to: url) + } + + private func decodeSnapshotOrdering( + from data: Data + ) throws -> SnapshotOrdering { + do { + return try JSONDecoder().decode( + SnapshotOrdering.self, + from: data + ) + } catch { + throw CurrentWeatherSnapshotStorageError.invalidSnapshot + } + } + + private func shouldReplace( + address: CurrentWeatherSnapshotAddress, + existing: StoredSnapshotOrdering, + candidate: SnapshotOrdering, + candidateGeneration: Int64 + ) -> Bool { + switch address { + case .currentLocation: + if candidate.regionCode != existing.regionCode { + return candidateGeneration > existing.locationGeneration + } + case .saved: + break + } + + if candidate.observationTime != existing.observationTime { + return candidate.observationTime > existing.observationTime + } + return candidateGeneration > existing.snapshotGeneration + } + + private func locationGeneration( + address: CurrentWeatherSnapshotAddress, + existing: StoredSnapshotOrdering?, + candidate: SnapshotOrdering, + candidateGeneration: Int64 + ) -> Int64 { + guard + address == .currentLocation, + let existing, + candidate.regionCode == existing.regionCode + else { + return candidateGeneration + } + return max(existing.locationGeneration, candidateGeneration) + } + + private func sha256(_ data: Data) -> String { + SHA256.hash(data: data) + .map { String(format: "%02x", $0) } + .joined() + } +} + +private extension CurrentWeatherSnapshotStorage { + enum LoadedOrderingState { + case legacy(LegacyOrderingState) + case current(OrderingState) + } + + struct OrderingStateVersion: Decodable { + let schemaVersion: Int + } + + struct LegacyOrderingState: Decodable { + let schemaVersion: Int + let lastIssuedGeneration: Int64 + } + + struct OrderingState: Codable { + let schemaVersion: Int + var lastIssuedGeneration: Int64 + var rejectedThroughGeneration: Int64 + var committedSnapshot: StoredSnapshotOrdering? + var pendingCommit: StoredSnapshotOrdering? + } + + struct StoredSnapshotOrdering: Codable { + let snapshotGeneration: Int64 + let locationGeneration: Int64 + let observationTime: Int64 + let regionCode: String + let snapshotSHA256: String + + func withLocationGeneration( + _ generation: Int64 + ) -> StoredSnapshotOrdering { + StoredSnapshotOrdering( + snapshotGeneration: snapshotGeneration, + locationGeneration: generation, + observationTime: observationTime, + regionCode: regionCode, + snapshotSHA256: snapshotSHA256 + ) + } + } + + struct SnapshotOrdering: Decodable { + let observationTime: Int64 + let regionCode: String + let legacyGeneration: Int64? + + private enum CodingKeys: String, CodingKey { + case observationTime + case regionCode + case legacyGeneration = "_dpipStorageWriteGeneration" + } + } } From 08594b09a6e6c8359a72bbd0a289818067897dda Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Tue, 22 Sep 2026 00:59:39 +0800 Subject: [PATCH 16/18] chore(weather): complete widget refresh Sendable contracts Platform: ios --- ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift | 4 ++-- ios/DPIPWidgets/CurrentWeatherWidgetTimeline.swift | 2 +- ios/DPIPWidgets/WidgetLocationTarget.swift | 2 +- ios/Shared/CurrentWeatherSnapshotAddress.swift | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift index 4440e4293..14604b4b7 100644 --- a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift @@ -1,7 +1,7 @@ import Foundation import SwiftUI -enum CurrentWeatherWidgetCondition: String, Decodable { +enum CurrentWeatherWidgetCondition: String, Decodable, Sendable { case clear case cloudy case overcast @@ -69,7 +69,7 @@ enum CurrentWeatherWidgetCondition: String, Decodable { } } -struct CurrentWeatherWidgetSnapshot: Codable { +struct CurrentWeatherWidgetSnapshot: Codable, Sendable { let schemaVersion: Int let sourceIdentifier: String? diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetTimeline.swift b/ios/DPIPWidgets/CurrentWeatherWidgetTimeline.swift index 1a3916ce5..2f179f3e5 100644 --- a/ios/DPIPWidgets/CurrentWeatherWidgetTimeline.swift +++ b/ios/DPIPWidgets/CurrentWeatherWidgetTimeline.swift @@ -1,6 +1,6 @@ import Foundation -struct CurrentWeatherWidgetTimelineState: Equatable { +struct CurrentWeatherWidgetTimelineState: Equatable, Sendable { /// Device-clock date supplied to WidgetKit for entry scheduling. let date: Date let isStale: Bool diff --git a/ios/DPIPWidgets/WidgetLocationTarget.swift b/ios/DPIPWidgets/WidgetLocationTarget.swift index 3f7c86781..23704e8c8 100644 --- a/ios/DPIPWidgets/WidgetLocationTarget.swift +++ b/ios/DPIPWidgets/WidgetLocationTarget.swift @@ -1,6 +1,6 @@ import Foundation -enum WidgetLocationTarget: Equatable { +enum WidgetLocationTarget: Equatable, Sendable { case currentLocation case saved(regionCode: String) case invalid(identifier: String) diff --git a/ios/Shared/CurrentWeatherSnapshotAddress.swift b/ios/Shared/CurrentWeatherSnapshotAddress.swift index 08bac3144..259c91303 100644 --- a/ios/Shared/CurrentWeatherSnapshotAddress.swift +++ b/ios/Shared/CurrentWeatherSnapshotAddress.swift @@ -1,4 +1,4 @@ -enum CurrentWeatherSnapshotAddress: Equatable { +enum CurrentWeatherSnapshotAddress: Equatable, Sendable { case currentLocation case saved(regionCode: String) From 2930bb8c55c8a83ee349962a7e71f739b706463e Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Tue, 22 Sep 2026 10:49:12 +0800 Subject: [PATCH 17/18] refactor(weather): consolidate widget refresh pipeline Platform: ios --- ...onCurrentWeatherWidgetRefreshService.swift | 216 +----- .../CurrentWeatherWidgetRefreshPipeline.swift | 172 +++++ .../CurrentWeatherWidgetSnapshotWriter.swift | 8 - .../DPIPWidgetProviderSupport.swift | 229 ++----- ...edCurrentWeatherWidgetRefreshService.swift | 212 +----- ios/Runner.xcodeproj/project.pbxproj | 9 + ...rentWeatherWidgetRefreshServiceTests.swift | 645 ++++-------------- ...rentWeatherWidgetSnapshotWriterTests.swift | 118 +--- .../CurrentWeatherWidgetTestFixtures.swift | 129 ++++ .../CurrentWeatherWidgetTests.swift | 118 ---- ios/RunnerTests/DPIPWidgetProviderTests.swift | 151 +--- ...rentWeatherWidgetRefreshServiceTests.swift | 562 +++++---------- .../WidgetLocationCatalogTests.swift | 71 -- .../WidgetRefreshTestSupport.swift | 207 ++++++ ios/RunnerTests/WidgetServerClockTests.swift | 108 +-- 15 files changed, 935 insertions(+), 2020 deletions(-) create mode 100644 ios/DPIPWidgets/CurrentWeatherWidgetRefreshPipeline.swift create mode 100644 ios/RunnerTests/CurrentWeatherWidgetTestFixtures.swift create mode 100644 ios/RunnerTests/WidgetRefreshTestSupport.swift diff --git a/ios/DPIPWidgets/CurrentLocationCurrentWeatherWidgetRefreshService.swift b/ios/DPIPWidgets/CurrentLocationCurrentWeatherWidgetRefreshService.swift index 890d26bac..20ae98dde 100644 --- a/ios/DPIPWidgets/CurrentLocationCurrentWeatherWidgetRefreshService.swift +++ b/ios/DPIPWidgets/CurrentLocationCurrentWeatherWidgetRefreshService.swift @@ -1,40 +1,17 @@ -enum CurrentLocationCurrentWeatherWidgetRefreshResult: Equatable, Sendable { - case refreshed - case superseded - case noObservation - case unavailable - case failed -} - struct CurrentLocationCurrentWeatherWidgetRefreshService: Sendable { typealias AcquireLocation = @MainActor @Sendable () async -> WidgetCurrentLocationResult typealias ResolveTownship = @Sendable ( WidgetCurrentLocation ) -> WidgetResolvedWeatherLocation? - typealias FetchWeather = @Sendable ( - Double, - Double - ) async throws -> CurrentWeatherRemoteDTO? - typealias SynchronizeClock = @Sendable () async - -> CurrentWeatherSnapshotTime? - typealias WriteSnapshot = @Sendable ( - CurrentWeatherWidgetSnapshot - ) throws -> Void typealias BeginWrite = @Sendable ( CurrentWeatherSnapshotAddress ) throws -> CurrentWeatherSnapshotWriteToken - typealias CommitSnapshot = @Sendable ( - CurrentWeatherWidgetSnapshot, - CurrentWeatherSnapshotWriteToken - ) throws -> CurrentWeatherSnapshotWriteResult private let acquireLocation: AcquireLocation private let resolveTownship: ResolveTownship - private let fetchWeather: FetchWeather - private let synchronizeClock: SynchronizeClock private let beginWrite: BeginWrite - private let commitSnapshot: CommitSnapshot + private let pipeline: CurrentWeatherWidgetRefreshPipeline init( acquireLocation: @escaping AcquireLocation, @@ -48,84 +25,30 @@ struct CurrentLocationCurrentWeatherWidgetRefreshService: Sendable { resolveTownship: { currentLocation in resolver.resolve(currentLocation) }, - fetchWeather: { latitude, longitude in - try await weatherClient.fetch( - latitude: latitude, - longitude: longitude - ) - }, - synchronizeClock: { - await Self.synchronizedSnapshotTime(clock: clock) - }, beginWrite: { address in try writer.beginWrite(for: address) }, - commitSnapshot: { snapshot, token in - try writer.write(snapshot, using: token) - } - ) - } - - init( - acquireLocation: @escaping AcquireLocation, - resolveTownship: @escaping ResolveTownship, - fetchWeather: @escaping FetchWeather, - clock: WidgetServerClock, - writeSnapshot: @escaping WriteSnapshot - ) { - self.init( - acquireLocation: acquireLocation, - resolveTownship: resolveTownship, - fetchWeather: fetchWeather, - synchronizeClock: { - await Self.synchronizedSnapshotTime(clock: clock) - }, - writeSnapshot: writeSnapshot - ) - } - - init( - acquireLocation: @escaping AcquireLocation, - resolveTownship: @escaping ResolveTownship, - fetchWeather: @escaping FetchWeather, - synchronizeClock: @escaping SynchronizeClock, - writeSnapshot: @escaping WriteSnapshot - ) { - self.init( - acquireLocation: acquireLocation, - resolveTownship: resolveTownship, - fetchWeather: fetchWeather, - synchronizeClock: synchronizeClock, - beginWrite: { address in - CurrentWeatherSnapshotWriteToken( - address: address, - generation: 1 - ) - }, - commitSnapshot: { snapshot, _ in - try writeSnapshot(snapshot) - return .written - } + pipeline: CurrentWeatherWidgetRefreshPipeline( + weatherClient: weatherClient, + clock: clock, + writer: writer + ) ) } init( acquireLocation: @escaping AcquireLocation, resolveTownship: @escaping ResolveTownship, - fetchWeather: @escaping FetchWeather, - synchronizeClock: @escaping SynchronizeClock, beginWrite: @escaping BeginWrite, - commitSnapshot: @escaping CommitSnapshot + pipeline: CurrentWeatherWidgetRefreshPipeline ) { self.acquireLocation = acquireLocation self.resolveTownship = resolveTownship - self.fetchWeather = fetchWeather - self.synchronizeClock = synchronizeClock self.beginWrite = beginWrite - self.commitSnapshot = commitSnapshot + self.pipeline = pipeline } - func refresh() async -> CurrentLocationCurrentWeatherWidgetRefreshResult { + func refresh() async -> CurrentWeatherWidgetRefreshResult { let writeToken: CurrentWeatherSnapshotWriteToken do { writeToken = try beginWrite(.currentLocation) @@ -169,126 +92,9 @@ struct CurrentLocationCurrentWeatherWidgetRefreshService: Sendable { ) #endif - // Once a township exists, weather and clock synchronization are - // independent. Starting them together bounds this phase to the slower - // operation without doing network work for unavailable locations. - #if DEBUG - async let observation = fetchWeatherWithDiagnostics(location) - #else - async let observation = fetchWeather( - location.latitude, - location.longitude - ) - #endif - async let snapshotTime = synchronizeClock() - - let resolvedObservation: CurrentWeatherRemoteDTO? - let resolvedSnapshotTime: CurrentWeatherSnapshotTime? - do { - (resolvedObservation, resolvedSnapshotTime) = try await ( - observation, - snapshotTime - ) - } catch { - return .failed - } - - guard let resolvedObservation else { - return .noObservation - } - guard let resolvedSnapshotTime else { - return .failed - } - - let snapshot = CurrentWeatherWidgetSnapshotFactory.make( - observation: resolvedObservation, + return await pipeline.refresh( location: location, - time: resolvedSnapshotTime - ) - #if DEBUG - WidgetWeatherRefreshDiagnostics.log( - "writer attempted sourceIdentifier=" - + (snapshot.sourceIdentifier ?? "none") - + " regionCode=\(snapshot.regionCode) " - + "observationTime=\(snapshot.observationTime)" - ) - #endif - do { - let writeResult = try commitSnapshot(snapshot, writeToken) - #if DEBUG - WidgetWeatherRefreshDiagnostics.log( - writeResult == .written - ? "writer succeeded" - : "writer superseded" - ) - #endif - return writeResult == .written ? .refreshed : .superseded - } catch { - #if DEBUG - WidgetWeatherRefreshDiagnostics.log("writer failed") - #endif - return .failed - } - } - - private static func synchronizedSnapshotTime( - clock: WidgetServerClock - ) async -> CurrentWeatherSnapshotTime? { - #if DEBUG - WidgetWeatherRefreshDiagnostics.log( - "clock synchronization started" + writeToken: writeToken ) - let synchronized = await clock.synchronize() - #else - _ = await clock.synchronize() - #endif - let hasSynchronized = await clock.hasSynchronized - #if DEBUG - if synchronized { - WidgetWeatherRefreshDiagnostics.log( - "clock synchronized" - ) - } else if hasSynchronized { - WidgetWeatherRefreshDiagnostics.log( - "clock failed retainedPreviousAnchor=true" - ) - } else { - WidgetWeatherRefreshDiagnostics.log( - "clock failed retainedPreviousAnchor=false" - ) - } - #endif - guard hasSynchronized else { - return nil - } - return await clock.currentWeatherSnapshotTime() - } - - #if DEBUG - private func fetchWeatherWithDiagnostics( - _ location: WidgetResolvedWeatherLocation - ) async throws -> CurrentWeatherRemoteDTO? { - WidgetWeatherRefreshDiagnostics.log("weather started") - do { - let observation = try await fetchWeather( - location.latitude, - location.longitude - ) - if let observation { - WidgetWeatherRefreshDiagnostics.log( - "weather observation received observationTime=" - + "\(observation.time)" - ) - } else { - WidgetWeatherRefreshDiagnostics.log( - "weather no observation" - ) - } - return observation - } catch { - WidgetWeatherRefreshDiagnostics.log("weather failed") - throw error - } } - #endif } diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetRefreshPipeline.swift b/ios/DPIPWidgets/CurrentWeatherWidgetRefreshPipeline.swift new file mode 100644 index 000000000..8b7c0a715 --- /dev/null +++ b/ios/DPIPWidgets/CurrentWeatherWidgetRefreshPipeline.swift @@ -0,0 +1,172 @@ +enum CurrentWeatherWidgetRefreshResult: Equatable, Sendable { + case refreshed + case superseded + case noObservation + case unavailable + case failed +} + +struct CurrentWeatherWidgetRefreshPipeline: Sendable { + typealias FetchWeather = @Sendable ( + Double, + Double + ) async throws -> CurrentWeatherRemoteDTO? + typealias SynchronizeClock = @Sendable () async + -> CurrentWeatherSnapshotTime? + typealias CommitSnapshot = @Sendable ( + CurrentWeatherWidgetSnapshot, + CurrentWeatherSnapshotWriteToken + ) throws -> CurrentWeatherSnapshotWriteResult + + private let fetchWeather: FetchWeather + private let synchronizeClock: SynchronizeClock + private let commitSnapshot: CommitSnapshot + + init( + weatherClient: CurrentWeatherClient, + clock: WidgetServerClock, + writer: CurrentWeatherWidgetSnapshotWriter + ) { + self.init( + fetchWeather: { latitude, longitude in + try await weatherClient.fetch( + latitude: latitude, + longitude: longitude + ) + }, + synchronizeClock: { + await Self.synchronizedSnapshotTime(clock: clock) + }, + commitSnapshot: { snapshot, token in + try writer.write(snapshot, using: token) + } + ) + } + + init( + fetchWeather: @escaping FetchWeather, + synchronizeClock: @escaping SynchronizeClock, + commitSnapshot: @escaping CommitSnapshot + ) { + self.fetchWeather = fetchWeather + self.synchronizeClock = synchronizeClock + self.commitSnapshot = commitSnapshot + } + + func refresh( + location: WidgetResolvedWeatherLocation, + writeToken: CurrentWeatherSnapshotWriteToken + ) async -> CurrentWeatherWidgetRefreshResult { + #if DEBUG + async let observation = fetchWeatherWithDiagnostics(location) + #else + async let observation = fetchWeather( + location.latitude, + location.longitude + ) + #endif + async let snapshotTime = synchronizeClock() + + let resolvedObservation: CurrentWeatherRemoteDTO? + let resolvedSnapshotTime: CurrentWeatherSnapshotTime? + do { + (resolvedObservation, resolvedSnapshotTime) = try await ( + observation, + snapshotTime + ) + } catch { + return .failed + } + + guard let resolvedObservation else { + return .noObservation + } + guard let resolvedSnapshotTime else { + return .failed + } + + let snapshot = CurrentWeatherWidgetSnapshotFactory.make( + observation: resolvedObservation, + location: location, + time: resolvedSnapshotTime + ) + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "snapshot write attempted sourceIdentifier=" + + (snapshot.sourceIdentifier ?? "none") + + " regionCode=\(snapshot.regionCode) " + + "observationTime=\(snapshot.observationTime)" + ) + #endif + do { + let writeResult = try commitSnapshot(snapshot, writeToken) + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + writeResult == .written + ? "snapshot write succeeded" + : "snapshot write superseded" + ) + #endif + return writeResult == .written ? .refreshed : .superseded + } catch { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log("snapshot write failed") + #endif + return .failed + } + } + + static func synchronizedSnapshotTime( + clock: WidgetServerClock + ) async -> CurrentWeatherSnapshotTime? { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log("clock synchronization started") + let synchronized = await clock.synchronize() + #else + _ = await clock.synchronize() + #endif + let hasSynchronized = await clock.hasSynchronized + #if DEBUG + if synchronized { + WidgetWeatherRefreshDiagnostics.log("clock synchronized") + } else { + WidgetWeatherRefreshDiagnostics.log( + "clock failed retainedPreviousAnchor=\(hasSynchronized)" + ) + } + #endif + guard hasSynchronized else { + return nil + } + return await clock.currentWeatherSnapshotTime() + } + + #if DEBUG + private func fetchWeatherWithDiagnostics( + _ location: WidgetResolvedWeatherLocation + ) async throws -> CurrentWeatherRemoteDTO? { + WidgetWeatherRefreshDiagnostics.log( + "weather started sourceIdentifier=" + + location.address.sourceIdentifier + ) + do { + let observation = try await fetchWeather( + location.latitude, + location.longitude + ) + if let observation { + WidgetWeatherRefreshDiagnostics.log( + "weather observation received observationTime=" + + "\(observation.time)" + ) + } else { + WidgetWeatherRefreshDiagnostics.log("weather no observation") + } + return observation + } catch { + WidgetWeatherRefreshDiagnostics.log("weather failed") + throw error + } + } + #endif +} diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotWriter.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotWriter.swift index 5bf58b50f..1e4925a47 100644 --- a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotWriter.swift +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotWriter.swift @@ -15,14 +15,6 @@ struct CurrentWeatherWidgetSnapshotWriter: Sendable { ).beginWrite(for: address) } - func write( - _ snapshot: CurrentWeatherWidgetSnapshot - ) throws { - let address = try address(for: snapshot) - let token = try beginWrite(for: address) - _ = try write(snapshot, using: token) - } - func write( _ snapshot: CurrentWeatherWidgetSnapshot, using token: CurrentWeatherSnapshotWriteToken diff --git a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift index 0a7a29fc9..4dd5d63c6 100644 --- a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift +++ b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift @@ -43,23 +43,9 @@ enum WidgetWeatherRefreshDiagnostics { return (previous == nil) != (current == nil) } - return previous.schemaVersion != current.schemaVersion - || previous.sourceIdentifier != current.sourceIdentifier + return previous.sourceIdentifier != current.sourceIdentifier || previous.regionCode != current.regionCode - || previous.regionName != current.regionName || previous.observationTime != current.observationTime - || previous.stationName != current.stationName - || previous.weather != current.weather - || previous.weatherCode != current.weatherCode - || previous.condition != current.condition - || previous.isNight != current.isNight - || previous.nextDayNightTransitionTime - != current.nextDayNightTransitionTime - || previous.calibratedTimeOffsetMilliseconds - != current.calibratedTimeOffsetMilliseconds - || previous.temperature != current.temperature - || previous.humidity != current.humidity - || previous.rain != current.rain } } #endif @@ -76,9 +62,9 @@ struct DPIPWidgetTimelinePlanner: Sendable { ) -> CurrentWeatherWidgetSnapshot? typealias RefreshSaved = @Sendable ( WidgetLocationTarget - ) async -> SavedCurrentWeatherWidgetRefreshResult + ) async -> CurrentWeatherWidgetRefreshResult typealias RefreshCurrent = @Sendable () async - -> CurrentLocationCurrentWeatherWidgetRefreshResult + -> CurrentWeatherWidgetRefreshResult typealias Now = @Sendable () -> Date private let staleAfter: TimeInterval @@ -200,51 +186,6 @@ struct DPIPWidgetTimelinePlanner: Sendable { } } -struct SavedCurrentWeatherWidgetRefreshRuntime: Sendable { - typealias RefreshWithClock = @Sendable ( - WidgetServerClock, - WidgetLocationTarget - ) async -> SavedCurrentWeatherWidgetRefreshResult - - private let serverClock: WidgetServerClock - private let refreshWithClock: RefreshWithClock - - init( - serverClock: WidgetServerClock, - refreshWithClock: @escaping RefreshWithClock - ) { - self.serverClock = serverClock - self.refreshWithClock = refreshWithClock - } - - func refresh( - target: WidgetLocationTarget - ) async -> SavedCurrentWeatherWidgetRefreshResult { - await refreshWithClock(serverClock, target) - } -} - -struct CurrentLocationCurrentWeatherWidgetRefreshRuntime: Sendable { - typealias RefreshWithClock = @Sendable ( - WidgetServerClock - ) async -> CurrentLocationCurrentWeatherWidgetRefreshResult - - private let serverClock: WidgetServerClock - private let refreshWithClock: RefreshWithClock - - init( - serverClock: WidgetServerClock, - refreshWithClock: @escaping RefreshWithClock - ) { - self.serverClock = serverClock - self.refreshWithClock = refreshWithClock - } - - func refresh() async -> CurrentLocationCurrentWeatherWidgetRefreshResult { - await refreshWithClock(serverClock) - } -} - struct DPIPWidgetProviderDependencies: Sendable { typealias LoadSnapshot = DPIPWidgetTimelinePlanner.LoadSnapshot @@ -288,86 +229,79 @@ enum DPIPWidgetProviderRuntime { let writer = containerURL.map { CurrentWeatherWidgetSnapshotWriter(containerURL: $0) } - let savedRefreshRuntime = SavedCurrentWeatherWidgetRefreshRuntime( - serverClock: serverClock, - refreshWithClock: { clock, target in - guard let containerURL, let writer else { - #if DEBUG - WidgetWeatherRefreshDiagnostics.log( - "catalog unavailable appGroupContainer=false" - ) - #endif - return .unavailable - } + let refreshSaved: DPIPWidgetTimelinePlanner.RefreshSaved = { target in + guard let containerURL, let writer else { + #if DEBUG + WidgetWeatherRefreshDiagnostics.log( + "catalog unavailable appGroupContainer=false" + ) + #endif + return .unavailable + } + #if DEBUG + WidgetWeatherRefreshDiagnostics.log("catalog load started") + #endif + let catalog = WidgetLocationCatalogStore( + containerURL: containerURL + ).load() + #if DEBUG + if let catalog { + WidgetWeatherRefreshDiagnostics.log( + "catalog load succeeded locations=" + + "\(catalog.locations.count)" + ) + } else { + WidgetWeatherRefreshDiagnostics.log( + "catalog load failed" + ) + } + #endif + let service = SavedCurrentWeatherWidgetRefreshService( + resolver: SavedWidgetLocationResolver( + catalog: catalog + ), + weatherClient: weatherClient, + clock: serverClock, + writer: writer + ) + + return await service.refresh(target: target) + } + let refreshCurrent: DPIPWidgetTimelinePlanner.RefreshCurrent = { + guard let writer else { #if DEBUG - WidgetWeatherRefreshDiagnostics.log("catalog load started") + WidgetWeatherRefreshDiagnostics.log( + "current location refresh unavailable " + + "appGroupContainer=false" + ) #endif - let catalog = WidgetLocationCatalogStore( - containerURL: containerURL - ).load() + return .unavailable + } + guard let resolver = + WidgetTownshipResolverRuntime.shared + else { #if DEBUG - if let catalog { - WidgetWeatherRefreshDiagnostics.log( - "catalog load succeeded locations=" - + "\(catalog.locations.count)" - ) - } else { - WidgetWeatherRefreshDiagnostics.log( - "catalog load failed" - ) - } + WidgetWeatherRefreshDiagnostics.log( + "township resolver unavailable" + ) #endif - let service = SavedCurrentWeatherWidgetRefreshService( - resolver: SavedWidgetLocationResolver( - catalog: catalog - ), + return .unavailable + } + + let service = + CurrentLocationCurrentWeatherWidgetRefreshService( + acquireLocation: { @MainActor in + await WidgetCurrentLocationClient() + .acquireLocation() + }, + resolver: resolver, weatherClient: weatherClient, - clock: clock, + clock: serverClock, writer: writer ) - - return await service.refresh(target: target) - } - ) - let currentRefreshRuntime = - CurrentLocationCurrentWeatherWidgetRefreshRuntime( - serverClock: serverClock, - refreshWithClock: { clock in - guard let writer else { - #if DEBUG - WidgetWeatherRefreshDiagnostics.log( - "current location refresh unavailable " - + "appGroupContainer=false" - ) - #endif - return .unavailable - } - guard let resolver = - WidgetTownshipResolverRuntime.shared - else { - #if DEBUG - WidgetWeatherRefreshDiagnostics.log( - "township resolver unavailable" - ) - #endif - return .unavailable - } - - let service = - CurrentLocationCurrentWeatherWidgetRefreshService( - acquireLocation: { @MainActor in - await WidgetCurrentLocationClient() - .acquireLocation() - }, - resolver: resolver, - weatherClient: weatherClient, - clock: clock, - writer: writer - ) - return await service.refresh() - } - ) + return await service.refresh() + } return DPIPWidgetProviderDependencies( loadSnapshot: loadSnapshot, @@ -375,12 +309,8 @@ enum DPIPWidgetProviderRuntime { staleAfter: staleAfter, refreshInterval: refreshInterval, loadSnapshot: loadSnapshot, - refreshSaved: { target in - await savedRefreshRuntime.refresh(target: target) - }, - refreshCurrent: { - await currentRefreshRuntime.refresh() - }, + refreshSaved: refreshSaved, + refreshCurrent: refreshCurrent, now: { Date.now } ) ) @@ -388,24 +318,7 @@ enum DPIPWidgetProviderRuntime { } #if DEBUG -private extension SavedCurrentWeatherWidgetRefreshResult { - var diagnosticName: String { - switch self { - case .refreshed: - return "refreshed" - case .superseded: - return "superseded" - case .noObservation: - return "noObservation" - case .unavailable: - return "unavailable" - case .failed: - return "failed" - } - } -} - -private extension CurrentLocationCurrentWeatherWidgetRefreshResult { +private extension CurrentWeatherWidgetRefreshResult { var diagnosticName: String { switch self { case .refreshed: diff --git a/ios/DPIPWidgets/SavedCurrentWeatherWidgetRefreshService.swift b/ios/DPIPWidgets/SavedCurrentWeatherWidgetRefreshService.swift index e61e83b4d..9de6d9e23 100644 --- a/ios/DPIPWidgets/SavedCurrentWeatherWidgetRefreshService.swift +++ b/ios/DPIPWidgets/SavedCurrentWeatherWidgetRefreshService.swift @@ -1,37 +1,14 @@ -enum SavedCurrentWeatherWidgetRefreshResult: Equatable, Sendable { - case refreshed - case superseded - case noObservation - case unavailable - case failed -} - struct SavedCurrentWeatherWidgetRefreshService: Sendable { typealias ResolveLocation = @Sendable ( WidgetLocationTarget ) -> WidgetResolvedWeatherLocation? - typealias FetchWeather = @Sendable ( - Double, - Double - ) async throws -> CurrentWeatherRemoteDTO? - typealias SynchronizeClock = @Sendable () async - -> CurrentWeatherSnapshotTime? - typealias WriteSnapshot = @Sendable ( - CurrentWeatherWidgetSnapshot - ) throws -> Void typealias BeginWrite = @Sendable ( CurrentWeatherSnapshotAddress ) throws -> CurrentWeatherSnapshotWriteToken - typealias CommitSnapshot = @Sendable ( - CurrentWeatherWidgetSnapshot, - CurrentWeatherSnapshotWriteToken - ) throws -> CurrentWeatherSnapshotWriteResult private let resolveLocation: ResolveLocation - private let fetchWeather: FetchWeather - private let synchronizeClock: SynchronizeClock private let beginWrite: BeginWrite - private let commitSnapshot: CommitSnapshot + private let pipeline: CurrentWeatherWidgetRefreshPipeline init( resolver: SavedWidgetLocationResolver, @@ -43,80 +20,30 @@ struct SavedCurrentWeatherWidgetRefreshService: Sendable { resolveLocation: { target in resolver.resolve(target: target) }, - fetchWeather: { latitude, longitude in - try await weatherClient.fetch( - latitude: latitude, - longitude: longitude - ) - }, - synchronizeClock: { - await Self.synchronizedSnapshotTime(clock: clock) - }, beginWrite: { address in try writer.beginWrite(for: address) }, - commitSnapshot: { snapshot, token in - try writer.write(snapshot, using: token) - } - ) - } - - init( - resolveLocation: @escaping ResolveLocation, - fetchWeather: @escaping FetchWeather, - clock: WidgetServerClock, - writeSnapshot: @escaping WriteSnapshot - ) { - self.init( - resolveLocation: resolveLocation, - fetchWeather: fetchWeather, - synchronizeClock: { - await Self.synchronizedSnapshotTime(clock: clock) - }, - writeSnapshot: writeSnapshot - ) - } - - init( - resolveLocation: @escaping ResolveLocation, - fetchWeather: @escaping FetchWeather, - synchronizeClock: @escaping SynchronizeClock, - writeSnapshot: @escaping WriteSnapshot - ) { - self.init( - resolveLocation: resolveLocation, - fetchWeather: fetchWeather, - synchronizeClock: synchronizeClock, - beginWrite: { address in - CurrentWeatherSnapshotWriteToken( - address: address, - generation: 1 - ) - }, - commitSnapshot: { snapshot, _ in - try writeSnapshot(snapshot) - return .written - } + pipeline: CurrentWeatherWidgetRefreshPipeline( + weatherClient: weatherClient, + clock: clock, + writer: writer + ) ) } init( resolveLocation: @escaping ResolveLocation, - fetchWeather: @escaping FetchWeather, - synchronizeClock: @escaping SynchronizeClock, beginWrite: @escaping BeginWrite, - commitSnapshot: @escaping CommitSnapshot + pipeline: CurrentWeatherWidgetRefreshPipeline ) { self.resolveLocation = resolveLocation - self.fetchWeather = fetchWeather - self.synchronizeClock = synchronizeClock self.beginWrite = beginWrite - self.commitSnapshot = commitSnapshot + self.pipeline = pipeline } func refresh( target: WidgetLocationTarget - ) async -> SavedCurrentWeatherWidgetRefreshResult { + ) async -> CurrentWeatherWidgetRefreshResult { guard let location = resolveLocation(target) else { #if DEBUG WidgetWeatherRefreshDiagnostics.log( @@ -142,126 +69,9 @@ struct SavedCurrentWeatherWidgetRefreshService: Sendable { return .failed } - #if DEBUG - async let observation = fetchWeatherWithDiagnostics(location) - #else - async let observation = fetchWeather( - location.latitude, - location.longitude - ) - #endif - async let snapshotTime = synchronizeClock() - - let resolvedObservation: CurrentWeatherRemoteDTO? - let resolvedSnapshotTime: CurrentWeatherSnapshotTime? - do { - (resolvedObservation, resolvedSnapshotTime) = try await ( - observation, - snapshotTime - ) - } catch { - return .failed - } - - guard let resolvedObservation else { - return .noObservation - } - guard let resolvedSnapshotTime else { - return .failed - } - - let snapshot = CurrentWeatherWidgetSnapshotFactory.make( - observation: resolvedObservation, + return await pipeline.refresh( location: location, - time: resolvedSnapshotTime - ) - #if DEBUG - WidgetWeatherRefreshDiagnostics.log( - "snapshot write attempted sourceIdentifier=" - + (snapshot.sourceIdentifier ?? "none") - + " regionCode=\(snapshot.regionCode) " - + "observationTime=\(snapshot.observationTime)" - ) - #endif - do { - let writeResult = try commitSnapshot(snapshot, writeToken) - #if DEBUG - WidgetWeatherRefreshDiagnostics.log( - writeResult == .written - ? "snapshot write succeeded" - : "snapshot write superseded" - ) - #endif - return writeResult == .written ? .refreshed : .superseded - } catch { - #if DEBUG - WidgetWeatherRefreshDiagnostics.log("snapshot write failed") - #endif - return .failed - } - } - - private static func synchronizedSnapshotTime( - clock: WidgetServerClock - ) async -> CurrentWeatherSnapshotTime? { - #if DEBUG - WidgetWeatherRefreshDiagnostics.log( - "clock synchronization started" - ) - let synchronized = await clock.synchronize() - #else - _ = await clock.synchronize() - #endif - let hasSynchronized = await clock.hasSynchronized - #if DEBUG - if synchronized { - WidgetWeatherRefreshDiagnostics.log( - "clock synchronized" - ) - } else if hasSynchronized { - WidgetWeatherRefreshDiagnostics.log( - "clock failed retainedPreviousAnchor=true" - ) - } else { - WidgetWeatherRefreshDiagnostics.log( - "clock failed retainedPreviousAnchor=false" - ) - } - #endif - guard hasSynchronized else { - return nil - } - return await clock.currentWeatherSnapshotTime() - } - - #if DEBUG - private func fetchWeatherWithDiagnostics( - _ location: WidgetResolvedWeatherLocation - ) async throws -> CurrentWeatherRemoteDTO? { - WidgetWeatherRefreshDiagnostics.log( - "weather started sourceIdentifier=" - + location.address.sourceIdentifier + writeToken: writeToken ) - do { - let observation = try await fetchWeather( - location.latitude, - location.longitude - ) - if let observation { - WidgetWeatherRefreshDiagnostics.log( - "weather observation received observationTime=" - + "\(observation.time)" - ) - } else { - WidgetWeatherRefreshDiagnostics.log( - "weather no observation" - ) - } - return observation - } catch { - WidgetWeatherRefreshDiagnostics.log("weather failed") - throw error - } } - #endif } diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 1ddd123a9..1e53a28ef 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 4F1000000000000000000002 /* WidgetCurrentLocationClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F1000000000000000000001 /* WidgetCurrentLocationClientTests.swift */; }; 4F3000000000000000000002 /* CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F3000000000000000000001 /* CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift */; }; + 4F4000000000000000000002 /* WidgetRefreshTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4000000000000000000001 /* WidgetRefreshTestSupport.swift */; }; + 4F5000000000000000000002 /* CurrentWeatherWidgetTestFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5000000000000000000001 /* CurrentWeatherWidgetTestFixtures.swift */; }; 522508B9301F863A006148C2 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 522508B7301F863A006148C2 /* InfoPlist.strings */; }; 72E4CBC23930C168D057AC64 /* Sounds/warn.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 3AE87ED82FDB896B2B5C5F1B /* Sounds/warn.aiff */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; @@ -133,6 +135,8 @@ 4D00D2962CF4598B61D0C722 /* Sounds/eew_alert.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/eew_alert.aiff; sourceTree = ""; }; 4F1000000000000000000001 /* WidgetCurrentLocationClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCurrentLocationClientTests.swift; sourceTree = ""; }; 4F3000000000000000000001 /* CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift; sourceTree = ""; }; + 4F4000000000000000000001 /* WidgetRefreshTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetRefreshTestSupport.swift; sourceTree = ""; }; + 4F5000000000000000000001 /* CurrentWeatherWidgetTestFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentWeatherWidgetTestFixtures.swift; sourceTree = ""; }; 522508B8301F863A006148C2 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/InfoPlist.strings; sourceTree = ""; }; 522508BA301F8687006148C2 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/InfoPlist.strings"; sourceTree = ""; }; 522508BB301F868B006148C2 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -251,6 +255,7 @@ CurrentLocationCurrentWeatherWidgetRefreshService.swift, CurrentWeatherSnapshotTime.swift, CurrentWeatherWidgetSnapshotFactory.swift, + CurrentWeatherWidgetRefreshPipeline.swift, CurrentWeatherWidgetSnapshotWriter.swift, DPIPWidgetProviderSupport.swift, SavedCurrentWeatherWidgetRefreshService.swift, @@ -337,6 +342,8 @@ 28A000000000000000000002 /* WidgetServerClockTests.swift */, 4F1000000000000000000001 /* WidgetCurrentLocationClientTests.swift */, 4F3000000000000000000001 /* CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift */, + 4F4000000000000000000001 /* WidgetRefreshTestSupport.swift */, + 4F5000000000000000000001 /* CurrentWeatherWidgetTestFixtures.swift */, D91A00000000000000000001 /* CurrentWeatherWidgetTests.swift */, D91A00000000000000000002 /* CurrentWeatherWidgetSnapshot.swift */, D91A00000000000000000003 /* CurrentWeatherWidgetTimeline.swift */, @@ -682,6 +689,8 @@ 28A000000000000000000004 /* WidgetServerClockTests.swift in Sources */, 4F1000000000000000000002 /* WidgetCurrentLocationClientTests.swift in Sources */, 4F3000000000000000000002 /* CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift in Sources */, + 4F4000000000000000000002 /* WidgetRefreshTestSupport.swift in Sources */, + 4F5000000000000000000002 /* CurrentWeatherWidgetTestFixtures.swift in Sources */, 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, D91A00000000000000000011 /* CurrentWeatherWidgetTests.swift in Sources */, D91A00000000000000000012 /* CurrentWeatherWidgetSnapshot.swift in Sources */, diff --git a/ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift b/ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift index 88850420d..a7aebd3f1 100644 --- a/ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift +++ b/ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift @@ -25,11 +25,13 @@ final class CurrentLocationCurrentWeatherWidgetRefreshServiceTests: async throws { let recorder = CurrentLocationRefreshEventRecorder() - let weather = CurrentLocationScriptedWeather( + let weather = ScriptedCurrentWeather( result: .success(try makeObservation()), - recorder: recorder + onFetch: { recorder.record(.fetchWeather) } + ) + let writer = CurrentWeatherSnapshotWriterSpy( + onWrite: { recorder.record(.writeSnapshot) } ) - let writer = CurrentLocationSnapshotWriterSpy(recorder: recorder) let service = makeService( recorder: recorder, weather: weather, @@ -48,6 +50,10 @@ final class CurrentLocationCurrentWeatherWidgetRefreshServiceTests: XCTAssertEqual(snapshot.calibratedTimeOffsetMilliseconds, 321) let events = recorder.events + XCTAssertLessThan( + try XCTUnwrap(events.firstIndex(of: .beginWrite)), + try XCTUnwrap(events.firstIndex(of: .acquireLocation)) + ) XCTAssertLessThan( try XCTUnwrap(events.firstIndex(of: .acquireLocation)), try XCTUnwrap(events.firstIndex(of: .resolveTownship)) @@ -73,12 +79,12 @@ final class CurrentLocationCurrentWeatherWidgetRefreshServiceTests: func testWeatherUsesTownshipCentroidInsteadOfPreciseLocation() async throws { - let weather = CurrentLocationScriptedWeather( + let weather = ScriptedCurrentWeather( result: .success(try makeObservation()) ) let service = makeService( weather: weather, - writeSnapshot: CurrentLocationSnapshotWriterSpy().write + writeSnapshot: CurrentWeatherSnapshotWriterSpy().write ) let result = await service.refresh() @@ -98,74 +104,49 @@ final class CurrentLocationCurrentWeatherWidgetRefreshServiceTests: ) } - func testLocationUnavailableDoesNotStartNetworkOrWrite() async throws { - let weather = CurrentLocationScriptedWeather( - result: .success(try makeObservation()) - ) - let clock = CurrentLocationScriptedClock(sample: makeSnapshotTime()) - let writer = CurrentLocationSnapshotWriterSpy() - let service = makeService( - locationResult: .unavailable, - weather: weather, - synchronizeClock: { - await clock.synchronizeAndSample() - }, - writeSnapshot: writer.write - ) - - let result = await service.refresh() - let weatherCallCount = await weather.callCount - let clockCallCount = await clock.callCount - XCTAssertEqual(result, .unavailable) - XCTAssertEqual(weatherCallCount, 0) - XCTAssertEqual(clockCallCount, 0) - XCTAssertEqual(writer.writeCount, 0) - } - - func testLocationTimeoutIsUnavailableWithoutWrite() async throws { - let weather = CurrentLocationScriptedWeather( - result: .success(try makeObservation()) - ) - let writer = CurrentLocationSnapshotWriterSpy() - let service = makeService( - locationResult: .timedOut, - weather: weather, - writeSnapshot: writer.write - ) + func testLocationAcquisitionFailuresDoNotStartPipeline() async throws { + let cases: [( + WidgetCurrentLocationResult, + CurrentWeatherWidgetRefreshResult + )] = [ + (.unavailable, .unavailable), + (.timedOut, .unavailable), + (.failed, .failed), + ] - let result = await service.refresh() - let weatherCallCount = await weather.callCount - XCTAssertEqual(result, .unavailable) - XCTAssertEqual(weatherCallCount, 0) - XCTAssertEqual(writer.writeCount, 0) - } - - func testLocationAcquisitionFailureFailsWithoutWrite() async throws { - let weather = CurrentLocationScriptedWeather( - result: .success(try makeObservation()) - ) - let writer = CurrentLocationSnapshotWriterSpy() - let service = makeService( - locationResult: .failed, - weather: weather, - writeSnapshot: writer.write - ) + for (locationResult, expectedResult) in cases { + let weather = ScriptedCurrentWeather( + result: .success(try makeObservation()) + ) + let clock = ScriptedSnapshotClock(sample: makeSnapshotTime()) + let writer = CurrentWeatherSnapshotWriterSpy() + let service = makeService( + locationResult: locationResult, + weather: weather, + synchronizeClock: { + await clock.synchronizeAndSample() + }, + writeSnapshot: writer.write + ) - let result = await service.refresh() - let weatherCallCount = await weather.callCount - XCTAssertEqual(result, .failed) - XCTAssertEqual(weatherCallCount, 0) - XCTAssertEqual(writer.writeCount, 0) + let result = await service.refresh() + let weatherCallCount = await weather.callCount + let clockCallCount = await clock.callCount + XCTAssertEqual(result, expectedResult) + XCTAssertEqual(weatherCallCount, 0) + XCTAssertEqual(clockCallCount, 0) + XCTAssertEqual(writer.writeCount, 0) + } } func testTownshipResolutionFailureDoesNotStartNetworkOrWrite() async throws { - let weather = CurrentLocationScriptedWeather( + let weather = ScriptedCurrentWeather( result: .success(try makeObservation()) ) - let clock = CurrentLocationScriptedClock(sample: makeSnapshotTime()) - let writer = CurrentLocationSnapshotWriterSpy() + let clock = ScriptedSnapshotClock(sample: makeSnapshotTime()) + let writer = CurrentWeatherSnapshotWriterSpy() let service = makeService( shouldResolveTownship: false, weather: weather, @@ -185,10 +166,10 @@ final class CurrentLocationCurrentWeatherWidgetRefreshServiceTests: } func testResolvedSavedAddressIsRejectedWithoutFallback() async throws { - let weather = CurrentLocationScriptedWeather( + let weather = ScriptedCurrentWeather( result: .success(try makeObservation()) ) - let writer = CurrentLocationSnapshotWriterSpy() + let writer = CurrentWeatherSnapshotWriterSpy() let savedLocation = try XCTUnwrap( WidgetResolvedWeatherLocation( address: .saved(regionCode: "407"), @@ -215,13 +196,19 @@ final class CurrentLocationCurrentWeatherWidgetRefreshServiceTests: async throws { let oldData = try seedExistingSnapshot() - let weather = CurrentLocationScriptedWeather(result: .success(nil)) + let weather = ScriptedCurrentWeather(result: .success(nil)) let writer = CurrentWeatherWidgetSnapshotWriter( containerURL: containerURL ) let service = makeService( weather: weather, - writeSnapshot: writer.write + writeSnapshot: { snapshot in + try CurrentWeatherWidgetTestFixtures.write( + snapshot, + to: .currentLocation, + using: writer + ) + } ) let result = await service.refresh() @@ -233,7 +220,7 @@ final class CurrentLocationCurrentWeatherWidgetRefreshServiceTests: async throws { let oldData = try seedExistingSnapshot() - let weather = CurrentLocationScriptedWeather( + let weather = ScriptedCurrentWeather( result: .failure(.scripted) ) let writer = CurrentWeatherWidgetSnapshotWriter( @@ -241,82 +228,17 @@ final class CurrentLocationCurrentWeatherWidgetRefreshServiceTests: ) let service = makeService( weather: weather, - writeSnapshot: writer.write - ) - - let result = await service.refresh() - XCTAssertEqual(result, .failed) - XCTAssertEqual(try cachedData(), oldData) - } - - func testFirstClockSyncFailureWithoutAnchorDoesNotWrite() async throws { - let weather = CurrentLocationScriptedWeather( - result: .success(try makeObservation()) - ) - let writer = CurrentLocationSnapshotWriterSpy() - let clock = makeClock(serverResults: [.failure(.scripted)]) - let service = makeService( - weather: weather, - clock: clock, - writeSnapshot: writer.write - ) - - let result = await service.refresh() - let hasSynchronized = await clock.hasSynchronized - XCTAssertEqual(result, .failed) - XCTAssertFalse(hasSynchronized) - XCTAssertEqual(writer.writeCount, 0) - } - - func testLaterClockSyncFailureUsesRetainedAnchor() async throws { - let monotonicClock = CurrentLocationTestMonotonicClock( - milliseconds: 100 - ) - let clock = makeClock( - monotonicClock: monotonicClock, - serverResults: [ - .success(10_000), - .failure(.scripted), - ] - ) - let firstSyncSucceeded = await clock.synchronize() - XCTAssertTrue(firstSyncSucceeded) - monotonicClock.set(milliseconds: 600) - let weather = CurrentLocationScriptedWeather( - result: .success(try makeObservation()) - ) - let writer = CurrentLocationSnapshotWriterSpy() - let service = makeService( - weather: weather, - clock: clock, - writeSnapshot: writer.write - ) - - let result = await service.refresh() - XCTAssertEqual(result, .refreshed) - XCTAssertEqual(writer.writeCount, 1) - XCTAssertEqual( - writer.snapshots.first?.calibratedTimeOffsetMilliseconds, - 5_500 - ) - } - - func testWriterFailureLeavesExistingCurrentLocationCacheUntouched() - async throws - { - let oldData = try seedExistingSnapshot() - let weather = CurrentLocationScriptedWeather( - result: .success(try makeObservation()) - ) - let writer = CurrentLocationSnapshotWriterSpy(error: .scripted) - let service = makeService( - weather: weather, - writeSnapshot: writer.write + writeSnapshot: { snapshot in + try CurrentWeatherWidgetTestFixtures.write( + snapshot, + to: .currentLocation, + using: writer + ) + } ) let result = await service.refresh() XCTAssertEqual(result, .failed) - XCTAssertEqual(writer.writeCount, 1) XCTAssertEqual(try cachedData(), oldData) } @@ -333,10 +255,16 @@ final class CurrentLocationCurrentWeatherWidgetRefreshServiceTests: latitude: 24.1813400, longitude: 120.6466200 ), - weather: CurrentLocationScriptedWeather( + weather: ScriptedCurrentWeather( result: .success(try makeObservation()) ), - writeSnapshot: writer.write + writeSnapshot: { snapshot in + try CurrentWeatherWidgetTestFixtures.write( + snapshot, + to: .currentLocation, + using: writer + ) + } ) let secondService = makeService( resolvedLocation: resolvedLocation( @@ -345,10 +273,16 @@ final class CurrentLocationCurrentWeatherWidgetRefreshServiceTests: latitude: 25.0333200, longitude: 121.5701000 ), - weather: CurrentLocationScriptedWeather( + weather: ScriptedCurrentWeather( result: .success(try makeObservation()) ), - writeSnapshot: writer.write + writeSnapshot: { snapshot in + try CurrentWeatherWidgetTestFixtures.write( + snapshot, + to: .currentLocation, + using: writer + ) + } ) let firstResult = await firstService.refresh() @@ -373,107 +307,22 @@ final class CurrentLocationCurrentWeatherWidgetRefreshServiceTests: ) } - func testWeatherAndClockRunConcurrentlyAfterTownshipResolution() - async throws - { - let weatherGate = CurrentLocationRefreshOperationGate() - let clockGate = CurrentLocationRefreshOperationGate() - let observation = try makeObservation() - let resolvedLocation = resolvedLocation() - let snapshotTime = makeSnapshotTime() - let writer = CurrentLocationSnapshotWriterSpy() - let service = CurrentLocationCurrentWeatherWidgetRefreshService( - acquireLocation: { @MainActor [preciseLocation] in - .acquired(preciseLocation) - }, - resolveTownship: { _ in - resolvedLocation - }, - fetchWeather: { _, _ in - await weatherGate.wait() - return observation - }, - synchronizeClock: { - await clockGate.wait() - return snapshotTime - }, - writeSnapshot: writer.write - ) - - let refresh = Task { await service.refresh() } - for _ in 0..<200 { - if await weatherGate.hasStarted, - await clockGate.hasStarted { - break - } - await Task.yield() - } - - let weatherStarted = await weatherGate.hasStarted - let clockStarted = await clockGate.hasStarted - XCTAssertTrue(weatherStarted) - XCTAssertTrue(clockStarted) - await weatherGate.open() - await clockGate.open() - let result = await refresh.value - XCTAssertEqual(result, .refreshed) - } - - func testRejectedWriteReturnsSuperseded() async throws { - let expectedToken = CurrentWeatherSnapshotWriteToken( - address: .currentLocation, - generation: 9 - ) - let weather = CurrentLocationScriptedWeather( - result: .success(try makeObservation()) - ) - let preciseLocation = preciseLocation - let resolvedLocation = resolvedLocation() - let service = CurrentLocationCurrentWeatherWidgetRefreshService( - acquireLocation: { @MainActor in - .acquired(preciseLocation) - }, - resolveTownship: { _ in - resolvedLocation - }, - fetchWeather: { latitude, longitude in - try await weather.fetch( - latitude: latitude, - longitude: longitude - ) - }, - synchronizeClock: makeSnapshotTime, - beginWrite: { address in - XCTAssertEqual(address, expectedToken.address) - return expectedToken - }, - commitSnapshot: { _, token in - XCTAssertEqual(token, expectedToken) - return .rejected - } - ) - - let result = await service.refresh() - - XCTAssertEqual(result, .superseded) - } - private func makeService( locationResult: WidgetCurrentLocationResult? = nil, resolvedLocation: WidgetResolvedWeatherLocation? = nil, shouldResolveTownship: Bool = true, recorder: CurrentLocationRefreshEventRecorder? = nil, - weather: CurrentLocationScriptedWeather, + weather: ScriptedCurrentWeather, synchronizeClock: @escaping - CurrentLocationCurrentWeatherWidgetRefreshService - .SynchronizeClock = { + CurrentWeatherWidgetRefreshPipeline.SynchronizeClock = { CurrentWeatherSnapshotTime( calibratedNowUnixMilliseconds: 1_710_907_200_000, calibratedTimeOffsetMilliseconds: 321 ) }, - writeSnapshot: @escaping - CurrentLocationCurrentWeatherWidgetRefreshService.WriteSnapshot + writeSnapshot: @escaping @Sendable ( + CurrentWeatherWidgetSnapshot + ) throws -> Void ) -> CurrentLocationCurrentWeatherWidgetRefreshService { let preciseLocation = preciseLocation let defaultResolvedLocation = self.resolvedLocation() @@ -494,46 +343,29 @@ final class CurrentLocationCurrentWeatherWidgetRefreshServiceTests: } return defaultResolvedLocation }, - fetchWeather: { latitude, longitude in - try await weather.fetch( - latitude: latitude, - longitude: longitude + beginWrite: { address in + recorder?.record(.beginWrite) + return CurrentWeatherSnapshotWriteToken( + address: address, + generation: 1 ) }, - synchronizeClock: { - recorder?.record(.synchronizeClock) - return await synchronizeClock() - }, - writeSnapshot: writeSnapshot - ) - } - - private func makeService( - weather: CurrentLocationScriptedWeather, - clock: WidgetServerClock, - writeSnapshot: @escaping - CurrentLocationCurrentWeatherWidgetRefreshService.WriteSnapshot - ) -> CurrentLocationCurrentWeatherWidgetRefreshService { - let preciseLocation = preciseLocation - let resolvedLocation = resolvedLocation() - return CurrentLocationCurrentWeatherWidgetRefreshService( - acquireLocation: { @MainActor in - .acquired(preciseLocation) - }, - resolveTownship: { location in - guard location == preciseLocation else { - return nil + pipeline: CurrentWeatherWidgetRefreshPipeline( + fetchWeather: { latitude, longitude in + try await weather.fetch( + latitude: latitude, + longitude: longitude + ) + }, + synchronizeClock: { + recorder?.record(.synchronizeClock) + return await synchronizeClock() + }, + commitSnapshot: { snapshot, _ in + try writeSnapshot(snapshot) + return .written } - return resolvedLocation - }, - fetchWeather: { latitude, longitude in - try await weather.fetch( - latitude: latitude, - longitude: longitude - ) - }, - clock: clock, - writeSnapshot: writeSnapshot + ) ) } @@ -543,98 +375,44 @@ final class CurrentLocationCurrentWeatherWidgetRefreshServiceTests: latitude: Double = 24.1813400, longitude: Double = 120.6466200 ) -> WidgetResolvedWeatherLocation { - WidgetResolvedWeatherLocation( - address: .currentLocation, + CurrentWeatherWidgetTestFixtures.resolvedLocation( regionCode: regionCode, regionName: regionName, latitude: latitude, longitude: longitude - )! + ) } private func makeObservation() throws -> CurrentWeatherRemoteDTO { - try JSONDecoder().decode( - CurrentWeatherRemoteDTO.self, - from: Data( - """ - { - "station": {"name": "西屯測站"}, - "time": 1710900000, - "data": { - "weather": "晴", - "weatherCode": 100, - "temperature": 28.5, - "humidity": 70, - "rain": 0 - } - } - """.utf8 - ) - ) + try CurrentWeatherWidgetTestFixtures.observation() } private func makeSnapshotTime() -> CurrentWeatherSnapshotTime { - CurrentWeatherSnapshotTime( - calibratedNowUnixMilliseconds: 1_710_907_200_000, - calibratedTimeOffsetMilliseconds: 321 - ) - } - - private func makeClock( - monotonicClock: CurrentLocationTestMonotonicClock = - CurrentLocationTestMonotonicClock(milliseconds: 100), - serverResults: [Result] - ) -> WidgetServerClock { - WidgetServerClock( - deviceClock: CurrentLocationTestWallClock(milliseconds: 5_000), - monotonicClock: monotonicClock, - serverTimeSource: CurrentLocationScriptedServerTimeSource( - results: serverResults - ), - timeoutRunner: CurrentLocationPassthroughTimeoutRunner() - ) + CurrentWeatherWidgetTestFixtures.snapshotTime() } @discardableResult private func seedExistingSnapshot() throws -> Data { - try CurrentWeatherWidgetSnapshotWriter( - containerURL: containerURL - ).write( - CurrentWeatherWidgetSnapshot( - schemaVersion: 5, + try CurrentWeatherWidgetTestFixtures.seed( + CurrentWeatherWidgetTestFixtures.snapshot( sourceIdentifier: "current-location", - regionCode: "407", - regionName: "舊快取", - observationTime: 1_700_000_000, - stationName: "舊測站", - weather: "陰", - weatherCode: 300, - condition: .overcast, - isNight: false, - nextDayNightTransitionTime: 1_700_010_000, - calibratedTimeOffsetMilliseconds: 123, - temperature: 20, - humidity: 60, - rain: 1 - ) + regionCode: "407" + ), + at: .currentLocation, + containerURL: containerURL ) - return try cachedData() } private func cachedData() throws -> Data { - try Data( - contentsOf: CurrentWeatherSnapshotStorage( - containerURL: containerURL - ).snapshotURL(for: .currentLocation) + try CurrentWeatherWidgetTestFixtures.cachedData( + at: .currentLocation, + containerURL: containerURL ) } } -private enum CurrentLocationRefreshTestError: Error, Sendable { - case scripted -} - private enum CurrentLocationRefreshEvent: Equatable { + case beginWrite case acquireLocation case resolveTownship case fetchWeather @@ -660,190 +438,3 @@ private final class CurrentLocationRefreshEventRecorder: lock.unlock() } } - -private actor CurrentLocationScriptedWeather { - struct Coordinates: Sendable { - let latitude: Double - let longitude: Double - } - - private(set) var callCount = 0 - private(set) var coordinates: [Coordinates] = [] - private let result: Result< - CurrentWeatherRemoteDTO?, - CurrentLocationRefreshTestError - > - private let recorder: CurrentLocationRefreshEventRecorder? - - init( - result: Result< - CurrentWeatherRemoteDTO?, - CurrentLocationRefreshTestError - >, - recorder: CurrentLocationRefreshEventRecorder? = nil - ) { - self.result = result - self.recorder = recorder - } - - func fetch( - latitude: Double, - longitude: Double - ) throws -> CurrentWeatherRemoteDTO? { - recorder?.record(.fetchWeather) - callCount += 1 - coordinates.append( - Coordinates(latitude: latitude, longitude: longitude) - ) - return try result.get() - } -} - -private actor CurrentLocationScriptedClock { - private(set) var callCount = 0 - private let sample: CurrentWeatherSnapshotTime? - - init(sample: CurrentWeatherSnapshotTime?) { - self.sample = sample - } - - func synchronizeAndSample() -> CurrentWeatherSnapshotTime? { - callCount += 1 - return sample - } -} - -private final class CurrentLocationSnapshotWriterSpy: @unchecked Sendable { - private let lock = NSLock() - private let error: CurrentLocationRefreshTestError? - private let recorder: CurrentLocationRefreshEventRecorder? - private var storedSnapshots: [CurrentWeatherWidgetSnapshot] = [] - private var storedWriteCount = 0 - - init( - error: CurrentLocationRefreshTestError? = nil, - recorder: CurrentLocationRefreshEventRecorder? = nil - ) { - self.error = error - self.recorder = recorder - } - - var snapshots: [CurrentWeatherWidgetSnapshot] { - lock.lock() - defer { lock.unlock() } - return storedSnapshots - } - - var writeCount: Int { - lock.lock() - defer { lock.unlock() } - return storedWriteCount - } - - func write(_ snapshot: CurrentWeatherWidgetSnapshot) throws { - recorder?.record(.writeSnapshot) - lock.lock() - storedWriteCount += 1 - let error = error - if error == nil { - storedSnapshots.append(snapshot) - } - lock.unlock() - - if let error { - throw error - } - } -} - -private actor CurrentLocationRefreshOperationGate { - private(set) var hasStarted = false - private var isOpen = false - private var continuation: CheckedContinuation? - - func wait() async { - hasStarted = true - guard !isOpen else { - return - } - await withCheckedContinuation { continuation in - self.continuation = continuation - } - } - - func open() { - isOpen = true - continuation?.resume() - continuation = nil - } -} - -private final class CurrentLocationTestWallClock: WidgetWallTimeSource, - @unchecked Sendable -{ - private let milliseconds: Int64 - - init(milliseconds: Int64) { - self.milliseconds = milliseconds - } - - func now() -> Date { - Date( - timeIntervalSince1970: TimeInterval(milliseconds) / 1_000 - ) - } -} - -private final class CurrentLocationTestMonotonicClock: - WidgetMonotonicTimeSource, - @unchecked Sendable -{ - private let lock = NSLock() - private var milliseconds: Int64 - - init(milliseconds: Int64) { - self.milliseconds = milliseconds - } - - func elapsedMilliseconds() -> Int64 { - lock.lock() - defer { lock.unlock() } - return milliseconds - } - - func set(milliseconds: Int64) { - lock.lock() - self.milliseconds = milliseconds - lock.unlock() - } -} - -private actor CurrentLocationScriptedServerTimeSource: - WidgetServerTimeSource -{ - private var results: [ - Result - ] - - init(results: [Result]) { - self.results = results - } - - func serverTimeUnixMilliseconds() throws -> Int64 { - guard !results.isEmpty else { - throw CurrentLocationRefreshTestError.scripted - } - return try results.removeFirst().get() - } -} - -private struct CurrentLocationPassthroughTimeoutRunner: - WidgetServerClockTimeoutRunning -{ - func serverTimeUnixMilliseconds( - from source: any WidgetServerTimeSource, - timeout: TimeInterval - ) async throws -> Int64 { - try await source.serverTimeUnixMilliseconds() - } -} diff --git a/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift b/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift index 0b6e3c2a3..e50085ee8 100644 --- a/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift +++ b/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift @@ -1034,7 +1034,7 @@ final class CurrentWeatherWidgetSnapshotWriterTests: XCTestCase { regionCode: "407" ) - try writer.write(snapshot) + try write(snapshot, to: .saved(regionCode: "407")) XCTAssertTrue( FileManager.default.fileExists( @@ -1053,7 +1053,7 @@ final class CurrentWeatherWidgetSnapshotWriterTests: XCTestCase { regionCode: "407" ) - try writer.write(snapshot) + try write(snapshot, to: .currentLocation) XCTAssertTrue( FileManager.default.fileExists( @@ -1062,97 +1062,6 @@ final class CurrentWeatherWidgetSnapshotWriterTests: XCTestCase { ) } - func testWrittenJSONRoundTripsSchemaVersionFiveFields() - throws - { - let original = makeSnapshot( - sourceIdentifier: "region:407", - regionCode: "407" - ) - - try writer.write(original) - - let data = try Data( - contentsOf: snapshotURL( - for: .saved(regionCode: "407") - ) - ) - let decoded = try JSONDecoder().decode( - CurrentWeatherWidgetSnapshot.self, - from: data - ) - - XCTAssertEqual(decoded.schemaVersion, original.schemaVersion) - XCTAssertEqual( - decoded.sourceIdentifier, - original.sourceIdentifier - ) - XCTAssertEqual(decoded.regionCode, original.regionCode) - XCTAssertEqual(decoded.regionName, original.regionName) - XCTAssertEqual( - decoded.observationTime, - original.observationTime - ) - XCTAssertEqual(decoded.stationName, original.stationName) - XCTAssertEqual(decoded.weather, original.weather) - XCTAssertEqual(decoded.weatherCode, original.weatherCode) - XCTAssertEqual(decoded.condition, original.condition) - XCTAssertEqual(decoded.isNight, original.isNight) - XCTAssertEqual( - decoded.nextDayNightTransitionTime, - original.nextDayNightTransitionTime - ) - XCTAssertEqual( - decoded.calibratedTimeOffsetMilliseconds, - original.calibratedTimeOffsetMilliseconds - ) - XCTAssertEqual(decoded.temperature, original.temperature) - XCTAssertEqual(decoded.humidity, original.humidity) - XCTAssertEqual(decoded.rain, original.rain) - } - - func testWrittenJSONContainsExactlyCanonicalSchemaVersionFiveKeys() - throws - { - try writer.write( - makeSnapshot( - sourceIdentifier: "region:407", - regionCode: "407" - ) - ) - - let data = try Data( - contentsOf: snapshotURL( - for: .saved(regionCode: "407") - ) - ) - let json = try XCTUnwrap( - JSONSerialization.jsonObject(with: data) - as? [String: Any] - ) - - XCTAssertEqual( - Set(json.keys), - Set([ - "schemaVersion", - "sourceIdentifier", - "regionCode", - "regionName", - "observationTime", - "stationName", - "weather", - "weatherCode", - "condition", - "isNight", - "nextDayNightTransitionTime", - "calibratedTimeOffsetMilliseconds", - "temperature", - "humidity", - "rain", - ]) - ) - } - func testSavedLocationCachesAreIndependent() throws { let region407 = makeSnapshot( sourceIdentifier: "region:407", @@ -1165,13 +1074,13 @@ final class CurrentWeatherWidgetSnapshotWriterTests: XCTestCase { regionName: "中正區" ) - try writer.write(region407) + try write(region407, to: .saved(regionCode: "407")) let region407Bytes = try Data( contentsOf: snapshotURL( for: .saved(regionCode: "407") ) ) - try writer.write(region100) + try write(region100, to: .saved(regionCode: "100")) XCTAssertEqual( try Data( @@ -1202,13 +1111,13 @@ final class CurrentWeatherWidgetSnapshotWriterTests: XCTestCase { regionCode: "407" ) - try writer.write(saved) + try write(saved, to: .saved(regionCode: "407")) let savedBytes = try Data( contentsOf: snapshotURL( for: .saved(regionCode: "407") ) ) - try writer.write(current) + try write(current, to: .currentLocation) XCTAssertEqual( try Data( @@ -1236,7 +1145,7 @@ final class CurrentWeatherWidgetSnapshotWriterTests: XCTestCase { regionCode: "407" ) - try writer.write(valid) + try write(valid, to: address) let originalBytes = try Data( contentsOf: snapshotURL(for: address) ) @@ -1247,7 +1156,7 @@ final class CurrentWeatherWidgetSnapshotWriterTests: XCTestCase { regionCode: "407" ) - XCTAssertThrowsError(try writer.write(legacy)) + XCTAssertThrowsError(try write(legacy, to: address)) XCTAssertEqual( try Data(contentsOf: snapshotURL(for: address)), originalBytes @@ -1260,6 +1169,17 @@ final class CurrentWeatherWidgetSnapshotWriterTests: XCTestCase { ) } + private func write( + _ snapshot: CurrentWeatherWidgetSnapshot, + to address: CurrentWeatherSnapshotAddress + ) throws { + try CurrentWeatherWidgetTestFixtures.write( + snapshot, + to: address, + using: writer + ) + } + private func snapshotURL( for address: CurrentWeatherSnapshotAddress ) -> URL { diff --git a/ios/RunnerTests/CurrentWeatherWidgetTestFixtures.swift b/ios/RunnerTests/CurrentWeatherWidgetTestFixtures.swift new file mode 100644 index 000000000..8dae084a5 --- /dev/null +++ b/ios/RunnerTests/CurrentWeatherWidgetTestFixtures.swift @@ -0,0 +1,129 @@ +import Foundation + +enum CurrentWeatherWidgetTestFixtures { + static func observation( + stationName: String = "西屯測站", + time: Int = 1_710_900_000 + ) throws -> CurrentWeatherRemoteDTO { + let data = Data( + """ + { + "station": {"name": "\(stationName)"}, + "time": \(time), + "data": { + "weather": "晴", + "weatherCode": 100, + "temperature": 28.5, + "humidity": 70, + "rain": 0 + } + } + """.utf8 + ) + return try JSONDecoder().decode(CurrentWeatherRemoteDTO.self, from: data) + } + + static func snapshotTime( + offsetMilliseconds: Int = 321 + ) -> CurrentWeatherSnapshotTime { + CurrentWeatherSnapshotTime( + calibratedNowUnixMilliseconds: 1_710_907_200_000, + calibratedTimeOffsetMilliseconds: offsetMilliseconds + ) + } + + static func resolvedLocation( + address: CurrentWeatherSnapshotAddress = .currentLocation, + regionCode: String = "407", + regionName: String = "西屯區", + latitude: Double = 24.1813400, + longitude: Double = 120.6466200 + ) -> WidgetResolvedWeatherLocation { + WidgetResolvedWeatherLocation( + address: address, + regionCode: regionCode, + regionName: regionName, + latitude: latitude, + longitude: longitude + )! + } + + static func snapshot( + sourceIdentifier: String, + regionCode: String, + regionName: String = "舊快取", + schemaVersion: Int = 5 + ) -> CurrentWeatherWidgetSnapshot { + CurrentWeatherWidgetSnapshot( + schemaVersion: schemaVersion, + sourceIdentifier: sourceIdentifier, + regionCode: regionCode, + regionName: regionName, + observationTime: 1_700_000_000, + stationName: "舊測站", + weather: "陰", + weatherCode: 300, + condition: .overcast, + isNight: false, + nextDayNightTransitionTime: 1_700_010_000, + calibratedTimeOffsetMilliseconds: 123, + temperature: 20, + humidity: 60, + rain: 1 + ) + } + + static func catalogLocation( + regionCode: String + ) -> WidgetLocationCatalogLocation? { + switch regionCode { + case "242": + return WidgetLocationCatalogLocation( + regionCode: "242", + displayName: "新莊區", + administrativeAreaName: "新北市", + latitude: 25.0358303, + longitude: 121.4500307 + ) + case "433": + return WidgetLocationCatalogLocation( + regionCode: "433", + displayName: "沙鹿區", + administrativeAreaName: "臺中市", + latitude: 24.2338622, + longitude: 120.565703 + ) + default: + return nil + } + } + + static func seed( + _ snapshot: CurrentWeatherWidgetSnapshot, + at address: CurrentWeatherSnapshotAddress, + containerURL: URL + ) throws -> Data { + let writer = CurrentWeatherWidgetSnapshotWriter(containerURL: containerURL) + try write(snapshot, to: address, using: writer) + return try cachedData(at: address, containerURL: containerURL) + } + + static func write( + _ snapshot: CurrentWeatherWidgetSnapshot, + to address: CurrentWeatherSnapshotAddress, + using writer: CurrentWeatherWidgetSnapshotWriter + ) throws { + let token = try writer.beginWrite(for: address) + _ = try writer.write(snapshot, using: token) + } + + static func cachedData( + at address: CurrentWeatherSnapshotAddress, + containerURL: URL + ) throws -> Data { + let url = CurrentWeatherSnapshotStorage( + containerURL: containerURL + ).snapshotURL(for: address) + return try Data(contentsOf: url) + } +} diff --git a/ios/RunnerTests/CurrentWeatherWidgetTests.swift b/ios/RunnerTests/CurrentWeatherWidgetTests.swift index b02009253..0e5ab7cec 100644 --- a/ios/RunnerTests/CurrentWeatherWidgetTests.swift +++ b/ios/RunnerTests/CurrentWeatherWidgetTests.swift @@ -124,26 +124,6 @@ final class CurrentWeatherRemoteDTOTests: XCTestCase { ) } - func testRejectsBooleanForNumericField() { - XCTAssertThrowsError( - try decode(validJSON(temperature: "true")) - ) - } - - func testRejectsOversizedInt() { - XCTAssertThrowsError( - try decode( - validJSON(time: "9223372036854775808") - ) - ) - } - - func testNegativeTimestampPreservesCurrentDecodingBehavior() throws { - let weather = try decode(validJSON(time: "-1")) - - XCTAssertEqual(weather.time, -1) - } - func testRejectsMissingStationName() { XCTAssertThrowsError( try decode( @@ -1464,12 +1444,6 @@ final class CurrentWeatherClientTests: XCTestCase { } } - func testFetchAcceptsOnlyHTTP200() async { - setHTTPResponse(statusCode: 204, data: Data()) - - await assertFetchThrows(.httpStatus(204)) - } - func testFetchRejectsOversizedResponse() async { setHTTPResponse( data: Data(repeating: 0x20, count: 128 * 1024 + 1) @@ -1770,33 +1744,6 @@ final class WidgetSolarTimeTests: XCTestCase { ) } - func testIsNightMatchesTaipeiSolarBoundaries() { - let latitude = 25.032188 - let longitude = 121.5183226 - - let cases: [(Int64, Bool)] = [ - (1_710_885_457_000, true), - (1_710_885_458_000, false), - (1_710_885_459_000, false), - - (1_710_929_102_000, false), - (1_710_929_103_000, true), - (1_710_929_104_000, true), - ] - - for (unixMilliseconds, expectedIsNight) in cases { - XCTAssertEqual( - WidgetSolarTime.isNight( - unixMilliseconds: unixMilliseconds, - latitude: latitude, - longitude: longitude - ), - expectedIsNight, - "Unexpected day/night state at \(unixMilliseconds)" - ) - } - } - func testIsNightIgnoresSubsecondWithinBoundarySecond() { let latitude = 25.032188 let longitude = 121.5183226 @@ -1818,53 +1765,6 @@ final class WidgetSolarTimeTests: XCTestCase { ) } - func testNextTransitionMatchesTaipeiSolarBoundaries() { - let latitude = 25.032188 - let longitude = 121.5183226 - - let cases: [(now: Int64, expected: Int64)] = [ - // one second before sunrise → today's sunrise - (1_710_885_457_000, 1_710_885_458), - - // exactly sunrise → today's sunset - (1_710_885_458_000, 1_710_929_103), - - // one second after sunrise → today's sunset - (1_710_885_459_000, 1_710_929_103), - - // one second before sunset → today's sunset - (1_710_929_102_000, 1_710_929_103), - - // exactly sunset → tomorrow's sunrise - (1_710_929_103_000, 1_710_971_796), - - // one second after sunset → tomorrow's sunrise - (1_710_929_104_000, 1_710_971_796), - ] - - for testCase in cases { - XCTAssertEqual( - WidgetSolarTime.nextDayNightTransition( - unixMilliseconds: testCase.now, - latitude: latitude, - longitude: longitude - ), - testCase.expected, - "Unexpected transition at \(testCase.now)" - ) - } - } - - func testNextTransitionCrossesYearBoundary() { - XCTAssertEqual( - WidgetSolarTime.nextDayNightTransition( - unixMilliseconds: 1_735_636_512_000, - latitude: 25.032188, - longitude: 121.5183226 - ), - 1_735_684_745 - ) - } } final class WidgetResolvedWeatherLocationTests: XCTestCase { @@ -1954,24 +1854,6 @@ final class WidgetResolvedWeatherLocationTests: XCTestCase { } } -final class CurrentWeatherSnapshotTimeTests: XCTestCase { - func testStoresCalibratedTimeSample() { - let time = CurrentWeatherSnapshotTime( - calibratedNowUnixMilliseconds: 1_789_567_200_123, - calibratedTimeOffsetMilliseconds: -5_000 - ) - - XCTAssertEqual( - time.calibratedNowUnixMilliseconds, - 1_789_567_200_123 - ) - XCTAssertEqual( - time.calibratedTimeOffsetMilliseconds, - -5_000 - ) - } -} - final class CurrentWeatherWidgetSnapshotFactoryTests: XCTestCase { func testCreatesSchemaFiveSnapshotFromResolvedInputs() throws { let observation = try makeObservation() diff --git a/ios/RunnerTests/DPIPWidgetProviderTests.swift b/ios/RunnerTests/DPIPWidgetProviderTests.swift index a4ec88f40..67be67349 100644 --- a/ios/RunnerTests/DPIPWidgetProviderTests.swift +++ b/ios/RunnerTests/DPIPWidgetProviderTests.swift @@ -202,26 +202,6 @@ final class DPIPWidgetProviderTests: XCTestCase { XCTAssertEqual(store.loadTargets, [target]) } - func testRefreshFailureDoesNotMutateOrDeleteCache() async { - let target = WidgetLocationTarget.saved(regionCode: "407") - let cachedSnapshot = snapshot( - for: target, - stationName: "unchanged-cache" - ) - let store = ProviderTimelineTestStore( - refreshResult: .failed, - snapshots: [(target, cachedSnapshot)] - ) - - _ = await planner(store: store).plan(for: target) - - XCTAssertEqual(store.cacheMutationCount, 0) - XCTAssertEqual( - store.cachedSnapshot(for: target)?.stationName, - "unchanged-cache" - ) - } - #if DEBUG func testDebugTimelineRequestsRefreshAfterSixtySeconds() async { let target = WidgetLocationTarget.currentLocation @@ -258,84 +238,6 @@ final class DPIPWidgetProviderTests: XCTestCase { } #endif - func testRuntimeKeepsThirtyMinuteStaleInterval() { - XCTAssertEqual(staleAfter, 30 * 60) - } - - func testTimelineProjectionDelegatesToCurrentWeatherTimeline() async { - let target = WidgetLocationTarget.currentLocation - let cachedSnapshot = snapshot( - for: target, - observationTime: 9_000, - isNight: false, - transitionTime: 10_500 - ) - let store = ProviderTimelineTestStore( - refreshResult: .failed, - snapshots: [(target, cachedSnapshot)] - ) - - let plan = await planner(store: store).plan(for: target) - - XCTAssertEqual( - plan.states, - CurrentWeatherWidgetTimeline.states( - snapshot: cachedSnapshot, - deviceNow: now, - staleAfter: staleAfter - ) - ) - } - - func testRefreshRuntimeReusesOneServerClock() async { - let recorder = ProviderClockIdentityRecorder() - let runtime = SavedCurrentWeatherWidgetRefreshRuntime( - serverClock: WidgetServerClock(), - refreshWithClock: { clock, _ in - recorder.record(clock) - return .failed - } - ) - - _ = await runtime.refresh( - target: .saved(regionCode: "407") - ) - _ = await runtime.refresh( - target: .saved(regionCode: "242") - ) - - XCTAssertEqual(recorder.identities.count, 2) - XCTAssertEqual(Set(recorder.identities).count, 1) - } - - func testSavedAndCurrentRefreshRuntimesShareOneServerClock() async { - let recorder = ProviderClockIdentityRecorder() - let clock = WidgetServerClock() - let savedRuntime = SavedCurrentWeatherWidgetRefreshRuntime( - serverClock: clock, - refreshWithClock: { receivedClock, _ in - recorder.record(receivedClock) - return .failed - } - ) - let currentRuntime = - CurrentLocationCurrentWeatherWidgetRefreshRuntime( - serverClock: clock, - refreshWithClock: { receivedClock in - recorder.record(receivedClock) - return .failed - } - ) - - _ = await savedRuntime.refresh( - target: .saved(regionCode: "407") - ) - _ = await currentRuntime.refresh() - - XCTAssertEqual(recorder.identities.count, 2) - XCTAssertEqual(Set(recorder.identities).count, 1) - } - func testSnapshotPathIsCacheOnlyAndDoesNotInvokeRefresh() { let target = WidgetLocationTarget.currentLocation let store = ProviderTimelineTestStore( @@ -372,10 +274,7 @@ final class DPIPWidgetProviderTests: XCTestCase { private func snapshot( for target: WidgetLocationTarget, - stationName: String = "station", - observationTime: Int = 10_000, - isNight: Bool = false, - transitionTime: Int = 0 + stationName: String = "station" ) -> CurrentWeatherWidgetSnapshot { let regionCode: String switch target { @@ -390,13 +289,13 @@ final class DPIPWidgetProviderTests: XCTestCase { sourceIdentifier: target.sourceIdentifier, regionCode: regionCode, regionName: "測試地區", - observationTime: observationTime, + observationTime: 10_000, stationName: stationName, weather: "晴", weatherCode: 100, condition: .clear, - isNight: isNight, - nextDayNightTransitionTime: transitionTime, + isNight: false, + nextDayNightTransitionTime: 0, calibratedTimeOffsetMilliseconds: 0, temperature: 28, humidity: 70, @@ -413,19 +312,16 @@ private enum ProviderTimelineEvent: Equatable { private final class ProviderTimelineTestStore: @unchecked Sendable { private let lock = NSLock() - private let savedRefreshResult: SavedCurrentWeatherWidgetRefreshResult - private let currentRefreshResult: - CurrentLocationCurrentWeatherWidgetRefreshResult + private let savedRefreshResult: CurrentWeatherWidgetRefreshResult + private let currentRefreshResult: CurrentWeatherWidgetRefreshResult private let refreshedSnapshot: CurrentWeatherWidgetSnapshot? private var storedSnapshots: [String: CurrentWeatherWidgetSnapshot] private var storedEvents: [ProviderTimelineEvent] = [] - private var storedCacheMutationCount = 0 init( - refreshResult: SavedCurrentWeatherWidgetRefreshResult, - currentRefreshResult: - CurrentLocationCurrentWeatherWidgetRefreshResult = .failed, + refreshResult: CurrentWeatherWidgetRefreshResult, + currentRefreshResult: CurrentWeatherWidgetRefreshResult = .failed, snapshots: [( WidgetLocationTarget, CurrentWeatherWidgetSnapshot @@ -444,14 +340,12 @@ private final class ProviderTimelineTestStore: @unchecked Sendable { func refreshSaved( target: WidgetLocationTarget - ) async -> SavedCurrentWeatherWidgetRefreshResult { + ) async -> CurrentWeatherWidgetRefreshResult { recordSavedRefresh(target) return savedRefreshResult } - func refreshCurrent() async - -> CurrentLocationCurrentWeatherWidgetRefreshResult - { + func refreshCurrent() async -> CurrentWeatherWidgetRefreshResult { recordCurrentRefresh() return currentRefreshResult } @@ -501,12 +395,6 @@ private final class ProviderTimelineTestStore: @unchecked Sendable { } } - var cacheMutationCount: Int { - lock.lock() - defer { lock.unlock() } - return storedCacheMutationCount - } - private func recordSavedRefresh( _ target: WidgetLocationTarget ) { @@ -517,7 +405,6 @@ private final class ProviderTimelineTestStore: @unchecked Sendable { if savedRefreshResult == .refreshed, let refreshedSnapshot { storedSnapshots[Self.key(for: target)] = refreshedSnapshot - storedCacheMutationCount += 1 } } @@ -530,7 +417,6 @@ private final class ProviderTimelineTestStore: @unchecked Sendable { if currentRefreshResult == .refreshed, let refreshedSnapshot { storedSnapshots[Self.key(for: target)] = refreshedSnapshot - storedCacheMutationCount += 1 } } @@ -547,20 +433,3 @@ private final class ProviderTimelineTestStore: @unchecked Sendable { } } } - -private final class ProviderClockIdentityRecorder: @unchecked Sendable { - private let lock = NSLock() - private var storedIdentities: [ObjectIdentifier] = [] - - func record(_ clock: WidgetServerClock) { - lock.lock() - defer { lock.unlock() } - storedIdentities.append(ObjectIdentifier(clock)) - } - - var identities: [ObjectIdentifier] { - lock.lock() - defer { lock.unlock() } - return storedIdentities - } -} diff --git a/ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift b/ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift index 2f6f7be6c..f9a482df4 100644 --- a/ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift +++ b/ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift @@ -18,7 +18,7 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { } func testSavedTargetWritesCorrectRegionCache() async throws { - let weather = ScriptedRefreshWeather( + let weather = ScriptedCurrentWeather( result: .success(try makeObservation()) ) let writer = CurrentWeatherWidgetSnapshotWriter( @@ -27,7 +27,13 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { let service = try makeService( codes: ["242", "433"], weather: weather, - writeSnapshot: writer.write + writeSnapshot: { snapshot in + try CurrentWeatherWidgetTestFixtures.write( + snapshot, + to: .saved(regionCode: "242"), + using: writer + ) + } ) let result = await service.refresh( @@ -51,100 +57,48 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { ) } - func testCurrentLocationTargetIsUnavailableWithoutStartingWork() async throws { - let weather = ScriptedRefreshWeather( - result: .success(try makeObservation()) - ) - let clock = ScriptedRefreshClock( - sample: makeSnapshotTime() - ) - let writer = RefreshSnapshotWriterSpy() - let service = try makeService( - codes: ["242"], - weather: weather, - synchronizeClock: { - await clock.synchronizeAndSample() - }, - writeSnapshot: writer.write - ) - - let result = await service.refresh(target: .currentLocation) + func testUnresolvableTargetsDoNotStartRefreshWork() async throws { + let targets: [WidgetLocationTarget] = [ + .currentLocation, + .invalid(identifier: "region:242"), + .saved(regionCode: "433"), + ] - XCTAssertEqual(result, .unavailable) - let weatherCallCount = await weather.callCount - let clockCallCount = await clock.callCount - XCTAssertEqual(weatherCallCount, 0) - XCTAssertEqual(clockCallCount, 0) - XCTAssertEqual(writer.writeCount, 0) - } - - func testInvalidTargetIsUnavailableWithoutStartingWork() async throws { - let weather = ScriptedRefreshWeather( - result: .success(try makeObservation()) - ) - let clock = ScriptedRefreshClock( - sample: makeSnapshotTime() - ) - let writer = RefreshSnapshotWriterSpy() - let service = try makeService( - codes: ["242"], - weather: weather, - synchronizeClock: { - await clock.synchronizeAndSample() - }, - writeSnapshot: writer.write - ) - - let result = await service.refresh( - target: .invalid(identifier: "region:242") - ) - - XCTAssertEqual(result, .unavailable) - let weatherCallCount = await weather.callCount - let clockCallCount = await clock.callCount - XCTAssertEqual(weatherCallCount, 0) - XCTAssertEqual(clockCallCount, 0) - XCTAssertEqual(writer.writeCount, 0) - } - - func testMissingSavedRegionIsUnavailableWithoutStartingWork() async throws { - let weather = ScriptedRefreshWeather( - result: .success(try makeObservation()) - ) - let clock = ScriptedRefreshClock( - sample: makeSnapshotTime() - ) - let writer = RefreshSnapshotWriterSpy() - let service = try makeService( - codes: ["242"], - weather: weather, - synchronizeClock: { - await clock.synchronizeAndSample() - }, - writeSnapshot: writer.write - ) + for target in targets { + let weather = ScriptedCurrentWeather( + result: .success(try makeObservation()) + ) + let clock = ScriptedSnapshotClock(sample: makeSnapshotTime()) + let writer = CurrentWeatherSnapshotWriterSpy() + let service = try makeService( + codes: ["242"], + weather: weather, + synchronizeClock: { + await clock.synchronizeAndSample() + }, + writeSnapshot: writer.write + ) - let result = await service.refresh( - target: .saved(regionCode: "433") - ) + let result = await service.refresh(target: target) + let weatherCallCount = await weather.callCount + let clockCallCount = await clock.callCount - XCTAssertEqual(result, .unavailable) - let weatherCallCount = await weather.callCount - let clockCallCount = await clock.callCount - XCTAssertEqual(weatherCallCount, 0) - XCTAssertEqual(clockCallCount, 0) - XCTAssertEqual(writer.writeCount, 0) + XCTAssertEqual(result, .unavailable, "\(target)") + XCTAssertEqual(weatherCallCount, 0, "\(target)") + XCTAssertEqual(clockCallCount, 0, "\(target)") + XCTAssertEqual(writer.writeCount, 0, "\(target)") + } } func testSuccessfulRefreshWritesSchemaFiveResolvedSnapshot() async throws { let observation = try makeObservation() - let weather = ScriptedRefreshWeather( + let weather = ScriptedCurrentWeather( result: .success(observation) ) let clock = makeClock( serverResults: [.success(10_000)] ) - let writer = RefreshSnapshotWriterSpy() + let writer = CurrentWeatherSnapshotWriterSpy() let service = try makeService( codes: ["242"], weather: weather, @@ -177,12 +131,12 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { } func testFirstClockSyncFailureDoesNotWrite() async throws { - let weather = ScriptedRefreshWeather( + let weather = ScriptedCurrentWeather( result: .success(try makeObservation()) ) - let writer = RefreshSnapshotWriterSpy() + let writer = CurrentWeatherSnapshotWriterSpy() let clock = makeClock( - serverResults: [.failure(.scripted)] + serverResults: [.failure(WidgetRefreshTestError.scripted)] ) let service = try makeService( codes: ["242"], @@ -204,24 +158,24 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { } func testLaterClockSyncFailureUsesRetainedAnchor() async throws { - let wallClock = RefreshTestWallClock(milliseconds: 5_000) - let monotonicClock = RefreshTestMonotonicClock(milliseconds: 100) + let wallClock = TestWallClock(milliseconds: 5_000) + let monotonicClock = TestMonotonicClock(milliseconds: 100) let clock = makeClock( wallClock: wallClock, monotonicClock: monotonicClock, serverResults: [ .success(10_000), - .failure(.scripted), + .failure(WidgetRefreshTestError.scripted), ] ) let firstSyncSucceeded = await clock.synchronize() XCTAssertTrue(firstSyncSucceeded) monotonicClock.set(milliseconds: 600) - let weather = ScriptedRefreshWeather( + let weather = ScriptedCurrentWeather( result: .success(try makeObservation()) ) - let writer = RefreshSnapshotWriterSpy() + let writer = CurrentWeatherSnapshotWriterSpy() let service = try makeService( codes: ["242"], weather: weather, @@ -245,7 +199,7 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { func testWeatherFailureKeepsExistingCache() async throws { let oldData = try seedExistingSnapshot(regionCode: "242") - let weather = ScriptedRefreshWeather( + let weather = ScriptedCurrentWeather( result: .failure(.scripted) ) let writer = CurrentWeatherWidgetSnapshotWriter( @@ -254,7 +208,13 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { let service = try makeService( codes: ["242"], weather: weather, - writeSnapshot: writer.write + writeSnapshot: { snapshot in + try CurrentWeatherWidgetTestFixtures.write( + snapshot, + to: .saved(regionCode: "242"), + using: writer + ) + } ) let result = await service.refresh( @@ -267,14 +227,20 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { func testNoObservationKeepsExistingCache() async throws { let oldData = try seedExistingSnapshot(regionCode: "242") - let weather = ScriptedRefreshWeather(result: .success(nil)) + let weather = ScriptedCurrentWeather(result: .success(nil)) let writer = CurrentWeatherWidgetSnapshotWriter( containerURL: containerURL ) let service = try makeService( codes: ["242"], weather: weather, - writeSnapshot: writer.write + writeSnapshot: { snapshot in + try CurrentWeatherWidgetTestFixtures.write( + snapshot, + to: .saved(regionCode: "242"), + using: writer + ) + } ) let result = await service.refresh( @@ -287,10 +253,10 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { func testWriterFailureKeepsExistingCache() async throws { let oldData = try seedExistingSnapshot(regionCode: "242") - let weather = ScriptedRefreshWeather( + let weather = ScriptedCurrentWeather( result: .success(try makeObservation()) ) - let writer = RefreshSnapshotWriterSpy(error: .scripted) + let writer = CurrentWeatherSnapshotWriterSpy(error: .scripted) let service = try makeService( codes: ["242"], weather: weather, @@ -308,7 +274,7 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { func testRegionARefreshNeverOverwritesRegionBCache() async throws { let regionBData = try seedExistingSnapshot(regionCode: "433") - let weather = ScriptedRefreshWeather( + let weather = ScriptedCurrentWeather( result: .success(try makeObservation()) ) let writer = CurrentWeatherWidgetSnapshotWriter( @@ -317,7 +283,13 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { let service = try makeService( codes: ["242", "433"], weather: weather, - writeSnapshot: writer.write + writeSnapshot: { snapshot in + try CurrentWeatherWidgetTestFixtures.write( + snapshot, + to: .saved(regionCode: "242"), + using: writer + ) + } ) let result = await service.refresh( @@ -330,22 +302,33 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { } func testWeatherAndClockStartBeforeEitherCompletes() async throws { - let weatherGate = RefreshOperationGate() - let clockGate = RefreshOperationGate() + let weatherGate = AsyncOperationGate() + let clockGate = AsyncOperationGate() let observation = try makeObservation() - let writer = RefreshSnapshotWriterSpy() + let writer = CurrentWeatherSnapshotWriterSpy() let resolver = try makeResolver(codes: ["242"]) let service = SavedCurrentWeatherWidgetRefreshService( resolveLocation: resolver.resolve, - fetchWeather: { _, _ in - await weatherGate.wait() - return observation - }, - synchronizeClock: { - await clockGate.wait() - return self.makeSnapshotTime() + beginWrite: { address in + CurrentWeatherSnapshotWriteToken( + address: address, + generation: 1 + ) }, - writeSnapshot: writer.write + pipeline: CurrentWeatherWidgetRefreshPipeline( + fetchWeather: { _, _ in + await weatherGate.wait() + return observation + }, + synchronizeClock: { + await clockGate.wait() + return self.makeSnapshotTime() + }, + commitSnapshot: { snapshot, _ in + try writer.write(snapshot) + return .written + } + ) ) let refresh = Task { @@ -373,7 +356,7 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { func testRejectedWriteReturnsSuperseded() async throws { let resolver = try makeResolver(codes: ["242"]) - let weather = ScriptedRefreshWeather( + let weather = ScriptedCurrentWeather( result: .success(try makeObservation()) ) let expectedToken = CurrentWeatherSnapshotWriteToken( @@ -382,21 +365,23 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { ) let service = SavedCurrentWeatherWidgetRefreshService( resolveLocation: resolver.resolve, - fetchWeather: { latitude, longitude in - try await weather.fetch( - latitude: latitude, - longitude: longitude - ) - }, - synchronizeClock: makeSnapshotTime, beginWrite: { address in XCTAssertEqual(address, expectedToken.address) return expectedToken }, - commitSnapshot: { _, token in - XCTAssertEqual(token, expectedToken) - return .rejected - } + pipeline: CurrentWeatherWidgetRefreshPipeline( + fetchWeather: { latitude, longitude in + try await weather.fetch( + latitude: latitude, + longitude: longitude + ) + }, + synchronizeClock: makeSnapshotTime, + commitSnapshot: { _, token in + XCTAssertEqual(token, expectedToken) + return .rejected + } + ) ) let result = await service.refresh( @@ -408,49 +393,76 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { private func makeService( codes: [String], - weather: ScriptedRefreshWeather, + weather: ScriptedCurrentWeather, synchronizeClock: @escaping - SavedCurrentWeatherWidgetRefreshService.SynchronizeClock = { + CurrentWeatherWidgetRefreshPipeline.SynchronizeClock = { CurrentWeatherSnapshotTime( calibratedNowUnixMilliseconds: 1_710_907_200_000, calibratedTimeOffsetMilliseconds: 0 ) }, - writeSnapshot: @escaping - SavedCurrentWeatherWidgetRefreshService.WriteSnapshot + writeSnapshot: @escaping @Sendable ( + CurrentWeatherWidgetSnapshot + ) throws -> Void ) throws -> SavedCurrentWeatherWidgetRefreshService { let resolver = try makeResolver(codes: codes) return SavedCurrentWeatherWidgetRefreshService( resolveLocation: resolver.resolve, - fetchWeather: { latitude, longitude in - try await weather.fetch( - latitude: latitude, - longitude: longitude + beginWrite: { address in + CurrentWeatherSnapshotWriteToken( + address: address, + generation: 1 ) }, - synchronizeClock: synchronizeClock, - writeSnapshot: writeSnapshot + pipeline: CurrentWeatherWidgetRefreshPipeline( + fetchWeather: { latitude, longitude in + try await weather.fetch( + latitude: latitude, + longitude: longitude + ) + }, + synchronizeClock: synchronizeClock, + commitSnapshot: { snapshot, _ in + try writeSnapshot(snapshot) + return .written + } + ) ) } private func makeService( codes: [String], - weather: ScriptedRefreshWeather, + weather: ScriptedCurrentWeather, clock: WidgetServerClock, - writeSnapshot: @escaping - SavedCurrentWeatherWidgetRefreshService.WriteSnapshot + writeSnapshot: @escaping @Sendable ( + CurrentWeatherWidgetSnapshot + ) throws -> Void ) throws -> SavedCurrentWeatherWidgetRefreshService { let resolver = try makeResolver(codes: codes) return SavedCurrentWeatherWidgetRefreshService( resolveLocation: resolver.resolve, - fetchWeather: { latitude, longitude in - try await weather.fetch( - latitude: latitude, - longitude: longitude + beginWrite: { address in + CurrentWeatherSnapshotWriteToken( + address: address, + generation: 1 ) }, - clock: clock, - writeSnapshot: writeSnapshot + pipeline: CurrentWeatherWidgetRefreshPipeline( + fetchWeather: { latitude, longitude in + try await weather.fetch( + latitude: latitude, + longitude: longitude + ) + }, + synchronizeClock: { + await CurrentWeatherWidgetRefreshPipeline + .synchronizedSnapshotTime(clock: clock) + }, + commitSnapshot: { snapshot, _ in + try writeSnapshot(snapshot) + return .written + } + ) ) } @@ -472,71 +484,36 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { private func makeCatalogLocation( regionCode: String ) -> WidgetLocationCatalogLocation? { - switch regionCode { - case "242": - return WidgetLocationCatalogLocation( - regionCode: "242", - displayName: "新莊區", - administrativeAreaName: "新北市", - latitude: 25.0358303, - longitude: 121.4500307 - ) - case "433": - return WidgetLocationCatalogLocation( - regionCode: "433", - displayName: "沙鹿區", - administrativeAreaName: "臺中市", - latitude: 24.2338622, - longitude: 120.565703 - ) - default: - return nil - } + CurrentWeatherWidgetTestFixtures.catalogLocation( + regionCode: regionCode + ) } private func makeObservation() throws -> CurrentWeatherRemoteDTO { - let data = Data( - """ - { - "station": {"name": "板橋"}, - "time": 1710900000, - "data": { - "weather": "晴", - "weatherCode": 100, - "temperature": 28.5, - "humidity": 70, - "rain": 0 - } - } - """.utf8 - ) - return try JSONDecoder().decode( - CurrentWeatherRemoteDTO.self, - from: data + try CurrentWeatherWidgetTestFixtures.observation( + stationName: "板橋" ) } private func makeSnapshotTime() -> CurrentWeatherSnapshotTime { - CurrentWeatherSnapshotTime( - calibratedNowUnixMilliseconds: 1_710_907_200_000, - calibratedTimeOffsetMilliseconds: 0 + CurrentWeatherWidgetTestFixtures.snapshotTime( + offsetMilliseconds: 0 ) } private func makeClock( - wallClock: RefreshTestWallClock = - RefreshTestWallClock(milliseconds: 5_000), - monotonicClock: RefreshTestMonotonicClock = - RefreshTestMonotonicClock(milliseconds: 100), - serverResults: [Result] + wallClock: TestWallClock = TestWallClock(milliseconds: 5_000), + monotonicClock: TestMonotonicClock = + TestMonotonicClock(milliseconds: 100), + serverResults: [Result] ) -> WidgetServerClock { WidgetServerClock( deviceClock: wallClock, monotonicClock: monotonicClock, - serverTimeSource: RefreshScriptedServerTimeSource( + serverTimeSource: ScriptedServerTimeSource( results: serverResults ), - timeoutRunner: RefreshPassthroughTimeoutRunner() + timeoutRunner: PassthroughServerClockTimeoutRunner() ) } @@ -544,208 +521,23 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { private func seedExistingSnapshot( regionCode: String ) throws -> Data { - let writer = CurrentWeatherWidgetSnapshotWriter( - containerURL: containerURL + let address = CurrentWeatherSnapshotAddress.saved( + regionCode: regionCode ) - try writer.write( - CurrentWeatherWidgetSnapshot( - schemaVersion: 5, + return try CurrentWeatherWidgetTestFixtures.seed( + CurrentWeatherWidgetTestFixtures.snapshot( sourceIdentifier: "region:\(regionCode)", - regionCode: regionCode, - regionName: "舊快取", - observationTime: 1_700_000_000, - stationName: "舊測站", - weather: "陰", - weatherCode: 300, - condition: .overcast, - isNight: false, - nextDayNightTransitionTime: 1_700_010_000, - calibratedTimeOffsetMilliseconds: 123, - temperature: 20, - humidity: 60, - rain: 1 - ) + regionCode: regionCode + ), + at: address, + containerURL: containerURL ) - return try cachedData(regionCode: regionCode) } private func cachedData(regionCode: String) throws -> Data { - let url = CurrentWeatherSnapshotStorage( + try CurrentWeatherWidgetTestFixtures.cachedData( + at: .saved(regionCode: regionCode), containerURL: containerURL - ).snapshotURL(for: .saved(regionCode: regionCode)) - return try Data(contentsOf: url) - } -} - -private enum RefreshTestError: Error, Sendable { - case scripted -} - -private actor ScriptedRefreshWeather { - struct Coordinates: Sendable { - let latitude: Double - let longitude: Double - } - - private(set) var callCount = 0 - private(set) var coordinates: [Coordinates] = [] - private let result: Result - - init(result: Result) { - self.result = result - } - - func fetch( - latitude: Double, - longitude: Double - ) throws -> CurrentWeatherRemoteDTO? { - callCount += 1 - coordinates.append( - Coordinates(latitude: latitude, longitude: longitude) ) - return try result.get() - } -} - -private actor ScriptedRefreshClock { - private(set) var callCount = 0 - private let sample: CurrentWeatherSnapshotTime? - - init(sample: CurrentWeatherSnapshotTime?) { - self.sample = sample - } - - func synchronizeAndSample() -> CurrentWeatherSnapshotTime? { - callCount += 1 - return sample - } -} - -private final class RefreshSnapshotWriterSpy: @unchecked Sendable { - private let lock = NSLock() - private let error: RefreshTestError? - private var _snapshots: [CurrentWeatherWidgetSnapshot] = [] - private var _writeCount = 0 - - init(error: RefreshTestError? = nil) { - self.error = error - } - - var snapshots: [CurrentWeatherWidgetSnapshot] { - lock.lock() - defer { lock.unlock() } - return _snapshots - } - - var writeCount: Int { - lock.lock() - defer { lock.unlock() } - return _writeCount - } - - func write(_ snapshot: CurrentWeatherWidgetSnapshot) throws { - lock.lock() - _writeCount += 1 - let error = error - if error == nil { - _snapshots.append(snapshot) - } - lock.unlock() - - if let error { - throw error - } - } -} - -private actor RefreshOperationGate { - private(set) var hasStarted = false - private var isOpen = false - private var continuation: CheckedContinuation? - - func wait() async { - hasStarted = true - guard !isOpen else { - return - } - await withCheckedContinuation { continuation in - self.continuation = continuation - } - } - - func open() { - isOpen = true - continuation?.resume() - continuation = nil - } -} - -private final class RefreshTestWallClock: WidgetWallTimeSource, - @unchecked Sendable -{ - private let lock = NSLock() - private var milliseconds: Int64 - - init(milliseconds: Int64) { - self.milliseconds = milliseconds - } - - func now() -> Date { - lock.lock() - let milliseconds = milliseconds - lock.unlock() - return Date( - timeIntervalSince1970: TimeInterval(milliseconds) / 1_000 - ) - } -} - -private final class RefreshTestMonotonicClock: WidgetMonotonicTimeSource, - @unchecked Sendable -{ - private let lock = NSLock() - private var milliseconds: Int64 - - init(milliseconds: Int64) { - self.milliseconds = milliseconds - } - - func elapsedMilliseconds() -> Int64 { - lock.lock() - let milliseconds = milliseconds - lock.unlock() - return milliseconds - } - - func set(milliseconds: Int64) { - lock.lock() - self.milliseconds = milliseconds - lock.unlock() - } -} - -private actor RefreshScriptedServerTimeSource: WidgetServerTimeSource { - private var results: [Result] - - init(results: [Result]) { - self.results = results - } - - func serverTimeUnixMilliseconds() throws -> Int64 { - guard !results.isEmpty else { - throw RefreshTestError.scripted - } - return try results.removeFirst().get() - } -} - -private struct RefreshPassthroughTimeoutRunner: - WidgetServerClockTimeoutRunning -{ - func serverTimeUnixMilliseconds( - from source: any WidgetServerTimeSource, - timeout: TimeInterval - ) async throws -> Int64 { - try await source.serverTimeUnixMilliseconds() } } diff --git a/ios/RunnerTests/WidgetLocationCatalogTests.swift b/ios/RunnerTests/WidgetLocationCatalogTests.swift index 8225823a5..d8a5804c6 100644 --- a/ios/RunnerTests/WidgetLocationCatalogTests.swift +++ b/ios/RunnerTests/WidgetLocationCatalogTests.swift @@ -56,51 +56,6 @@ final class WidgetLocationCatalogTests: XCTestCase { } } - func testIntegerJSONCoordinatesDecodeAsDouble() throws { - let json = """ - { - "schemaVersion": 1, - "locations": [ - { - "regionCode": "242", - "displayName": "新莊區", - "administrativeAreaName": "新北市", - "latitude": 25, - "longitude": 121 - } - ] - } - """ - - let catalog = try XCTUnwrap( - WidgetLocationCatalog.decode(Data(json.utf8)) - ) - - XCTAssertEqual(catalog.locations[0].latitude, 25.0) - XCTAssertEqual(catalog.locations[0].longitude, 121.0) - } - - func testEmptyDisplayNamePreservesCurrentBehavior() throws { - let catalog = try XCTUnwrap( - WidgetLocationCatalog.decode( - validCatalogData(displayName: "") - ) - ) - - XCTAssertEqual(catalog.locations[0].displayName, "") - } - - func testUnicodeDisplayNamePreservesCurrentBehavior() throws { - let displayName = "臺北🌧️" - let catalog = try XCTUnwrap( - WidgetLocationCatalog.decode( - validCatalogData(displayName: displayName) - ) - ) - - XCTAssertEqual(catalog.locations[0].displayName, displayName) - } - func testRejectsUnsupportedSchemaVersion() { XCTAssertNil( WidgetLocationCatalog.decode( @@ -330,32 +285,6 @@ final class SavedWidgetLocationResolverTests: XCTestCase { ) } - func testUnsupportedCatalogReturnsUnresolved() { - let catalog = WidgetLocationCatalog.decode( - Data(#"{"schemaVersion":2,"locations":[]}"#.utf8) - ) - let resolver = SavedWidgetLocationResolver(catalog: catalog) - - XCTAssertNil( - resolver.resolve( - target: WidgetLocationTarget(identifier: "region:242") - ) - ) - } - - func testMalformedCatalogReturnsUnresolved() { - let catalog = WidgetLocationCatalog.decode( - Data(#"{"schemaVersion":1,"locations":[{}]}"#.utf8) - ) - let resolver = SavedWidgetLocationResolver(catalog: catalog) - - XCTAssertNil( - resolver.resolve( - target: WidgetLocationTarget(identifier: "region:242") - ) - ) - } - func testCurrentLocationIsNotResolved() throws { let resolver = try makeResolver(codes: ["242"]) diff --git a/ios/RunnerTests/WidgetRefreshTestSupport.swift b/ios/RunnerTests/WidgetRefreshTestSupport.swift new file mode 100644 index 000000000..5b82b9bd0 --- /dev/null +++ b/ios/RunnerTests/WidgetRefreshTestSupport.swift @@ -0,0 +1,207 @@ +import Foundation + +enum WidgetRefreshTestError: Error, Sendable { + case scripted +} + +final class TestWallClock: WidgetWallTimeSource, @unchecked Sendable { + private let lock = NSLock() + private var milliseconds: Int64 + private var storedReadCount = 0 + + init(milliseconds: Int64) { + self.milliseconds = milliseconds + } + + var readCount: Int { + lock.withLock { storedReadCount } + } + + func now() -> Date { + lock.withLock { + storedReadCount += 1 + return Date( + timeIntervalSince1970: TimeInterval(milliseconds) / 1_000 + ) + } + } + + func set(milliseconds: Int64) { + lock.withLock { + self.milliseconds = milliseconds + } + } + + func resetReadCount() { + lock.withLock { + storedReadCount = 0 + } + } +} + +final class TestMonotonicClock: WidgetMonotonicTimeSource, + @unchecked Sendable +{ + private let lock = NSLock() + private var milliseconds: Int64 + private var storedReadCount = 0 + + init(milliseconds: Int64) { + self.milliseconds = milliseconds + } + + var readCount: Int { + lock.withLock { storedReadCount } + } + + func elapsedMilliseconds() -> Int64 { + lock.withLock { + storedReadCount += 1 + return milliseconds + } + } + + func set(milliseconds: Int64) { + lock.withLock { + self.milliseconds = milliseconds + } + } + + func resetReadCount() { + lock.withLock { + storedReadCount = 0 + } + } +} + +actor ScriptedServerTimeSource: WidgetServerTimeSource { + private(set) var callCount = 0 + private var results: [Result] + + init(results: [Result]) { + self.results = results + } + + func serverTimeUnixMilliseconds() throws -> Int64 { + callCount += 1 + guard !results.isEmpty else { + throw WidgetSNTPError.allHostsFailed + } + return try results.removeFirst().get() + } +} + +struct PassthroughServerClockTimeoutRunner: WidgetServerClockTimeoutRunning { + func serverTimeUnixMilliseconds( + from source: any WidgetServerTimeSource, + timeout: TimeInterval + ) async throws -> Int64 { + try await source.serverTimeUnixMilliseconds() + } +} + +actor AsyncOperationGate { + private(set) var hasStarted = false + private var isOpen = false + private var continuation: CheckedContinuation? + + func wait() async { + hasStarted = true + guard !isOpen else { + return + } + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func open() { + isOpen = true + continuation?.resume() + continuation = nil + } +} + +actor ScriptedCurrentWeather { + struct Coordinates: Equatable, Sendable { + let latitude: Double + let longitude: Double + } + + private(set) var callCount = 0 + private(set) var coordinates: [Coordinates] = [] + private let result: Result + private let onFetch: @Sendable () -> Void + + init( + result: Result, + onFetch: @escaping @Sendable () -> Void = {} + ) { + self.result = result + self.onFetch = onFetch + } + + func fetch( + latitude: Double, + longitude: Double + ) throws -> CurrentWeatherRemoteDTO? { + onFetch() + callCount += 1 + coordinates.append( + Coordinates(latitude: latitude, longitude: longitude) + ) + return try result.get() + } +} + +actor ScriptedSnapshotClock { + private(set) var callCount = 0 + private let sample: CurrentWeatherSnapshotTime? + + init(sample: CurrentWeatherSnapshotTime?) { + self.sample = sample + } + + func synchronizeAndSample() -> CurrentWeatherSnapshotTime? { + callCount += 1 + return sample + } +} + +final class CurrentWeatherSnapshotWriterSpy: @unchecked Sendable { + private let lock = NSLock() + private let error: WidgetRefreshTestError? + private let onWrite: @Sendable () -> Void + private var storedSnapshots: [CurrentWeatherWidgetSnapshot] = [] + private var storedWriteCount = 0 + + init( + error: WidgetRefreshTestError? = nil, + onWrite: @escaping @Sendable () -> Void = {} + ) { + self.error = error + self.onWrite = onWrite + } + + var snapshots: [CurrentWeatherWidgetSnapshot] { + lock.withLock { storedSnapshots } + } + + var writeCount: Int { + lock.withLock { storedWriteCount } + } + + func write(_ snapshot: CurrentWeatherWidgetSnapshot) throws { + onWrite() + let error = lock.withLock { + storedWriteCount += 1 + if self.error == nil { + storedSnapshots.append(snapshot) + } + return self.error + } + if let error { + throw error + } + } +} diff --git a/ios/RunnerTests/WidgetServerClockTests.swift b/ios/RunnerTests/WidgetServerClockTests.swift index e420026d6..370bbd4ba 100644 --- a/ios/RunnerTests/WidgetServerClockTests.swift +++ b/ios/RunnerTests/WidgetServerClockTests.swift @@ -228,106 +228,11 @@ final class WidgetServerClockTests: XCTestCase { deviceClock: wallClock, monotonicClock: monotonic, serverTimeSource: source, - timeoutRunner: PassthroughTimeoutRunner() + timeoutRunner: PassthroughServerClockTimeoutRunner() ) } } -private final class TestWallClock: WidgetWallTimeSource, - @unchecked Sendable -{ - private let lock = NSLock() - private var milliseconds: Int64 - private var _readCount = 0 - - init(milliseconds: Int64) { - self.milliseconds = milliseconds - } - - var readCount: Int { - lock.lock() - defer { lock.unlock() } - return _readCount - } - - func now() -> Date { - lock.lock() - _readCount += 1 - let milliseconds = milliseconds - lock.unlock() - return Date( - timeIntervalSince1970: TimeInterval(milliseconds) / 1_000 - ) - } - - func set(milliseconds: Int64) { - lock.lock() - self.milliseconds = milliseconds - lock.unlock() - } - - func resetReadCount() { - lock.lock() - _readCount = 0 - lock.unlock() - } -} - -private final class TestMonotonicClock: WidgetMonotonicTimeSource, - @unchecked Sendable -{ - private let lock = NSLock() - private var milliseconds: Int64 - private var _readCount = 0 - - init(milliseconds: Int64) { - self.milliseconds = milliseconds - } - - var readCount: Int { - lock.lock() - defer { lock.unlock() } - return _readCount - } - - func elapsedMilliseconds() -> Int64 { - lock.lock() - _readCount += 1 - let milliseconds = milliseconds - lock.unlock() - return milliseconds - } - - func set(milliseconds: Int64) { - lock.lock() - self.milliseconds = milliseconds - lock.unlock() - } - - func resetReadCount() { - lock.lock() - _readCount = 0 - lock.unlock() - } -} - -private actor ScriptedServerTimeSource: WidgetServerTimeSource { - private(set) var callCount = 0 - private var results: [Result] - - init(results: [Result]) { - self.results = results - } - - func serverTimeUnixMilliseconds() async throws -> Int64 { - callCount += 1 - guard !results.isEmpty else { - throw WidgetSNTPError.allHostsFailed - } - return try results.removeFirst().get() - } -} - private actor GatedServerTimeSource: WidgetServerTimeSource { private(set) var callCount = 0 private var continuation: CheckedContinuation? @@ -345,17 +250,6 @@ private actor GatedServerTimeSource: WidgetServerTimeSource { } } -private struct PassthroughTimeoutRunner: - WidgetServerClockTimeoutRunning -{ - func serverTimeUnixMilliseconds( - from source: any WidgetServerTimeSource, - timeout: TimeInterval - ) async throws -> Int64 { - try await source.serverTimeUnixMilliseconds() - } -} - private actor ScriptedTimeoutRunner: WidgetServerClockTimeoutRunning { private(set) var timeouts: [TimeInterval] = [] private var results: [Result] From cbe4550f18f9feaed857efb2c55e3910e14961cf Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Tue, 22 Sep 2026 11:09:36 +0800 Subject: [PATCH 18/18] feat(weather): indicate current-location widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios New(zh-Hant): 使用「所在地」的天氣小工具會顯示定位圖示 New(en-US): weather widgets using Current Location now show a location icon --- ios/DPIPWidgets/DPIPWidgets.swift | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index effd35026..e0ca3157a 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -128,11 +128,20 @@ struct DPIPWidgetsEntryView : View { VStack(alignment: .leading) { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 2) { - Text(snapshot.regionName) - .font(.headline) - .lineLimit(1) - .minimumScaleFactor(0.8) - .layoutPriority(1) + HStack(spacing: 4) { + Text(snapshot.regionName) + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.8) + .layoutPriority(1) + + if snapshot.sourceIdentifier == "current-location" { + Image(systemName: "location.fill") + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize() + } + } Text(observationDate, style: .time) .lineLimit(1)