Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions CalenTask/Services/CalendarSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 14 additions & 5 deletions CalenTask/Services/NotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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] = []
Expand Down
91 changes: 91 additions & 0 deletions CalenTaskTests/NotificationSyncTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading