Add 'CalendarrKit/' from commit '9ea1a8705d608b1aa476c0b35685c57466bb0c50'

git-subtree-dir: CalendarrKit
git-subtree-mainline: 8bc32faa7f
git-subtree-split: 9ea1a8705d
This commit is contained in:
Scarriffle
2026-10-19 05:34:15 +02:00
21 changed files with 1672 additions and 0 deletions
@@ -0,0 +1,51 @@
import Testing
@testable import CalendarrCore
/// The server reports `calendar_id` two different ways, and normalising them
/// inconsistently is what made per-calendar filtering silently do nothing for
/// some sources. These pin the rule down.
struct CalendarKeyTests {
@Test("CalDAV ids arrive bare and are used as-is")
func bareNumericId() {
#expect(CalendarKey.make(source: "caldav", calendarId: "12") == "caldav:12")
}
@Test("Prefixed ids from the other sources get the prefix stripped")
func strippedPrefix() {
#expect(CalendarKey.make(source: "local", calendarId: "local-3") == "local:3")
#expect(CalendarKey.make(source: "google", calendarId: "google-5") == "google:5")
#expect(CalendarKey.make(source: "ical", calendarId: "ical-7") == "ical:7")
#expect(CalendarKey.make(source: "homeassistant",
calendarId: "homeassistant-9") == "homeassistant:9")
}
@Test("Both spellings of the same calendar produce the same key")
func bothFormsAgree() {
#expect(CalendarKey.make(source: "local", calendarId: "local-3")
== CalendarKey.make(source: "local", calendarId: "3"))
}
@Test("Only a leading prefix is stripped, and only once")
func stripsOnlyLeadingPrefix() {
// A prefix appearing later in the id must survive untouched.
#expect(CalendarKey.make(source: "local", calendarId: "3-local") == "local:3-local")
// Two prefixes: only the first goes.
#expect(CalendarKey.make(source: "local", calendarId: "local-local-3") == "local:local-3")
}
@Test("parse round-trips make")
func roundTrip() {
let key = CalendarKey.make(source: "local", calendarId: "local-42")
let parsed = CalendarKey.parse(key)
#expect(parsed?.source == "local")
#expect(parsed?.id == 42)
}
@Test("parse rejects what it cannot represent")
func rejectsMalformed() {
#expect(CalendarKey.parse("nocolon") == nil)
#expect(CalendarKey.parse("local:abc") == nil) // non-numeric id
#expect(CalendarKey.parse("local:") == nil)
}
}
@@ -0,0 +1,110 @@
import Foundation
import Testing
@testable import CalendarrCore
struct ComplicationTimelineTests {
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
private static func event(_ id: String, startOffset: TimeInterval,
duration: TimeInterval) -> SnapshotEvent {
SnapshotEvent(id: id, title: id,
start: now.addingTimeInterval(startOffset),
end: now.addingTimeInterval(startOffset + duration),
isAllDay: false, colorHex: "#4285f4", location: "", calendarKey: "local:1")
}
private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
CalendarrSnapshot(writtenAt: now,
coverageStart: now.addingTimeInterval(-86400),
coverageEnd: now.addingTimeInterval(14 * 86400),
isLoggedIn: true, writerVersion: "1.0", events: events,
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
line: "#4", primary: "#5", accent: "#6"),
language: "de")
}
@Test("The first entry is always now, so the complication renders immediately")
func startsAtNow() {
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([]))
#expect(dates.first == Self.now)
}
@Test("Entries are sorted and free of duplicates")
func sortedAndDeduplicated() {
let snap = Self.snapshot([Self.event("a", startOffset: 3600, duration: 1800),
Self.event("b", startOffset: 5400, duration: 1800)])
let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap)
#expect(dates == dates.sorted())
#expect(Set(dates).count == dates.count)
}
@Test("Event starts and ends inside the horizon become entries")
func includesEventBoundaries() {
let snap = Self.snapshot([Self.event("a", startOffset: 3600, duration: 1800)])
let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap)
#expect(dates.contains(Self.now.addingTimeInterval(3600)))
#expect(dates.contains(Self.now.addingTimeInterval(5400)))
}
@Test("Boundaries outside the horizon are ignored")
func ignoresBoundariesBeyondHorizon() {
let snap = Self.snapshot([Self.event("far", startOffset: 40 * 3600, duration: 1800)])
let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap, horizon: 24 * 3600)
#expect(dates.contains(Self.now.addingTimeInterval(40 * 3600)) == false)
#expect(dates.allSatisfy { $0 <= Self.now.addingTimeInterval(24 * 3600) })
}
@Test("Boundaries already in the past are ignored")
func ignoresPastBoundaries() {
let snap = Self.snapshot([Self.event("past", startOffset: -7200, duration: 1800)])
let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap)
#expect(dates.allSatisfy { $0 >= Self.now })
}
@Test("An empty calendar still gets hourly entries across the horizon")
func fillsHourlyWhenEmpty() {
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([]),
horizon: 24 * 3600)
#expect(dates.count >= 24)
}
@Test("The first hour gets minute-granular entries, so a compact countdown stays honest")
func minuteGranularFirstHour() {
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([]))
let firstHour = dates.filter { $0 <= Self.now.addingTimeInterval(3600) }
// 60 minute marks plus `now` itself.
#expect(firstHour.count >= 60)
#expect(firstHour.contains(Self.now.addingTimeInterval(60)))
#expect(firstHour.contains(Self.now.addingTimeInterval(1800)))
}
@Test("Past the first hour the entries thin out to hourly")
func hourlyAfterFirstHour() {
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([]))
let later = dates.filter { $0 > Self.now.addingTimeInterval(3600) }
// 23 further hours, not 23 * 60 minutes.
#expect(later.count < 40)
#expect(later.contains(Self.now.addingTimeInterval(2 * 3600)))
}
@Test("The entry count is capped, keeping the earliest entries")
func capsEntries() {
var events: [SnapshotEvent] = []
for i in 0..<300 {
events.append(Self.event("e\(i)", startOffset: Double(i) * 60, duration: 30))
}
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot(events),
maxEntries: 50)
#expect(dates.count == 50)
#expect(dates.first == Self.now)
#expect(dates == dates.sorted())
}
}
@@ -0,0 +1,51 @@
import Foundation
import Testing
@testable import CalendarrCore
struct SnapshotFilterTests {
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
private static func event(_ id: String, key: String) -> SnapshotEvent {
SnapshotEvent(id: id, title: id, start: now, end: now.addingTimeInterval(3600),
isAllDay: false, colorHex: "#4285f4", location: "", calendarKey: key)
}
private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
CalendarrSnapshot(writtenAt: now,
coverageStart: now.addingTimeInterval(-86400),
coverageEnd: now.addingTimeInterval(86400),
isLoggedIn: true, writerVersion: "1.0", events: events,
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
line: "#4", primary: "#5", accent: "#6"),
language: "de")
}
@Test("An empty allow-list means every calendar, not no calendar")
func emptyAllowListKeepsEverything() {
let snap = Self.snapshot([Self.event("a", key: "local:1"), Self.event("b", key: "caldav:2")])
#expect(snap.filtered(toCalendarKeys: []).events.count == 2)
}
@Test("An allow-list keeps only the listed calendars")
func allowListNarrows() {
let snap = Self.snapshot([Self.event("a", key: "local:1"), Self.event("b", key: "caldav:2")])
#expect(snap.filtered(toCalendarKeys: ["local:1"]).events.map(\.id) == ["a"])
}
@Test("A deny-list removes only the listed calendars")
func denyListNarrows() {
let snap = Self.snapshot([Self.event("a", key: "local:1"), Self.event("b", key: "caldav:2")])
#expect(snap.filtered(excludingCalendarKeys: ["local:1"]).events.map(\.id) == ["b"])
}
@Test("Filtering narrows the events but never the window")
func windowIsUntouched() {
let snap = Self.snapshot([Self.event("a", key: "local:1")])
let filtered = snap.filtered(toCalendarKeys: ["nothing:0"])
#expect(filtered.events.isEmpty)
#expect(filtered.coverageStart == snap.coverageStart)
#expect(filtered.coverageEnd == snap.coverageEnd)
}
}
@@ -0,0 +1,172 @@
import Foundation
import Testing
@testable import CalendarrCore
/// The wire format is a contract between apps that ship independently, so both
/// directions of compatibility need a test: a new reader must understand an old
/// file, and an old reader must survive a new one.
struct SnapshotSchemaTests {
private static func decoder() -> JSONDecoder {
let d = JSONDecoder(); d.dateDecodingStrategy = .iso8601; return d
}
private static func encoder() -> JSONEncoder {
let e = JSONEncoder(); e.dateEncodingStrategy = .iso8601; return e
}
/// A real v1 file: no schemaVersion, no coverage window, no isLoggedIn,
/// colours flat at the top level.
private static let v1Fixture = """
{
"writtenAt": "2026-08-10T12:00:00Z",
"events": [
{
"id": "abc",
"title": "Zahnarzt",
"start": "2026-08-11T09:00:00Z",
"end": "2026-08-11T10:00:00Z",
"isAllDay": false,
"colorHex": "#58B900",
"location": "Bern",
"calendarKey": "local:3"
}
],
"todayColorHex": "#6FB669",
"textColorHex": "#FFFFFF",
"backgroundColorHex": "#000000",
"lineColorHex": "#3B3B3D",
"primaryColorHex": "#58B900",
"accentColorHex": "#45A148",
"language": "de"
}
"""
@Test("A v1 file decodes, and its missing fields get sane defaults")
func decodesV1() throws {
let snap = try Self.decoder().decode(
CalendarrSnapshot.self, from: Data(Self.v1Fixture.utf8))
#expect(snap.schemaVersion == 1)
#expect(snap.events.count == 1)
#expect(snap.events[0].calendarKey == "local:3")
#expect(snap.language == "de")
#expect(snap.theme.today == "#6FB669")
#expect(snap.theme.accent == "#45A148")
// v1 never said whether anyone was signed in; assuming yes preserves the
// old behaviour of simply rendering whatever was cached.
#expect(snap.isLoggedIn)
#expect(snap.writerVersion == "unknown")
// The window is reconstructed from the policy v1 always used.
let cal = Calendar(identifier: .gregorian)
let dayStart = cal.startOfDay(for: snap.writtenAt)
#expect(snap.coverageStart == cal.date(byAdding: .day, value: -7, to: dayStart))
#expect(snap.coverageEnd == cal.date(byAdding: .day, value: 42, to: dayStart))
}
/// Older still: written before per-calendar filtering existed, so the events
/// carry no calendarKey at all.
private static let v1FixtureNoCalendarKey = """
{
"writtenAt": "2026-08-10T12:00:00Z",
"events": [
{
"id": "abc",
"title": "Zahnarzt",
"start": "2026-08-11T09:00:00Z",
"end": "2026-08-11T10:00:00Z",
"isAllDay": false,
"colorHex": "#58B900",
"location": "Bern"
}
],
"todayColorHex": "#6FB669",
"textColorHex": "#FFFFFF",
"backgroundColorHex": "#000000",
"lineColorHex": "#3B3B3D",
"language": "de"
}
"""
@Test("A v1 file that predates calendarKey and the newer colours still decodes")
func decodesV1WithoutCalendarKey() throws {
let snap = try Self.decoder().decode(
CalendarrSnapshot.self, from: Data(Self.v1FixtureNoCalendarKey.utf8))
#expect(snap.events[0].calendarKey == "")
// primary/accent were added later too, so they fall back to defaults.
#expect(snap.theme.primary == "#4285f4")
#expect(snap.theme.accent == "#ea4335")
}
@Test("v2 round-trips without losing anything")
func roundTripsV2() throws {
let start = Date(timeIntervalSince1970: 1_780_000_000)
let original = CalendarrSnapshot(
writtenAt: start,
coverageStart: start.addingTimeInterval(-7 * 86_400),
coverageEnd: start.addingTimeInterval(42 * 86_400),
isLoggedIn: true,
writerVersion: "3.5",
events: [SnapshotEvent(id: "x", title: "Termin", start: start,
end: start.addingTimeInterval(3600),
isAllDay: false, colorHex: "#58B900",
location: "", calendarKey: "local:1")],
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
line: "#4", primary: "#5", accent: "#6"),
language: "ch")
let decoded = try Self.decoder().decode(
CalendarrSnapshot.self, from: Self.encoder().encode(original))
#expect(decoded.schemaVersion == CalendarrSnapshot.currentSchema)
#expect(decoded.writerVersion == "3.5")
#expect(decoded.language == "ch")
#expect(decoded.coverageStart == original.coverageStart)
#expect(decoded.coverageEnd == original.coverageEnd)
#expect(decoded.theme == original.theme)
#expect(decoded.events == original.events)
}
/// The reason the colours are stored flat rather than nested under `theme`:
/// a v1 reader requires them at the top level, so nesting them would have
/// broken every older build on the first v2 write.
@Test("v2 keeps the colour keys where a v1 reader expects them")
func v2StaysReadableByV1() throws {
let start = Date(timeIntervalSince1970: 1_780_000_000)
let snap = CalendarrSnapshot(
writtenAt: start, coverageStart: start, coverageEnd: start,
isLoggedIn: true, writerVersion: "3.5", events: [],
theme: SnapshotTheme(today: "#a", text: "#b", background: "#c",
line: "#d", primary: "#e", accent: "#f"),
language: "de")
let object = try JSONSerialization.jsonObject(
with: Self.encoder().encode(snap)) as? [String: Any]
for key in ["todayColorHex", "textColorHex", "backgroundColorHex",
"lineColorHex", "primaryColorHex", "accentColorHex",
"writtenAt", "events", "language"] {
#expect(object?[key] != nil, "v1 readers require \(key) at the top level")
}
#expect(object?["theme"] == nil, "theme must stay a computed view, not a stored key")
}
@Test("covers() separates 'no events' from 'no information'")
func coverageWindow() {
let now = Date(timeIntervalSince1970: 1_780_000_000)
let snap = CalendarrSnapshot(
writtenAt: now,
coverageStart: now.addingTimeInterval(-86_400),
coverageEnd: now.addingTimeInterval(86_400),
isLoggedIn: true, writerVersion: "3.5", events: [],
theme: SnapshotTheme(today: "", text: "", background: "",
line: "", primary: "", accent: ""),
language: "de")
#expect(snap.covers(now))
#expect(snap.covers(now.addingTimeInterval(-86_399)))
#expect(!snap.covers(now.addingTimeInterval(-90_000)))
#expect(!snap.covers(snap.coverageEnd)) // half-open: end is excluded
}
}
@@ -0,0 +1,76 @@
import Foundation
import Testing
@testable import CalendarrCore
struct WatchPushPolicyTests {
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
private static func event(_ id: String, startOffset: TimeInterval,
duration: TimeInterval = 3600) -> SnapshotEvent {
SnapshotEvent(id: id, title: id,
start: now.addingTimeInterval(startOffset),
end: now.addingTimeInterval(startOffset + duration),
isAllDay: false, colorHex: "#4285f4", location: "", calendarKey: "local:1")
}
private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
CalendarrSnapshot(writtenAt: now,
coverageStart: now.addingTimeInterval(-86400),
coverageEnd: now.addingTimeInterval(14 * 86400),
isLoggedIn: true, writerVersion: "1.0", events: events,
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
line: "#4", primary: "#5", accent: "#6"),
language: "de")
}
@Test("The next event is the first one that has not ended yet")
func picksFirstUnfinished() {
let snap = Self.snapshot([Self.event("past", startOffset: -7200),
Self.event("running", startOffset: -600),
Self.event("later", startOffset: 3600)])
#expect(WatchPushPolicy.nextEventIdentity(in: snap, at: Self.now)?.id == "running")
}
@Test("An empty calendar has no next event")
func emptyHasNoIdentity() {
#expect(WatchPushPolicy.nextEventIdentity(in: Self.snapshot([]), at: Self.now) == nil)
}
@Test("Events are considered in start order regardless of input order")
func sortsBeforePicking() {
let snap = Self.snapshot([Self.event("late", startOffset: 7200),
Self.event("soon", startOffset: 1800)])
#expect(WatchPushPolicy.nextEventIdentity(in: snap, at: Self.now)?.id == "soon")
}
@Test("An unchanged next event does not spend a push")
func unchangedDoesNotPush() {
let identity = NextEventIdentity(id: "a", start: Self.now)
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: identity, current: identity) == false)
}
@Test("A different event, a moved event, or a first event all spend a push")
func changesPush() {
let a = NextEventIdentity(id: "a", start: Self.now)
let b = NextEventIdentity(id: "b", start: Self.now)
let moved = NextEventIdentity(id: "a", start: Self.now.addingTimeInterval(600))
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: a, current: b))
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: a, current: moved))
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: nil, current: a))
}
@Test("Losing the last event spends a push, so the watch stops showing it")
func clearingPushes() {
let a = NextEventIdentity(id: "a", start: Self.now)
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: a, current: nil))
}
@Test("Two empty calendars in a row do not push")
func stayingEmptyDoesNotPush() {
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: nil, current: nil) == false)
}
}
@@ -0,0 +1,81 @@
import Foundation
import Testing
@testable import CalendarrCore
/// The phone and the watch ship on independent schedules, so the transport gets
/// the same treatment as the file format: refuse a newer schema rather than
/// mis-decode it.
struct WatchTransportTests {
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
private static func snapshot() -> CalendarrSnapshot {
CalendarrSnapshot(writtenAt: now,
coverageStart: now.addingTimeInterval(-86400),
coverageEnd: now.addingTimeInterval(14 * 86400),
isLoggedIn: true, writerVersion: "1.2",
events: [SnapshotEvent(id: "a", title: "Zahnarzt",
start: now.addingTimeInterval(3600),
end: now.addingTimeInterval(7200),
isAllDay: false, colorHex: "#4285f4",
location: "Bern", calendarKey: "local:3")],
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
line: "#4", primary: "#5", accent: "#6"),
language: "de")
}
@Test("A payload survives a round trip")
func roundTrips() throws {
let payload = WatchTransportPayload(
snapshot: Self.snapshot(),
calendars: [SnapshotCalendar(id: "local:3", name: "Privat", colorHex: "#4285f4")],
session: SharedSession(baseURL: "https://cal.example.com", username: "guido",
isLoggedIn: true, writtenAt: Self.now))
let decoded = try WatchTransportPayload.decode(try payload.encoded())
#expect(decoded.snapshot.events.map(\.id) == ["a"])
#expect(decoded.snapshot.language == "de")
#expect(decoded.calendars.map(\.name) == ["Privat"])
#expect(decoded.session?.username == "guido")
}
@Test("A payload with no session record still decodes")
func sessionIsOptional() throws {
let payload = WatchTransportPayload(snapshot: Self.snapshot(), calendars: [], session: nil)
let decoded = try WatchTransportPayload.decode(try payload.encoded())
#expect(decoded.session == nil)
#expect(decoded.calendars.isEmpty)
}
@Test("The encoded form only contains property-list types")
func encodesToPlistTypes() throws {
let dict = try WatchTransportPayload(snapshot: Self.snapshot(), calendars: [], session: nil).encoded()
#expect(dict[WatchTransportKey.schema] is Int)
#expect(dict[WatchTransportKey.snapshot] is Data)
#expect(PropertyListSerialization.propertyList(dict, isValidFor: .binary))
}
@Test("A newer schema is refused rather than guessed at")
func refusesNewerSchema() throws {
var dict = try WatchTransportPayload(snapshot: Self.snapshot(), calendars: [], session: nil).encoded()
dict[WatchTransportKey.schema] = WatchTransportPayload.maxSupportedSchema + 1
#expect(throws: WatchTransportError.unsupportedSchema(
found: WatchTransportPayload.maxSupportedSchema + 1,
maxSupported: WatchTransportPayload.maxSupportedSchema)) {
try WatchTransportPayload.decode(dict)
}
}
@Test("A payload without a snapshot is rejected")
func rejectsMissingSnapshot() {
let dict: [String: Any] = [WatchTransportKey.schema: WatchTransportPayload.currentSchema]
#expect(throws: WatchTransportError.missingSnapshot) {
try WatchTransportPayload.decode(dict)
}
}
}
@@ -0,0 +1,118 @@
import Foundation
import Testing
@testable import CalendarrCore
/// The watch payload is a narrower snapshot, not a different one. These tests
/// pin down the part that is easy to get wrong: the coverage window has to
/// shrink with the events, or the watch renders an empty week it knows nothing
/// about.
struct WatchTrimmingTests {
private static let cal = Calendar(identifier: .gregorian)
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
private static func event(_ id: String, dayOffset: Int, hour: Int = 9) -> SnapshotEvent {
let dayStart = cal.startOfDay(for: now)
let day = cal.date(byAdding: .day, value: dayOffset, to: dayStart)!
let start = cal.date(byAdding: .hour, value: hour, to: day)!
return SnapshotEvent(id: id, title: "Event \(id)",
start: start, end: start.addingTimeInterval(3600),
isAllDay: false, colorHex: "#4285f4",
location: "", calendarKey: "local:1")
}
private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
let dayStart = cal.startOfDay(for: now)
return CalendarrSnapshot(
writtenAt: now,
coverageStart: cal.date(byAdding: .day, value: -SnapshotCoverage.daysBehind, to: dayStart)!,
coverageEnd: cal.date(byAdding: .day, value: SnapshotCoverage.daysAhead, to: dayStart)!,
isLoggedIn: true,
writerVersion: "1.0",
events: events,
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
line: "#4", primary: "#5", accent: "#6"),
language: "de")
}
@Test("The window narrows to the watch policy, not just the events")
func narrowsWindow() {
let trimmed = Self.snapshot([]).trimmedForWatch(now: Self.now, calendar: Self.cal)
let dayStart = Self.cal.startOfDay(for: Self.now)
#expect(trimmed.coverageStart == Self.cal.date(byAdding: .day, value: -WatchCoverage.daysBehind, to: dayStart))
#expect(trimmed.coverageEnd == Self.cal.date(byAdding: .day, value: WatchCoverage.daysAhead, to: dayStart))
}
@Test("Events outside the watch window are dropped")
func dropsEventsOutsideWindow() {
let inside = Self.event("in", dayOffset: 3)
let outside = Self.event("out", dayOffset: 30)
let trimmed = Self.snapshot([inside, outside]).trimmedForWatch(now: Self.now, calendar: Self.cal)
#expect(trimmed.events.map(\.id) == ["in"])
}
@Test("An event still running at the window start is kept")
func keepsRunningEvent() {
let dayStart = Self.cal.startOfDay(for: Self.now)
let start = Self.cal.date(byAdding: .day, value: -5, to: dayStart)!
let running = SnapshotEvent(id: "running", title: "Urlaub",
start: start,
end: Self.cal.date(byAdding: .day, value: 5, to: dayStart)!,
isAllDay: true, colorHex: "#4285f4",
location: "", calendarKey: "local:1")
let trimmed = Self.snapshot([running]).trimmedForWatch(now: Self.now, calendar: Self.cal)
#expect(trimmed.events.map(\.id) == ["running"])
}
@Test("Metadata survives trimming unchanged")
func preservesMetadata() {
let original = Self.snapshot([Self.event("a", dayOffset: 1)])
let trimmed = original.trimmedForWatch(now: Self.now, calendar: Self.cal)
#expect(trimmed.language == "de")
#expect(trimmed.isLoggedIn == true)
#expect(trimmed.writerVersion == "1.0")
#expect(trimmed.theme.accent == "#6")
#expect(trimmed.writtenAt == original.writtenAt)
#expect(trimmed.schemaVersion == CalendarrSnapshot.currentSchema)
}
@Test("When the event cap truncates, coverageEnd pulls back to what we can prove")
func capPullsBackCoverage() {
var many: [SnapshotEvent] = []
for day in 0..<14 {
for hour in 8..<18 { many.append(Self.event("d\(day)h\(hour)", dayOffset: day, hour: hour)) }
}
#expect(many.count > WatchCoverage.maxEvents)
let trimmed = Self.snapshot(many).trimmedForWatch(now: Self.now, calendar: Self.cal)
#expect(trimmed.events.count <= WatchCoverage.maxEvents)
#expect(trimmed.events.allSatisfy { $0.start < trimmed.coverageEnd })
let dayStart = Self.cal.startOfDay(for: Self.now)
let fullEnd = Self.cal.date(byAdding: .day, value: WatchCoverage.daysAhead, to: dayStart)!
#expect(trimmed.coverageEnd < fullEnd)
#expect(trimmed.coverageEnd > trimmed.coverageStart)
}
@Test("Trimming never widens a window the writer already narrowed")
func neverWidens() {
let dayStart = Self.cal.startOfDay(for: Self.now)
let narrow = CalendarrSnapshot(
writtenAt: Self.now,
coverageStart: dayStart,
coverageEnd: Self.cal.date(byAdding: .day, value: 2, to: dayStart)!,
isLoggedIn: true, writerVersion: "1.0", events: [],
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
line: "#4", primary: "#5", accent: "#6"),
language: "de")
let trimmed = narrow.trimmedForWatch(now: Self.now, calendar: Self.cal)
#expect(trimmed.coverageStart == dayStart)
#expect(trimmed.coverageEnd == Self.cal.date(byAdding: .day, value: 2, to: dayStart))
}
}