diff --git a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift index 100179e72..8ad0be796 100644 --- a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift +++ b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift @@ -218,7 +218,7 @@ struct DPIPWidgetTimelinePlanner: Sendable { deviceNow: deviceNow, staleAfter: staleAfter ) - let reloadDate = deviceNow.addingTimeInterval(refreshInterval) + var reloadDate = deviceNow.addingTimeInterval(refreshInterval) let forecast: ForecastWidgetSnapshot? if ForecastWidgetFamilyPolicy.supportsForecast(family), let snapshot, @@ -235,7 +235,14 @@ struct DPIPWidgetTimelinePlanner: Sendable { calibratedTimeOffsetMilliseconds: snapshot.calibratedTimeOffsetMilliseconds ) - if expiry > deviceNow, expiry < reloadDate, + reloadDate = ForecastWidgetExpiry.requestedReloadDate( + now: deviceNow, + normalReloadDate: reloadDate, + expiry: expiry + ) + // Reload eligibility is not an execution guarantee. Supply the + // expiry state even when it falls after the requested reload. + if expiry > deviceNow, !states.contains(where: { $0.date == expiry }) { states.append(CurrentWeatherWidgetTimeline.state( snapshot: snapshot, diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index 34cf1be08..ea7eec9be 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -136,6 +136,25 @@ struct DPIPWidgetEntry: TimelineEntry { self.isNight = isNight self.forecast = forecast } + + /// Fail closed at the display boundary as well as the cache boundary. + /// Current weather remains usable independently of forecast availability. + func upcomingForecastPoints(for family: WidgetFamily) -> [ForecastWidgetPoint] { + guard let snapshot, let forecast, + forecast.sourceIdentifier == snapshot.sourceIdentifier, + forecast.regionCode == snapshot.regionCode, + ForecastWidgetExpiry.isUsable( + forecast, at: date, + calibratedTimeOffsetMilliseconds: + snapshot.calibratedTimeOffsetMilliseconds + ) else { return [] } + let calibratedDate = CurrentWeatherWidgetTimeCalibration( + snapshot: snapshot + ).calibratedDate(fromDeviceDate: date) + return ForecastWidgetFamilyPolicy.visiblePoints( + in: forecast, at: calibratedDate, family: family + ) + } } /// - Parameter calibratedDate: a calibrated instant, never a WidgetKit entry @@ -282,11 +301,19 @@ private struct SmallCurrentWeatherView: View { } } -private struct MediumCurrentWeatherView: View { +struct MediumCurrentWeatherView: View { let entry: DPIPWidgetEntry + var localizationBundle: Bundle = .main + + @Environment(\.dynamicTypeSize) private var dynamicTypeSize var body: some View { - if let snapshot = entry.snapshot { + if dynamicTypeSize.isAccessibilitySize, let snapshot = entry.snapshot { + AccessibleCurrentWeatherContent( + entry: entry, snapshot: snapshot, family: .systemMedium, + localizationBundle: localizationBundle + ) + } else if let snapshot = entry.snapshot { let observationDate = Date( timeIntervalSince1970: TimeInterval(snapshot.observationTime) ) @@ -343,7 +370,7 @@ private struct MediumCurrentWeatherView: View { if let apparentTemperature = snapshot.apparentTemperature { HStack(spacing: 4) { - Text("widget.feels_like") + Text("widget.feels_like", bundle: localizationBundle) Text("\(apparentTemperature, specifier: "%.0f")°") } .font(.caption) @@ -359,7 +386,7 @@ private struct MediumCurrentWeatherView: View { )) .font(.title2) - Text(snapshot.presentationCondition.localizedDisplayName) + Text(snapshot.presentationCondition.localizedDisplayName, bundle: localizationBundle) .font(.subheadline) .lineLimit(1) .minimumScaleFactor(0.8) @@ -367,16 +394,14 @@ private struct MediumCurrentWeatherView: View { .foregroundStyle(.secondary) } - if let forecast = entry.forecast, - !forecast.points.isEmpty { - HourlyForecastSection( - forecast: forecast, - family: .systemMedium, - calibratedDate: calibratedDate, - isCurrentlyNight: entry.isNight, - nextTransitionTime: snapshot.nextDayNightTransitionTime - ) - } + HourlyForecastSection( + entry: entry, + family: .systemMedium, + calibratedDate: calibratedDate, + isCurrentlyNight: entry.isNight, + nextTransitionTime: snapshot.nextDayNightTransitionTime, + localizationBundle: localizationBundle + ) } .frame( maxWidth: .infinity, @@ -388,7 +413,7 @@ private struct MediumCurrentWeatherView: View { Image(systemName: "cloud.fill") .font(.title) - Text("widget.no_weather_data") + Text("widget.no_weather_data", bundle: localizationBundle) .font(.caption) } .foregroundStyle(.secondary) @@ -400,11 +425,19 @@ private struct MediumCurrentWeatherView: View { } } -private struct LargeCurrentWeatherView: View { +struct LargeCurrentWeatherView: View { let entry: DPIPWidgetEntry + var localizationBundle: Bundle = .main + + @Environment(\.dynamicTypeSize) private var dynamicTypeSize var body: some View { - if let snapshot = entry.snapshot { + if dynamicTypeSize.isAccessibilitySize, let snapshot = entry.snapshot { + AccessibleCurrentWeatherContent( + entry: entry, snapshot: snapshot, family: .systemLarge, + localizationBundle: localizationBundle + ) + } else if let snapshot = entry.snapshot { let observationDate = Date( timeIntervalSince1970: TimeInterval(snapshot.observationTime) ) @@ -466,7 +499,7 @@ private struct LargeCurrentWeatherView: View { if let apparentTemperature = snapshot.apparentTemperature { HStack(spacing: 4) { - Text("widget.feels_like") + Text("widget.feels_like", bundle: localizationBundle) Text("\(apparentTemperature, specifier: "%.0f")°") } .font(.caption) @@ -482,7 +515,7 @@ private struct LargeCurrentWeatherView: View { )) .font(.title) - Text(snapshot.presentationCondition.localizedDisplayName) + Text(snapshot.presentationCondition.localizedDisplayName, bundle: localizationBundle) .font(.headline) .lineLimit(1) .minimumScaleFactor(0.8) @@ -492,23 +525,21 @@ private struct LargeCurrentWeatherView: View { Spacer() - if let forecast = entry.forecast, - !forecast.points.isEmpty { - HourlyForecastSection( - forecast: forecast, - family: .systemLarge, - calibratedDate: calibratedDate, - isCurrentlyNight: entry.isNight, - nextTransitionTime: snapshot.nextDayNightTransitionTime - ) - } + HourlyForecastSection( + entry: entry, + family: .systemLarge, + calibratedDate: calibratedDate, + isCurrentlyNight: entry.isNight, + nextTransitionTime: snapshot.nextDayNightTransitionTime, + localizationBundle: localizationBundle + ) Spacer() VStack(alignment: .leading, spacing: 14) { HStack(alignment: .top, spacing: 16) { VStack(alignment: .leading, spacing: 2) { - Text("widget.humidity") + Text("widget.humidity", bundle: localizationBundle) .font(.caption2) .foregroundStyle(.secondary) @@ -525,7 +556,7 @@ private struct LargeCurrentWeatherView: View { .frame(maxWidth: .infinity, alignment: .leading) VStack(alignment: .leading, spacing: 2) { - Text("widget.rainfall") + Text("widget.rainfall", bundle: localizationBundle) .font(.caption2) .foregroundStyle(.secondary) @@ -542,7 +573,7 @@ private struct LargeCurrentWeatherView: View { .frame(maxWidth: .infinity, alignment: .leading) VStack(alignment: .leading, spacing: 2) { - Text(entry.isNight ? "widget.sunrise" : "widget.sunset") + Text(entry.isNight ? "widget.sunrise" : "widget.sunset", bundle: localizationBundle) .font(.caption2) .foregroundStyle(.secondary) @@ -556,14 +587,14 @@ private struct LargeCurrentWeatherView: View { HStack(alignment: .top, spacing: 16) { VStack(alignment: .leading, spacing: 2) { - Text("widget.wind") + Text("widget.wind", bundle: localizationBundle) .font(.caption2) .foregroundStyle(.secondary) if let windSpeed = snapshot.windSpeed { HStack(spacing: 4) { if let direction = snapshot.presentationWindDirection { - Text(direction.localizedDisplayName) + Text(direction.localizedDisplayName, bundle: localizationBundle) .font(.caption) .fontWeight(.medium) } else if let windDirection = snapshot.windDirection { @@ -589,7 +620,7 @@ private struct LargeCurrentWeatherView: View { .frame(maxWidth: .infinity, alignment: .leading) VStack(alignment: .leading, spacing: 2) { - Text("widget.station") + Text("widget.station", bundle: localizationBundle) .font(.caption2) .foregroundStyle(.secondary) @@ -612,7 +643,7 @@ private struct LargeCurrentWeatherView: View { Image(systemName: "cloud.fill") .font(.title) - Text("widget.no_weather_data") + Text("widget.no_weather_data", bundle: localizationBundle) .font(.caption) } .foregroundStyle(.secondary) @@ -624,6 +655,139 @@ private struct LargeCurrentWeatherView: View { } } +/// Accessibility sizes need fewer columns and less vertical stacking inside +/// WidgetKit's fixed bounds. Keep the main temperature and nearest forecast +/// readable; cap only supporting metadata, whose rows retain their full values. +private struct AccessibleCurrentWeatherContent: View { + let entry: DPIPWidgetEntry + let snapshot: CurrentWeatherWidgetSnapshot + let family: WidgetFamily + let localizationBundle: Bundle + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, 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) + } + Spacer(minLength: 0) + Text(Date(timeIntervalSince1970: TimeInterval(snapshot.observationTime)), style: .time) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + + HStack(spacing: 8) { + Group { + if let temperature = snapshot.temperature { + Text("\(temperature, specifier: "%.0f")°") + } else { + Text("—°").foregroundStyle(.secondary) + } + } + .font(.system(size: family == .systemMedium ? 38 : 42, + weight: .semibold, design: .rounded)) + .fixedSize() + + if let apparentTemperature = snapshot.apparentTemperature { + VStack(alignment: .leading, spacing: 0) { + Text("widget.feels_like", bundle: localizationBundle) + Text("\(apparentTemperature, specifier: "%.0f")°") + } + .font(.caption2) + .foregroundStyle(.secondary) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + } + Spacer(minLength: 0) + HStack(spacing: 4) { + Image(systemName: snapshot.presentationCondition.systemImageName(isNight: entry.isNight)) + Text(snapshot.presentationCondition.localizedDisplayName, bundle: localizationBundle) + .lineLimit(2) + } + .font(.caption) + .foregroundStyle(.secondary) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + } + .fixedSize(horizontal: false, vertical: true) + + HourlyForecastSection( + entry: entry, family: family, + calibratedDate: CurrentWeatherWidgetTimeCalibration(snapshot: snapshot) + .calibratedDate(fromDeviceDate: entry.date), + isCurrentlyNight: entry.isNight, + nextTransitionTime: snapshot.nextDayNightTransitionTime, + localizationBundle: localizationBundle + ) + + if family == .systemLarge { + Spacer(minLength: 0) + details + .font(.caption2) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var details: some View { + VStack(alignment: .leading, spacing: 2) { + detail("widget.humidity") { + Text(snapshot.humidity.map { "\($0)%" } ?? "--%") + } + detail("widget.rainfall") { + if let rain = snapshot.rain { + Text("\(rain, specifier: "%.1f") mm") + } else { + Text("-- mm") + } + } + detail(entry.isNight ? "widget.sunrise" : "widget.sunset") { + Text(Date(timeIntervalSince1970: TimeInterval(snapshot.nextDayNightTransitionTime)), style: .time) + } + detail("widget.wind") { + HStack(spacing: 4) { + if let direction = snapshot.presentationWindDirection { + Text(direction.localizedDisplayName, bundle: localizationBundle) + } else if let direction = snapshot.windDirection { + Text(direction) + } + if let speed = snapshot.windSpeed { + Text("\(speed, specifier: "%.1f") m/s") + } else if snapshot.windDirection == nil { + Text("--") + } + } + } + detail("widget.station") { + Text(snapshot.stationName.isEmpty ? "--" : snapshot.stationName) + .lineLimit(2) + } + } + } + + private func detail( + _ key: LocalizedStringKey, @ViewBuilder value: () -> Value + ) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(key, bundle: localizationBundle) + .foregroundStyle(.secondary) + .fixedSize() + Spacer(minLength: 0) + value().font(.caption2.weight(.medium)) + } + .fixedSize(horizontal: false, vertical: true) + .accessibilityElement(children: .combine) + } +} + private struct HourlyForecastPointView: View { let point: ForecastWidgetPoint let compact: Bool @@ -673,8 +837,39 @@ private struct HourlyForecastPointView: View { } } -private struct HourlyForecastSection: View { - let forecast: ForecastWidgetSnapshot +struct AccessibleForecastPointView: View { + let point: ForecastWidgetPoint + let isNight: Bool + + var body: some View { + HStack(spacing: 4) { + Text(point.time) + .foregroundStyle(.secondary) + .fixedSize() + Image(systemName: point.presentationCondition.systemImageName(isNight: isNight)) + .font(.system(size: 22)) + .fixedSize() + Text("\(point.temperature, specifier: "%.0f")°") + .fontWeight(.semibold) + .fixedSize() + Spacer(minLength: 0) + Text(point.pop.map { "\($0)%" } ?? "—") + .font(.caption) + .foregroundStyle(.secondary) + // Keep the time and temperature at the requested size. The + // secondary probability must leave room for full HH:mm labels, + // negative temperatures and 100% even on narrow widget cards. + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .fixedSize() + } + .font(.caption2) + .fixedSize(horizontal: false, vertical: true) + .accessibilityElement(children: .combine) + } +} + +struct HourlyForecastSection: View { + let entry: DPIPWidgetEntry let family: WidgetFamily /// Calibrated, not the WidgetKit entry date: API clock labels are Taipei @@ -682,13 +877,30 @@ private struct HourlyForecastSection: View { let calibratedDate: Date let isCurrentlyNight: Bool let nextTransitionTime: Int + var localizationBundle: Bundle = .main + + @Environment(\.dynamicTypeSize) private var dynamicTypeSize var body: some View { - let points = ForecastWidgetFamilyPolicy.visiblePoints( - in: forecast, at: calibratedDate, family: family - ) + let points = entry.upcomingForecastPoints(for: family) let compact = family == .systemMedium - if !points.isEmpty { + if !points.isEmpty, dynamicTypeSize.isAccessibilitySize { + // Fixed WidgetKit cards cannot scroll. Prioritize the nearest hour(s) + // at larger text sizes, with time, condition, temperature and rain + // probability on a single row instead of four stacked lines. + VStack(spacing: 4) { + ForEach(Array(points.prefix(compact ? 1 : 2)), id: \.time) { point in + AccessibleForecastPointView( + point: point, + isNight: forecastIsNight( + pointTime: point.time, calibratedDate: calibratedDate, + isCurrentlyNight: isCurrentlyNight, + nextTransitionTime: nextTransitionTime + ) + ) + } + } + } else if !points.isEmpty { VStack( alignment: .leading, spacing: compact ? 4 : 12 @@ -709,8 +921,35 @@ private struct HourlyForecastSection: View { } } } + } else if ForecastWidgetFamilyPolicy.supportsForecast(family) { + unavailableLabel + .font(.caption) + .foregroundStyle(.secondary) + // This secondary status shares a fixed forecast area. Bound + // only its growth; never shrink the current-weather content. + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + @ViewBuilder + private var unavailableLabel: some View { + if #available(iOS 16.0, *) { + ViewThatFits(in: .horizontal) { + Text("widget.hourly_forecast_unavailable", bundle: localizationBundle) + .fixedSize() + compactUnavailableLabel + } + } else { + compactUnavailableLabel } } + + private var compactUnavailableLabel: some View { + Text("widget.forecast_unavailable", bundle: localizationBundle) + .lineLimit(1) + .minimumScaleFactor(0.8) + } } struct DPIPWidgetsEntryView : View { @@ -809,7 +1048,7 @@ struct DPIPWidgets_Previews: PreviewProvider { private static func previewView( entry: DPIPWidgetEntry ) -> some View { - if #available(iOSApplicationExtension 17.0, *) { + if #available(iOS 17.0, *) { DPIPWidgetsEntryView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } else { diff --git a/ios/DPIPWidgets/ForecastWidgetExpiry.swift b/ios/DPIPWidgets/ForecastWidgetExpiry.swift index 71ac52606..a9c4fb091 100644 --- a/ios/DPIPWidgets/ForecastWidgetExpiry.swift +++ b/ios/DPIPWidgets/ForecastWidgetExpiry.swift @@ -27,6 +27,23 @@ enum ForecastWidgetExpiry { ).addingTimeInterval(-offset) } + /// All dates are in the device clock domain. Bound the lead to two + /// minutes and requests to at least five minutes apart in Release. + /// A shorter configured interval (DEBUG) keeps its existing cadence. + /// If expiry is too close, prefer the request floor over a retry loop; + /// the independently supplied expiry entry removes the predictions. + static func requestedReloadDate( + now: Date, + normalReloadDate: Date, + expiry: Date + ) -> Date { + guard expiry > now else { return normalReloadDate } + let interval = max(0, normalReloadDate.timeIntervalSince(now)) + let earliest = now.addingTimeInterval(min(5 * 60, interval)) + let beforeExpiry = expiry.addingTimeInterval(-min(2 * 60, interval)) + return min(normalReloadDate, max(earliest, beforeExpiry)) + } + static func isUsable( _ snapshot: ForecastWidgetSnapshot, at deviceDate: Date, diff --git a/ios/DPIPWidgets/ForecastWidgetSnapshotStore.swift b/ios/DPIPWidgets/ForecastWidgetSnapshotStore.swift index 1ada72a85..74a46197e 100644 --- a/ios/DPIPWidgets/ForecastWidgetSnapshotStore.swift +++ b/ios/DPIPWidgets/ForecastWidgetSnapshotStore.swift @@ -68,6 +68,8 @@ struct ForecastWidgetSnapshotStore: Sendable { result = Result { // A late response for an older API publication cannot replace // a newer same-region forecast in this target's cache. + // Receiving the same publication again must not move its + // first-receipt hour boundary and resurrect expired values. let existingAttributes = try? FileManager.default.attributesOfItem( atPath: coordinatedURL.path ) @@ -81,7 +83,7 @@ struct ForecastWidgetSnapshotStore: Sendable { from: existingData ), existing.sourceIdentifier == snapshot.sourceIdentifier, existing.regionCode == snapshot.regionCode, - existing.updateTime > snapshot.updateTime { + existing.updateTime >= snapshot.updateTime { return false } try data.write(to: coordinatedURL, options: .atomic) diff --git a/ios/DPIPWidgets/Localizable.xcstrings b/ios/DPIPWidgets/Localizable.xcstrings index 886127636..d448516d7 100644 --- a/ios/DPIPWidgets/Localizable.xcstrings +++ b/ios/DPIPWidgets/Localizable.xcstrings @@ -555,6 +555,78 @@ } } }, + "widget.forecast_unavailable" : { + "comment" : "Compact alternative in the hourly forecast area when space is limited. Does not imply an update is in progress.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No forecast" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "予報なし" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예보 없음" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂无预报" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "暫無預報" + } + } + } + }, + "widget.hourly_forecast_unavailable" : { + "comment" : "Shown in the hourly forecast area when no valid upcoming forecast is available. Does not imply an update is in progress.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hourly forecast unavailable" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "時間別予報は利用できません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시간별 예보를 이용할 수 없습니다" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂无逐小时预报" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "每小時預報暫無資料" + } + } + } + }, "widget.humidity" : { "extractionState" : "manual", "localizations" : { diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index bbeffb906..ba1ba14dc 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -273,6 +273,9 @@ CurrentWeatherWidgetRefreshPipeline.swift, CurrentWeatherWidgetSnapshotWriter.swift, DPIPWidgetProviderSupport.swift, + DPIPWidgets.swift, + Localizable.xcstrings, + WeatherWidgetConfiguration.intentdefinition, ForecastClient.swift, ForecastRemoteDTO.swift, ForecastWidgetExpiry.swift, diff --git a/ios/RunnerTests/ForecastWidgetTests.swift b/ios/RunnerTests/ForecastWidgetTests.swift index c7a1ae9d1..49a232177 100644 --- a/ios/RunnerTests/ForecastWidgetTests.swift +++ b/ios/RunnerTests/ForecastWidgetTests.swift @@ -1,5 +1,7 @@ import Foundation import WidgetKit +import SwiftUI +import UIKit import XCTest final class ForecastRemoteDTOTests: XCTestCase { @@ -206,6 +208,21 @@ final class ForecastClientTests: XCTestCase { } } + func testTimeoutFailsWithoutWaitingForWallTime() async { + ForecastMockURLProtocol.handler = { request in + XCTAssertEqual(request.timeoutInterval, 6) + throw URLError(.timedOut) + } + do { + _ = try await client.fetch(regionCode: "407") + XCTFail("Expected timeout") + } catch let error as URLError { + XCTAssertEqual(error.code, .timedOut) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + func testOversizedResponseFails() async { ForecastMockURLProtocol.handler = { request in (self.response(for: request, status: 200), Data(repeating: 65, @@ -622,9 +639,14 @@ final class ForecastWidgetProviderTests: XCTestCase { let state = ForecastPlannerState( current: currentSnapshot(target: target, regionCode: "407")) let gate = AsyncOperationGate() + let fetchStarted = expectation(description: "Forecast fetch started") let dto = try! forecastDTO(times: ["23:00"]) let service = ForecastWidgetRefreshService( - fetch: { _ in await gate.wait(); return dto }, + fetch: { _ in + fetchStarted.fulfill() + await gate.wait() + return dto + }, loadCurrent: { state.loadCurrent($0) }, savedIsResolved: { _ in true }, write: { snapshot, _ in state.forecast = snapshot; return true }, @@ -632,8 +654,9 @@ final class ForecastWidgetProviderTests: XCTestCase { ) let task = Task { await service.refresh(target: target, regionCode: "407") } - while !(await gate.hasStarted) { await Task.yield() } + await fulfillment(of: [fetchStarted], timeout: 5) state.current = currentSnapshot(target: target, regionCode: "242") + // Release even after expectation timeout; open also covers a late waiter. await gate.open() await task.value XCTAssertNil(state.forecast) @@ -1037,7 +1060,11 @@ private final class ForecastPlannerState: @unchecked Sendable { guard let forecast, forecast.sourceIdentifier == target.sourceIdentifier, forecast.regionCode == regionCode, - ForecastWidgetExpiry.isUsable(forecast, at: date) else { + ForecastWidgetExpiry.isUsable( + forecast, at: date, + calibratedTimeOffsetMilliseconds: + current?.calibratedTimeOffsetMilliseconds ?? 0 + ) else { return nil } return forecast @@ -1121,7 +1148,8 @@ private func forecastPoints(times: [String]) -> [ForecastWidgetPoint] { } private func currentSnapshot(target: WidgetLocationTarget, - regionCode: String = "407") + regionCode: String = "407", + offset: Int = 0) -> CurrentWeatherWidgetSnapshot { CurrentWeatherWidgetSnapshot( schemaVersion: 7, @@ -1136,7 +1164,7 @@ private func currentSnapshot(target: WidgetLocationTarget, condition: .clear, isNight: false, nextDayNightTransitionTime: 0, - calibratedTimeOffsetMilliseconds: 0, + calibratedTimeOffsetMilliseconds: offset, temperature: 22, humidity: 50, rain: 0 @@ -1150,3 +1178,528 @@ private func instant(_ string: String) -> Date { private func milliseconds(_ string: String) -> Int64 { Int64(instant(string).timeIntervalSince1970 * 1_000) } + +// Explicit Release intervals keep these regressions independent of the DEBUG +// runtime cadence. Every async boundary and clock is controlled by the test. +final class ForecastExpiryRegressionTests: XCTestCase { + private let target = WidgetLocationTarget.saved(regionCode: "407") + private let initial = instant("2026-09-25T07:21:00Z") + + func testExpiryBeforeNormalReloadRequestsTwoMinuteLeadAndKeepsExpiryEntry() async { + for family: WidgetFamily in [.systemMedium, .systemLarge] { + let plan = await planner(forecast: cached()).plan(for: target, family: family) + XCTAssertEqual(plan.reloadDate, instant("2026-09-25T07:38:00Z")) + XCTAssertEqual(plan.states.map(\.date), [ + initial, instant("2026-09-25T07:40:00Z"), instant("2026-09-25T07:51:00Z"), + ]) + XCTAssertNotNil(plan.forecast(at: initial)) + XCTAssertNil(plan.forecast(at: instant("2026-09-25T07:40:00Z"))) + } + } + + func testExpiryAfterRequestedReloadStillSuppliesSafetyEntry() async { + let plan = await planner(forecast: cached(publication: "2026-09-25T07:20:00Z")) + .plan(for: target, family: .systemLarge) + XCTAssertEqual(plan.reloadDate, instant("2026-09-25T07:41:00Z")) + XCTAssertTrue(plan.states.contains { $0.date == instant("2026-09-25T07:50:00Z") }) + XCTAssertNil(plan.forecast(at: instant("2026-09-25T07:50:00Z"))) + } + + func testDelayedWidgetKitReloadSelectsExpiryStateAndPreservesCurrentWeather() async throws { + let plan = await planner(forecast: cached(publication: "2026-09-25T07:20:00Z")) + .plan(for: target, family: .systemMedium) + // Simulate WidgetKit keeping this timeline past reload eligibility. + let delayed = instant("2026-09-25T07:50:30Z") + let selected = try XCTUnwrap(plan.states.last { $0.date <= delayed }) + XCTAssertEqual(selected.date, instant("2026-09-25T07:50:00Z")) + XCTAssertNil(plan.forecast(at: selected.date)) + XCTAssertNil(plan.forecast(at: delayed)) + XCTAssertEqual(plan.snapshot?.temperature, 22) + XCTAssertFalse(selected.isStale) + } + + func testNearExpiryUsesFiveMinuteFloorInsteadOfImmediateRetry() async { + let plan = await planner(forecast: cached(publication: "2026-09-25T06:52:00Z")) + .plan(for: target, family: .systemMedium) + XCTAssertEqual(plan.reloadDate, instant("2026-09-25T07:26:00Z")) + XCTAssertTrue(plan.states.contains { $0.date == instant("2026-09-25T07:22:00Z") }) + XCTAssertNil(plan.forecast(at: instant("2026-09-25T07:22:00Z"))) + } + + func testMissingAndExpiredForecastKeepNormalCadenceAndCurrentWeather() async { + for forecast in [nil, cached(publication: "2026-09-25T06:50:00Z")] { + let plan = await planner(forecast: forecast).plan(for: target, family: .systemMedium) + XCTAssertEqual(plan.reloadDate, instant("2026-09-25T07:41:00Z")) + XCTAssertNil(plan.forecast) + XCTAssertEqual(plan.snapshot?.stationName, "station") + } + } + + func testSmallKeepsNormalTimelineAndNeverRequestsForecast() async { + let state = ForecastPlannerState(current: currentSnapshot(target: target), forecast: cached()) + let plan = await 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: { self.initial } + ).plan(for: target, family: .systemSmall) + XCTAssertEqual(plan.reloadDate, instant("2026-09-25T07:41:00Z")) + XCTAssertEqual(plan.states, CurrentWeatherWidgetTimeline.states( + snapshot: plan.snapshot, deviceNow: initial, staleAfter: 30 * 60 + )) + XCTAssertNil(plan.forecast) + XCTAssertEqual(state.forecastRefreshCount, 0) + } + + func testDebugCadenceIsNotLengthenedByReleaseFloor() { + XCTAssertEqual(ForecastWidgetExpiry.requestedReloadDate( + now: initial, normalReloadDate: initial.addingTimeInterval(60), + expiry: initial.addingTimeInterval(30) + ), initial.addingTimeInterval(60)) + } + + func testMidnightRolloverUsesReceiptHourAndRetainsExpirySafety() async { + let now = instant("2026-09-25T15:50:00Z") // 23:50 Taipei + let forecast = forecastSnapshot( + updateTime: milliseconds("2026-09-25T15:40:00Z"), + receivedAt: milliseconds("2026-09-25T15:50:00Z"), + times: ["00:00", "01:00"] + ) + let plan = await planner(forecast: forecast, now: now) + .plan(for: target, family: .systemLarge) + XCTAssertEqual(plan.reloadDate, instant("2026-09-25T15:58:00Z")) + XCTAssertNotNil(plan.forecast(at: now)) + XCTAssertTrue(plan.states.contains { $0.date == instant("2026-09-25T16:00:00Z") }) + XCTAssertNil(plan.forecast(at: instant("2026-09-25T16:00:00Z"))) + let entry = makeEntry(plan: plan, date: now) + XCTAssertEqual(entry.upcomingForecastPoints(for: .systemLarge).map(\.time), + ["00:00", "01:00"]) + } + + func testPositiveAndNegativeClockOffsetsShiftReloadAndExpiryTogether() async { + for offset in [-300_000, 300_000] { + let seconds = Double(offset) / 1_000 + let deviceNow = initial.addingTimeInterval(-seconds) + let forecast = forecastSnapshot( + updateTime: milliseconds("2026-09-25T07:10:00Z"), + receivedAt: Int64(deviceNow.timeIntervalSince1970 * 1_000), + times: ["16:00", "17:00"] + ) + let plan = await planner(forecast: forecast, now: deviceNow, offset: offset) + .plan(for: target, family: .systemMedium) + let expiry = instant("2026-09-25T07:40:00Z").addingTimeInterval(-seconds) + XCTAssertEqual(plan.reloadDate, expiry.addingTimeInterval(-120)) + XCTAssertTrue(plan.states.contains { $0.date == expiry }) + XCTAssertNotNil(plan.forecast(at: expiry.addingTimeInterval(-1))) + XCTAssertNil(plan.forecast(at: expiry)) + XCTAssertEqual(makeEntry(plan: plan, date: deviceNow) + .upcomingForecastPoints(for: .systemMedium).map(\.time), ["16:00", "17:00"]) + } + } + + func testExpiryCoincidingWithStaleStateIsNotDuplicated() async { + let now = instant("2026-09-25T07:21:00Z") + let forecast = cached(publication: "2026-09-25T07:21:00Z") + let plan = await planner(forecast: forecast, now: now) + .plan(for: target, family: .systemLarge) + let expiry = instant("2026-09-25T07:51:00Z") + XCTAssertEqual(plan.states.filter { $0.date == expiry }.count, 1) + XCTAssertTrue(plan.states.last!.isStale) + XCTAssertNil(plan.forecast(at: expiry)) + } + + func testForecastTimeoutAndHTTPFailureKeepCacheUntilExpiryThenSuccessfulRefreshRestoresIt() async throws { + for error: Error in [URLError(.timedOut), ForecastClientError.httpStatus(503)] { + let clock = TestWallClock(milliseconds: milliseconds("2026-09-25T07:21:00Z")) + let state = ForecastPlannerState(current: currentSnapshot(target: target), forecast: cached()) + let service = ForecastWidgetRefreshService( + fetch: { _ in throw error }, + loadCurrent: { state.loadCurrent($0) }, savedIsResolved: { _ in true }, + write: { snapshot, _ in state.forecast = snapshot; return true }, + now: { clock.now() } + ) + let failedPlanner = DPIPWidgetTimelinePlanner( + staleAfter: 30 * 60, refreshInterval: 20 * 60, + loadSnapshot: { state.loadCurrent($0) }, + refreshSaved: { _ in .failed }, refreshCurrent: { .failed }, + loadForecast: { state.loadForecast($0, $1, $2) }, + refreshForecast: { await service.refresh(target: $0, regionCode: $1) }, + now: { clock.now() } + ) + let before = await failedPlanner.plan(for: target, family: .systemMedium) + XCTAssertEqual(before.forecast?.updateTime, cached().updateTime) + clock.set(milliseconds: milliseconds("2026-09-25T07:40:00Z")) + let after = await failedPlanner.plan(for: target, family: .systemMedium) + XCTAssertNil(after.forecast) + XCTAssertEqual(after.snapshot?.temperature, 22) + XCTAssertEqual(after.reloadDate, instant("2026-09-25T08:00:00Z")) + + let dto = try refreshedDTO(publication: "2026-09-25T07:35:00Z") + let success = ForecastWidgetRefreshService( + fetch: { _ in dto }, + loadCurrent: { state.loadCurrent($0) }, savedIsResolved: { _ in true }, + write: { snapshot, _ in state.forecast = snapshot; return true }, + now: { clock.now() } + ) + await success.refresh(target: target, regionCode: "407") + let restored = await failedPlanner.plan(for: target, family: .systemMedium) + XCTAssertEqual(restored.forecast?.updateTime, dto.updateTime) + XCTAssertEqual(makeEntry(plan: restored, date: clock.now()) + .upcomingForecastPoints(for: .systemMedium).map(\.time), ["16:00", "17:00"]) + } + } + + func testExpiredPublicationIsRejectedWithoutOverwritingCache() async throws { + for offset in [-300_000, 0, 300_000] { + let state = ForecastPlannerState( + current: currentSnapshot(target: target, offset: offset), forecast: cached() + ) + let dto = try refreshedDTO(publication: "2026-09-25T06:51:00Z") + let service = ForecastWidgetRefreshService( + fetch: { _ in dto }, + loadCurrent: { state.loadCurrent($0) }, savedIsResolved: { _ in true }, + write: { snapshot, _ in state.forecast = snapshot; return true }, + now: { self.initial.addingTimeInterval(-Double(offset) / 1_000) } + ) + await service.refresh(target: target, regionCode: "407") + XCTAssertEqual(state.forecast?.updateTime, cached().updateTime) + } + } + + func testSamePublicationReceivedAcrossHourCannotResurrectExpiredCache() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ForecastWidgetSnapshotStore(containerURL: directory) + let first = forecastSnapshot( + updateTime: milliseconds("2026-09-25T07:45:00Z"), + receivedAt: milliseconds("2026-09-25T07:59:00Z"), times: ["16:00", "17:00"] + ) + XCTAssertTrue(try store.write(first, for: target)) + let repeated = forecastSnapshot( + updateTime: first.updateTime, receivedAt: milliseconds("2026-09-25T08:01:00Z"), + times: ["16:00", "17:00"] + ) + XCTAssertFalse(try store.write(repeated, for: target)) + XCTAssertEqual(store.load(for: target, regionCode: "407", + at: instant("2026-09-25T07:59:30Z"))?.receivedAt, first.receivedAt) + XCTAssertNil(store.load(for: target, regionCode: "407", + at: instant("2026-09-25T08:01:00Z"))) + let newer = forecastSnapshot( + updateTime: milliseconds("2026-09-25T08:00:00Z"), + receivedAt: repeated.receivedAt, times: ["17:00"] + ) + XCTAssertTrue(try store.write(newer, for: target)) + XCTAssertEqual(store.load(for: target, regionCode: "407", + at: instant("2026-09-25T08:01:00Z"))?.updateTime, newer.updateTime) + } + + func testMissingAndInvalidDiskCacheKeepCurrentWithoutForecast() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ForecastWidgetSnapshotStore(containerURL: directory) + let current = currentSnapshot(target: target) + let planner = DPIPWidgetTimelinePlanner( + staleAfter: 30 * 60, refreshInterval: 20 * 60, + loadSnapshot: { _ in current }, + refreshSaved: { _ in .failed }, refreshCurrent: { .failed }, + loadForecast: { store.load(for: $0, regionCode: $1, at: $2) }, + now: { self.initial } + ) + let missing = await planner.plan(for: target, family: .systemLarge) + XCTAssertNil(missing.forecast) + XCTAssertEqual(missing.snapshot?.temperature, 22) + let url = try XCTUnwrap(store.snapshotURL(for: target)) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data(#"{"schemaVersion":1,"points":[]}"#.utf8).write(to: url) + let invalid = await planner.plan(for: target, family: .systemLarge) + XCTAssertNil(invalid.forecast) + XCTAssertEqual(invalid.snapshot?.temperature, 22) + } + + func testLocationChangeDuringForecastAwaitRechecksPlannerSnapshotAndCache() async throws { + let currentTarget = WidgetLocationTarget.currentLocation + let state = ForecastPlannerState(current: currentSnapshot(target: currentTarget)) + let dto = try refreshedDTO(publication: "2026-09-25T07:10:00Z") + let gate = AsyncOperationGate() + let fetchStarted = expectation(description: "Forecast fetch started") + let service = ForecastWidgetRefreshService( + fetch: { _ in + fetchStarted.fulfill() + await gate.wait() + return dto + }, + loadCurrent: { state.loadCurrent($0) }, savedIsResolved: { _ in true }, + write: { snapshot, _ in state.forecast = snapshot; return true }, + now: { self.initial } + ) + let planner = DPIPWidgetTimelinePlanner( + staleAfter: 30 * 60, refreshInterval: 20 * 60, + loadSnapshot: { state.loadCurrent($0) }, + refreshSaved: { _ in .failed }, refreshCurrent: { .failed }, + loadForecast: { state.loadForecast($0, $1, $2) }, + refreshForecast: { await service.refresh(target: $0, regionCode: $1) }, + now: { self.initial } + ) + let task = Task { await planner.plan(for: currentTarget, family: .systemLarge) } + await fulfillment(of: [fetchStarted], timeout: 5) + state.current = currentSnapshot(target: currentTarget, regionCode: "242") + // Release even after expectation timeout; open also covers a late waiter. + await gate.open() + let plan = await task.value + XCTAssertEqual(plan.snapshot?.regionCode, "242") + XCTAssertNil(state.forecast) + XCTAssertNil(plan.forecast) + } + + func testDisplayRejectsNoUpcomingExpiredAndWrongLocationForecasts() { + let forecasts = [ + cached(times: ["14:00", "15:00"]), + cached(publication: "2026-09-25T06:50:00Z"), + forecastSnapshot(sourceIdentifier: "region:242", regionCode: "242"), + ] + for forecast in forecasts { + let entry = DPIPWidgetEntry(date: initial, snapshot: currentSnapshot(target: target), + isStale: false, isNight: false, forecast: forecast) + for family: WidgetFamily in [.systemMedium, .systemLarge, .systemSmall] { + XCTAssertTrue(entry.upcomingForecastPoints(for: family).isEmpty) + } + } + } + + private func cached(publication: String = "2026-09-25T07:10:00Z", + times: [String] = ["16:00", "17:00"]) -> ForecastWidgetSnapshot { + forecastSnapshot(updateTime: milliseconds(publication), + receivedAt: Int64(initial.timeIntervalSince1970 * 1_000), times: times) + } + + private func planner(forecast: ForecastWidgetSnapshot?, now: Date? = nil, + offset: Int = 0) -> DPIPWidgetTimelinePlanner { + let date = now ?? initial + let current = currentSnapshot(target: target, offset: offset) + return DPIPWidgetTimelinePlanner( + staleAfter: 30 * 60, refreshInterval: 20 * 60, + loadSnapshot: { _ in current }, + refreshSaved: { _ in .failed }, refreshCurrent: { .failed }, + loadForecast: { target, region, date in + guard let forecast, forecast.sourceIdentifier == target.sourceIdentifier, + forecast.regionCode == region, + ForecastWidgetExpiry.isUsable(forecast, at: date, + calibratedTimeOffsetMilliseconds: offset) else { return nil } + return forecast + }, + now: { date } + ) + } + + private func makeEntry(plan: DPIPWidgetTimelinePlan, date: Date) -> DPIPWidgetEntry { + DPIPWidgetEntry(date: date, snapshot: plan.snapshot, isStale: false, isNight: false, + forecast: plan.forecast(at: date)) + } + + private func refreshedDTO(publication: String) throws -> ForecastRemoteDTO { + var json = try XCTUnwrap(JSONSerialization.jsonObject( + with: forecastJSON(times: ["16:00", "17:00"]) + ) as? [String: Any]) + json["updateTime"] = milliseconds(publication) + return try JSONDecoder().decode(ForecastRemoteDTO.self, + from: JSONSerialization.data(withJSONObject: json)) + } +} + +@MainActor +final class ForecastUnavailablePresentationTests: XCTestCase { + private let date = instant("2026-09-25T07:21:00Z") + private let locales = ["en", "zh-Hant", "zh-Hans", "ja", "ko"] + + func testMediumAndLargeUnavailableSectionFitsAllLocalesAndLargestText() throws { + let current = currentSnapshot(target: .saved(regionCode: "407")) + let entry = DPIPWidgetEntry(date: date, snapshot: current, isStale: false, isNight: false) + for family: WidgetFamily in [.systemMedium, .systemLarge] { + for locale in locales { + for size: DynamicTypeSize in [.large, .accessibility5] { + let section = HourlyForecastSection( + entry: entry, family: family, calibratedDate: date, + isCurrentlyNight: false, nextTransitionTime: 0, + localizationBundle: Bundle(for: Self.self) + ) + .environment(\.locale, Locale(identifier: locale)) + .environment(\.dynamicTypeSize, size) + let host = UIHostingController(rootView: section) + let fitting = host.sizeThatFits(in: CGSize(width: 300, height: 1_000)) + XCTAssertGreaterThan(fitting.height, 10, "Missing unavailable text: \(locale)") + XCTAssertLessThanOrEqual(fitting.height, 34, "Unavailable state must remain compact") + XCTAssertLessThanOrEqual(fitting.width, 300) + } + } + } + } + + func testUnavailableStringsExistInEverySupportedLocale() throws { + let expected = [ + "en": ["Hourly forecast unavailable", "No forecast"], + "zh-Hant": ["每小時預報暫無資料", "暫無預報"], + "zh-Hans": ["暂无逐小时预报", "暂无预报"], + "ja": ["時間別予報は利用できません", "予報なし"], + "ko": ["시간별 예보를 이용할 수 없습니다", "예보 없음"], + ] + let bundle = Bundle(for: Self.self) + for locale in locales { + let path = try XCTUnwrap(bundle.path(forResource: locale, ofType: "lproj")) + let localized = try XCTUnwrap(Bundle(path: path)) + for (index, key) in ["widget.hourly_forecast_unavailable", "widget.forecast_unavailable"].enumerated() { + XCTAssertEqual(localized.localizedString(forKey: key, value: nil, table: nil), + expected[locale]![index]) + } + } + } + + func testSmallForecastSectionStaysEmpty() { + let entry = DPIPWidgetEntry( + date: date, snapshot: currentSnapshot(target: .saved(regionCode: "407")), + isStale: false, isNight: false + ) + let host = UIHostingController(rootView: HourlyForecastSection( + entry: entry, family: .systemSmall, calibratedDate: date, + isCurrentlyNight: false, nextTransitionTime: 0 + )) + XCTAssertEqual(host.sizeThatFits(in: CGSize(width: 300, height: 1_000)).height, 0) + } + + func testUnavailableAndRestoredWidgetsRenderInLightDarkAndLargerText() throws { + let current = currentSnapshot(target: .saved(regionCode: "407")) + let available = forecastSnapshot( + updateTime: milliseconds("2026-09-25T07:10:00Z"), + receivedAt: milliseconds("2026-09-25T07:21:00Z"), times: ["16:00", "17:00"] + ) + for family: WidgetFamily in [.systemMedium, .systemLarge] { + for scheme: ColorScheme in [.light, .dark] { + for size: DynamicTypeSize in [.large, .accessibility5] { + for forecast in [nil, available] { + let entry = DPIPWidgetEntry( + date: date, snapshot: current, isStale: false, isNight: false, + forecast: forecast + ) + let contentSize = CGSize(width: 338, height: family == .systemMedium ? 158 : 354) + let widget = family == .systemMedium + ? AnyView(MediumCurrentWeatherView(entry: entry, localizationBundle: Bundle(for: Self.self))) + : AnyView(LargeCurrentWeatherView(entry: entry, localizationBundle: Bundle(for: Self.self))) + let view = widget + .environment(\.locale, Locale(identifier: "zh-Hant")) + .environment(\.dynamicTypeSize, size) + .environment(\.colorScheme, scheme) + .frame(width: contentSize.width, height: contentSize.height, alignment: .topLeading) + .padding(16) + .background(scheme == .dark ? Color.black : Color.white) + let host = UIHostingController(rootView: view) + if #available(iOS 16.4, *) { + host.safeAreaRegions = [] + } + host.view.frame = CGRect(origin: .zero, size: CGSize( + width: contentSize.width + 32, height: contentSize.height + 32 + )) + host.view.backgroundColor = .clear + host.view.setNeedsLayout() + host.view.layoutIfNeeded() + let image = UIGraphicsImageRenderer(size: host.view.bounds.size).image { _ in + host.view.drawHierarchy(in: host.view.bounds, afterScreenUpdates: true) + } + XCTAssertNotNil(image.cgImage) + let name = "\(family)-\(scheme)-\(size)-\(forecast == nil ? "unavailable" : "forecast")" + let attachment = XCTAttachment(image: image) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + } + } + } + } + + func testAccessibilityForecastRowFitsFullTimeNegativeTemperatureAndProbability() throws { + for size: DynamicTypeSize in [.accessibility1, .accessibility3, .accessibility5] { + for probability: Int? in [nil, 0, 100] { + let point = try XCTUnwrap(ForecastWidgetPoint( + time: "16:00", temperature: -20, weather: "雷雨", weatherCode: 319, pop: probability + )) + let row = AccessibleForecastPointView(point: point, isNight: false) + .environment(\.dynamicTypeSize, size) + // With an unconstrained ideal width, no parent frame can + // mask truncation of the time or either numeric value. + .fixedSize(horizontal: true, vertical: true) + let host = UIHostingController(rootView: row) + if #available(iOS 16.4, *) { host.safeAreaRegions = [] } + let fitting = host.sizeThatFits(in: CGSize(width: 300, height: 1_000)) + XCTAssertLessThanOrEqual(fitting.width, 300, + "Forecast row clips at \(size), probability=\(String(describing: probability))") + } + } + } + + func testAccessibilityContentFitsWithoutVerticalCompression() throws { + // Measure the ideal height, before the fixed widget frame can hide + // overflow. Include four hours and full observations, unlike the + // minimal render fixture, to exercise both forecast and detail rows. + let current = CurrentWeatherWidgetSnapshot( + schemaVersion: 7, sourceIdentifier: "current-location", regionCode: "407", + regionName: "臺中市西屯區", observationTime: Int(date.timeIntervalSince1970), + stationName: "臺中氣象觀測站", weather: "雷雨", weatherCode: 319, + condition: .thunderstorm, isNight: false, + nextDayNightTransitionTime: Int(date.addingTimeInterval(3_600).timeIntervalSince1970), + calibratedTimeOffsetMilliseconds: 0, temperature: 32, humidity: 100, + rain: 123.4, windDirection: "NE", windSpeed: 12.3, apparentTemperature: 38 + ) + let available = try XCTUnwrap(ForecastWidgetSnapshot( + sourceIdentifier: "current-location", regionCode: "407", + updateTime: milliseconds("2026-09-25T07:10:00Z"), + receivedAt: milliseconds("2026-09-25T07:21:00Z"), + points: ["16:00", "17:00", "18:00", "19:00"].map { + ForecastWidgetPoint(time: $0, temperature: -20, weather: "晴", weatherCode: 100, pop: 100)! + } + )) + for family: WidgetFamily in [.systemMedium, .systemLarge] { + for locale in locales { + for size: DynamicTypeSize in [.accessibility1, .accessibility3, .accessibility5] { + for forecast in [nil, available] { + let entry = DPIPWidgetEntry(date: date, snapshot: current, + isStale: false, isNight: false, forecast: forecast) + let widget = family == .systemMedium + ? AnyView(MediumCurrentWeatherView(entry: entry, localizationBundle: Bundle(for: Self.self))) + : AnyView(LargeCurrentWeatherView(entry: entry, localizationBundle: Bundle(for: Self.self))) + let view = widget + .environment(\.locale, Locale(identifier: locale)) + .environment(\.dynamicTypeSize, size) + .fixedSize(horizontal: false, vertical: true) + let host = UIHostingController(rootView: view) + if #available(iOS 16.4, *) { host.safeAreaRegions = [] } + let fitting = host.sizeThatFits(in: CGSize(width: 300, height: 1_000)) + let limit: CGFloat = family == .systemMedium ? 158 : 354 + XCTAssertGreaterThan(fitting.height, 50) + XCTAssertLessThanOrEqual(fitting.height, limit, + "Content clips: \(family) \(locale) \(size) forecast=\(forecast != nil)") + XCTAssertLessThanOrEqual(fitting.width, 300) + if size == .accessibility5, ["en", "zh-Hant"].contains(locale) { + let render = UIHostingController(rootView: view + .frame(width: 300, height: limit, alignment: .topLeading) + .padding(16).background(Color.white) + .environment(\.colorScheme, .light)) + if #available(iOS 16.4, *) { render.safeAreaRegions = [] } + render.view.frame = CGRect(x: 0, y: 0, width: 332, height: limit + 32) + render.view.setNeedsLayout() + render.view.layoutIfNeeded() + let image = UIGraphicsImageRenderer(size: render.view.bounds.size).image { _ in + render.view.drawHierarchy(in: render.view.bounds, afterScreenUpdates: true) + } + let attachment = XCTAttachment(image: image) + attachment.name = "full-observations-\(family)-\(locale)-AX5-\(forecast == nil ? "unavailable" : "forecast")" + attachment.lifetime = .keepAlways + add(attachment) + } + } + } + } + } + } +}