* Fix sidebar notification persisting after being read latestNotification(forTabId:) fell back to latestByTabId when no unread notifications existed, causing read notifications to persist in the sidebar even after the user marked them as read, killed all processes, or switched branches. The sidebar should only display unread notifications. Remove the fallback to latestByTabId so the sidebar notification text clears once all notifications for a workspace are read. Fixes #1642 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update test expectation for unread-only latestNotification semantics After markRead, latestNotification(forTabId:) now returns nil since it no longer falls back to read notifications. Update the test assertion to match. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: CHE-3 <schumannzheng@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1070 lines
42 KiB
Swift
1070 lines
42 KiB
Swift
import XCTest
|
|
import AppKit
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
import WebKit
|
|
import ObjectiveC.runtime
|
|
import Bonsplit
|
|
import UserNotifications
|
|
|
|
#if canImport(cmux_DEV)
|
|
@testable import cmux_DEV
|
|
#elseif canImport(cmux)
|
|
@testable import cmux
|
|
#endif
|
|
|
|
@MainActor
|
|
final class NotificationDockBadgeTests: XCTestCase {
|
|
private final class NotificationSettingsAlertSpy: NSAlert {
|
|
private(set) var beginSheetModalCallCount = 0
|
|
private(set) var runModalCallCount = 0
|
|
var nextResponse: NSApplication.ModalResponse = .alertFirstButtonReturn
|
|
|
|
override func beginSheetModal(
|
|
for sheetWindow: NSWindow,
|
|
completionHandler handler: ((NSApplication.ModalResponse) -> Void)?
|
|
) {
|
|
beginSheetModalCallCount += 1
|
|
handler?(nextResponse)
|
|
}
|
|
|
|
override func runModal() -> NSApplication.ModalResponse {
|
|
runModalCallCount += 1
|
|
return nextResponse
|
|
}
|
|
}
|
|
|
|
override func tearDown() {
|
|
TerminalNotificationStore.shared.resetNotificationSettingsPromptHooksForTesting()
|
|
TerminalNotificationStore.shared.replaceNotificationsForTesting([])
|
|
TerminalNotificationStore.shared.resetNotificationDeliveryHandlerForTesting()
|
|
TerminalNotificationStore.shared.resetSuppressedNotificationFeedbackHandlerForTesting()
|
|
super.tearDown()
|
|
}
|
|
|
|
func testDockBadgeLabelEnabledAndCounted() {
|
|
XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 1, isEnabled: true), "1")
|
|
XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 42, isEnabled: true), "42")
|
|
XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 100, isEnabled: true), "99+")
|
|
}
|
|
|
|
func testDockBadgeLabelHiddenWhenDisabledOrZero() {
|
|
XCTAssertNil(TerminalNotificationStore.dockBadgeLabel(unreadCount: 0, isEnabled: true))
|
|
XCTAssertNil(TerminalNotificationStore.dockBadgeLabel(unreadCount: 5, isEnabled: false))
|
|
}
|
|
|
|
func testDockBadgeLabelShowsRunTagEvenWithoutUnread() {
|
|
XCTAssertEqual(
|
|
TerminalNotificationStore.dockBadgeLabel(unreadCount: 0, isEnabled: true, runTag: "verify-tag"),
|
|
"verify-tag"
|
|
)
|
|
}
|
|
|
|
func testDockBadgeLabelCombinesRunTagAndUnreadCount() {
|
|
XCTAssertEqual(
|
|
TerminalNotificationStore.dockBadgeLabel(unreadCount: 7, isEnabled: true, runTag: "verify"),
|
|
"verify:7"
|
|
)
|
|
XCTAssertEqual(
|
|
TerminalNotificationStore.dockBadgeLabel(unreadCount: 120, isEnabled: true, runTag: "verify"),
|
|
"verify:99+"
|
|
)
|
|
}
|
|
|
|
func testNotificationBadgePreferenceDefaultsToEnabled() {
|
|
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
XCTAssertTrue(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults))
|
|
|
|
defaults.set(false, forKey: NotificationBadgeSettings.dockBadgeEnabledKey)
|
|
XCTAssertFalse(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults))
|
|
|
|
defaults.set(true, forKey: NotificationBadgeSettings.dockBadgeEnabledKey)
|
|
XCTAssertTrue(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults))
|
|
}
|
|
|
|
func testNotificationPaneFlashPreferenceDefaultsToEnabled() {
|
|
let suiteName = "NotificationPaneFlashSettingsTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
XCTAssertTrue(NotificationPaneFlashSettings.isEnabled(defaults: defaults))
|
|
|
|
defaults.set(false, forKey: NotificationPaneFlashSettings.enabledKey)
|
|
XCTAssertFalse(NotificationPaneFlashSettings.isEnabled(defaults: defaults))
|
|
|
|
defaults.set(true, forKey: NotificationPaneFlashSettings.enabledKey)
|
|
XCTAssertTrue(NotificationPaneFlashSettings.isEnabled(defaults: defaults))
|
|
}
|
|
|
|
func testMenuBarExtraPreferenceDefaultsToVisible() {
|
|
let suiteName = "MenuBarExtraVisibilityTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
XCTAssertTrue(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults))
|
|
|
|
defaults.set(false, forKey: MenuBarExtraSettings.showInMenuBarKey)
|
|
XCTAssertFalse(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults))
|
|
|
|
defaults.set(true, forKey: MenuBarExtraSettings.showInMenuBarKey)
|
|
XCTAssertTrue(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults))
|
|
}
|
|
|
|
func testNotificationSoundUsesSystemSoundForDefaultAndNamedSounds() {
|
|
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
XCTAssertTrue(NotificationSoundSettings.usesSystemSound(defaults: defaults))
|
|
|
|
defaults.set("Ping", forKey: NotificationSoundSettings.key)
|
|
XCTAssertTrue(NotificationSoundSettings.usesSystemSound(defaults: defaults))
|
|
XCTAssertNotNil(NotificationSoundSettings.sound(defaults: defaults))
|
|
}
|
|
|
|
func testNotificationSoundDisablesSystemSoundForNoneAndCustomFile() {
|
|
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
defaults.set("none", forKey: NotificationSoundSettings.key)
|
|
XCTAssertFalse(NotificationSoundSettings.usesSystemSound(defaults: defaults))
|
|
XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults))
|
|
|
|
defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
|
|
XCTAssertFalse(NotificationSoundSettings.usesSystemSound(defaults: defaults))
|
|
XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults))
|
|
}
|
|
|
|
func testNotificationCustomFileURLExpandsTildePath() {
|
|
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
let rawPath = "~/Library/Sounds/my-custom.wav"
|
|
defaults.set(rawPath, forKey: NotificationSoundSettings.customFilePathKey)
|
|
let expectedPath = (rawPath as NSString).expandingTildeInPath
|
|
XCTAssertEqual(NotificationSoundSettings.customFileURL(defaults: defaults)?.path, expectedPath)
|
|
}
|
|
|
|
func testNotificationCustomFileSelectionMustBeExplicit() {
|
|
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
defaults.set("~/Library/Sounds/my-custom.wav", forKey: NotificationSoundSettings.customFilePathKey)
|
|
|
|
defaults.set("none", forKey: NotificationSoundSettings.key)
|
|
XCTAssertFalse(NotificationSoundSettings.isCustomFileSelected(defaults: defaults))
|
|
|
|
defaults.set("Ping", forKey: NotificationSoundSettings.key)
|
|
XCTAssertFalse(NotificationSoundSettings.isCustomFileSelected(defaults: defaults))
|
|
|
|
defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
|
|
XCTAssertTrue(NotificationSoundSettings.isCustomFileSelected(defaults: defaults))
|
|
}
|
|
|
|
func testNotificationCustomStagingPreservesSourceFileWithCmuxPrefix() {
|
|
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
let fileManager = FileManager.default
|
|
let soundsDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
|
|
.appendingPathComponent("Library", isDirectory: true)
|
|
.appendingPathComponent("Sounds", isDirectory: true)
|
|
do {
|
|
try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true)
|
|
} catch {
|
|
XCTFail("Failed to create sounds directory: \(error)")
|
|
return
|
|
}
|
|
|
|
let sourceURL = soundsDirectory.appendingPathComponent(
|
|
"cmux-custom-notification-sound.source-\(UUID().uuidString).wav",
|
|
isDirectory: false
|
|
)
|
|
defer {
|
|
try? fileManager.removeItem(at: sourceURL)
|
|
}
|
|
|
|
do {
|
|
try Data("test".utf8).write(to: sourceURL, options: .atomic)
|
|
} catch {
|
|
XCTFail("Failed to write source custom sound file: \(error)")
|
|
return
|
|
}
|
|
|
|
defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
|
|
defaults.set(sourceURL.path, forKey: NotificationSoundSettings.customFilePathKey)
|
|
|
|
_ = NotificationSoundSettings.sound(defaults: defaults)
|
|
|
|
guard let stagedName = NotificationSoundSettings.stagedCustomSoundName(defaults: defaults) else {
|
|
XCTFail("Expected staged custom sound name")
|
|
return
|
|
}
|
|
let stagedURL = soundsDirectory.appendingPathComponent(stagedName, isDirectory: false)
|
|
defer {
|
|
try? fileManager.removeItem(at: stagedURL)
|
|
}
|
|
|
|
XCTAssertTrue(fileManager.fileExists(atPath: sourceURL.path))
|
|
XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path))
|
|
XCTAssertTrue(stagedName.hasPrefix("cmux-custom-notification-sound-"))
|
|
XCTAssertTrue(stagedName.hasSuffix(".wav"))
|
|
}
|
|
|
|
func testNotificationCustomUnsupportedExtensionsStageAsCaf() {
|
|
XCTAssertEqual(
|
|
NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "mp3"),
|
|
"caf"
|
|
)
|
|
XCTAssertEqual(
|
|
NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "M4A"),
|
|
"caf"
|
|
)
|
|
XCTAssertEqual(
|
|
NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "wav"),
|
|
"wav"
|
|
)
|
|
XCTAssertEqual(
|
|
NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "AIFF"),
|
|
"aiff"
|
|
)
|
|
|
|
let sourceA = URL(fileURLWithPath: "/tmp/custom-a.mp3")
|
|
let sourceB = URL(fileURLWithPath: "/tmp/custom-b.mp3")
|
|
let stagedA = NotificationSoundSettings.stagedCustomSoundFileName(
|
|
forSourceURL: sourceA,
|
|
destinationExtension: "caf"
|
|
)
|
|
let stagedB = NotificationSoundSettings.stagedCustomSoundFileName(
|
|
forSourceURL: sourceB,
|
|
destinationExtension: "caf"
|
|
)
|
|
XCTAssertNotEqual(stagedA, stagedB)
|
|
XCTAssertTrue(stagedA.hasPrefix("cmux-custom-notification-sound-"))
|
|
XCTAssertTrue(stagedA.hasSuffix(".caf"))
|
|
}
|
|
|
|
func testNotificationCustomPreparationKeepsActiveSourceMetadataSidecar() {
|
|
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
let fileManager = FileManager.default
|
|
let soundsDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
|
|
.appendingPathComponent("Library", isDirectory: true)
|
|
.appendingPathComponent("Sounds", isDirectory: true)
|
|
do {
|
|
try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true)
|
|
} catch {
|
|
XCTFail("Failed to create sounds directory: \(error)")
|
|
return
|
|
}
|
|
|
|
let sourceURL = soundsDirectory.appendingPathComponent(
|
|
"cmux-custom-notification-sound.metadata-\(UUID().uuidString).wav",
|
|
isDirectory: false
|
|
)
|
|
do {
|
|
try Data("test".utf8).write(to: sourceURL, options: .atomic)
|
|
} catch {
|
|
XCTFail("Failed to write source custom sound file: \(error)")
|
|
return
|
|
}
|
|
defer {
|
|
try? fileManager.removeItem(at: sourceURL)
|
|
}
|
|
|
|
defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
|
|
defaults.set(sourceURL.path, forKey: NotificationSoundSettings.customFilePathKey)
|
|
|
|
let prepareResult = NotificationSoundSettings.prepareCustomFileForNotifications(path: sourceURL.path)
|
|
let stagedName: String
|
|
switch prepareResult {
|
|
case .success(let name):
|
|
stagedName = name
|
|
case .failure(let issue):
|
|
XCTFail("Expected custom sound preparation success, got \(issue)")
|
|
return
|
|
}
|
|
|
|
let stagedURL = soundsDirectory.appendingPathComponent(stagedName, isDirectory: false)
|
|
let metadataURL = stagedURL.appendingPathExtension("source-metadata")
|
|
defer {
|
|
try? fileManager.removeItem(at: stagedURL)
|
|
try? fileManager.removeItem(at: metadataURL)
|
|
}
|
|
|
|
XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path))
|
|
XCTAssertTrue(fileManager.fileExists(atPath: metadataURL.path))
|
|
}
|
|
|
|
func testNotificationCustomSoundReturnsNilWhenPreparationFails() {
|
|
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
let invalidSourceURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-invalid-sound-\(UUID().uuidString).mp3", isDirectory: false)
|
|
defer {
|
|
try? FileManager.default.removeItem(at: invalidSourceURL)
|
|
let stagedURL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
|
|
.appendingPathComponent("Library", isDirectory: true)
|
|
.appendingPathComponent("Sounds", isDirectory: true)
|
|
.appendingPathComponent("cmux-custom-notification-sound.caf", isDirectory: false)
|
|
try? FileManager.default.removeItem(at: stagedURL)
|
|
}
|
|
|
|
do {
|
|
try Data("not-audio".utf8).write(to: invalidSourceURL, options: .atomic)
|
|
} catch {
|
|
XCTFail("Failed to write invalid custom sound source: \(error)")
|
|
return
|
|
}
|
|
|
|
defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
|
|
defaults.set(invalidSourceURL.path, forKey: NotificationSoundSettings.customFilePathKey)
|
|
|
|
XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults))
|
|
}
|
|
|
|
func testNotificationCustomPreparationReportsMissingFile() {
|
|
let missingPath = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-missing-\(UUID().uuidString).wav", isDirectory: false)
|
|
.path
|
|
|
|
let result = NotificationSoundSettings.prepareCustomFileForNotifications(path: missingPath)
|
|
switch result {
|
|
case .success:
|
|
XCTFail("Expected missing file failure")
|
|
case .failure(let issue):
|
|
guard case .missingFile = issue else {
|
|
XCTFail("Expected missingFile issue, got \(issue)")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func testFocusedTerminalNotificationStillRunsLocalSoundFeedbackWhenExternalDeliveryIsSuppressed() throws {
|
|
guard let appDelegate = AppDelegate.shared else {
|
|
XCTFail("AppDelegate.shared must be set for this test")
|
|
return
|
|
}
|
|
let manager = TabManager()
|
|
let store = TerminalNotificationStore.shared
|
|
|
|
let originalTabManager = appDelegate.tabManager
|
|
let originalNotificationStore = appDelegate.notificationStore
|
|
let originalAppFocusOverride = AppFocusState.overrideIsFocused
|
|
|
|
var deliveredNotificationIDs: [UUID] = []
|
|
var localFeedbackNotificationIDs: [UUID] = []
|
|
|
|
store.replaceNotificationsForTesting([])
|
|
store.configureNotificationDeliveryHandlerForTesting { _, notification in
|
|
deliveredNotificationIDs.append(notification.id)
|
|
}
|
|
store.configureSuppressedNotificationFeedbackHandlerForTesting { _, notification in
|
|
localFeedbackNotificationIDs.append(notification.id)
|
|
}
|
|
appDelegate.tabManager = manager
|
|
appDelegate.notificationStore = store
|
|
AppFocusState.overrideIsFocused = true
|
|
|
|
defer {
|
|
store.replaceNotificationsForTesting([])
|
|
appDelegate.tabManager = originalTabManager
|
|
appDelegate.notificationStore = originalNotificationStore
|
|
AppFocusState.overrideIsFocused = originalAppFocusOverride
|
|
}
|
|
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let terminalPanel = workspace.focusedTerminalPanel else {
|
|
XCTFail("Expected selected workspace with a focused terminal panel")
|
|
return
|
|
}
|
|
|
|
store.addNotification(
|
|
tabId: workspace.id,
|
|
surfaceId: terminalPanel.id,
|
|
title: "Unread",
|
|
subtitle: "",
|
|
body: ""
|
|
)
|
|
|
|
let createdNotificationID = try XCTUnwrap(store.notifications.first?.id)
|
|
XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id))
|
|
XCTAssertTrue(deliveredNotificationIDs.isEmpty)
|
|
XCTAssertEqual(localFeedbackNotificationIDs.count, 1)
|
|
XCTAssertEqual(localFeedbackNotificationIDs, [createdNotificationID])
|
|
}
|
|
|
|
func testFocusedTerminalSuppressedNotificationRunsCustomCommand() throws {
|
|
guard let appDelegate = AppDelegate.shared else {
|
|
XCTFail("AppDelegate.shared must be set for this test")
|
|
return
|
|
}
|
|
let manager = TabManager()
|
|
let store = TerminalNotificationStore.shared
|
|
let defaults = UserDefaults.standard
|
|
let commandOutputURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-notification-command-\(UUID().uuidString).txt", isDirectory: false)
|
|
|
|
let originalTabManager = appDelegate.tabManager
|
|
let originalNotificationStore = appDelegate.notificationStore
|
|
let originalAppFocusOverride = AppFocusState.overrideIsFocused
|
|
let hadSoundValue = defaults.object(forKey: NotificationSoundSettings.key) != nil
|
|
let originalSoundValue = defaults.object(forKey: NotificationSoundSettings.key)
|
|
let hadCommandValue = defaults.object(forKey: NotificationSoundSettings.customCommandKey) != nil
|
|
let originalCommandValue = defaults.object(forKey: NotificationSoundSettings.customCommandKey)
|
|
|
|
var deliveredNotificationIDs: [UUID] = []
|
|
|
|
store.replaceNotificationsForTesting([])
|
|
store.configureNotificationDeliveryHandlerForTesting { _, notification in
|
|
deliveredNotificationIDs.append(notification.id)
|
|
}
|
|
appDelegate.tabManager = manager
|
|
appDelegate.notificationStore = store
|
|
AppFocusState.overrideIsFocused = true
|
|
defaults.set("none", forKey: NotificationSoundSettings.key)
|
|
defaults.set(
|
|
"printf '%s\\n%s\\n%s' \"$CMUX_NOTIFICATION_TITLE\" \"$CMUX_NOTIFICATION_SUBTITLE\" \"$CMUX_NOTIFICATION_BODY\" > '\(commandOutputURL.path)'",
|
|
forKey: NotificationSoundSettings.customCommandKey
|
|
)
|
|
|
|
defer {
|
|
store.replaceNotificationsForTesting([])
|
|
appDelegate.tabManager = originalTabManager
|
|
appDelegate.notificationStore = originalNotificationStore
|
|
AppFocusState.overrideIsFocused = originalAppFocusOverride
|
|
if hadSoundValue {
|
|
defaults.set(originalSoundValue, forKey: NotificationSoundSettings.key)
|
|
} else {
|
|
defaults.removeObject(forKey: NotificationSoundSettings.key)
|
|
}
|
|
if hadCommandValue {
|
|
defaults.set(originalCommandValue, forKey: NotificationSoundSettings.customCommandKey)
|
|
} else {
|
|
defaults.removeObject(forKey: NotificationSoundSettings.customCommandKey)
|
|
}
|
|
try? FileManager.default.removeItem(at: commandOutputURL)
|
|
}
|
|
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let terminalPanel = workspace.focusedTerminalPanel else {
|
|
XCTFail("Expected selected workspace with a focused terminal panel")
|
|
return
|
|
}
|
|
|
|
store.addNotification(
|
|
tabId: workspace.id,
|
|
surfaceId: terminalPanel.id,
|
|
title: "",
|
|
subtitle: "Focused subtitle",
|
|
body: "Focused body"
|
|
)
|
|
|
|
let commandFinished = XCTNSPredicateExpectation(
|
|
predicate: NSPredicate { _, _ in
|
|
FileManager.default.fileExists(atPath: commandOutputURL.path)
|
|
},
|
|
object: NSObject()
|
|
)
|
|
XCTAssertEqual(XCTWaiter().wait(for: [commandFinished], timeout: 2.0), .completed)
|
|
XCTAssertTrue(deliveredNotificationIDs.isEmpty)
|
|
|
|
let output = try String(contentsOf: commandOutputURL, encoding: .utf8)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let expectedTitle = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
|
|
?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
|
|
?? "cmux"
|
|
XCTAssertEqual(output.components(separatedBy: "\n"), [expectedTitle, "Focused subtitle", "Focused body"])
|
|
}
|
|
|
|
func testNotificationAuthorizationStateMappingCoversKnownUNAuthorizationStatuses() {
|
|
XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .notDetermined), .notDetermined)
|
|
XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .denied), .denied)
|
|
XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .authorized), .authorized)
|
|
XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .provisional), .provisional)
|
|
}
|
|
|
|
func testNotificationAuthorizationStateDeliveryCapability() {
|
|
XCTAssertFalse(NotificationAuthorizationState.unknown.allowsDelivery)
|
|
XCTAssertFalse(NotificationAuthorizationState.notDetermined.allowsDelivery)
|
|
XCTAssertFalse(NotificationAuthorizationState.denied.allowsDelivery)
|
|
XCTAssertTrue(NotificationAuthorizationState.authorized.allowsDelivery)
|
|
XCTAssertTrue(NotificationAuthorizationState.provisional.allowsDelivery)
|
|
XCTAssertTrue(NotificationAuthorizationState.ephemeral.allowsDelivery)
|
|
}
|
|
|
|
func testNotificationAuthorizationDefersFirstPromptWhileAppIsInactive() {
|
|
XCTAssertTrue(
|
|
TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest(
|
|
status: .notDetermined,
|
|
isAppActive: false
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest(
|
|
status: .notDetermined,
|
|
isAppActive: true
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest(
|
|
status: .authorized,
|
|
isAppActive: false
|
|
)
|
|
)
|
|
}
|
|
|
|
func testNotificationAuthorizationRequestGatingAllowsSettingsRetry() {
|
|
XCTAssertTrue(
|
|
TerminalNotificationStore.shouldRequestAuthorization(
|
|
isAutomaticRequest: false,
|
|
hasRequestedAutomaticAuthorization: true
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
TerminalNotificationStore.shouldRequestAuthorization(
|
|
isAutomaticRequest: true,
|
|
hasRequestedAutomaticAuthorization: false
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
TerminalNotificationStore.shouldRequestAuthorization(
|
|
isAutomaticRequest: true,
|
|
hasRequestedAutomaticAuthorization: true
|
|
)
|
|
)
|
|
}
|
|
|
|
func testNotificationSettingsPromptUsesSheetAndNeverRunsModal() {
|
|
let store = TerminalNotificationStore.shared
|
|
let alertSpy = NotificationSettingsAlertSpy()
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
|
|
styleMask: [.titled],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
|
|
var openedURL: URL?
|
|
store.configureNotificationSettingsPromptHooksForTesting(
|
|
windowProvider: { window },
|
|
alertFactory: { alertSpy },
|
|
scheduler: { _, block in block() },
|
|
urlOpener: { openedURL = $0 }
|
|
)
|
|
|
|
store.promptToEnableNotificationsForTesting()
|
|
let drained = expectation(description: "main queue drained")
|
|
DispatchQueue.main.async { drained.fulfill() }
|
|
wait(for: [drained], timeout: 1.0)
|
|
|
|
XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1)
|
|
XCTAssertEqual(alertSpy.runModalCallCount, 0)
|
|
XCTAssertEqual(
|
|
openedURL?.absoluteString,
|
|
"x-apple.systempreferences:com.apple.preference.notifications"
|
|
)
|
|
}
|
|
|
|
func testNotificationSettingsPromptRetriesUntilWindowExists() {
|
|
let store = TerminalNotificationStore.shared
|
|
let alertSpy = NotificationSettingsAlertSpy()
|
|
alertSpy.nextResponse = .alertSecondButtonReturn
|
|
|
|
var queuedRetryBlocks: [() -> Void] = []
|
|
var promptWindow: NSWindow?
|
|
store.configureNotificationSettingsPromptHooksForTesting(
|
|
windowProvider: { promptWindow },
|
|
alertFactory: { alertSpy },
|
|
scheduler: { _, block in queuedRetryBlocks.append(block) },
|
|
urlOpener: { _ in XCTFail("Should not open settings for Not Now response") }
|
|
)
|
|
|
|
store.promptToEnableNotificationsForTesting()
|
|
let drained = expectation(description: "main queue drained")
|
|
DispatchQueue.main.async { drained.fulfill() }
|
|
wait(for: [drained], timeout: 1.0)
|
|
|
|
XCTAssertEqual(alertSpy.beginSheetModalCallCount, 0)
|
|
XCTAssertEqual(alertSpy.runModalCallCount, 0)
|
|
XCTAssertEqual(queuedRetryBlocks.count, 1)
|
|
|
|
promptWindow = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
|
|
styleMask: [.titled],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
queuedRetryBlocks.removeFirst()()
|
|
|
|
XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1)
|
|
XCTAssertEqual(alertSpy.runModalCallCount, 0)
|
|
}
|
|
|
|
func testNotificationIndexesTrackUnreadCountsByTabAndSurface() {
|
|
let tabA = UUID()
|
|
let tabB = UUID()
|
|
let surfaceA = UUID()
|
|
let surfaceB = UUID()
|
|
let notificationAUnread = TerminalNotification(
|
|
id: UUID(),
|
|
tabId: tabA,
|
|
surfaceId: surfaceA,
|
|
title: "A unread",
|
|
subtitle: "",
|
|
body: "",
|
|
createdAt: Date(),
|
|
isRead: false
|
|
)
|
|
let notificationARead = TerminalNotification(
|
|
id: UUID(),
|
|
tabId: tabA,
|
|
surfaceId: surfaceB,
|
|
title: "A read",
|
|
subtitle: "",
|
|
body: "",
|
|
createdAt: Date(),
|
|
isRead: true
|
|
)
|
|
let notificationBUnread = TerminalNotification(
|
|
id: UUID(),
|
|
tabId: tabB,
|
|
surfaceId: nil,
|
|
title: "B unread",
|
|
subtitle: "",
|
|
body: "",
|
|
createdAt: Date(),
|
|
isRead: false
|
|
)
|
|
|
|
let store = TerminalNotificationStore.shared
|
|
store.replaceNotificationsForTesting([
|
|
notificationAUnread,
|
|
notificationARead,
|
|
notificationBUnread
|
|
])
|
|
|
|
XCTAssertEqual(store.unreadCount, 2)
|
|
XCTAssertEqual(store.unreadCount(forTabId: tabA), 1)
|
|
XCTAssertEqual(store.unreadCount(forTabId: tabB), 1)
|
|
XCTAssertTrue(store.hasUnreadNotification(forTabId: tabA, surfaceId: surfaceA))
|
|
XCTAssertFalse(store.hasUnreadNotification(forTabId: tabA, surfaceId: surfaceB))
|
|
XCTAssertTrue(store.hasUnreadNotification(forTabId: tabB, surfaceId: nil))
|
|
XCTAssertEqual(store.latestNotification(forTabId: tabA)?.id, notificationAUnread.id)
|
|
XCTAssertEqual(store.latestNotification(forTabId: tabB)?.id, notificationBUnread.id)
|
|
}
|
|
|
|
func testNotificationIndexesUpdateAfterReadAndClearMutations() {
|
|
let tab = UUID()
|
|
let surfaceUnread = UUID()
|
|
let surfaceRead = UUID()
|
|
let unreadNotification = TerminalNotification(
|
|
id: UUID(),
|
|
tabId: tab,
|
|
surfaceId: surfaceUnread,
|
|
title: "Unread",
|
|
subtitle: "",
|
|
body: "",
|
|
createdAt: Date(),
|
|
isRead: false
|
|
)
|
|
let readNotification = TerminalNotification(
|
|
id: UUID(),
|
|
tabId: tab,
|
|
surfaceId: surfaceRead,
|
|
title: "Read",
|
|
subtitle: "",
|
|
body: "",
|
|
createdAt: Date(),
|
|
isRead: true
|
|
)
|
|
|
|
let store = TerminalNotificationStore.shared
|
|
store.replaceNotificationsForTesting([unreadNotification, readNotification])
|
|
XCTAssertEqual(store.unreadCount(forTabId: tab), 1)
|
|
XCTAssertTrue(store.hasUnreadNotification(forTabId: tab, surfaceId: surfaceUnread))
|
|
|
|
store.markRead(forTabId: tab, surfaceId: surfaceUnread)
|
|
XCTAssertEqual(store.unreadCount(forTabId: tab), 0)
|
|
XCTAssertFalse(store.hasUnreadNotification(forTabId: tab, surfaceId: surfaceUnread))
|
|
XCTAssertNil(store.latestNotification(forTabId: tab))
|
|
|
|
store.clearNotifications(forTabId: tab)
|
|
XCTAssertEqual(store.unreadCount(forTabId: tab), 0)
|
|
XCTAssertNil(store.latestNotification(forTabId: tab))
|
|
}
|
|
}
|
|
|
|
|
|
final class MenuBarBadgeLabelFormatterTests: XCTestCase {
|
|
func testBadgeLabelFormatting() {
|
|
XCTAssertNil(MenuBarBadgeLabelFormatter.badgeText(for: 0))
|
|
XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 1), "1")
|
|
XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 9), "9")
|
|
XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 10), "9+")
|
|
XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 47), "9+")
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class FocusedNotificationIndicatorTests: XCTestCase {
|
|
func testFocusedNotificationIndicatorRemainsVisibleAfterFocusedNotificationIsRead() {
|
|
let appDelegate = AppDelegate.shared ?? AppDelegate()
|
|
let manager = TabManager()
|
|
let store = TerminalNotificationStore.shared
|
|
|
|
let originalTabManager = appDelegate.tabManager
|
|
let originalNotificationStore = appDelegate.notificationStore
|
|
let originalAppFocusOverride = AppFocusState.overrideIsFocused
|
|
|
|
store.replaceNotificationsForTesting([])
|
|
store.configureNotificationDeliveryHandlerForTesting { _, _ in }
|
|
appDelegate.tabManager = manager
|
|
appDelegate.notificationStore = store
|
|
AppFocusState.overrideIsFocused = true
|
|
|
|
defer {
|
|
store.replaceNotificationsForTesting([])
|
|
store.resetNotificationDeliveryHandlerForTesting()
|
|
appDelegate.tabManager = originalTabManager
|
|
appDelegate.notificationStore = originalNotificationStore
|
|
AppFocusState.overrideIsFocused = originalAppFocusOverride
|
|
}
|
|
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let panelId = workspace.focusedPanelId else {
|
|
XCTFail("Expected selected workspace with focused panel")
|
|
return
|
|
}
|
|
|
|
store.addNotification(
|
|
tabId: workspace.id,
|
|
surfaceId: panelId,
|
|
title: "Focused",
|
|
subtitle: "",
|
|
body: ""
|
|
)
|
|
|
|
XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId))
|
|
XCTAssertTrue(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: panelId))
|
|
|
|
store.markRead(forTabId: workspace.id, surfaceId: panelId)
|
|
|
|
XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId))
|
|
XCTAssertTrue(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: panelId))
|
|
|
|
store.clearFocusedReadIndicator(forTabId: workspace.id, surfaceId: panelId)
|
|
|
|
XCTAssertFalse(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: panelId))
|
|
}
|
|
|
|
func testNewNotificationOnDifferentSurfaceClearsPreviousFocusedReadIndicator() {
|
|
let appDelegate = AppDelegate.shared ?? AppDelegate()
|
|
let manager = TabManager()
|
|
let store = TerminalNotificationStore.shared
|
|
|
|
let originalTabManager = appDelegate.tabManager
|
|
let originalNotificationStore = appDelegate.notificationStore
|
|
let originalAppFocusOverride = AppFocusState.overrideIsFocused
|
|
|
|
store.replaceNotificationsForTesting([])
|
|
store.configureNotificationDeliveryHandlerForTesting { _, _ in }
|
|
appDelegate.tabManager = manager
|
|
appDelegate.notificationStore = store
|
|
AppFocusState.overrideIsFocused = true
|
|
|
|
defer {
|
|
store.replaceNotificationsForTesting([])
|
|
store.resetNotificationDeliveryHandlerForTesting()
|
|
appDelegate.tabManager = originalTabManager
|
|
appDelegate.notificationStore = originalNotificationStore
|
|
AppFocusState.overrideIsFocused = originalAppFocusOverride
|
|
}
|
|
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let leftPanelId = workspace.focusedPanelId,
|
|
let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
|
|
XCTFail("Expected split workspace setup")
|
|
return
|
|
}
|
|
|
|
workspace.focusPanel(rightPanel.id)
|
|
|
|
store.setFocusedReadIndicator(forTabId: workspace.id, surfaceId: rightPanel.id)
|
|
XCTAssertTrue(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: rightPanel.id))
|
|
|
|
store.addNotification(
|
|
tabId: workspace.id,
|
|
surfaceId: leftPanelId,
|
|
title: "Left",
|
|
subtitle: "",
|
|
body: ""
|
|
)
|
|
|
|
XCTAssertFalse(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: rightPanel.id))
|
|
}
|
|
}
|
|
|
|
|
|
final class NotificationMenuSnapshotBuilderTests: XCTestCase {
|
|
func testSnapshotCountsUnreadAndLimitsRecentItems() {
|
|
let notifications = (0..<8).map { index in
|
|
TerminalNotification(
|
|
id: UUID(),
|
|
tabId: UUID(),
|
|
surfaceId: nil,
|
|
title: "N\(index)",
|
|
subtitle: "",
|
|
body: "",
|
|
createdAt: Date(timeIntervalSince1970: TimeInterval(index)),
|
|
isRead: index.isMultiple(of: 2)
|
|
)
|
|
}
|
|
|
|
let snapshot = NotificationMenuSnapshotBuilder.make(
|
|
notifications: notifications,
|
|
maxInlineNotificationItems: 3
|
|
)
|
|
|
|
XCTAssertEqual(snapshot.unreadCount, 4)
|
|
XCTAssertTrue(snapshot.hasNotifications)
|
|
XCTAssertTrue(snapshot.hasUnreadNotifications)
|
|
XCTAssertEqual(snapshot.recentNotifications.count, 3)
|
|
XCTAssertEqual(snapshot.recentNotifications.map(\.id), Array(notifications.prefix(3)).map(\.id))
|
|
}
|
|
|
|
func testStateHintTitleHandlesSingularPluralAndZero() {
|
|
XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 0), "No unread notifications")
|
|
XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 1), "1 unread notification")
|
|
XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 2), "2 unread notifications")
|
|
}
|
|
}
|
|
|
|
|
|
final class MenuBarBuildHintFormatterTests: XCTestCase {
|
|
func testReleaseBuildShowsNoHint() {
|
|
XCTAssertNil(MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV menubar-extra", isDebugBuild: false))
|
|
}
|
|
|
|
func testDebugBuildWithTagShowsTag() {
|
|
XCTAssertEqual(
|
|
MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV menubar-extra", isDebugBuild: true),
|
|
"Build Tag: menubar-extra"
|
|
)
|
|
}
|
|
|
|
func testDebugBuildWithoutTagShowsUntagged() {
|
|
XCTAssertEqual(
|
|
MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV", isDebugBuild: true),
|
|
"Build: DEV (untagged)"
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
final class MenuBarNotificationLineFormatterTests: XCTestCase {
|
|
func testPlainTitleContainsUnreadDotBodyAndTab() {
|
|
let notification = TerminalNotification(
|
|
id: UUID(),
|
|
tabId: UUID(),
|
|
surfaceId: nil,
|
|
title: "Build finished",
|
|
subtitle: "",
|
|
body: "All checks passed",
|
|
createdAt: Date(timeIntervalSince1970: 0),
|
|
isRead: false
|
|
)
|
|
|
|
let line = MenuBarNotificationLineFormatter.plainTitle(notification: notification, tabTitle: "workspace-1")
|
|
XCTAssertTrue(line.hasPrefix("● Build finished"))
|
|
XCTAssertTrue(line.contains("All checks passed"))
|
|
XCTAssertTrue(line.contains("workspace-1"))
|
|
}
|
|
|
|
func testPlainTitleFallsBackToSubtitleWhenBodyEmpty() {
|
|
let notification = TerminalNotification(
|
|
id: UUID(),
|
|
tabId: UUID(),
|
|
surfaceId: nil,
|
|
title: "Deploy",
|
|
subtitle: "staging",
|
|
body: "",
|
|
createdAt: Date(timeIntervalSince1970: 0),
|
|
isRead: true
|
|
)
|
|
|
|
let line = MenuBarNotificationLineFormatter.plainTitle(notification: notification, tabTitle: nil)
|
|
XCTAssertTrue(line.hasPrefix(" Deploy"))
|
|
XCTAssertTrue(line.contains("staging"))
|
|
}
|
|
|
|
func testMenuTitleWrapsAndTruncatesToThreeLines() {
|
|
let notification = TerminalNotification(
|
|
id: UUID(),
|
|
tabId: UUID(),
|
|
surfaceId: nil,
|
|
title: "Extremely long notification title for wrapping behavior validation",
|
|
subtitle: "",
|
|
body: Array(repeating: "this body should wrap and eventually truncate", count: 8).joined(separator: " "),
|
|
createdAt: Date(timeIntervalSince1970: 0),
|
|
isRead: false
|
|
)
|
|
|
|
let title = MenuBarNotificationLineFormatter.menuTitle(
|
|
notification: notification,
|
|
tabTitle: "workspace-with-a-very-long-name",
|
|
maxWidth: 120,
|
|
maxLines: 3
|
|
)
|
|
|
|
XCTAssertLessThanOrEqual(title.components(separatedBy: "\n").count, 3)
|
|
XCTAssertTrue(title.hasSuffix("…"))
|
|
}
|
|
|
|
func testMenuTitlePreservesShortTextWithoutEllipsis() {
|
|
let notification = TerminalNotification(
|
|
id: UUID(),
|
|
tabId: UUID(),
|
|
surfaceId: nil,
|
|
title: "Done",
|
|
subtitle: "",
|
|
body: "All checks passed",
|
|
createdAt: Date(timeIntervalSince1970: 0),
|
|
isRead: false
|
|
)
|
|
|
|
let title = MenuBarNotificationLineFormatter.menuTitle(
|
|
notification: notification,
|
|
tabTitle: "w1",
|
|
maxWidth: 320,
|
|
maxLines: 3
|
|
)
|
|
|
|
XCTAssertFalse(title.hasSuffix("…"))
|
|
}
|
|
}
|
|
|
|
|
|
final class MenuBarIconDebugSettingsTests: XCTestCase {
|
|
func testDisplayedUnreadCountUsesPreviewOverrideWhenEnabled() {
|
|
let suiteName = "MenuBarIconDebugSettingsTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer { defaults.removePersistentDomain(forName: suiteName) }
|
|
|
|
defaults.set(true, forKey: MenuBarIconDebugSettings.previewEnabledKey)
|
|
defaults.set(7, forKey: MenuBarIconDebugSettings.previewCountKey)
|
|
|
|
XCTAssertEqual(MenuBarIconDebugSettings.displayedUnreadCount(actualUnreadCount: 2, defaults: defaults), 7)
|
|
}
|
|
|
|
func testBadgeRenderConfigClampsInvalidValues() {
|
|
let suiteName = "MenuBarIconDebugSettingsTests.Clamp.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer { defaults.removePersistentDomain(forName: suiteName) }
|
|
|
|
defaults.set(-100, forKey: MenuBarIconDebugSettings.badgeRectXKey)
|
|
defaults.set(200, forKey: MenuBarIconDebugSettings.badgeRectYKey)
|
|
defaults.set(-100, forKey: MenuBarIconDebugSettings.singleDigitFontSizeKey)
|
|
defaults.set(100, forKey: MenuBarIconDebugSettings.multiDigitXAdjustKey)
|
|
|
|
let config = MenuBarIconDebugSettings.badgeRenderConfig(defaults: defaults)
|
|
XCTAssertEqual(config.badgeRect.origin.x, 0, accuracy: 0.001)
|
|
XCTAssertEqual(config.badgeRect.origin.y, 20, accuracy: 0.001)
|
|
XCTAssertEqual(config.singleDigitFontSize, 6, accuracy: 0.001)
|
|
XCTAssertEqual(config.multiDigitXAdjust, 4, accuracy: 0.001)
|
|
}
|
|
|
|
func testBadgeRenderConfigUsesLegacySingleDigitXAdjustWhenNewKeyMissing() {
|
|
let suiteName = "MenuBarIconDebugSettingsTests.LegacyX.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer { defaults.removePersistentDomain(forName: suiteName) }
|
|
|
|
defaults.set(2.5, forKey: MenuBarIconDebugSettings.legacySingleDigitXAdjustKey)
|
|
|
|
let config = MenuBarIconDebugSettings.badgeRenderConfig(defaults: defaults)
|
|
XCTAssertEqual(config.singleDigitXAdjust, 2.5, accuracy: 0.001)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
|
|
|
|
final class MenuBarIconRendererTests: XCTestCase {
|
|
func testImageWidthDoesNotShiftWhenBadgeAppears() {
|
|
let noBadge = MenuBarIconRenderer.makeImage(unreadCount: 0)
|
|
let withBadge = MenuBarIconRenderer.makeImage(unreadCount: 2)
|
|
|
|
XCTAssertEqual(noBadge.size.width, 18, accuracy: 0.001)
|
|
XCTAssertEqual(withBadge.size.width, 18, accuracy: 0.001)
|
|
}
|
|
}
|