diff --git a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift index 961327bd1..100179e72 100644 --- a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift +++ b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift @@ -280,6 +280,77 @@ struct DPIPWidgetTimelinePlanner: Sendable { } } +/// In-memory presentation only; never passed to a cache writer or planner. +struct DPIPWidgetSnapshotContent: Sendable { + let snapshot: CurrentWeatherWidgetSnapshot? + let forecast: ForecastWidgetSnapshot? +} + +enum DPIPWidgetGalleryPreview { + /// Use a complete sample, rather than attaching invented forecast values + /// to a real observation. Also used by Canvas and WidgetKit placeholders. + static func sample(at date: Date) -> DPIPWidgetSnapshotContent { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Taipei")! + let hour = calendar.component(.hour, from: date) + let isNight = hour < 6 || hour >= 18 + let transition = calendar.nextDate( + after: date, + matching: DateComponents(hour: isNight ? 6 : 18, minute: 0), + matchingPolicy: .nextTime + )! + let nextHour = calendar.dateInterval(of: .hour, for: date)!.end + let conditions: [(String, Int, Double, Int)] = [ + ("晴", 100, 28, 0), + ("多雲", 200, 28, 10), + ("多雲", 200, 27, 20), + ("多雲有雨", 206, 27, 60), + ("多雲有雨", 206, 26, 60), + ] + let points = conditions.enumerated().map { index, value in + let pointDate = nextHour.addingTimeInterval(Double(index) * 3_600) + let pointHour = calendar.component(.hour, from: pointDate) + return ForecastWidgetPoint( + time: String(format: "%02d:00", pointHour), + temperature: value.2, + weather: value.0, + weatherCode: value.1, + pop: value.3 + )! + } + let milliseconds = Int64(date.timeIntervalSince1970 * 1_000) + return DPIPWidgetSnapshotContent( + snapshot: CurrentWeatherWidgetSnapshot( + schemaVersion: 7, + sourceIdentifier: "region:407", + regionCode: "407", + regionName: String(localized: "widget.preview_location"), + observationTime: Int(date.timeIntervalSince1970), + stationName: "西屯", + weather: "晴", + weatherCode: 100, + condition: .clear, + isNight: isNight, + nextDayNightTransitionTime: Int(transition.timeIntervalSince1970), + calibratedTimeOffsetMilliseconds: 0, + temperature: 28, + humidity: 76, + rain: 0, + windDirection: "北北西", + windSpeed: 1.3, + apparentTemperature: 30 + ), + forecast: ForecastWidgetSnapshot( + sourceIdentifier: "region:407", + regionCode: "407", + updateTime: milliseconds, + receivedAt: milliseconds, + points: points + ) + ) + } +} + struct DPIPWidgetProviderDependencies: Sendable { typealias LoadSnapshot = DPIPWidgetTimelinePlanner.LoadSnapshot typealias LoadForecast = DPIPWidgetTimelinePlanner.LoadForecast @@ -302,6 +373,49 @@ struct DPIPWidgetProviderDependencies: Sendable { loadSnapshot(target) } + /// WidgetKit snapshots remain cache-only. The gallery fallback is selected + /// here, outside getTimeline's planner and all persistence dependencies. + func snapshotContent( + for target: WidgetLocationTarget, + at date: Date, + family: WidgetFamily, + isPreview: Bool + ) -> DPIPWidgetSnapshotContent { + let current = snapshot(for: target) + let forecast = forecastSnapshot( + for: target, currentSnapshot: current, at: date, family: family + ) + let cached = DPIPWidgetSnapshotContent(snapshot: current, forecast: forecast) + guard isPreview, target.sourceIdentifier != nil else { return cached } + + // Small has no forecast: only the current-weather half needs a sample, + // and only when the cache holds nothing for this target. + guard ForecastWidgetFamilyPolicy.supportsForecast(family) else { + if let current, target.matches(snapshot: current) { return cached } + return DPIPWidgetSnapshotContent( + snapshot: DPIPWidgetGalleryPreview.sample(at: date).snapshot, + forecast: nil + ) + } + + if let current, target.matches(snapshot: current), let forecast, + forecast.sourceIdentifier == target.sourceIdentifier, + forecast.regionCode == current.regionCode, + ForecastWidgetExpiry.isUsable( + forecast, at: date, + calibratedTimeOffsetMilliseconds: + current.calibratedTimeOffsetMilliseconds + ), !ForecastWidgetFamilyPolicy.visiblePoints( + in: forecast, + at: CurrentWeatherWidgetTimeCalibration(snapshot: current) + .calibratedDate(fromDeviceDate: date), + family: family + ).isEmpty { + return cached + } + return DPIPWidgetGalleryPreview.sample(at: date) + } + func forecastSnapshot( for target: WidgetLocationTarget, currentSnapshot: CurrentWeatherWidgetSnapshot?, diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index dd5106fb3..34cf1be08 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -14,22 +14,20 @@ struct DPIPWidgetProvider: IntentTimelineProvider { self.dependencies = dependencies } - private func snapshot( - for configuration: WeatherWidgetConfigurationIntent - ) -> CurrentWeatherWidgetSnapshot? { - let target = WidgetLocationTarget( - identifier: configuration.location?.identifier - ) - - return dependencies.snapshot(for: target) - } - func placeholder(in context: Context) -> DPIPWidgetEntry { - DPIPWidgetEntry( - date: .now, - snapshot: nil, + let date = Date.now + let sample = DPIPWidgetGalleryPreview.sample(at: date) + let content = DPIPWidgetSnapshotContent( + snapshot: sample.snapshot, + forecast: ForecastWidgetFamilyPolicy.supportsForecast(context.family) + ? sample.forecast : nil + ) + return DPIPWidgetEntry( + date: date, + snapshot: content.snapshot, isStale: false, - isNight: false, + isNight: content.snapshot?.isNight ?? false, + forecast: content.forecast ) } @@ -38,12 +36,19 @@ struct DPIPWidgetProvider: IntentTimelineProvider { in context: Context, completion: @escaping (DPIPWidgetEntry) -> Void ) { - let snapshot = snapshot(for: configuration) let deviceNow = Date.now let target = WidgetLocationTarget( identifier: configuration.location?.identifier ) + let content = dependencies.snapshotContent( + for: target, + at: deviceNow, + family: context.family, + isPreview: context.isPreview + ) + let snapshot = content.snapshot + let state = CurrentWeatherWidgetTimeline.state( snapshot: snapshot, at: deviceNow, @@ -55,12 +60,7 @@ struct DPIPWidgetProvider: IntentTimelineProvider { snapshot: snapshot, isStale: state.isStale, isNight: state.isNight, - forecast: dependencies.forecastSnapshot( - for: target, - currentSnapshot: snapshot, - at: deviceNow, - family: context.family - ) + forecast: content.forecast ) completion(entry) @@ -768,98 +768,30 @@ struct DPIPWidgets: Widget { struct DPIPWidgets_Previews: PreviewProvider { private static let previewNow = Date() - private static let previewTransitionTime = Int( - previewNow - .addingTimeInterval(6 * 60 * 60) - .timeIntervalSince1970 - ) - - private static let previewForecast = ForecastWidgetSnapshot( - sourceIdentifier: "current-location", - regionCode: "407", - - updateTime: 1_790_316_445_925, - receivedAt: 1_790_316_600_000, - - points: [ - ForecastWidgetPoint( - time: "01:00", - temperature: 26.0, - weather: "晴", - weatherCode: 100, - pop: 0 - )!, - ForecastWidgetPoint( - time: "02:00", - temperature: 26.0, - weather: "多雲", - weatherCode: 200, - pop: 10 - )!, - ForecastWidgetPoint( - time: "03:00", - temperature: 25.0, - weather: "多雲有雨", - weatherCode: 206, - pop: 60 - )!, - ForecastWidgetPoint( - time: "04:00", - temperature: 25.0, - weather: "多雲有雷雨", - weatherCode: 214, - pop: 80 - )!, - ForecastWidgetPoint( - time: "05:00", - temperature: 24.0, - weather: "多雲有霧", - weatherCode: 205, - pop: 20 - )!, - ] ) + private static let previewContent = DPIPWidgetGalleryPreview.sample(at: previewNow) private static let previewEntry = DPIPWidgetEntry( date: previewNow, - snapshot: CurrentWeatherWidgetSnapshot( - schemaVersion: 7, - sourceIdentifier: "current-location", - regionCode: "407", - regionName: "西屯區", - observationTime: Int(previewNow.timeIntervalSince1970), - stationName: "西屯", - weather: "晴", - weatherCode: 100, - condition: .clear, - isNight: true, - nextDayNightTransitionTime: previewTransitionTime, - calibratedTimeOffsetMilliseconds: 0, - temperature: 28.0, - humidity: 76, - rain: 0.0, - windDirection: "北北西", - windSpeed: 1.3, - apparentTemperature: 30.2 - ), + snapshot: previewContent.snapshot, isStale: false, - isNight: true, - forecast: previewForecast + isNight: previewContent.snapshot?.isNight ?? false, + forecast: previewContent.forecast ) private static let missingOptionalDataEntry = DPIPWidgetEntry( - date: .now, + date: previewNow, snapshot: CurrentWeatherWidgetSnapshot( schemaVersion: 7, - sourceIdentifier: "current-location", + sourceIdentifier: "region:407", regionCode: "407", - regionName: "西屯區", - observationTime: Int(Date().timeIntervalSince1970), + regionName: String(localized: "widget.preview_location"), + observationTime: Int(previewNow.timeIntervalSince1970), stationName: "西屯", weather: "多雲", weatherCode: 200, condition: .cloudy, - isNight: false, - nextDayNightTransitionTime: 1_789_562_700, + isNight: previewContent.snapshot!.isNight, + nextDayNightTransitionTime: previewContent.snapshot!.nextDayNightTransitionTime, calibratedTimeOffsetMilliseconds: 0, temperature: 28.0, humidity: 76, @@ -869,8 +801,8 @@ struct DPIPWidgets_Previews: PreviewProvider { apparentTemperature: nil, ), isStale: false, - isNight: false, - forecast: previewForecast + isNight: previewContent.snapshot!.isNight, + forecast: previewContent.forecast ) @ViewBuilder diff --git a/ios/DPIPWidgets/Localizable.xcstrings b/ios/DPIPWidgets/Localizable.xcstrings index 95293fcd9..886127636 100644 --- a/ios/DPIPWidgets/Localizable.xcstrings +++ b/ios/DPIPWidgets/Localizable.xcstrings @@ -625,6 +625,42 @@ } } }, + "widget.preview_location" : { + "comment" : "Example location label for a complete Widget Gallery sample; never a real observation.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sample Location" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "プレビュー地点" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미리보기 위치" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "预览地点" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "預覽地點" + } + } + } + }, "widget.rainfall" : { "extractionState" : "manual", "localizations" : { diff --git a/ios/RunnerTests/ForecastWidgetTests.swift b/ios/RunnerTests/ForecastWidgetTests.swift index da5631174..c7a1ae9d1 100644 --- a/ios/RunnerTests/ForecastWidgetTests.swift +++ b/ios/RunnerTests/ForecastWidgetTests.swift @@ -698,6 +698,301 @@ final class ForecastWidgetProviderTests: XCTestCase { } } +final class ForecastWidgetGalleryPreviewTests: XCTestCase { + private let date = instant("2026-09-25T07:21:00Z") // 15:21 Taipei + private let target = WidgetLocationTarget.saved(regionCode: "407") + + func testFirstGalleryPreviewWithoutForecastUsesCompleteSampleForMediumAndLarge() throws { + for family: WidgetFamily in [.systemMedium, .systemLarge] { + let state = ForecastPlannerState(current: currentSnapshot(target: target)) + let content = dependencies(state).snapshotContent( + for: target, at: date, family: family, isPreview: true + ) + let current = try XCTUnwrap(content.snapshot) + let forecast = try XCTUnwrap(content.forecast) + // Both sides come from the sample; no real observation is mixed in. + XCTAssertEqual(current.temperature, 28) + XCTAssertEqual(current.apparentTemperature, 30) + XCTAssertNotEqual(current.stationName, "station") + XCTAssertEqual(current.sourceIdentifier, forecast.sourceIdentifier) + XCTAssertEqual(current.regionCode, forecast.regionCode) + XCTAssertEqual(current.observationTime, Int(date.timeIntervalSince1970)) + XCTAssertEqual(forecast.updateTime, Int64(date.timeIntervalSince1970 * 1_000)) + XCTAssertEqual(ForecastWidgetFamilyPolicy.visiblePoints( + in: forecast, at: date, family: family + ).map(\.time), ["16:00", "17:00", "18:00", "19:00", "20:00"]) + XCTAssertEqual(state.forecastRefreshCount, 0) + XCTAssertNil(state.forecast) + XCTAssertEqual(state.current?.temperature, 22) + } + } + + func testGalleryWithoutAnyWeatherCacheStillHasCompleteSample() { + let content = dependencies(ForecastPlannerState(current: nil)).snapshotContent( + for: target, at: date, family: .systemMedium, isPreview: true + ) + XCTAssertNotNil(content.snapshot) + XCTAssertEqual(content.forecast?.points.count, 5) + } + + func testGalleryPrefersMatchingValidForecastCacheForBothFamilies() { + let forecast = freshForecast(times: ["16:00", "17:00"]) + let state = ForecastPlannerState( + current: currentSnapshot(target: target), forecast: forecast + ) + for family: WidgetFamily in [.systemMedium, .systemLarge] { + let content = dependencies(state).snapshotContent( + for: target, at: date, family: family, isPreview: true + ) + XCTAssertEqual(content.snapshot?.stationName, "station") + XCTAssertEqual(content.snapshot?.temperature, 22) + XCTAssertEqual(content.forecast?.points, forecast.points) + XCTAssertEqual(content.forecast?.updateTime, forecast.updateTime) + } + XCTAssertEqual(state.forecastRefreshCount, 0) + } + + func testGalleryWithExpiredForecastUsesWholeSample() { + let state = ForecastPlannerState( + current: currentSnapshot(target: target), + forecast: forecastSnapshot( + updateTime: milliseconds("2026-09-25T06:00:00Z"), + receivedAt: milliseconds("2026-09-25T06:00:00Z"), + times: ["16:00"] + ) + ) + for family: WidgetFamily in [.systemMedium, .systemLarge] { + let content = dependencies(state).snapshotContent( + for: target, at: date, family: family, isPreview: true + ) + XCTAssertEqual(content.snapshot?.temperature, 28) + XCTAssertEqual(content.forecast?.points.count, 5) + } + } + + func testGalleryWithFreshCacheButNoVisibleHoursUsesWholeSample() { + let state = ForecastPlannerState( + current: currentSnapshot(target: target), + forecast: freshForecast(times: ["13:00", "14:00"]) + ) + let content = dependencies(state).snapshotContent( + for: target, at: date, family: .systemMedium, isPreview: true + ) + XCTAssertEqual(content.snapshot?.temperature, 28) + XCTAssertEqual(content.forecast?.points.count, 5) + } + + func testSmallGalleryKeepsMatchingCurrentWeatherAndNeverGainsForecast() { + let state = ForecastPlannerState( + current: currentSnapshot(target: target), forecast: freshForecast() + ) + let content = dependencies(state).snapshotContent( + for: target, at: date, family: .systemSmall, isPreview: true + ) + XCTAssertEqual(content.snapshot?.temperature, 22) + XCTAssertEqual(content.snapshot?.stationName, "station") + XCTAssertNil(content.forecast) + XCTAssertEqual(state.forecastRefreshCount, 0) + } + + func testSmallGalleryWithoutMatchingCurrentWeatherUsesSampleWithoutForecast() { + let currentTarget = WidgetLocationTarget.currentLocation + let states = [ + ForecastPlannerState(current: nil), + // A cached snapshot for a different location must not be shown. + ForecastPlannerState(current: currentSnapshot(target: target)), + ] + for (index, state) in states.enumerated() { + let requested = index == 0 ? target : currentTarget + let content = dependencies(state).snapshotContent( + for: requested, at: date, family: .systemSmall, isPreview: true + ) + XCTAssertEqual(content.snapshot?.temperature, 28) + XCTAssertEqual(content.snapshot?.sourceIdentifier, "region:407") + XCTAssertNotEqual(content.snapshot?.stationName, "station") + XCTAssertNil(content.forecast) + XCTAssertEqual(state.forecastRefreshCount, 0) + } + } + + func testSmallSampleStaysOutOfNormalSnapshotsAndInvalidTargets() { + let normal = dependencies(ForecastPlannerState(current: nil)).snapshotContent( + for: target, at: date, family: .systemSmall, isPreview: false + ) + XCTAssertNil(normal.snapshot) + let invalid = dependencies(ForecastPlannerState(current: nil)).snapshotContent( + for: .invalid(identifier: "bad"), at: date, + family: .systemSmall, isPreview: true + ) + XCTAssertNil(invalid.snapshot) + } + + func testSampleNeverAppearsInNormalSnapshotsOrTimelinePlans() async { + for current in [currentSnapshot(target: target), nil] { + for family: WidgetFamily in [.systemMedium, .systemLarge] { + let state = ForecastPlannerState(current: current) + let deps = dependencies(state) + let gallery = deps.snapshotContent( + for: target, at: date, family: family, isPreview: true + ) + XCTAssertEqual(gallery.forecast?.points.count, 5) + let normal = deps.snapshotContent( + for: target, at: date, family: family, isPreview: false + ) + XCTAssertEqual(normal.snapshot?.temperature, current?.temperature) + XCTAssertNil(normal.forecast) + let plan = await deps.timelinePlanner.plan(for: target, family: family) + XCTAssertEqual(plan.snapshot?.temperature, current?.temperature) + XCTAssertNil(plan.forecast) + for entry in plan.states { + XCTAssertNil(plan.forecast(at: entry.date)) + } + } + } + } + + func testLocationSwitchRejectsPreviousTownshipAndSavedTargetForecasts() { + let currentTarget = WidgetLocationTarget.currentLocation + let state = ForecastPlannerState( + current: currentSnapshot(target: currentTarget, regionCode: "242"), + forecast: freshForecast(source: "current-location", region: "407") + ) + for family: WidgetFamily in [.systemMedium, .systemLarge] { + let content = dependencies(state).snapshotContent( + for: currentTarget, at: date, family: family, isPreview: true + ) + // A complete labelled sample replaces both halves, never town B + // weather plus town A forecast. + XCTAssertNotEqual(content.snapshot?.stationName, "station") + XCTAssertEqual(content.snapshot?.sourceIdentifier, "region:407") + XCTAssertEqual(content.snapshot?.sourceIdentifier, content.forecast?.sourceIdentifier) + state.forecast = freshForecast(source: "region:242", region: "242") + let wrongSource = dependencies(state).snapshotContent( + for: currentTarget, at: date, family: family, isPreview: true + ) + XCTAssertNotEqual(wrongSource.snapshot?.stationName, "station") + state.forecast = freshForecast(source: "current-location", region: "242") + let matching = dependencies(state).snapshotContent( + for: currentTarget, at: date, family: family, isPreview: true + ) + XCTAssertEqual(matching.snapshot?.regionCode, "242") + XCTAssertEqual(matching.snapshot?.stationName, "station") + XCTAssertEqual(matching.forecast?.regionCode, "242") + state.forecast = freshForecast(source: "current-location", region: "407") + } + } + + func testInvalidTargetDoesNotGainSampleOrOtherLocationData() { + let content = dependencies(ForecastPlannerState(current: nil)).snapshotContent( + for: .invalid(identifier: "bad"), at: date, + family: .systemMedium, isPreview: true + ) + XCTAssertNil(content.snapshot) + XCTAssertNil(content.forecast) + } + + func testRelativePreviewHoursAcrossDayAndMidnightIgnoreDeviceTimezone() throws { + let original = NSTimeZone.default + defer { NSTimeZone.default = original } + let cases: [(String, [String], Bool)] = [ + ("2026-09-25T00:30:00Z", ["09:00", "10:00", "11:00", "12:00", "13:00"], false), + ("2026-09-25T07:21:00Z", ["16:00", "17:00", "18:00", "19:00", "20:00"], false), + ("2026-09-25T12:30:00Z", ["21:00", "22:00", "23:00", "00:00", "01:00"], true), + ("2026-09-25T15:59:59Z", ["00:00", "01:00", "02:00", "03:00", "04:00"], true), + ("2026-09-25T16:00:00Z", ["01:00", "02:00", "03:00", "04:00", "05:00"], true), + ] + for zone in ["Asia/Taipei", "America/Los_Angeles", "Pacific/Kiritimati"] { + NSTimeZone.default = TimeZone(identifier: zone)! + for (instantString, expected, night) in cases { + let now = instant(instantString) + let content = DPIPWidgetGalleryPreview.sample(at: now) + let current = try XCTUnwrap(content.snapshot) + let forecast = try XCTUnwrap(content.forecast) + XCTAssertEqual(current.isNight, night) + XCTAssertGreaterThan(Double(current.nextDayNightTransitionTime), now.timeIntervalSince1970) + XCTAssertTrue(ForecastWidgetExpiry.isUsable(forecast, at: now)) + for family: WidgetFamily in [.systemMedium, .systemLarge] { + XCTAssertEqual(ForecastWidgetFamilyPolicy.visiblePoints( + in: forecast, at: now, family: family + ).map(\.time), expected, "\(zone) / \(instantString)") + } + } + } + } + + func testGallerySampleNeverWritesToPersistentStorage() throws { + let container = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: container, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: container) } + let store = WidgetSnapshotStore(containerURL: container) + let forecasts = ForecastWidgetSnapshotStore(containerURL: container) + let current = currentSnapshot(target: target) + _ = try CurrentWeatherWidgetTestFixtures.seed( + current, at: .saved(regionCode: "407"), containerURL: container + ) + let deps = DPIPWidgetProviderDependencies( + loadSnapshot: { store.loadCurrentWeatherSnapshot(for: $0) }, + timelinePlanner: dependencies(ForecastPlannerState(current: nil)).timelinePlanner, + loadForecast: { forecasts.load(for: $0, regionCode: $1, at: $2) } + ) + // Include a stale cache file as well as the initially absent cache. + for stale in [false, true] { + if stale { + try forecasts.write(forecastSnapshot( + updateTime: milliseconds("2026-09-25T06:00:00Z"), + receivedAt: milliseconds("2026-09-25T06:00:00Z") + ), for: target) + } + let before = try persistedFiles(in: container) + for family: WidgetFamily in [.systemMedium, .systemLarge] { + let content = deps.snapshotContent( + for: target, at: date, family: family, isPreview: true + ) + XCTAssertEqual(content.forecast?.points.count, 5) + XCTAssertEqual(content.snapshot?.temperature, 28) + } + XCTAssertEqual(try persistedFiles(in: container), before) + } + } + + private func persistedFiles(in directory: URL) throws -> [String: Data] { + let urls = FileManager.default.enumerator( + at: directory, includingPropertiesForKeys: [.isRegularFileKey] + )!.allObjects as! [URL] + return try Dictionary(uniqueKeysWithValues: urls.filter { + try $0.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true + }.map { ($0.path, try Data(contentsOf: $0)) }) + } + + private func dependencies(_ state: ForecastPlannerState) -> DPIPWidgetProviderDependencies { + let now = date + return DPIPWidgetProviderDependencies( + loadSnapshot: { state.loadCurrent($0) }, + timelinePlanner: DPIPWidgetTimelinePlanner( + staleAfter: 30 * 60, refreshInterval: 20 * 60, + loadSnapshot: { state.loadCurrent($0) }, + refreshSaved: { _ in .failed }, refreshCurrent: { .failed }, + loadForecast: { state.loadForecast($0, $1, $2) }, + refreshForecast: { await state.refreshForecast($0, $1) }, + now: { now } + ), + loadForecast: { state.loadForecast($0, $1, $2) } + ) + } + + private func freshForecast( + source: String = "region:407", region: String = "407", + times: [String] = ["16:00", "17:00", "18:00", "19:00", "20:00"] + ) -> ForecastWidgetSnapshot { + forecastSnapshot( + sourceIdentifier: source, regionCode: region, + updateTime: Int64(date.timeIntervalSince1970 * 1_000), + receivedAt: Int64(date.timeIntervalSince1970 * 1_000), times: times + ) + } +} + private final class ForecastCallCounter: @unchecked Sendable { private let lock = NSLock() private var value = 0