diff --git a/CHANGELOG.md b/CHANGELOG.md index 42a61bd..f9330d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - 在线升级:启动时和之后每天检查官网版本清单,发现新版本时弹出版本号与更新摘要,可一键安装、以后再说或跳过此版本;下载后依次核对 sha256、包名与版本、开发者签名团队和 Apple 公证,任何一项不通过都放弃安装;旧版先备份再原地替换,失败自动还原,装完自动重启到新版。「设置 · 关于」与应用菜单里可以手动检查更新。 - 辅助工具版本检查:启动时核对正在运行的辅助工具版本;旧进程还在运行时先断开让它退出,由系统启动新版本,仍然旧时在风扇、合盖运行和辅助工具设置里提示“重新安装”,侧边栏对应项目显示提醒圆点;有新版本时“关于”旁同样显示圆点。 - 导出诊断信息(设置 · 关于):把版本、系统与辅助工具状态、主要设置、最近 3 天的运行日志、清理记录和最近的崩溃报告打包成 zip,反馈问题时附上;自动去掉用户目录名、IP 与硬件地址,不含序列号。应用与辅助工具的关键操作和错误写入系统日志。 +- 系统通知(设置 · 通知):CPU 过热(持续 1 分钟高于设定温度)、内存压力严重(持续 30 秒)、磁盘空间不足(低于 10% 或 10 GB)、网络断开超过 20 秒(恢复后再提示)、电池最大容量低于 80%,逐项开关,默认关闭;同一状况持续期间只提醒一次并有冷却时间,点通知打开对应页面;可发送测试通知,通知被关闭时提示去系统设置打开。 #### 调整 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift index e5e4256..eeb746e 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift @@ -53,6 +53,11 @@ public final class AppController: NSObject, NSApplicationDelegate { updateNetworkVisibility() Task { await model.geo.updateIfNeeded() } startUpdateChecks() + model.alerts.openTab = { [weak self] tab in + self?.menuBar.dismissPopovers() + self?.mainWindow.show(tab: tab) + } + model.alerts.start() // 开发调试:--show-panel [cpu|memory|network|gpu|temperature|fan] 启动后展开并固定弹窗;--show-window 打开主窗口 let arguments = CommandLine.arguments @@ -150,9 +155,11 @@ public final class AppController: NSObject, NSApplicationDelegate { _ = model.isNetworkDetailVisible _ = model.settings.probeEnabled _ = model.settings.probeInBackground + _ = model.settings.enabledAlerts } onChange: { [weak self] in Task { @MainActor in guard let self else { return } + self.model.alerts.applySettings() await self.model.hub.update(self.model.demand) self.applyAppearance() self.menuBar.update() diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift index 8eefc1d..6504b40 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift @@ -39,6 +39,7 @@ public struct MainWindowView: View { case .cleaner: CleanerPage() case .settingsGeneral: SettingsTabPage { GeneralSettings() } case .settingsMenuBar: SettingsTabPage { MenuBarSettings() } + case .settingsNotifications: SettingsTabPage { NotificationSettings() } case .settingsHelper: SettingsTabPage { HelperSettings() } case .settingsAbout: SettingsTabPage { AboutSettings() } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift index d12d575..20a7e6a 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift @@ -89,6 +89,59 @@ struct GeneralSettings: View { } } +// MARK: - 通知 + +struct NotificationSettings: View { + @Environment(AppModel.self) private var model + + var body: some View { + @Bindable var settings = model.settings + let alerts = model.alerts + + if alerts.authorization == .denied { + InfoBanner(icon: "bell.slash", text: "OpenStats 的通知被关闭了,打开的提醒不会显示。", tone: .warning) { + Button("打开通知设置") { alerts.openNotificationSettings() } + .buttonStyle(DSButtonStyle(kind: .primary)) + } + } + + SettingsGroup(caption: "发生这些状况时发送系统通知,点通知打开对应页面") { + ForEach(Array(AlertKind.allCases.enumerated()), id: \.element) { index, kind in + GroupRow(showsDivider: index > 0) { + SettingRow(title: kind.title, subtitle: kind.detail, icon: kind.symbol) { + DSToggle(isOn: Binding(get: { settings.enabledAlerts.contains(kind) }, + set: { on in + if on { settings.enabledAlerts.insert(kind) } else { settings.enabledAlerts.remove(kind) } + }), + label: kind.title) + } + } + if kind == .cpuTemperature, settings.enabledAlerts.contains(.cpuTemperature) { + GroupRow { + SettingRow(title: "过热温度") { + SegmentedControl(selection: $settings.alertCPUTemperature, + options: AppSettings.alertTemperatureOptions.map { + ($0, Format.temperature(Double($0), fahrenheit: settings.useFahrenheit)) + }) + .frame(width: DS.Size.sidebarWidth + DS.Space.s12) + } + } + } + } + } + + SettingsGroup { + GroupRow(showsDivider: false) { + SettingRow(title: "发送测试通知", subtitle: alerts.authorization == .allowed ? "确认通知能正常显示" : "首次发送时系统会询问是否允许通知") { + Button("发送") { alerts.sendTest() } + .buttonStyle(DSButtonStyle(kind: .secondary)) + } + } + } + .onAppear { alerts.refreshAuthorization() } + } +} + // MARK: - 诊断 struct DiagnosticsSettings: View { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AlertController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AlertController.swift new file mode 100644 index 0000000..df2cb91 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AlertController.swift @@ -0,0 +1,306 @@ +import AppKit +import Foundation +import Metrics +import Network +import Observation +import UserNotifications + +/// 可以发系统通知的状况 +public enum AlertKind: String, CaseIterable, Identifiable, Sendable { + case cpuTemperature, memoryPressure, diskSpace, networkDown, batteryHealth + + public var id: String { rawValue } + + var title: String { + switch self { + case .cpuTemperature: "CPU 过热" + case .memoryPressure: "内存压力严重" + case .diskSpace: "磁盘空间不足" + case .networkDown: "网络断开" + case .batteryHealth: "电池健康度下降" + } + } + + var detail: String { + switch self { + case .cpuTemperature: "CPU 温度持续 1 分钟高于设定值" + case .memoryPressure: "内存压力持续 30 秒处于严重,系统开始大量换页" + case .diskSpace: "启动磁盘可用空间低于 10% 或 10 GB,每 10 分钟检查一次" + case .networkDown: "网络连接中断超过 20 秒,恢复后再提示一次" + case .batteryHealth: "电池最大容量低于 80%,每 30 天最多提醒一次" + } + } + + var symbol: String { + switch self { + case .cpuTemperature: "thermometer.high" + case .memoryPressure: "memorychip" + case .diskSpace: "internaldrive" + case .networkDown: "wifi.slash" + case .batteryHealth: "battery.25percent" + } + } + + /// 点通知打开的页面 + var tab: PanelTab { + switch self { + case .cpuTemperature: .thermal + case .memoryPressure: .memory + case .diskSpace: .cleaner + case .networkDown: .network + case .batteryHealth: .system + } + } + + /// 状况持续多久才提醒 + var sustain: TimeInterval { + switch self { + case .cpuTemperature: 60 + case .memoryPressure: 30 + case .networkDown: 20 + case .diskSpace, .batteryHealth: 0 + } + } + + /// 同一状况两次提醒的最短间隔 + var cooldown: TimeInterval { + switch self { + case .cpuTemperature, .memoryPressure, .networkDown: 30 * 60 + case .diskSpace: 6 * 60 * 60 + case .batteryHealth: 30 * 24 * 60 * 60 + } + } +} + +/// 一种状况的触发判断:持续满 sustain 秒才触发,同一段持续期内只触发一次,两次之间至少间隔 cooldown +struct AlertTracker: Equatable { + private(set) var activeSince: Date? + private(set) var firedThisEpisode = false + private(set) var lastFired: Date? + + /// 返回 true 表示现在应该发通知 + mutating func update(isActive: Bool, now: Date, sustain: TimeInterval, cooldown: TimeInterval) -> Bool { + guard isActive else { + activeSince = nil + firedThisEpisode = false + return false + } + let since = activeSince ?? now + activeSince = since + guard !firedThisEpisode, now.timeIntervalSince(since) >= sustain else { return false } + if let lastFired, now.timeIntervalSince(lastFired) < cooldown { return false } + firedThisEpisode = true + lastFired = now + return true + } +} + +/// 判断各项状况并发出系统通知;点通知打开主窗口的对应页面 +@MainActor +@Observable +public final class AlertController: NSObject { + public enum Authorization: Equatable { + case unknown, allowed, denied + } + + public private(set) var authorization: Authorization = .unknown + + @ObservationIgnored var openTab: (PanelTab) -> Void = { _ in } + @ObservationIgnored private let settings: AppSettings + @ObservationIgnored private let defaults: UserDefaults + @ObservationIgnored private var trackers: [AlertKind: AlertTracker] = [:] + @ObservationIgnored private var pathMonitor: NWPathMonitor? + @ObservationIgnored private var networkNotified = false + @ObservationIgnored private var networkDownTask: Task? + @ObservationIgnored private var periodicTask: Task? + + /// 单元测试与截图进程没有应用包标识,不能使用通知中心 + private var center: UNUserNotificationCenter? { + Bundle.main.bundleIdentifier == nil ? nil : UNUserNotificationCenter.current() + } + + init(settings: AppSettings, defaults: UserDefaults = .standard) { + self.settings = settings + self.defaults = defaults + super.init() + } + + func start() { + center?.delegate = self + refreshAuthorization() + applySettings() + } + + /// 开关变化后启动或停止网络监听与定期检查;首次打开任一提醒时请求通知权限 + func applySettings() { + let enabled = settings.enabledAlerts + if !enabled.isEmpty, authorization == .unknown { requestAuthorization() } + + if enabled.contains(.networkDown) { + if pathMonitor == nil { startNetworkMonitor() } + } else { + pathMonitor?.cancel() + pathMonitor = nil + networkDownTask?.cancel() + networkNotified = false + } + + if enabled.contains(.diskSpace) || enabled.contains(.batteryHealth) { + if periodicTask == nil { + periodicTask = Task { [weak self] in + while !Task.isCancelled { + self?.checkPeriodic() + try? await Task.sleep(for: .seconds(10 * 60), tolerance: .seconds(60)) + } + } + } + } else { + periodicTask?.cancel() + periodicTask = nil + } + } + + // MARK: 判断 + + /// 每次采样后调用 + func evaluate(_ store: MetricsStore, now: Date = Date()) { + let enabled = settings.enabledAlerts + guard !enabled.isEmpty else { return } + if enabled.contains(.cpuTemperature), + let cpu = store.sensors?.temperatures.first(where: { $0.group == .cpu })?.maximum { + let limit = Double(settings.alertCPUTemperature) + fireIfNeeded(.cpuTemperature, isActive: cpu >= limit, now: now, + body: "CPU 温度 \(Format.temperature(cpu, fahrenheit: settings.useFahrenheit)),已持续超过 1 分钟。可以检查高占用进程,或在“温度与风扇”里提高风扇转速。") + } + if enabled.contains(.memoryPressure), let memory = store.memory { + fireIfNeeded(.memoryPressure, isActive: memory.pressure == .critical, now: now, + body: "可用内存只剩 \(Format.bytes(memory.available)),系统正在压缩和交换内存。关闭不用的应用可以缓解。") + } + } + + private func checkPeriodic(now: Date = Date()) { + let enabled = settings.enabledAlerts + if enabled.contains(.diskSpace), let disk = DiskSampler.sample() { + let low = disk.total > 0 && (Double(disk.available) / Double(disk.total) < 0.1 || disk.available < 10_000_000_000) + fireIfNeeded(.diskSpace, isActive: low, now: now, + body: "“\(disk.volumeName)”只剩 \(Format.bytes(disk.available, base: .decimal)) 可用。可以用 OpenStats 的清理功能释放缓存。") + } + if enabled.contains(.batteryHealth), let health = BatterySampler.sample()?.health { + // 冷却时间跨越重启,记在偏好设置里 + let last = defaults.object(forKey: Keys.batteryNotified) as? Date + if health < 0.8, last.map({ now.timeIntervalSince($0) >= AlertKind.batteryHealth.cooldown }) ?? true { + defaults.set(now, forKey: Keys.batteryNotified) + send(.batteryHealth, body: "电池最大容量为 \(Format.percent(health)),续航会明显缩短。可以在“系统设置 › 电池”查看是否建议维修。") + } + } + } + + private func fireIfNeeded(_ kind: AlertKind, isActive: Bool, now: Date, body: @autoclosure () -> String) { + var tracker = trackers[kind] ?? AlertTracker() + let fire = tracker.update(isActive: isActive, now: now, sustain: kind.sustain, cooldown: kind.cooldown) + trackers[kind] = tracker + if fire { send(kind, body: body()) } + } + + private func startNetworkMonitor() { + let monitor = NWPathMonitor() + monitor.pathUpdateHandler = { [weak self] path in + let satisfied = path.status == .satisfied + Task { @MainActor in self?.networkChanged(satisfied: satisfied) } + } + monitor.start(queue: DispatchQueue(label: "com.openstats.alerts.network")) + pathMonitor = monitor + } + + private func networkChanged(satisfied: Bool) { + networkDownTask?.cancel() + if satisfied { + if networkNotified { + networkNotified = false + send(.networkDown, title: "网络已恢复", body: "网络连接已经恢复。") + } + return + } + guard !networkNotified else { return } + networkDownTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(AlertKind.networkDown.sustain)) + guard !Task.isCancelled, let self else { return } + self.networkNotified = true + Log.network.notice("网络断开超过 20 秒") + self.send(.networkDown, body: "网络连接已中断超过 20 秒。可以打开网络详情查看接口与路由器状态。") + } + } + + // MARK: 通知 + + func refreshAuthorization() { + guard let center else { return } + center.getNotificationSettings { [weak self] settings in + let status = settings.authorizationStatus + Task { @MainActor in + switch status { + case .authorized, .provisional: self?.authorization = .allowed + case .denied: self?.authorization = .denied + default: self?.authorization = .unknown + } + } + } + } + + func requestAuthorization(then action: (@MainActor () -> Void)? = nil) { + center?.requestAuthorization(options: [.alert, .sound]) { [weak self] granted, _ in + Task { @MainActor in + self?.authorization = granted ? .allowed : .denied + if granted { action?() } + } + } + } + + func openNotificationSettings() { + let id = Bundle.main.bundleIdentifier ?? "com.openstats.app" + if let url = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension?id=\(id)") { + NSWorkspace.shared.open(url) + } + } + + /// 还没询问过权限时先请求,允许后再发 + func sendTest() { + guard authorization != .unknown else { + requestAuthorization { [weak self] in self?.sendTest() } + return + } + send(.cpuTemperature, title: "OpenStats 通知测试", body: "通知可以正常显示。发生你打开的状况时,会像这样提醒你。") + } + + private func send(_ kind: AlertKind, title: String? = nil, body: String) { + guard let center else { return } + Log.app.notice("发送通知:\(kind.rawValue, privacy: .public)") + let content = UNMutableNotificationContent() + content.title = title ?? kind.title + content.body = body + content.sound = .default + content.userInfo = ["tab": kind.tab.rawValue] + // 同一种状况用固定标识,新的提醒替换旧的,不在通知中心堆积 + center.add(UNNotificationRequest(identifier: "openstats.\(kind.rawValue)", content: content, trigger: nil)) + } + + private enum Keys { + static let batteryNotified = "alertBatteryHealthNotified" + } +} + +extension AlertController: UNUserNotificationCenterDelegate { + /// 应用在前台时也显示横幅 + nonisolated public func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.banner, .sound]) + } + + nonisolated public func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + let tab = (response.notification.request.content.userInfo["tab"] as? String).flatMap(PanelTab.init(rawValue:)) + completionHandler() + guard let tab else { return } + Task { @MainActor [weak self] in self?.openTab(tab) } + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index d951796..39eafd0 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -20,6 +20,7 @@ public final class AppModel { public let explainer = ProcessExplainer() public let updates: UpdateController let diagnostics = DiagnosticsExporter() + public let alerts: AlertController @ObservationIgnored public let hub = MetricsHub() public var isMainWindowVisible = false @@ -45,6 +46,7 @@ public final class AppModel { geo = GeoDatabaseController(settings: settings) network = NetworkController(settings: settings, geo: geo) updates = UpdateController(settings: settings) + alerts = AlertController(settings: settings) } /// 根据当前可见内容决定采集范围:主窗口看标签页,详情弹窗看是哪一项 @@ -73,6 +75,7 @@ public final class AppModel { if (window && tab == .thermal) || thermalPopover { groups.formUnion(TemperatureGroup.allCases) } if menu.contains(.temperature) || fans.mode != .automatic || showing(.cpu, .cpu) { groups.insert(.cpu) } if showing(.gpu, .gpu) { groups.insert(.gpu) } + if settings.enabledAlerts.contains(.cpuTemperature) { groups.insert(.cpu) } demand.temperatures = groups demand.fans = (window && (tab == .thermal || tab == .overview)) || menu.contains(.fan) || fans.mode != .automatic || thermalPopover @@ -136,5 +139,6 @@ public final class AppModel { store.apply(snapshot) fans.evaluateSafety() keepAwake.evaluateBattery(store.battery) + alerts.evaluate(store) } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift index 4596612..f2fd63f 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -220,13 +220,13 @@ public enum AppearanceMode: String, CaseIterable, Identifiable, Sendable { /// 主窗口侧边栏的页面 public enum PanelTab: String, CaseIterable, Identifiable, Sendable { case overview, system, cpu, gpu, memory, network, thermal, processes, keepAwake, cleaner - case settingsGeneral, settingsMenuBar, settingsHelper, settingsAbout + case settingsGeneral, settingsMenuBar, settingsNotifications, settingsHelper, settingsAbout public var id: String { rawValue } static let monitors: [PanelTab] = [.overview, .system, .cpu, .gpu, .memory, .network, .thermal] static let tools: [PanelTab] = [.processes, .keepAwake, .cleaner] - static let settings: [PanelTab] = [.settingsGeneral, .settingsMenuBar, .settingsHelper, .settingsAbout] + static let settings: [PanelTab] = [.settingsGeneral, .settingsMenuBar, .settingsNotifications, .settingsHelper, .settingsAbout] var isSettings: Bool { Self.settings.contains(self) } @@ -247,6 +247,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable { case .cleaner: "清理" case .settingsGeneral: "通用" case .settingsMenuBar: "菜单栏" + case .settingsNotifications: "通知" case .settingsHelper: "辅助工具" case .settingsAbout: "关于" } @@ -266,6 +267,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable { case .cleaner: "sparkles" case .settingsGeneral: "gearshape" case .settingsMenuBar: "menubar.rectangle" + case .settingsNotifications: "bell.badge" case .settingsHelper: "lock.shield" case .settingsAbout: "info.circle" } @@ -371,6 +373,14 @@ public final class AppSettings { public var geoAutoUpdate: Bool { didSet { defaults.set(geoAutoUpdate, forKey: Keys.geoAutoUpdate) } } + /// 打开了系统通知的状况 + public var enabledAlerts: Set { + didSet { defaults.set(enabledAlerts.map(\.rawValue).sorted(), forKey: Keys.enabledAlerts) } + } + /// CPU 过热提醒的温度(摄氏度) + public var alertCPUTemperature: Int { + didSet { defaults.set(alertCPUTemperature, forKey: Keys.alertCPUTemperature) } + } /// 启动时与每天检查一次新版本 public var autoCheckUpdates: Bool { didSet { defaults.set(autoCheckUpdates, forKey: Keys.autoCheckUpdates) } @@ -384,6 +394,7 @@ public final class AppSettings { public static let probeOptions = [1, 2, 5] public static let batteryFloorOptions = [10, 20, 30, 40] public static let fanSafetyOptions = [85, 90, 95, 100] + public static let alertTemperatureOptions = [85, 90, 95, 100] public init(defaults: UserDefaults = .standard) { self.defaults = defaults @@ -418,6 +429,9 @@ public final class AppSettings { geoIncludeCity = defaults.bool(forKey: Keys.geoIncludeCity) geoAutoUpdate = defaults.object(forKey: Keys.geoAutoUpdate) as? Bool ?? true autoCheckUpdates = defaults.object(forKey: Keys.autoCheckUpdates) as? Bool ?? true + enabledAlerts = Set(defaults.stringArray(forKey: Keys.enabledAlerts)?.compactMap(AlertKind.init(rawValue:)) ?? []) + alertCPUTemperature = Self.alertTemperatureOptions.contains(defaults.integer(forKey: Keys.alertCPUTemperature)) + ? defaults.integer(forKey: Keys.alertCPUTemperature) : 95 } /// 按固定顺序返回已启用的菜单栏项目 @@ -469,5 +483,7 @@ public final class AppSettings { static let geoIncludeCity = "geoIncludeCity" static let geoAutoUpdate = "geoAutoUpdate" static let autoCheckUpdates = "autoCheckUpdates" + static let enabledAlerts = "enabledAlerts" + static let alertCPUTemperature = "alertCPUTemperature" } } diff --git a/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift b/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift index cd62f05..d421721 100644 --- a/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift +++ b/Packages/OpenStatsKit/Tests/OpenStatsUITests/OpenStatsUITests.swift @@ -189,3 +189,39 @@ private func isolatedDefaults() -> UserDefaults { #expect(names.contains("openstats.log")) } } + +@Suite struct AlertTrackerTests { + @Test func firesOnceAfterSustainAndRespectsCooldown() { + var tracker = AlertTracker() + func step(_ active: Bool, _ seconds: TimeInterval) -> Bool { + tracker.update(isActive: active, now: Date(timeIntervalSince1970: seconds), sustain: 60, cooldown: 1800) + } + #expect(!step(true, 0)) + #expect(!step(true, 59)) + #expect(step(true, 60)) + // 同一段持续期内不重复 + #expect(!step(true, 600)) + // 恢复后再次出现,但还在冷却期 + #expect(!step(false, 700)) + #expect(!step(true, 800)) + #expect(!step(true, 900)) + // 冷却结束后同一段持续期仍可触发 + #expect(step(true, 1900)) + } + + @Test func briefSpikesDoNotFire() { + var tracker = AlertTracker() + var fired = false + for second in stride(from: 0.0, to: 600, by: 30) { + // 每 30 秒一升一降,从未持续满 60 秒 + fired = fired || tracker.update(isActive: Int(second) % 60 == 0, now: Date(timeIntervalSince1970: second), + sustain: 60, cooldown: 1800) + } + #expect(!fired) + } + + @Test func notificationTabsExist() { + #expect(PanelTab.settings.contains(.settingsNotifications)) + #expect(Set(AlertKind.allCases.map(\.tab)).isSubset(of: Set(PanelTab.allCases))) + } +}