- Sync reminders: new "Sync reminders when mirroring" option (global and per-route) copies source event alarms into mirrored placeholders. CLI flag --sync-reminders. - CLI polish: --help, --list-calendars, --status (all support --json). Scheduled/headless runs record last-run time/result/summary. Failure paths now exit with distinct nonzero codes (2 = no calendar access, 3 = no saved routes) instead of always exiting 0. - Fix settings silently wiped on upgrade: SettingsPayload's auto-synthesized Codable threw on any settings blob missing a field added since (e.g. the new syncReminders key), failing the whole decode and letting the next autosave persist an empty state over real routes/filters. Custom decode now falls back per-field like Route already did, and loadSettingsFromDefaults recovers routes from the legacy routes.v1 key if settings.v2 comes back empty, repairing installs already hit by this. - Roadmap: event-driven background sync (SMAppService + EKEventStoreChanged + wake notification) and an external MCP wrapper around the CLI queued next; decided against direct Google/CalDAV API integration since EventKit already covers it via System Settings accounts. All 47 unit tests pass (45 existing + 2 new SettingsPayloadTests). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
65 lines
2.9 KiB
Swift
65 lines
2.9 KiB
Swift
import XCTest
|
|
@testable import BusyMirror
|
|
|
|
final class SettingsPayloadTests: XCTestCase {
|
|
|
|
// Only the fields present in the very first schema. Every key added since
|
|
// (syncReminders, filterByWorkHours, workHoursStart/End, excludedTitleFilters,
|
|
// excludedOrganizerFilters, mirrorAcceptedOnly, selectedSourceID/TargetIDs,
|
|
// appVersion, exportedAt) is deliberately missing here, simulating a
|
|
// settings.v2 blob written by an older build.
|
|
private let oldSchemaJSON = """
|
|
{
|
|
"daysBack": 3,
|
|
"daysForward": 10,
|
|
"mergeGapHours": 1,
|
|
"hideDetails": false,
|
|
"copyDescription": true,
|
|
"mirrorAllDay": true,
|
|
"overlapMode": "skipCovered",
|
|
"titlePrefix": "🪞 ",
|
|
"placeholderTitle": "Busy",
|
|
"autoDeleteMissing": true,
|
|
"routes": []
|
|
}
|
|
"""
|
|
|
|
func testDecodeOldSchemaMissingNewerKeysDoesNotThrow() throws {
|
|
let data = oldSchemaJSON.data(using: .utf8)!
|
|
let payload = try JSONDecoder().decode(ContentView.SettingsPayload.self, from: data)
|
|
|
|
// Fields present in the old blob are preserved.
|
|
XCTAssertEqual(payload.daysBack, 3)
|
|
XCTAssertEqual(payload.daysForward, 10)
|
|
XCTAssertEqual(payload.overlapMode, "skipCovered")
|
|
|
|
// Fields missing from the old blob fall back to their defaults instead
|
|
// of failing the whole decode.
|
|
XCTAssertEqual(payload.syncReminders, false)
|
|
XCTAssertEqual(payload.filterByWorkHours, false)
|
|
XCTAssertEqual(payload.workHoursStart, 9)
|
|
XCTAssertEqual(payload.workHoursEnd, 17)
|
|
XCTAssertEqual(payload.excludedTitleFilters, [])
|
|
XCTAssertEqual(payload.mirrorAcceptedOnly, false)
|
|
XCTAssertNil(payload.selectedSourceID)
|
|
XCTAssertNil(payload.selectedTargetIDs)
|
|
}
|
|
|
|
func testEncodeDecodeRoundTrip() throws {
|
|
let route = Route(sourceID: "a", targetIDs: ["b"], privacy: true, copyNotes: false, syncReminders: true, mergeGapHours: 1, overlap: .allow, allDay: false)
|
|
let original = ContentView.SettingsPayload(
|
|
daysBack: 2, daysForward: 5, mergeGapHours: 0, hideDetails: true, copyDescription: false,
|
|
mirrorAllDay: false, filterByWorkHours: true, workHoursStart: 8, workHoursEnd: 18,
|
|
excludedTitleFilters: ["standup"], excludedOrganizerFilters: [], mirrorAcceptedOnly: true,
|
|
overlapMode: "allow", titlePrefix: "🪞 ", placeholderTitle: "Busy", autoDeleteMissing: true,
|
|
routes: [route]
|
|
)
|
|
let data = try JSONEncoder().encode(original)
|
|
let decoded = try JSONDecoder().decode(ContentView.SettingsPayload.self, from: data)
|
|
XCTAssertEqual(decoded.daysBack, original.daysBack)
|
|
XCTAssertEqual(decoded.excludedTitleFilters, original.excludedTitleFilters)
|
|
XCTAssertEqual(decoded.routes.count, 1)
|
|
XCTAssertEqual(decoded.routes[0].sourceID, "a")
|
|
}
|
|
}
|