Files
Termind/FrameworkTests/SystemNotificationsTests.swift

230 lines
9.4 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import TermindApplication
import Testing
import UserNotifications
@testable import TermindInfrastructure
@Suite @MainActor
struct SystemNotificationsTests {
@Test(arguments: [UNAuthorizationStatus.authorized, .denied, .provisional])
func configuredAuthorizationDoesNotPromptAgain(status: UNAuthorizationStatus) async {
let center = Center()
center.status = status
let service = SystemNotifications(center: center)
let result = await service.authorization(request: true)
#expect(result == (status == .denied ? .denied : .allowed))
#expect(center.authorizationRequests == 0)
#expect(center.requests.isEmpty)
}
@Test func permissionIsRequestedOnlyExplicitlyAndOnlyWhenUndetermined() async {
let center = Center()
center.status = .notDetermined
let service = SystemNotifications(center: center)
#expect(await service.authorization() == .notDetermined)
#expect(center.requestedOptions.isEmpty)
#expect(await service.authorization(request: true) == .allowed)
#expect(center.requestedOptions == [.alert, .sound, .badge])
center.status = .denied
#expect(await service.authorization(request: true) == .denied)
#expect(center.authorizationRequests == 1)
}
@Test func settingsChangeWhileCheckingAuthorizationPreventsSubmission() async {
let center = Center()
let service = SystemNotifications(center: center)
var current = true
center.readAuthorization = {
current = false
return .authorized
}
await service.send(notification()) { current }
#expect(center.requests.isEmpty)
}
@Test(arguments: [false, true])
func staleOrCancelledDeliveryRemovesPendingAndDeliveredRequests(cancel: Bool) async {
let center = Center()
let service = SystemNotifications(center: center)
var current = true
center.holdDelivery = true
let notification = notification()
let task = Task { await service.send(notification) { current } }
_ = await center.nextSubmission()
if cancel { task.cancel() } else { current = false }
center.finishSubmission()
await task.value
#expect(center.removedPending == [notification.id])
#expect(center.removedDelivered == [notification.id])
}
@Test func notificationTargetsRoundTripAndEarlyClicksAreConsumedOnce() async {
let center = Center()
let service = SystemNotifications(center: center)
for target in [NotificationTarget.terminal(UUID()), .assistant(UUID()), .transfers] {
await service.send(AppNotification(target: target, title: "Title", body: "Body")) { true }
let request = center.requests.last!
#expect(SystemNotifications.target(request.content) == target)
#expect(request.content.title == "Title")
#expect(request.content.body == "Body")
#expect(request.content.sound != nil)
}
#expect(SystemNotifications.target(UNMutableNotificationContent()) == nil)
let target = NotificationTarget.assistant(UUID())
service.receiveResponse(action: UNNotificationDismissActionIdentifier, identifier: "ignored", target: target)
service.receiveResponse(action: UNNotificationDefaultActionIdentifier, identifier: "clicked", target: target)
var opened: [NotificationTarget] = []
service.onOpen = { opened.append($0) }
service.onOpen = { opened.append($0) }
#expect(opened == [target])
#expect(center.removedPending == ["clicked"])
#expect(center.removedDelivered == ["clicked"])
}
@Test func badgeWritesSerializeAndConvergeOnTheLatestCount() async {
let center = Center()
center.holdBadge = true
let service = SystemNotifications(center: center)
#expect(await center.nextBadge() == 0)
service.setBadgeCount(3)
service.setBadgeCount(7)
center.finishBadge()
#expect(await center.nextBadge() == 7)
service.setBadgeCount(-1)
center.finishBadge()
#expect(await center.nextBadge() == 0)
center.finishBadge()
#expect(center.badges == [0, 7, 0])
#expect(center.maximumConcurrentBadgeWrites == 1)
}
@Test(arguments: [
(UNNotificationDefaultActionIdentifier, true),
(UNNotificationDismissActionIdentifier, true),
(UNNotificationDefaultActionIdentifier, false),
])
func backgroundResponseCompletesOnMainThreadAfterRouting(action: String, hasTarget: Bool) async {
let center = Center()
let service = SystemNotifications(center: center)
let target = NotificationTarget.assistant(UUID())
var opened: [NotificationTarget] = []
service.onOpen = {
#expect(Thread.isMainThread)
opened.append($0)
}
let completedOnMainThread = await withCheckedContinuation { continuation in
DispatchQueue.global().async {
#expect(!Thread.isMainThread)
service.receiveResponse(action: action, identifier: "clicked", target: hasTarget ? target : nil) {
continuation.resume(returning: Thread.isMainThread)
}
}
}
#expect(completedOnMainThread)
let shouldOpen = action == UNNotificationDefaultActionIdentifier && hasTarget
#expect(opened == (shouldOpen ? [target] : []))
#expect(center.removedPending == (shouldOpen ? ["clicked"] : []))
#expect(center.removedDelivered == (shouldOpen ? ["clicked"] : []))
}
@Test func backgroundResponseBeforeRouterIsReadyCompletesAndRetainsTheTarget() async {
let service = SystemNotifications(center: Center())
let target = NotificationTarget.terminal(UUID())
let completedOnMainThread = await withCheckedContinuation { continuation in
DispatchQueue.global().async {
service.receiveResponse(
action: UNNotificationDefaultActionIdentifier, identifier: "early", target: target
) {
continuation.resume(returning: Thread.isMainThread)
}
}
}
#expect(completedOnMainThread)
var opened: [NotificationTarget] = []
service.onOpen = { opened.append($0) }
service.onOpen = { opened.append($0) }
#expect(opened == [target])
}
private func notification() -> AppNotification {
AppNotification(target: .assistant(UUID()), title: "Title", body: "Body")
}
private final class Center: UserNotificationCenterClient {
weak var delegate: (any UNUserNotificationCenterDelegate)?
var status = UNAuthorizationStatus.authorized
var requestedOptions: UNAuthorizationOptions = []
var authorizationRequests = 0
var readAuthorization: (() -> UNAuthorizationStatus)?
var requests: [UNNotificationRequest] = []
var removedPending: [String] = []
var removedDelivered: [String] = []
var badges: [Int] = []
var holdDelivery = false
var holdBadge = false
var maximumConcurrentBadgeWrites = 0
private var concurrentBadgeWrites = 0
private var delivery: CheckedContinuation<Void, Never>?
private var badge: CheckedContinuation<Void, Never>?
private let submissions = AsyncStream<Void>.makeStream()
private let badgeWrites = AsyncStream<Int>.makeStream()
func authorizationStatus() async -> UNAuthorizationStatus { readAuthorization?() ?? status }
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool {
requestedOptions = options
authorizationRequests += 1
status = .authorized
return true
}
func add(_ request: UNNotificationRequest) async throws {
requests.append(request)
if holdDelivery {
await withCheckedContinuation {
delivery = $0
submissions.continuation.yield(())
}
}
}
func removePendingNotificationRequests(withIdentifiers identifiers: [String]) {
removedPending.append(contentsOf: identifiers)
}
func removeDeliveredNotifications(withIdentifiers identifiers: [String]) {
removedDelivered.append(contentsOf: identifiers)
}
func setBadgeCount(_ count: Int) async throws {
concurrentBadgeWrites += 1
maximumConcurrentBadgeWrites = max(maximumConcurrentBadgeWrites, concurrentBadgeWrites)
defer { concurrentBadgeWrites -= 1 }
badges.append(count)
if holdBadge {
await withCheckedContinuation {
badge = $0
badgeWrites.continuation.yield(count)
}
}
}
func nextSubmission() async {
var iterator = submissions.stream.makeAsyncIterator()
_ = await iterator.next()
}
func finishSubmission() {
delivery?.resume()
delivery = nil
}
func nextBadge() async -> Int? {
var iterator = badgeWrites.stream.makeAsyncIterator()
return await iterator.next()
}
func finishBadge() {
badge?.resume()
badge = nil
}
}
}