import Testing import Foundation @testable import WeatherProvider // WeatherKit rechnet nach Abrufen ab (500.000 im Monat inklusive). Ein Widget, // das bei jedem Öffnen des Panels neu lädt, verbrennt das Kontingent für Daten, // die sich stündlich ändern. Der Zwischenspeicher ist deshalb kein Feinschliff, // sondern Teil der Funktion. @Suite("Wetter-Zwischenspeicher") struct WeatherCacheTests { private let place = WeatherPlace.fixed(name: "Lemgo", latitude: 52.02, longitude: 8.9) private let other = WeatherPlace.fixed(name: "Berlin", latitude: 52.52, longitude: 13.40) private func snapshot(at date: Date) -> WeatherSnapshot { WeatherSnapshot(placeName: "Lemgo", temperature: .init(value: 18, unit: .celsius), apparentTemperature: .init(value: 17, unit: .celsius), condition: .partlyCloudy, isDaylight: true, humidity: 0.6, windSpeed: .init(value: 10, unit: .kilometersPerHour), high: nil, low: nil, hourly: [], asOf: date, attribution: nil) } @Test("Frischer Eintrag wird wiederverwendet") func freshEntryIsReused() { let now = Date() var cache = WeatherCache(url: nil) cache.store(snapshot(at: now), for: place) #expect(cache.valid(for: place, now: now.addingTimeInterval(60)) != nil) } @Test("Nach Ablauf der Frist wird neu geladen") func staleEntryIsDiscarded() { let now = Date() var cache = WeatherCache(url: nil) cache.store(snapshot(at: now), for: place) let later = now.addingTimeInterval(WeatherCache.maxAge + 1) #expect(cache.valid(for: place, now: later) == nil) } @Test("Genau auf der Frist gilt der Eintrag noch") func exactlyAtMaxAgeIsStillValid() { let now = Date() var cache = WeatherCache(url: nil) cache.store(snapshot(at: now), for: place) #expect(cache.valid(for: place, now: now.addingTimeInterval(WeatherCache.maxAge)) != nil) } @Test("Ein anderer Ort benutzt nicht den Eintrag des ersten") func differentPlaceMisses() { // Sonst zeigt das Widget nach dem Umstellen des Ortes weiter das alte // Wetter — und zwar überzeugend, weil Zahlen dastehen. let now = Date() var cache = WeatherCache(url: nil) cache.store(snapshot(at: now), for: place) #expect(cache.valid(for: other, now: now) == nil) } @Test("Der aktuelle Standort ist ein eigener Eintrag") func currentLocationIsItsOwnEntry() { let now = Date() var cache = WeatherCache(url: nil) cache.store(snapshot(at: now), for: .current) #expect(cache.valid(for: .current, now: now) != nil) #expect(cache.valid(for: place, now: now) == nil) } @Test("Leerer Zwischenspeicher liefert nichts") func emptyCacheMisses() { #expect(WeatherCache(url: nil).valid(for: place, now: Date()) == nil) } @Test("Ein abgelaufener Eintrag bleibt als letzter bekannter Stand erhalten") func staleEntryRemainsAsLastKnown() { // Bei einem Netzfehler ist ein alter Messwert mit sichtbarem Zeitstempel // deutlich besser als ein leeres Feld. let now = Date() var cache = WeatherCache(url: nil) cache.store(snapshot(at: now), for: place) let later = now.addingTimeInterval(WeatherCache.maxAge * 10) #expect(cache.valid(for: place, now: later) == nil) #expect(cache.lastKnown(for: place) != nil) } @Test("Ein neuer Eintrag ersetzt den alten desselben Ortes") func storingReplaces() { let now = Date() var cache = WeatherCache(url: nil) cache.store(snapshot(at: now.addingTimeInterval(-1000)), for: place) cache.store(snapshot(at: now), for: place) #expect(cache.lastKnown(for: place)?.asOf == now) } @Test("Die Frist entspricht dem Plan: 15 Minuten") func maxAgeMatchesPlan() { #expect(WeatherCache.maxAge == 15 * 60) } @Test("Der Stand überlebt einen Neustart") func survivesRestart() throws { // Der eigentliche Punkt. Ohne Datei steht beim Start ein Rädchen da, // bis der erste Abruf durch ist — und wenn der hängt, für immer. let url = Self.temporaryURL() defer { try? FileManager.default.removeItem(at: url) } var cache = WeatherCache(url: url) cache.store(snapshot(at: Date()), for: place) try cache.save() let restored = WeatherCache(url: url).lastKnown(for: place) #expect(restored?.temperature.value == 18) #expect(restored?.placeName == "Lemgo") } @Test("Auch die Stundenwerte überleben") func hourlySurvives() throws { let url = Self.temporaryURL() defer { try? FileManager.default.removeItem(at: url) } let point = HourlyPoint(date: Date(), temperature: .init(value: 19, unit: .celsius), condition: .clear, precipitationChance: 0.1) var cache = WeatherCache(url: url) cache.store(WeatherSnapshot(placeName: "Lemgo", temperature: .init(value: 18, unit: .celsius), apparentTemperature: .init(value: 17, unit: .celsius), condition: .partlyCloudy, isDaylight: true, humidity: 0.6, windSpeed: .init(value: 10, unit: .kilometersPerHour), high: nil, low: nil, hourly: [point], asOf: Date(), attribution: nil), for: place) try cache.save() #expect(WeatherCache(url: url).lastKnown(for: place)?.hourly.count == 1) } @Test("Eine unlesbare Datei führt nicht zum Absturz") func brokenFileIsSurvivable() throws { let url = Self.temporaryURL() defer { try? FileManager.default.removeItem(at: url) } try Data("kein JSON".utf8).write(to: url) // Leer ist hier richtig: ein Zwischenspeicher, dem man nicht trauen // kann, ist kein Grund, das Widget aufzugeben. #expect(WeatherCache(url: url).lastKnown(for: place) == nil) } /// Nie der echte Ablageort — Tests dürfen ihn nicht anfassen. private static func temporaryURL() -> URL { URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("onyx-weather-\(UUID().uuidString).json") } } @Suite("Wettersymbole") struct WeatherSymbolTests { @Test("Klarer Himmel zeigt tagsüber die Sonne, nachts den Mond") func clearDependsOnDaylight() { #expect(WeatherCondition.clear.symbolName(isDaylight: true) == "sun.max.fill") #expect(WeatherCondition.clear.symbolName(isDaylight: false) == "moon.stars.fill") } @Test("Teilweise bewölkt unterscheidet ebenfalls Tag und Nacht") func partlyCloudyDependsOnDaylight() { #expect(WeatherCondition.partlyCloudy.symbolName(isDaylight: true) != WeatherCondition.partlyCloudy.symbolName(isDaylight: false)) } @Test("Regen sieht nachts aus wie tagsüber") func rainIgnoresDaylight() { // Nicht jede Lage hat eine sinnvolle Nachtvariante. Eine erzwungene // Unterscheidung erzeugt nur Symbole, die niemand wiedererkennt. #expect(WeatherCondition.rain.symbolName(isDaylight: true) == WeatherCondition.rain.symbolName(isDaylight: false)) } @Test("Jede Lage liefert ein Symbol — auch die unbekannte") func everyConditionHasASymbol() { for condition in WeatherCondition.allCases { #expect(!condition.symbolName(isDaylight: true).isEmpty) #expect(!condition.symbolName(isDaylight: false).isEmpty) } } } @Suite("Zeitlimit") struct TimeoutTests { @Test("Ein schneller Aufruf kommt durch") func fastCallReturns() async { let value = await withTimeout(seconds: 1) { 42 } #expect(value == 42) } @Test("Ein hängender Aufruf gibt auf") func slowCallGivesUp() async { // Genau das ist passiert: WeatherKit lieferte das Wetter in 185 ms, // danach hing die Abfrage der Namensnennung — und das Widget zeigte // für immer ein Rädchen. let value: Int? = await withTimeout(seconds: 0.05) { try? await Task.sleep(for: .seconds(5)) return 42 } #expect(value == nil) } }