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)) } }