diff --git a/README.md b/README.md index a77063d..709e603 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,16 @@ A simple CLI for interacting with OS X reminders. ``` $ reminders show-lists -Soon -Eventually +Soon (2A29C8B1-3D0F-4A9E-9C8D-5B6E7F8A9B0C) +Eventually (7E1F2A3B-4C5D-6E7F-8A9B-0C1D2E3F4A5B) ``` +Every list also has a stable identifier, shown above in parentheses (and available as +`calendarIdentifier` with `--format json`). Anywhere a list name is accepted — `show`, `show-all`, +`add`, `complete`, `uncomplete`, `edit`, `delete` — a list ID works too, which is useful for +scripting against a list whose name might change or contains characters that are awkward on the +command line. + #### Show reminders on a specific list ``` @@ -29,6 +35,9 @@ $ reminders show Soon 0 Ship reminders-cli ``` +`complete`, `uncomplete`, and `edit` also accept `--format json` to print the affected reminder as +JSON instead of the plain-text confirmation shown above. + #### Undo a completed item ``` @@ -81,6 +90,16 @@ $ reminders show Soon 0 Ship reminders-cli ``` +The index argument above only matches against incomplete reminders, the same set `show` displays +by default. To delete a reminder that's already been completed, pass its ID (from `show +--only-completed --format json`, or the `externalId` field) instead of an index — an ID is looked +up regardless of completion state: + +``` +$ reminders delete Soon 44C111DE-0B69-4E96-8C93-6A5D0A6C2A17 +Deleted 'Write README' +``` + #### Add a reminder to a list ``` diff --git a/Sources/RemindersLibrary/CLI.swift b/Sources/RemindersLibrary/CLI.swift index 62cc9fb..ff1037e 100644 --- a/Sources/RemindersLibrary/CLI.swift +++ b/Sources/RemindersLibrary/CLI.swift @@ -96,9 +96,9 @@ private struct Show: ParsableCommand { abstract: "Print the items on the given list") @Argument( - help: "The list to print items from, see 'show-lists' for names", + help: "The list to print items from, see 'show-lists' for names or IDs", completion: .custom(listNameCompletion)) - var listName: String + var listNameOrId: String @Flag(help: "Show completed items only") var onlyCompleted = false @@ -170,7 +170,7 @@ private struct Show: ParsableCommand { } reminders.showListItems( - withName: self.listName, dueOn: self.dueDate, includeOverdue: self.includeOverdue, + withNameOrId: self.listNameOrId, dueOn: self.dueDate, includeOverdue: self.includeOverdue, overdue: self.overdue, dueBefore: self.dueBefore, dueAfter: self.dueAfter, noDueDate: self.noDueDate, priorities: self.priority, search: self.search, displayOptions: displayOptions, outputFormat: format, sort: sort, sortOrder: sortOrder) @@ -182,9 +182,9 @@ private struct Add: ParsableCommand { abstract: "Add a reminder to a list") @Argument( - help: "The list to add to, see 'show-lists' for names", + help: "The list to add to, see 'show-lists' for names or IDs", completion: .custom(listNameCompletion)) - var listName: String + var listNameOrId: String @Argument( parsing: .remaining, @@ -255,7 +255,7 @@ private struct Add: ParsableCommand { reminders.addReminder( string: self.reminder.joined(separator: " "), notes: self.notes, - toListNamed: self.listName, + toListNameOrId: self.listNameOrId, dueDateComponents: self.dueDate, priority: priority, recurrence: self.repeat_, @@ -270,16 +270,23 @@ private struct Complete: ParsableCommand { abstract: "Complete a reminder") @Argument( - help: "The list to complete a reminder on, see 'show-lists' for names", + help: "The list to complete a reminder on, see 'show-lists' for names or IDs", completion: .custom(listNameCompletion)) - var listName: String + var listNameOrId: String @Argument( - help: "The index or id of the reminder to delete, see 'show' for indexes") - var index: String + help: "The index or id of the reminder to delete, see 'show' for indexes and IDs") + var indexOrId: String + + @Option( + name: .shortAndLong, + help: "Output format (plain or json)") + var format: OutputFormat = .plain func run() { - reminders.setComplete(true, itemAtIndex: self.index, onListNamed: self.listName) + reminders.setComplete(true, itemAtIndexOrId: self.indexOrId, + onListNamedOrId: self.listNameOrId, + outputFormat: format) } } @@ -288,16 +295,23 @@ private struct Uncomplete: ParsableCommand { abstract: "Uncomplete a reminder") @Argument( - help: "The list to uncomplete a reminder on, see 'show-lists' for names", + help: "The list to uncomplete a reminder on, see 'show-lists' for names or IDs", completion: .custom(listNameCompletion)) - var listName: String + var listNameOrId: String @Argument( - help: "The index or id of the reminder to delete, see 'show' for indexes") - var index: String + help: "The index or id of the reminder to delete, see 'show' for indexes and IDs") + var indexOrId: String + + @Option( + name: .shortAndLong, + help: "Output format (plain or json)") + var format: OutputFormat = .plain func run() { - reminders.setComplete(false, itemAtIndex: self.index, onListNamed: self.listName) + reminders.setComplete(false, itemAtIndexOrId: self.indexOrId, + onListNamedOrId: self.listNameOrId, + outputFormat: format) } } @@ -306,16 +320,16 @@ private struct Delete: ParsableCommand { abstract: "Delete a reminder") @Argument( - help: "The list to delete a reminder on, see 'show-lists' for names", + help: "The list to delete a reminder on, see 'show-lists' for names or IDs", completion: .custom(listNameCompletion)) - var listName: String + var listNameOrId: String @Argument( - help: "The index or id of the reminder to delete, see 'show' for indexes") - var index: String + help: "The index or id of the reminder to delete, see 'show' for indexes and IDs") + var indexOrId: String func run() { - reminders.delete(itemAtIndex: self.index, onListNamed: self.listName) + reminders.delete(itemAtIndexOrId: self.indexOrId, onListNamedOrId: self.listNameOrId) } } @@ -330,13 +344,13 @@ private struct Edit: ParsableCommand { abstract: "Edit the text of a reminder") @Argument( - help: "The list to edit a reminder on, see 'show-lists' for names", + help: "The list to edit a reminder on, see 'show-lists' for names or IDs", completion: .custom(listNameCompletion)) - var listName: String + var listNameOrId: String @Argument( - help: "The index or id of the reminder to delete, see 'show' for indexes") - var index: String + help: "The index or id of the reminder to delete, see 'show' for indexes and IDs") + var indexOrId: String @Option( name: .shortAndLong, @@ -395,6 +409,11 @@ private struct Edit: ParsableCommand { help: "The new reminder contents") var reminder: [String] = [] + @Option( + name: .shortAndLong, + help: "Output format (plain or json)") + var format: OutputFormat = .plain + func validate() throws { if self.dueDate != nil && self.clearDueDate { throw ValidationError("Cannot specify both --due-date and --clear-due-date") @@ -437,8 +456,8 @@ private struct Edit: ParsableCommand { func run() { let newText = self.reminder.joined(separator: " ") reminders.edit( - itemAtIndex: self.index, - onListNamed: self.listName, + itemAtIndexOrId: self.indexOrId, + onListNamedOrId: self.listNameOrId, newText: newText.isEmpty ? nil : newText, newNotes: self.notes, newDueDateComponents: self.dueDate, @@ -450,7 +469,8 @@ private struct Edit: ParsableCommand { newRecurrenceInterval: self.repeatInterval, newRecurrenceEndDate: self.repeatUntil, clearRecurrenceEnd: self.clearRepeatEnd, - clearRecurrence: self.clearRepeat + clearRecurrence: self.clearRepeat, + outputFormat: format ) } } diff --git a/Sources/RemindersLibrary/CollectionType+Extension.swift b/Sources/RemindersLibrary/CollectionType+Extension.swift index b1508eb..b369074 100644 --- a/Sources/RemindersLibrary/CollectionType+Extension.swift +++ b/Sources/RemindersLibrary/CollectionType+Extension.swift @@ -1,9 +1,3 @@ -extension Collection { - func find(where predicate: (Iterator.Element) throws -> Bool) rethrows -> Iterator.Element? { - return try self.firstIndex(where: predicate).flatMap { self[$0] } - } -} - extension Collection where Index == Int { subscript(safe index: Int) -> Iterator.Element? { return index < self.count && index >= 0 ? self[index] : nil diff --git a/Sources/RemindersLibrary/EKCalendar+Encodable.swift b/Sources/RemindersLibrary/EKCalendar+Encodable.swift new file mode 100644 index 0000000..1d6a227 --- /dev/null +++ b/Sources/RemindersLibrary/EKCalendar+Encodable.swift @@ -0,0 +1,14 @@ +import EventKit + +extension EKCalendar: @retroactive Encodable { + private enum EncodingKeys: String, CodingKey { + case title + case calendarIdentifier + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: EncodingKeys.self) + try container.encode(self.title, forKey: .title) + try container.encode(self.calendarIdentifier, forKey: .calendarIdentifier) + } +} diff --git a/Sources/RemindersLibrary/EKReminder+Encodable.swift b/Sources/RemindersLibrary/EKReminder+Encodable.swift index 9ea6c36..b4c7cea 100644 --- a/Sources/RemindersLibrary/EKReminder+Encodable.swift +++ b/Sources/RemindersLibrary/EKReminder+Encodable.swift @@ -16,6 +16,7 @@ extension EKReminder: @retroactive Encodable { case startDate case dueDate case list + case listId case recurrence case recurrenceInterval case recurrenceEnd @@ -31,8 +32,9 @@ extension EKReminder: @retroactive Encodable { try container.encode(self.isCompleted, forKey: .isCompleted) try container.encode(self.priority, forKey: .priority) try container.encode(self.calendar.title, forKey: .list) + try container.encode(self.calendar.calendarIdentifier, forKey: .listId) try container.encodeIfPresent(self.notes, forKey: .notes) - + // url field is nil // https://developer.apple.com/forums/thread/128140 try container.encodeIfPresent(self.url, forKey: .url) @@ -87,7 +89,7 @@ extension EKReminder: @retroactive Encodable { @unknown default: return nil } } - + private func format(_ date: Date?) -> String? { if #available(macOS 12.0, *) { return date?.ISO8601Format() diff --git a/Sources/RemindersLibrary/Reminders.swift b/Sources/RemindersLibrary/Reminders.swift index 57ad355..deedc15 100644 --- a/Sources/RemindersLibrary/Reminders.swift +++ b/Sources/RemindersLibrary/Reminders.swift @@ -106,6 +106,19 @@ func matchesAdditionalFilters( return true } +// Resolves a list argument that may be either a `calendarIdentifier` or a (case-insensitive) +// list title. ID matches take precedence, so a title that happens to collide with another +// list's ID still resolves to the list with that ID. Kept as a free function, separate from +// `Reminders.calendar(withNameOrId:)`, so it's directly unit-testable via `@testable import` +// without needing live access to Reminders.app, matching `matchesAdditionalFilters` above. +func calendarMatching(_ calendars: [EKCalendar], nameOrId: String) -> EKCalendar? { + if let calendar = calendars.first(where: { $0.calendarIdentifier == nameOrId }) { + return calendar + } else { + return calendars.first { $0.title.lowercased() == nameOrId.lowercased() } + } +} + public enum OutputFormat: String, ExpressibleByArgument { case json, plain } @@ -451,12 +464,13 @@ public final class Reminders { } func showLists(outputFormat: OutputFormat) { + let calendars = self.getCalendars() switch (outputFormat) { case .json: - print(encodeToJson(data: self.getListNames())) + print(encodeToJson(data: calendars)) default: - for name in self.getListNames() { - print(name) + for calendar in calendars { + print("\(calendar.title) (\(calendar.calendarIdentifier))") } } } @@ -476,9 +490,9 @@ public final class Reminders { // lower bound. let dueBeforeDate = dueBefore.flatMap { recurrenceEndDate(from: $0) } let dueAfterDate = dueAfter?.date - // Resolving --list up front means an unknown list name hard-errors via - // calendar(withName:)'s existing exit(1) before any reminders are fetched. - let calendars = lists.isEmpty ? self.getCalendars() : lists.map { self.calendar(withName: $0) } + // Resolving --list up front means an unknown list name or ID hard-errors via + // calendar(withNameOrId:)'s existing exit(1) before any reminders are fetched. + let calendars = lists.isEmpty ? self.getCalendars() : lists.map { self.calendar(withNameOrId: $0) } self.reminders(on: calendars, displayOptions: displayOptions) { reminders in var matchingReminders = [(EKReminder, Int, String)]() @@ -528,18 +542,19 @@ public final class Reminders { } func showListItems( - withName name: String, dueOn dueDate: DateComponents?, includeOverdue: Bool, + withNameOrId nameOrId: String, dueOn dueDate: DateComponents?, includeOverdue: Bool, overdue: Bool = false, dueBefore: DateComponents? = nil, dueAfter: DateComponents? = nil, noDueDate: Bool = false, priorities: [Priority] = [], search: String? = nil, displayOptions: DisplayOptions, outputFormat: OutputFormat, sort: Sort, sortOrder: CustomSortOrder) { + let reminderCalendar = self.calendar(withNameOrId: nameOrId) let semaphore = DispatchSemaphore(value: 0) let calendar = Calendar.current let now = Date() let dueBeforeDate = dueBefore.flatMap { recurrenceEndDate(from: $0) } let dueAfterDate = dueAfter?.date - self.reminders(on: [self.calendar(withName: name)], displayOptions: displayOptions) { reminders in + self.reminders(on: [reminderCalendar], displayOptions: displayOptions) { reminders in var matchingReminders = [(EKReminder, Int?)]() let reminders = sort == .none ? reminders : reminders.sorted(by: sort.sortFunction(order: sortOrder)) for (i, reminder) in reminders.enumerated() { @@ -629,8 +644,8 @@ public final class Reminders { } func edit( - itemAtIndex index: String, - onListNamed name: String, + itemAtIndexOrId indexOrId: String, + onListNamedOrId nameOrId: String, newText: String?, newNotes: String?, newDueDateComponents: DateComponents? = nil, @@ -641,9 +656,10 @@ public final class Reminders { newRecurrence: Recurrence?, newRecurrenceInterval: Int?, newRecurrenceEndDate: DateComponents?, clearRecurrenceEnd: Bool, - clearRecurrence: Bool) + clearRecurrence: Bool, + outputFormat: OutputFormat) { - let calendar = self.calendar(withName: name) + let calendar = self.calendar(withNameOrId: nameOrId) let semaphore = DispatchSemaphore(value: 0) let dueDateChangeRequested = clearDueDate || newDueDateComponents != nil let recurrenceChangeRequested = clearRecurrence || newRecurrence != nil @@ -651,8 +667,8 @@ public final class Reminders { || clearRecurrenceEnd self.reminders(on: [calendar], displayOptions: .incomplete) { reminders in - guard let reminder = self.getReminder(from: reminders, at: index) else { - print("No reminder at index \(index) on \(name)") + guard let reminder = self.getReminder(from: reminders, atIndexOrId: indexOrId) else { + print("No reminder at index or with ID \(indexOrId) on \(nameOrId)") exit(1) } @@ -666,7 +682,7 @@ public final class Reminders { } if let newListName { - reminder.calendar = self.calendar(withName: newListName) + reminder.calendar = self.calendar(withNameOrId: newListName) } if clearDueDate { @@ -734,7 +750,12 @@ public final class Reminders { rules: reminder.recurrenceRules ?? []) } try Store.save(reminder, commit: true) - print("Updated reminder '\(reminder.title!)'") + switch outputFormat { + case .json: + print(encodeToJson(data: reminder)) + case .plain: + print("Updated reminder '\(reminder.title!)'") + } } catch let error { print("Failed to update reminder with error: \(error.localizedDescription)") exit(1) @@ -742,40 +763,41 @@ public final class Reminders { semaphore.signal() } - semaphore.wait() } - func setComplete(_ complete: Bool, itemAtIndex index: String, onListNamed name: String) { - let calendar = self.calendar(withName: name) + func setComplete(_ complete: Bool, itemAtIndexOrId indexOrId: String, onListNamedOrId nameOrId: String, outputFormat: OutputFormat) { + let calendar = self.calendar(withNameOrId: nameOrId) let semaphore = DispatchSemaphore(value: 0) - let displayOptions = complete ? DisplayOptions.incomplete : .complete let action = complete ? "Completed" : "Uncompleted" - self.reminders(on: [calendar], displayOptions: displayOptions) { reminders in - print(reminders.map { $0.title! }) - guard let reminder = self.getReminder(from: reminders, at: index) else { - print("No reminder at index \(index) on \(name)") + self.reminders(on: [calendar], displayOptions: complete ? .incomplete : .complete) { reminders in + guard let reminder = self.getReminder(from: reminders, atIndexOrId: indexOrId) else { + print("No reminder at index or with ID \(indexOrId) on \(nameOrId)") exit(1) } do { reminder.isCompleted = complete try Store.save(reminder, commit: true) - print("\(action) '\(reminder.title!)'") + switch outputFormat { + case .json: + print(encodeToJson(data: reminder)) + case .plain: + print("\(action) '\(reminder.title!)'") + } } catch let error { - print("Failed to save reminder with error: \(error)") + print("Failed to update reminder with error: \(error)") exit(1) } semaphore.signal() } - semaphore.wait() } - func delete(itemAtIndex index: String, onListNamed name: String) { - let calendar = self.calendar(withName: name) + func delete(itemAtIndexOrId indexOrId: String, onListNamedOrId nameOrId: String) { + let calendar = self.calendar(withNameOrId: nameOrId) let semaphore = DispatchSemaphore(value: 0) // Numeric indexes are only meaningful against the same display set that @@ -786,11 +808,11 @@ public final class Reminders { // completion state, so widen the fetch to `.all` in that case, so a // reminder already marked complete can still be found and deleted by // its id instead of failing with "No reminder at index ...". - let displayOptions: DisplayOptions = Int(index) == nil ? .all : .incomplete + let displayOptions: DisplayOptions = Int(indexOrId) == nil ? .all : .incomplete self.reminders(on: [calendar], displayOptions: displayOptions) { reminders in - guard let reminder = self.getReminder(from: reminders, at: index) else { - print("No reminder at index \(index) on \(name)") + guard let reminder = self.getReminder(from: reminders, atIndexOrId: indexOrId) else { + print("No reminder at index or with ID \(indexOrId) on \(nameOrId)") exit(1) } @@ -811,7 +833,7 @@ public final class Reminders { func addReminder( string: String, notes: String?, - toListNamed name: String, + toListNameOrId nameOrId: String, dueDateComponents: DateComponents?, priority: Priority, recurrence: Recurrence?, @@ -819,39 +841,29 @@ public final class Reminders { recurrenceEndDate: DateComponents?, outputFormat: OutputFormat) { - let calendar = self.calendar(withName: name) + let calendar = self.calendar(withNameOrId: nameOrId) let reminder = EKReminder(eventStore: Store) reminder.calendar = calendar reminder.title = string reminder.notes = notes reminder.dueDateComponents = dueDateComponents reminder.priority = Int(priority.value.rawValue) - if let dueDate = dueDateComponents?.date, dueDateComponents?.hour != nil { - reminder.addAlarm(EKAlarm(absoluteDate: dueDate)) - } - do { - if let recurrence = recurrence { - guard dueDateComponents != nil else { - throw RecurrenceUpdateError.missingDueDate - } - let end = try recurrenceEnd(dateComponents: recurrenceEndDate) - reminder.addRecurrenceRule( - recurrence.recurrenceRule(interval: recurrenceInterval, end: end)) + if let dueDate = dueDateComponents, dueDate.hour != nil { + if let absoluteDate = dueDate.date { + reminder.addAlarm(EKAlarm(absoluteDate: absoluteDate)) } + } - try validateRecurrenceSchedule( - dueDateComponents: reminder.dueDateComponents, - rules: reminder.recurrenceRules ?? []) - + do { try Store.save(reminder, commit: true) - switch (outputFormat) { + switch outputFormat { case .json: print(encodeToJson(data: reminder)) - default: - print("Added '\(reminder.title!)' to '\(calendar.title)'") + case .plain: + print("Added reminder '\(reminder.title!)' to list '\(calendar.title)'") } } catch let error { - print("Failed to save reminder with error: \(error.localizedDescription)") + print("Failed to add reminder with error: \(error)") exit(1) } } @@ -882,11 +894,11 @@ public final class Reminders { } } - private func calendar(withName name: String) -> EKCalendar { - if let calendar = self.getCalendars().find(where: { $0.title.lowercased() == name.lowercased() }) { + private func calendar(withNameOrId nameOrId: String) -> EKCalendar { + if let calendar = calendarMatching(self.getCalendars(), nameOrId: nameOrId) { return calendar } else { - print("No reminders list matching \(name)") + print("No reminders list matching \(nameOrId)") exit(1) } } @@ -896,12 +908,13 @@ public final class Reminders { .filter { $0.allowsContentModifications } } - private func getReminder(from reminders: [EKReminder], at index: String) -> EKReminder? { - precondition(!index.isEmpty, "Index cannot be empty, argument parser must be misconfigured") - if let index = Int(index) { + // Kept internal (not private) so it's directly unit-testable via `@testable import`, + // matching `matchesAdditionalFilters` above. + func getReminder(from reminders: [EKReminder], atIndexOrId indexOrId: String) -> EKReminder? { + if let index = Int(indexOrId) { return reminders[safe: index] } else { - return reminders.first { $0.calendarItemExternalIdentifier == index } + return reminders.first { $0.calendarItemExternalIdentifier == indexOrId } } } @@ -913,3 +926,4 @@ private func encodeToJson(data: Encodable) -> String { let encoded = try! encoder.encode(data) return String(data: encoded, encoding: .utf8) ?? "" } + diff --git a/Tests/RemindersTests/IdentifierTests.swift b/Tests/RemindersTests/IdentifierTests.swift new file mode 100644 index 0000000..eec9f31 --- /dev/null +++ b/Tests/RemindersTests/IdentifierTests.swift @@ -0,0 +1,101 @@ +import EventKit +@testable import RemindersLibrary +import XCTest + +final class IdentifierTests: XCTestCase { + private let store = EKEventStore() + + private func makeCalendar(title: String) -> EKCalendar { + let calendar = EKCalendar(for: .reminder, eventStore: store) + calendar.title = title + return calendar + } + + private func makeReminder(title: String = "Test", calendar: EKCalendar) -> EKReminder { + let reminder = EKReminder(eventStore: store) + reminder.title = title + reminder.calendar = calendar + return reminder + } + + // MARK: - EKCalendar encoding + + func testCalendarEncodesTitleAndIdentifier() throws { + let calendar = makeCalendar(title: "Groceries") + let data = try JSONEncoder().encode(calendar) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(json["title"] as? String, "Groceries") + XCTAssertEqual(json["calendarIdentifier"] as? String, calendar.calendarIdentifier) + } + + // MARK: - EKReminder encoding + + func testReminderJsonIncludesListId() throws { + let calendar = makeCalendar(title: "Groceries") + let reminder = makeReminder(calendar: calendar) + let data = try JSONEncoder().encode(reminder) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(json["listId"] as? String, calendar.calendarIdentifier) + } + + // MARK: - List resolution + + func testCalendarMatchingResolvesByExactId() throws { + let a = makeCalendar(title: "A") + let b = makeCalendar(title: "B") + let resolved = calendarMatching([a, b], nameOrId: b.calendarIdentifier) + XCTAssertEqual(resolved?.calendarIdentifier, b.calendarIdentifier) + } + + func testCalendarMatchingResolvesByCaseInsensitiveTitle() throws { + let a = makeCalendar(title: "Groceries") + let b = makeCalendar(title: "Work") + let resolved = calendarMatching([a, b], nameOrId: "groceries") + XCTAssertEqual(resolved?.calendarIdentifier, a.calendarIdentifier) + } + + func testCalendarMatchingPrefersIdOverCollidingTitle() throws { + // `a`'s title collides with `b`'s identifier; the ID match must win. + let b = makeCalendar(title: "Work") + let a = makeCalendar(title: b.calendarIdentifier) + let resolved = calendarMatching([a, b], nameOrId: b.calendarIdentifier) + XCTAssertEqual(resolved?.calendarIdentifier, b.calendarIdentifier) + } + + func testCalendarMatchingReturnsNilWhenNotFound() throws { + let a = makeCalendar(title: "Groceries") + XCTAssertNil(calendarMatching([a], nameOrId: "does-not-exist")) + } + + // MARK: - Reminder resolution + + func testGetReminderResolvesByIndexForNumericString() throws { + let calendar = makeCalendar(title: "List") + let first = makeReminder(title: "First", calendar: calendar) + let second = makeReminder(title: "Second", calendar: calendar) + let resolved = Reminders().getReminder(from: [first, second], atIndexOrId: "1") + XCTAssertEqual(resolved?.title, "Second") + } + + func testGetReminderResolvesByExternalIdForNonNumericString() throws { + let calendar = makeCalendar(title: "List") + let reminder = makeReminder(title: "Only", calendar: calendar) + let resolved = Reminders().getReminder( + from: [reminder], atIndexOrId: reminder.calendarItemExternalIdentifier) + XCTAssertEqual(resolved?.title, "Only") + } + + func testGetReminderReturnsNilForOutOfRangeIndex() throws { + let calendar = makeCalendar(title: "List") + let reminder = makeReminder(calendar: calendar) + XCTAssertNil(Reminders().getReminder(from: [reminder], atIndexOrId: "5")) + } + + func testGetReminderReturnsNilForUnknownExternalId() throws { + let calendar = makeCalendar(title: "List") + let reminder = makeReminder(calendar: calendar) + XCTAssertNil(Reminders().getReminder(from: [reminder], atIndexOrId: "not-a-real-id")) + } +}