Phase 8: Ablage, AirDrop und Calendarr

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>
This commit is contained in:
Scarriffle
2026-08-11 10:16:19 +02:00
parent 28b1b4270e
commit 8308536227
25 changed files with 2917 additions and 868 deletions

View File

@@ -0,0 +1,123 @@
import Testing
import Foundation
@testable import CalendarProvider
private let day: TimeInterval = 86_400
private let base = Date(timeIntervalSince1970: 1_770_000_000)
private func event(_ id: String, at offset: TimeInterval) -> OnyxEvent {
OnyxEvent(id: id, title: id, start: base + offset, end: base + offset + 3600,
isAllDay: false, colorHex: nil, location: nil,
calendarID: "c", calendarTitle: "C")
}
@Suite("Beide Quellen zusammen")
struct SourceMergeTests {
@Test("Termine beider Quellen erscheinen, nach Zeit sortiert")
func mergesAndSorts() {
let apple = EventWindow(events: [event("spät", at: 2 * day)])
let calendarr = EventWindow(events: [event("früh", at: day)],
coverage: DateInterval(start: base, duration: 49 * day))
let merged = EventWindow.merged([apple, calendarr])
#expect(merged.events.map(\.id) == ["früh", "spät"])
}
@Test("Die Abdeckung ist der Schnitt, nicht die Vereinigung")
func coverageIsIntersection() {
// Der Kern: ein Tag gilt nur dann als bekannt, wenn ihn **jede**
// aktive Quelle abdeckt. Deckt Calendarr den März nicht ab, dann ist
// der März unbekannt auch wenn der Apple-Kalender dafür Auskunft
// geben könnte. Sonst zeigt das Raster einen halben März als frei".
let unbounded = EventWindow(events: [], coverage: nil)
let narrow = EventWindow(events: [],
coverage: DateInterval(start: base, duration: 49 * day))
let merged = EventWindow.merged([unbounded, narrow])
#expect(merged.coverage == DateInterval(start: base, duration: 49 * day))
}
@Test("Zwei begrenzte Quellen ergeben die gemeinsame Schnittmenge")
func twoBoundedIntersect() {
let a = EventWindow(events: [], coverage: DateInterval(start: base, duration: 30 * day))
let b = EventWindow(events: [],
coverage: DateInterval(start: base + 10 * day, duration: 30 * day))
let merged = EventWindow.merged([a, b])
#expect(merged.coverage?.start == base + 10 * day)
#expect(merged.coverage?.end == base + 30 * day)
}
@Test("Ohne Überschneidung ist nichts abgedeckt")
func disjointCoverageIsEmpty() {
let a = EventWindow(events: [], coverage: DateInterval(start: base, duration: day))
let b = EventWindow(events: [],
coverage: DateInterval(start: base + 10 * day, duration: day))
let merged = EventWindow.merged([a, b])
#expect(merged.coverage?.duration == 0)
}
@Test("Nur unbegrenzte Quellen bleiben unbegrenzt")
func allUnboundedStaysUnbounded() {
let merged = EventWindow.merged([EventWindow(events: []), EventWindow(events: [])])
#expect(merged.coverage == nil)
}
@Test("Der Stand ist der älteste — er begrenzt die Verlässlichkeit")
func asOfIsTheOldest() {
// Zeigt man den neueren Stand, behauptet man eine Aktualität, die
// die andere Quelle nicht hat.
let a = EventWindow(events: [], asOf: base)
let b = EventWindow(events: [], asOf: base + day)
#expect(EventWindow.merged([a, b]).asOf == base)
}
@Test("Eine einzelne Quelle bleibt unverändert")
func singleIsUnchanged() {
let only = EventWindow(events: [event("x", at: 0)],
coverage: DateInterval(start: base, duration: day), asOf: base)
#expect(EventWindow.merged([only]) == only)
}
@Test("Gar keine Quelle ergibt leer, nicht abgedeckt")
func noneIsEmpty() {
#expect(EventWindow.merged([]) == .empty)
}
}
@Suite("Zustand beider Quellen")
struct SourceStateMergeTests {
@Test("Ein Fehler einer Quelle verdeckt die andere nicht")
func oneFailureKeepsTheOther() {
// Calendarr nie gestartet zu haben darf den Apple-Kalender nicht
// ausknipsen sonst ist beide" schlechter als jede einzelne.
let merged = CalendarSourceKind.merge([.ready(EventWindow(events: [event("a", at: 0)])),
.neverWritten])
guard case .ready(let window) = merged else {
Issue.record("Erwartet .ready, bekommen \(merged)"); return
}
#expect(window.events.count == 1)
}
@Test("Fällt eine Quelle aus, gilt nur noch die Abdeckung der anderen")
func failedSourceContributesNoCoverage() {
let good = EventWindow(events: [],
coverage: DateInterval(start: base, duration: 10 * day))
guard case .ready(let window) =
CalendarSourceKind.merge([.ready(good), .denied]) else {
Issue.record("Erwartet .ready"); return
}
#expect(window.coverage == good.coverage)
}
@Test("Fallen alle Quellen aus, wird der erste Grund gemeldet")
func allFailedReportsFirstReason() {
// Irgendeine Begründung ist besser als eine leere Liste, die aussieht,
// als wäre nichts los.
#expect(CalendarSourceKind.merge([.denied, .neverWritten]) == .denied)
}
@Test("Ohne Quellen ist der Zustand leer und bereit")
func noSourcesIsReadyEmpty() {
#expect(CalendarSourceKind.merge([]) == .ready(.empty))
}
}