diff --git a/CalenTask/Services/CalendarSyncService.swift b/CalenTask/Services/CalendarSyncService.swift index 9091a92..650acf2 100644 --- a/CalenTask/Services/CalendarSyncService.swift +++ b/CalenTask/Services/CalendarSyncService.swift @@ -431,10 +431,7 @@ final class CalendarSyncService { if let existing = localEvent(for: task) { event = existing.event span = existing.span - } else if linkedKey.flatMap(EventLinkRegistry.occurrence(fromKey:)) != nil - || (task.eventIdentifier != nil && linkedKey == nil) { - // #1 — linked to ANOTHER device's event that isn't here (yet), or - // #2 an occurrence not found: a new event would be a duplicate. + } else if !Self.mayCreateEvent(linkedKey: linkedKey, syncedIdentifier: task.eventIdentifier) { return } else { span = .futureEvents @@ -488,6 +485,14 @@ final class CalendarSyncService { } } + /// #1 — Se l'evento locale manca, crearne uno nuovo è lecito solo se la + /// task non è collegata a un evento di un ALTRO dispositivo (non ancora + /// arrivato qui) e #2 non è un'occorrenza non trovata: sarebbe un doppione. + static func mayCreateEvent(linkedKey: String?, syncedIdentifier: String?) -> Bool { + if linkedKey.flatMap(EventLinkRegistry.occurrence(fromKey:)) != nil { return false } + return syncedIdentifier == nil || linkedKey != nil + } + private func removeRemoteEvent(for task: TodoTask) throws { guard let local = localEvent(for: task) else { return } let key = links.eventIdentifier(forTask: task.id) diff --git a/CalenTask/Services/NotificationService.swift b/CalenTask/Services/NotificationService.swift index ff66dbf..570f46f 100644 --- a/CalenTask/Services/NotificationService.swift +++ b/CalenTask/Services/NotificationService.swift @@ -53,11 +53,7 @@ final class NotificationService { Log.store.error("Riallineamento notifiche saltato: \(String(describing: error), privacy: .public)") return } - let now = Date.now - let planned = tasks - .flatMap { Self.plannedRequests(for: NotificationPlan(task: $0), now: now) } - .sorted { $0.fireDate < $1.fireDate } - .prefix(Self.maxPendingTaskRequests) + let planned = Self.nearestRequests(for: tasks, now: .now) resyncGeneration += 1 let generation = resyncGeneration Task { @@ -161,6 +157,19 @@ final class NotificationService { .map { ($0.request.identifier, $0.fireDate) } } + /// Identificatori e orari che `resyncAll` programmerebbe ora (per i test). + static func resyncSchedule(for tasks: [TodoTask], now: Date = .now) -> [(id: String, fireDate: Date)] { + nearestRequests(for: tasks, now: now).map { ($0.request.identifier, $0.fireDate) } + } + + /// Le richieste più vicine di tutte le attività, entro il limite di sistema. + private static func nearestRequests(for tasks: [TodoTask], now: Date) -> [PlannedRequest] { + Array(tasks + .flatMap { plannedRequests(for: NotificationPlan(task: $0), now: now) } + .sorted { $0.fireDate < $1.fireDate } + .prefix(maxPendingTaskRequests)) + } + private static func plannedRequests(for plan: NotificationPlan, now: Date) -> [PlannedRequest] { guard plan.isActive else { return [] } var planned: [PlannedRequest] = [] diff --git a/CalenTaskTests/NotificationSyncTests.swift b/CalenTaskTests/NotificationSyncTests.swift new file mode 100644 index 0000000..3b761a6 --- /dev/null +++ b/CalenTaskTests/NotificationSyncTests.swift @@ -0,0 +1,91 @@ +import Foundation +import Testing +@testable import CalenTask + +/// #19 — test sulle parti critiche: piano delle notifiche e decisioni della +/// sync EventKit che non richiedono un `EKEventStore`. +@MainActor +struct NotificationSyncTests { + + /// #4 — promemoria, scadenza e "Parti ora" hanno id e orari attesi; + /// gli orari passati e le attività eliminate non notificano. + @Test func remindDueAndTravelAreScheduled() throws { + // Ora fissa a metà mattina: la scadenza non cade mai a mezzanotte (→ 9:00). + let now = try #require(Calendar.current.date( + bySettingHour: 10, minute: 7, second: 0, of: Date(timeIntervalSince1970: 1_790_000_000) + )) + let start = now.addingTimeInterval(3 * 3600) + let task = TodoTask(workspaceID: UUID(), title: "Sopralluogo", kind: .event, + startAt: start, + dueAt: now.addingTimeInterval(5 * 3600), + remindAt: now.addingTimeInterval(3600), + locationName: "Teatro", createdByID: UUID()) + task.travelMinutes = 30 + + let schedule = NotificationService.plannedSchedule(for: task, now: now) + let byID = Dictionary(uniqueKeysWithValues: schedule.map { ($0.id, $0.fireDate) }) + #expect(byID.count == 3) + #expect(byID[NotificationService.remindID(task.id)] == task.remindAt) + #expect(byID[NotificationService.dueID(task.id)] == task.dueAt) + #expect(byID[NotificationService.travelID(task.id)] == start.addingTimeInterval(-30 * 60)) + + // Promemoria già passato: resta solo il resto. + task.remindAt = now.addingTimeInterval(-60) + let ids = NotificationService.plannedSchedule(for: task, now: now).map { $0.id } + #expect(!ids.contains(NotificationService.remindID(task.id))) + #expect(ids.count == 2) + + task.deletedAt = now + #expect(NotificationService.plannedSchedule(for: task, now: now).isEmpty) + } + + /// #4 — il riallineamento tiene solo le richieste più vicine, ordinate, + /// entro il limite di sistema; le completate non contano. + @Test func resyncKeepsNearestRequestsWithinLimit() { + let now = Date.now + let workspace = UUID(), author = UUID() + let tasks = (0..<70).map { index in + TodoTask(workspaceID: workspace, title: "Task \(index)", + remindAt: now.addingTimeInterval(TimeInterval((70 - index) * 3600)), + createdByID: author) + } + let done = TodoTask(workspaceID: workspace, title: "Fatta", status: .done, + remindAt: now.addingTimeInterval(60), createdByID: author) + + let schedule = NotificationService.resyncSchedule(for: tasks + [done], now: now) + #expect(schedule.count == NotificationService.maxPendingTaskRequests) + #expect(zip(schedule, schedule.dropFirst()).allSatisfy { $0.fireDate <= $1.fireDate }) + // La più vicina è l'ultima creata; le 10 più lontane restano fuori. + #expect(schedule.first?.id == NotificationService.remindID(tasks[69].id)) + let ids = Set(schedule.map { $0.id }) + #expect(!ids.contains(NotificationService.remindID(tasks[0].id))) + #expect(!ids.contains(NotificationService.remindID(done.id))) + } + + @Test func alertSubtitles() { + #expect(NotificationService.alertSubtitle(minutesBefore: 0) == "Inizia ora") + #expect(NotificationService.alertSubtitle(minutesBefore: 15) == "Tra 15 min") + #expect(NotificationService.alertSubtitle(minutesBefore: 120) == "Tra 2 h") + #expect(NotificationService.alertSubtitle(minutesBefore: 90) == "Tra 1 h 30 min") + #expect(NotificationService.alertSubtitle(minutesBefore: 1440) == "Domani") + #expect(NotificationService.alertSubtitle(minutesBefore: 2880) == "Tra 2 giorni") + } + + /// #1, #2 — senza evento locale se ne crea uno nuovo solo quando non + /// sarebbe un doppione di un evento di un altro dispositivo o di + /// un'occorrenza non trovata. + @Test func newEventOnlyWhenNotADuplicate() { + let occurrence = EventLinkRegistry.occurrenceKey( + series: "UID|riunione", occurrenceDate: Date(timeIntervalSince1970: 1_790_000_000) + ) + // Mai sincronizzata: si crea. + #expect(CalendarSyncService.mayCreateEvent(linkedKey: nil, syncedIdentifier: nil)) + // Collegata qui ma l'evento è sparito dal calendario: si ricrea. + #expect(CalendarSyncService.mayCreateEvent(linkedKey: "EV-1", syncedIdentifier: "EV-1")) + // Evento di un altro dispositivo non ancora arrivato: niente doppione. + #expect(!CalendarSyncService.mayCreateEvent(linkedKey: nil, syncedIdentifier: "EV-MAC")) + // Occorrenza di una serie non trovata: niente evento nuovo. + #expect(!CalendarSyncService.mayCreateEvent(linkedKey: occurrence, syncedIdentifier: "EV-1")) + #expect(!CalendarSyncService.mayCreateEvent(linkedKey: occurrence, syncedIdentifier: nil)) + } +}