Add 'CalendarrKit/' from commit '9ea1a8705d608b1aa476c0b35685c57466bb0c50'
git-subtree-dir: CalendarrKit git-subtree-mainline:8bc32faa7fgit-subtree-split:9ea1a8705d
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user