Calendarr liefert Termine über den Gruppencontainer. Streng lesend: keine eigene Anmeldung, kein Netz, kein Schreiben zurück. Die Abdeckung ist der Kern und kein Detail. Der Schnappschuss reicht 49 Tage nach vorne, und das steht nirgends in der Datei — es ist zugesagtes Verhalten des Schreibers. Außerhalb liegt keine Information vor, nicht keine Termine; das Monatsraster graut solche Tage aus, statt „frei" zu behaupten. Verlockend wäre, die Abdeckung aus dem frühesten Termin abzuleiten. Das ist falsch, und ein Test hält es fest: mehrtägige Termine ragen ins Fenster hinein und werden mitgeschrieben, obwohl ihr Anfang nicht abgedeckt ist. Bei „beide Quellen" ist die Abdeckung der Schnitt, nicht die Vereinigung. Ein Tag gilt nur als bekannt, wenn ihn jede Quelle abdeckt. Umgekehrt darf ein Ausfall einer Quelle die andere nicht mitreißen: Calendarr nie geöffnet zu haben knipst den Apple-Kalender nicht aus. Die Ablage kopiert, statt zu verweisen. Anhänge aus Mail oder WhatsApp liegen in temporären Verzeichnissen — ein Verweis wäre in Stunden tot, und die Ablage wäre leer, wenn man sie braucht. Aus demselben Grund nimmt die Fläche UTType.item statt public.file-url an: solche Anhänge kommen als Dateiversprechen, und loadFileRepresentation löst beides ein. Die übergebene Datei existiert nur innerhalb des Rückrufs, also wird sie dort sofort gesichert statt später auf dem MainActor. Gelöscht wird in den Papierkorb. Automatisches Aufräumen ist standardmäßig aus — eine Ablage, die ungefragt löscht, ist ein Papierkorb mit Zeitschaltuhr. Nebenbei: „mixer.on" und „network.vpn.active" standen als rohe Schlüssel in der Oberfläche. Label(_:systemImage:) kennt kein Bundle und sucht immer im Hauptbundle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
199 lines
8.8 KiB
Swift
199 lines
8.8 KiB
Swift
import Testing
|
|
import Foundation
|
|
@testable import CalendarProvider
|
|
|
|
// Calendarr schreibt einen Schnappschuss in den Gruppencontainer. Onyx liest
|
|
// ihn — und nur ihn: keine eigene Anmeldung, kein eigenes Netz, kein Schreiben.
|
|
// Die Regeln hier sind die aus der Integrationsanleitung, als Test festgehalten
|
|
// statt als Konvention.
|
|
|
|
private func snapshotJSON(writtenAt: String = "2026-08-10T15:22:56Z",
|
|
events: String = "[]",
|
|
extra: String = "") -> Data {
|
|
Data("""
|
|
{
|
|
"language": "system",
|
|
"textColorHex": "#FFFFFF",
|
|
"primaryColorHex": "#58B900",
|
|
"backgroundColorHex": "#000000",
|
|
"todayColorHex": "#6FB669",
|
|
"lineColorHex": "#3B3B3D",
|
|
"accentColorHex": "#45A148",
|
|
"writtenAt": "\(writtenAt)",
|
|
"events": \(events)\(extra)
|
|
}
|
|
""".utf8)
|
|
}
|
|
|
|
private let oneEvent = """
|
|
[{"id": "abc", "title": "Hasen hüten", "start": "2026-08-12T08:00:00Z",
|
|
"end": "2026-08-12T09:00:00Z", "isAllDay": false, "location": "",
|
|
"calendarKey": "local:2", "colorHex": "#43af6a"}]
|
|
"""
|
|
|
|
@Suite("Calendarr-Schnappschuss")
|
|
struct CalendarrSnapshotTests {
|
|
|
|
@Test("Ein gültiger Schnappschuss wird gelesen")
|
|
func readsValidSnapshot() throws {
|
|
let state = CalendarrSnapshotReader.state(fromSnapshot: snapshotJSON(events: oneEvent),
|
|
calendars: nil)
|
|
guard case .ready(let window) = state else {
|
|
Issue.record("Erwartet .ready, bekommen \(state)"); return
|
|
}
|
|
#expect(window.events.count == 1)
|
|
#expect(window.events.first?.title == "Hasen hüten")
|
|
#expect(window.events.first?.colorHex == "#43af6a")
|
|
#expect(window.asOf != nil)
|
|
}
|
|
|
|
@Test("Ein leerer Ort ist kein Ort")
|
|
func emptyLocationIsNil() throws {
|
|
let state = CalendarrSnapshotReader.state(fromSnapshot: snapshotJSON(events: oneEvent),
|
|
calendars: nil)
|
|
guard case .ready(let window) = state else { Issue.record("nicht bereit"); return }
|
|
// Sonst zeigt das Widget eine leere Ortszeile unter jedem Termin.
|
|
#expect(window.events.first?.location == nil)
|
|
}
|
|
|
|
@Test("Der Kalendername kommt aus der Kalenderliste")
|
|
func joinsCalendarTitle() throws {
|
|
let calendars = Data("""
|
|
[{"id": "local:2", "name": "Persönlich", "colorHex": "#43af6a"}]
|
|
""".utf8)
|
|
let state = CalendarrSnapshotReader.state(fromSnapshot: snapshotJSON(events: oneEvent),
|
|
calendars: calendars)
|
|
guard case .ready(let window) = state else { Issue.record("nicht bereit"); return }
|
|
#expect(window.events.first?.calendarTitle == "Persönlich")
|
|
#expect(window.events.first?.calendarID == "local:2")
|
|
}
|
|
|
|
@Test("Ohne Kalenderliste bleibt die Kennung stehen")
|
|
func withoutCalendarsKeepsKey() throws {
|
|
let state = CalendarrSnapshotReader.state(fromSnapshot: snapshotJSON(events: oneEvent),
|
|
calendars: nil)
|
|
guard case .ready(let window) = state else { Issue.record("nicht bereit"); return }
|
|
#expect(window.events.first?.calendarID == "local:2")
|
|
}
|
|
|
|
@Test("Keine Datei heißt: noch nie geschrieben")
|
|
func missingFileIsNeverWritten() {
|
|
// Nicht `.unreadable`: Calendarr ist vielleicht installiert, aber noch
|
|
// nie gestartet worden. Der richtige Hinweis lautet „einmal öffnen".
|
|
#expect(CalendarrSnapshotReader.state(fromSnapshot: nil, calendars: nil)
|
|
== .neverWritten)
|
|
}
|
|
|
|
@Test("Unlesbares bleibt unlesbar und wird nicht als leer ausgegeben")
|
|
func garbageIsUnreadable() {
|
|
let state = CalendarrSnapshotReader.state(fromSnapshot: Data("kein JSON".utf8),
|
|
calendars: nil)
|
|
guard case .unreadable = state else {
|
|
Issue.record("Erwartet .unreadable, bekommen \(state)"); return
|
|
}
|
|
}
|
|
|
|
@Test("Ein Schnappschuss ohne Termine ist bereit, nicht kaputt")
|
|
func emptyEventsIsReady() {
|
|
// Der wichtige Unterschied: „nichts los" ist eine Auskunft,
|
|
// „nichts bekannt" ist keine. Hier liegt eine vor.
|
|
let state = CalendarrSnapshotReader.state(fromSnapshot: snapshotJSON(), calendars: nil)
|
|
guard case .ready(let window) = state else {
|
|
Issue.record("Erwartet .ready, bekommen \(state)"); return
|
|
}
|
|
#expect(window.events.isEmpty)
|
|
#expect(window.coverage != nil)
|
|
}
|
|
|
|
@Test("Ein zu neues Format wird als solches gemeldet")
|
|
func futureVersionIsIncompatible() {
|
|
// Lieber „bitte Onyx aktualisieren" als Felder still zu verschlucken,
|
|
// deren Bedeutung sich geändert hat.
|
|
let data = snapshotJSON(extra: ", \"version\": 99")
|
|
guard case .incompatible(let found, let max) =
|
|
CalendarrSnapshotReader.state(fromSnapshot: data, calendars: nil) else {
|
|
Issue.record("Erwartet .incompatible"); return
|
|
}
|
|
#expect(found == 99)
|
|
#expect(max == CalendarrSnapshotReader.maxSupportedVersion)
|
|
}
|
|
|
|
@Test("Die heutige Fassung ohne Versionsfeld gilt als Version 1")
|
|
func missingVersionIsVersionOne() {
|
|
let state = CalendarrSnapshotReader.state(fromSnapshot: snapshotJSON(), calendars: nil)
|
|
guard case .ready = state else { Issue.record("Erwartet .ready"); return }
|
|
}
|
|
|
|
@Test("Abgemeldet verwirft die alten Termine")
|
|
func loggedOutDropsEvents() {
|
|
// Alte Termine eines abgemeldeten Kontos weiter anzuzeigen wäre die
|
|
// schlimmste Variante: sie sehen aktuell aus und sind es nicht.
|
|
let data = snapshotJSON(events: oneEvent, extra: ", \"loggedOut\": true")
|
|
guard case .loggedOut(let asOf) =
|
|
CalendarrSnapshotReader.state(fromSnapshot: data, calendars: nil) else {
|
|
Issue.record("Erwartet .loggedOut"); return
|
|
}
|
|
#expect(asOf != nil)
|
|
}
|
|
}
|
|
|
|
@Suite("Abdeckung des Calendarr-Fensters")
|
|
struct CalendarrCoverageTests {
|
|
|
|
private let writtenAt = ISO8601DateFormatter().date(from: "2026-08-10T15:22:56Z")!
|
|
private let calendar = Calendar(identifier: .gregorian)
|
|
|
|
@Test("Die Abdeckung beginnt am Tag des Schreibens")
|
|
func coverageStartsAtWriteDay() {
|
|
let coverage = CalendarrSnapshotReader.coverage(writtenAt: writtenAt, calendar: calendar)
|
|
#expect(coverage.start == calendar.startOfDay(for: writtenAt))
|
|
}
|
|
|
|
@Test("Die Abdeckung endet nach dem bekannten Fenster")
|
|
func coverageEndsAfterWindow() {
|
|
let coverage = CalendarrSnapshotReader.coverage(writtenAt: writtenAt, calendar: calendar)
|
|
let expected = calendar.date(byAdding: .day,
|
|
value: CalendarrSnapshotReader.coverageDays,
|
|
to: calendar.startOfDay(for: writtenAt))!
|
|
#expect(coverage.end == expected)
|
|
}
|
|
|
|
@Test("Vergangene Tage gelten als unbekannt, nicht als frei")
|
|
func pastIsUnknown() {
|
|
// Der eigentliche Punkt. Calendarr schreibt nach vorne. Ein Tag vor dem
|
|
// Schreibzeitpunkt ohne Termine kann bedeuten „war nichts" — oder
|
|
// „wurde nie geschrieben". Beides sieht gleich aus, also wird es nicht
|
|
// als frei behauptet.
|
|
let coverage = CalendarrSnapshotReader.coverage(writtenAt: writtenAt, calendar: calendar)
|
|
let yesterday = calendar.date(byAdding: .day, value: -1, to: writtenAt)!
|
|
#expect(!coverage.contains(yesterday))
|
|
}
|
|
|
|
@Test("Ein weit entfernter Termin liegt außerhalb der Abdeckung")
|
|
func farFutureIsOutside() {
|
|
let coverage = CalendarrSnapshotReader.coverage(writtenAt: writtenAt, calendar: calendar)
|
|
let far = calendar.date(byAdding: .day, value: 120, to: writtenAt)!
|
|
#expect(!coverage.contains(far))
|
|
}
|
|
|
|
@Test("Ein mehrtägiger Termin von früher weitet die Abdeckung nicht aus")
|
|
func overlappingEventDoesNotWidenCoverage() {
|
|
// Verlockend wäre, die Abdeckung aus dem frühesten Termin abzuleiten.
|
|
// Das ist falsch: mehrtägige Termine ragen in das Fenster hinein und
|
|
// werden mitgeschrieben, obwohl ihr Anfang gar nicht abgedeckt ist.
|
|
let events = """
|
|
[{"id": "x", "title": "Urlaub", "start": "2026-07-17T22:00:00Z",
|
|
"end": "2026-08-12T22:00:00Z", "isAllDay": true, "location": "",
|
|
"calendarKey": "local:2", "colorHex": "#000000"}]
|
|
"""
|
|
let state = CalendarrSnapshotReader.state(
|
|
fromSnapshot: snapshotJSON(events: events), calendars: nil,
|
|
calendar: calendar)
|
|
guard case .ready(let window) = state, let coverage = window.coverage else {
|
|
Issue.record("Erwartet .ready mit Abdeckung"); return
|
|
}
|
|
let julyDay = ISO8601DateFormatter().date(from: "2026-07-20T12:00:00Z")!
|
|
#expect(!coverage.contains(julyDay))
|
|
}
|
|
}
|