Files
onyx/Packages/OnyxKit/Tests/WeatherProviderTests/WeatherCacheTests.swift
Guido Schmit fc92d226b4 Phase 3d: Wetter-Widget (WeatherKit-Entitlement noch offen)
Eigene WeatherCondition statt WeatherKits Typ: die Widget-Ebene soll nicht an
ein Framework gebunden sein, das eine kostenpflichtige Mitgliedschaft und eine
Entitlement voraussetzt. Fällt WeatherKit weg, wird nur die Abbildung getauscht.

Der Zwischenspeicher ist Teil der Funktion, kein Feinschliff: WeatherKit rechnet
nach Abrufen ab, und ein Widget, das bei jedem Öffnen des Panels neu lädt,
verbrennt das Kontingent für Daten, die sich stündlich ändern. 15 Minuten
Gültigkeit, und der Schlüssel führt den Ort mit — sonst zeigt das Widget nach
einem Ortswechsel weiter das alte Wetter, überzeugend, weil Zahlen dastehen.

Ein abgelaufener Stand bleibt als "letzter bekannter" erhalten. Bei einem
Netzfehler ist ein alter Messwert mit sichtbarem Zeitstempel ehrlicher und
nützlicher als ein leeres Feld; er wird abgeblendet und mit Warnzeichen gezeigt.

Apples Namensnennung steht im Datenmodell, nicht in einer Notiz: sie ist
Bedingung der Nutzung von WeatherKit und wird sonst beim Bauen der Oberfläche
übersehen. In der 2x1-Kachel als klickbarer Link, in der 1x1 im Tooltip.

Temperaturen ohne Nachkommastellen — die täuschen eine Genauigkeit vor, die
die Vorhersage nicht hat.

NICHT aktiviert: com.apple.developer.weatherkit. Die Entitlement erzwingt ein
echtes Provisioning-Profil, und die App-ID lässt sich nicht registrieren
("cannot be registered to your development team because it is not available").
Bis das im Portal geklärt ist, bleibt sie auskommentiert — die App baut und
läuft, das Widget meldet ehrlich "Wetter nicht abrufbar".

106 Tests grün.
2026-08-10 20:33:32 +02:00

139 lines
5.2 KiB
Swift

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()
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()
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()
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()
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()
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().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()
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()
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)
}
}
@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)
}
}
}