系统的登录窗口在后台队列上回调结果,而回调闭包写在主线程隔离的方法里,Swift 6 在闭包入口检查执行器, 不在主线程就中止进程:选完 GitHub / Google / Apple 账号应用立即闪退,令牌还没换到,重开也没有登录状态。 回调改为在非隔离的上下文里创建,收到结果后再回到主线程处理;补了在后台队列调用回调的回归测试。
416 lines
18 KiB
Swift
416 lines
18 KiB
Swift
import AccountSync
|
|
import Foundation
|
|
@testable import Metrics
|
|
import Testing
|
|
@testable import OpenStatsUI
|
|
|
|
private func isolatedDefaults() -> UserDefaults {
|
|
let name = "OpenStatsUITests.\(UUID().uuidString)"
|
|
let defaults = UserDefaults(suiteName: name)!
|
|
defaults.removePersistentDomain(forName: name)
|
|
return defaults
|
|
}
|
|
|
|
@MainActor
|
|
@Suite struct SettingsTests {
|
|
@Test func defaultsAndInvalidValues() {
|
|
let defaults = isolatedDefaults()
|
|
defaults.set(7, forKey: "refreshSeconds")
|
|
defaults.set("bogus", forKey: "panelTab")
|
|
let settings = AppSettings(defaults: defaults)
|
|
#expect(settings.refreshSeconds == 2)
|
|
#expect(settings.panelTab == .overview)
|
|
#expect(settings.menuBarItems == [.cpu, .memory, .network])
|
|
#expect(settings.probeEnabled && settings.probeInBackground && settings.autoCheckUpdates)
|
|
// 从未调整过弹窗区块时,默认隐藏的区块生效;走势默认 1 分钟
|
|
#expect(settings.hiddenPopoverSections == PopoverSection.hiddenByDefault)
|
|
#expect(!settings.isVisible(.cpuCores) && settings.isVisible(.cpuHeatmap))
|
|
#expect(settings.cpuChartSeconds == 60)
|
|
}
|
|
|
|
@Test func persistsChanges() {
|
|
let defaults = isolatedDefaults()
|
|
let settings = AppSettings(defaults: defaults)
|
|
settings.menuBarItems = [.gpu, .fan]
|
|
settings.probeInBackground = false
|
|
settings.hiddenPopoverSections = [.cpuHeatmap]
|
|
let reloaded = AppSettings(defaults: defaults)
|
|
#expect(reloaded.menuBarItems == [.gpu, .fan])
|
|
#expect(!reloaded.probeInBackground)
|
|
#expect(reloaded.hiddenPopoverSections == [.cpuHeatmap])
|
|
#expect(reloaded.orderedMenuBarItems == [.gpu, .fan])
|
|
}
|
|
}
|
|
|
|
@Suite struct PanelTabTests {
|
|
@Test func sidebarGroupsCoverEveryTabOnce() {
|
|
let grouped = PanelTab.monitors + PanelTab.tools + PanelTab.settings
|
|
#expect(grouped.count == PanelTab.allCases.count)
|
|
#expect(Set(grouped) == Set(PanelTab.allCases))
|
|
#expect(PanelTab.settings.allSatisfy { $0.isSettings })
|
|
#expect(PanelTab.settingsAbout.headerTitle == "设置 · 关于")
|
|
}
|
|
|
|
@Test func menuBarItemsMapToPages() {
|
|
for item in MenuBarItem.allCases {
|
|
let tab = PanelTab(item: item)
|
|
#expect(tab.menuBarItem == item || (item == .fan && tab == .thermal))
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Suite struct DemandTests {
|
|
private func model() -> AppModel {
|
|
AppModel(settings: AppSettings(defaults: isolatedDefaults()), historyURL: nil)
|
|
}
|
|
|
|
@Test func menuBarOnlySamplesAtUserInterval() {
|
|
let model = model()
|
|
model.settings.refreshSeconds = 5
|
|
#expect(model.demand.interval == .seconds(5))
|
|
#expect(!model.demand.systemProcesses)
|
|
#expect(!model.demand.disk)
|
|
}
|
|
|
|
@Test func processesPageRefreshesEveryTwoSeconds() {
|
|
let model = model()
|
|
model.isMainWindowVisible = true
|
|
model.settings.panelTab = .processes
|
|
#expect(model.demand.interval == .seconds(2))
|
|
#expect(model.demand.processes && model.demand.systemProcesses)
|
|
|
|
model.openPopover = .cpu
|
|
#expect(model.demand.interval == .seconds(1))
|
|
|
|
model.openPopover = nil
|
|
model.settings.panelTab = .overview
|
|
#expect(model.demand.interval == .seconds(1))
|
|
#expect(!model.demand.systemProcesses)
|
|
}
|
|
|
|
@Test func networkDetailVisibility() {
|
|
let model = model()
|
|
#expect(!model.isNetworkDetailVisible)
|
|
model.openPopover = .network
|
|
#expect(model.isNetworkDetailVisible)
|
|
model.openPopover = nil
|
|
model.isMainWindowVisible = true
|
|
model.settings.panelTab = .network
|
|
#expect(model.isNetworkDetailVisible)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Suite struct UpdateControllerTests {
|
|
@Test func automaticCheckRespectsSettingAndInterval() {
|
|
let defaults = isolatedDefaults()
|
|
let settings = AppSettings(defaults: defaults)
|
|
settings.autoCheckUpdates = false
|
|
let controller = UpdateController(settings: settings, defaults: defaults)
|
|
controller.checkIfNeeded()
|
|
#expect(controller.phase == .idle)
|
|
|
|
settings.autoCheckUpdates = true
|
|
defaults.set(Date(), forKey: "updateLastChecked")
|
|
let recent = UpdateController(settings: settings, defaults: defaults)
|
|
recent.checkIfNeeded()
|
|
#expect(recent.phase == .idle)
|
|
}
|
|
|
|
@Test func developmentBuildCannotInstall() {
|
|
// 测试进程不是签名的 OpenStats.app
|
|
let controller = UpdateController(settings: AppSettings(defaults: isolatedDefaults()))
|
|
#expect(controller.installBlockedReason != nil)
|
|
}
|
|
}
|
|
|
|
@Suite struct DiagnosticsTests {
|
|
@Test func redactsAddressesAndHome() {
|
|
let text = "\(NSHomeDirectory())/Library/x 192.168.1.20 fe80::1c2b:3aff:fe4d:5e6f 2001:db8:0:0:1:2:3:4 ::1 a4:83:e7:12:34:56 12:30:45 版本 0.2.0"
|
|
let redacted = DiagnosticsExporter.redact(text)
|
|
#expect(redacted.hasPrefix("~/Library/x"))
|
|
#expect(!redacted.contains("192.168.1.20"))
|
|
#expect(!redacted.contains("a4:83:e7"))
|
|
#expect(!redacted.contains("fe80::1c2b"))
|
|
#expect(!redacted.contains("2001:db8"))
|
|
#expect(!redacted.contains("::1"))
|
|
#expect(redacted.contains("<MAC>"))
|
|
#expect(redacted.contains("12:30:45"))
|
|
#expect(redacted.contains("0.2.0"))
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Suite struct ProcessRowTests {
|
|
private func process(_ pid: Int32, _ name: String, bundle: String?, cpu: Double, memory: UInt64, owned: Bool = true) -> ProcessUsage {
|
|
var usage = ProcessUsage(pid: pid, name: name, executablePath: nil, appBundlePath: bundle, cpu: cpu, memory: memory)
|
|
usage.isOwned = owned
|
|
usage.threads = 4
|
|
return usage
|
|
}
|
|
|
|
@Test func groupsHelpersIntoTheirApp() {
|
|
let rows = ProcessRowModel.grouped([
|
|
process(10, "Safari", bundle: "/Applications/Safari.app", cpu: 10, memory: 100),
|
|
process(11, "Safari Web Content", bundle: "/Applications/Safari.app", cpu: 5, memory: 300),
|
|
process(12, "cloudd", bundle: nil, cpu: 1, memory: 50),
|
|
])
|
|
#expect(rows.count == 2)
|
|
let safari = rows.first { $0.count == 2 }
|
|
#expect(safari?.cpu == 15)
|
|
#expect(safari?.memory == 400)
|
|
#expect(safari?.threads == 8)
|
|
#expect(safari?.pid == nil)
|
|
}
|
|
|
|
@Test func blocksQuittingProtectedProcesses() {
|
|
#expect(ProcessRowModel(process: process(1, "launchd", bundle: nil, cpu: 0, memory: 1, owned: false)).quitBlockedReason != nil)
|
|
#expect(ProcessRowModel(process: process(getpid(), "OpenStats", bundle: nil, cpu: 0, memory: 1)).quitBlockedReason != nil)
|
|
#expect(ProcessRowModel(process: process(99, "loginwindow", bundle: nil, cpu: 0, memory: 1)).quitBlockedReason != nil)
|
|
#expect(ProcessRowModel(process: process(98, "Notes", bundle: nil, cpu: 0, memory: 1)).quitBlockedReason == nil)
|
|
}
|
|
|
|
@Test func narrowTablesDropColumns() {
|
|
#expect(ProcessColumn.visible(forWidth: 2000) == ProcessColumn.allCases)
|
|
#expect(ProcessColumn.visible(forWidth: 300) == [.name, .cpu, .memory, .user])
|
|
}
|
|
}
|
|
|
|
@Suite struct DiagnosticsArchiveTests {
|
|
@Test func writesZipWithSummaryAndLog() throws {
|
|
let destination = FileManager.default.temporaryDirectory.appendingPathComponent("diag-\(UUID().uuidString).zip")
|
|
defer { try? FileManager.default.removeItem(at: destination) }
|
|
try DiagnosticsExporter.writeArchive(summary: "# 测试\n", to: destination)
|
|
let listing = Process()
|
|
listing.executableURL = URL(fileURLWithPath: "/usr/bin/zipinfo")
|
|
listing.arguments = ["-1", destination.path]
|
|
let pipe = Pipe()
|
|
listing.standardOutput = pipe
|
|
try listing.run()
|
|
let names = String(decoding: pipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
|
|
listing.waitUntilExit()
|
|
#expect(names.contains("summary.txt"))
|
|
#expect(names.contains("openstats.log"))
|
|
}
|
|
}
|
|
|
|
@Suite struct CoreRoleTests {
|
|
@Test func describesEachTierInPlainWords() {
|
|
let fast = CPUCluster(id: 0, name: "超级核", coreIndices: [12, 13])
|
|
let middle = CPUCluster(id: 1, name: "性能核", coreIndices: [4, 5])
|
|
let slow = CPUCluster(id: 2, name: "能效核", coreIndices: [0, 1])
|
|
let three = CPUTopology(brand: "Apple", logicalCores: 6, clusters: [fast, middle, slow])
|
|
#expect(fast.role(in: three) == "最快,重活优先交给它们")
|
|
#expect(middle.role(in: three) == "速度与省电介于两者之间")
|
|
#expect(slow.role(in: three) == "更省电,负责后台和轻量任务")
|
|
// 只有一类核心(Intel)时没有分工可言
|
|
let single = CPUTopology(brand: "Intel", logicalCores: 8, clusters: [CPUCluster(id: 0, name: "核心", coreIndices: Array(0..<8))])
|
|
#expect(single.clusters[0].role(in: single) == nil)
|
|
}
|
|
}
|
|
|
|
@Suite struct TimedLineChartTests {
|
|
@Test func positionsByTimeAndBreaksAtGaps() {
|
|
let end = Date(timeIntervalSince1970: 1_000)
|
|
let points = [
|
|
TimedValue(date: end.addingTimeInterval(-70), value: 0.9), // 超出时长,不画
|
|
TimedValue(date: end.addingTimeInterval(-60), value: 0),
|
|
TimedValue(date: end.addingTimeInterval(-55), value: 1),
|
|
TimedValue(date: end.addingTimeInterval(-50), value: 0.5),
|
|
TimedValue(date: end.addingTimeInterval(-10), value: 0.5), // 与上一点隔了 40 秒(睡眠),断开
|
|
TimedValue(date: end.addingTimeInterval(-5), value: 0.5),
|
|
TimedValue(date: end, value: 0.5),
|
|
]
|
|
let segments = TimedLineChart.segments(points, duration: 60, end: end, in: CGSize(width: 120, height: 40))
|
|
#expect(segments.count == 2)
|
|
#expect(segments[0].map(\.x) == [0, 10, 20])
|
|
#expect(segments[1].map(\.x) == [100, 110, 120])
|
|
// 0 在底、1 在顶,越大越靠上
|
|
#expect(segments[0][0].y > segments[0][2].y && segments[0][2].y > segments[0][1].y)
|
|
#expect(TimedLineChart.segments([], duration: 60, end: end, in: CGSize(width: 120, height: 40)).isEmpty)
|
|
}
|
|
}
|
|
|
|
@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)))
|
|
}
|
|
}
|
|
|
|
@Suite struct HotKeyTests {
|
|
@Test func displaysAndValidates() {
|
|
let hotKey = HotKey(keyCode: 1, modifiers: [.command, .option, .capsLock], key: "s")
|
|
#expect(hotKey.display == "⌥⌘S")
|
|
#expect(hotKey.isValid)
|
|
#expect(!HotKey(keyCode: 1, modifiers: [.shift], key: "s").isValid)
|
|
#expect(HotKey(keyCode: 49, modifiers: [.control], key: " ").display == "⌃空格")
|
|
}
|
|
|
|
@MainActor
|
|
@Test func persistsBindings() {
|
|
let defaults = isolatedDefaults()
|
|
let settings = AppSettings(defaults: defaults)
|
|
settings.hotKeys[.toggleKeepAwake] = HotKey(keyCode: 40, modifiers: [.command, .shift], key: "k")
|
|
let reloaded = AppSettings(defaults: defaults)
|
|
#expect(reloaded.hotKeys[.toggleKeepAwake]?.display == "⇧⌘K")
|
|
}
|
|
}
|
|
|
|
@Suite struct FunctionKeyTests {
|
|
@Test func namesFunctionKeys() {
|
|
#expect(HotKey(keyCode: 0x7A, modifiers: [.command], key: "\u{F704}").display == "⌘F1")
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Suite struct SettingsDocumentTests {
|
|
@Test func exportsAndAppliesRoundTrip() {
|
|
let source = AppSettings(defaults: isolatedDefaults())
|
|
source.menuBarItems = [.gpu, .temperature]
|
|
source.menuBarStyle = .ring
|
|
source.styleOverrides = [.gpu: .pie]
|
|
source.refreshSeconds = 5
|
|
source.useFahrenheit = true
|
|
source.hiddenPopoverSections = [.cpuHeatmap, .networkDNS]
|
|
source.probeTarget = .aliyun
|
|
source.enabledAlerts = [.cpuLoad]
|
|
source.alertCPULoad = 90
|
|
source.hotKeys = [.showProcesses: HotKey(keyCode: 35, modifiers: [.command, .option], key: "p")]
|
|
source.panelTab = .thermal
|
|
|
|
let document = source.exportDocument()
|
|
let target = AppSettings(defaults: isolatedDefaults())
|
|
target.panelTab = .cleaner
|
|
target.apply(document)
|
|
|
|
#expect(target.menuBarItems == [.gpu, .temperature])
|
|
#expect(target.menuBarStyle == .ring)
|
|
#expect(target.styleOverrides == [.gpu: .pie])
|
|
#expect(target.refreshSeconds == 5)
|
|
#expect(target.useFahrenheit)
|
|
#expect(target.hiddenPopoverSections == [.cpuHeatmap, .networkDNS])
|
|
#expect(target.probeTarget == .aliyun)
|
|
#expect(target.enabledAlerts == [.cpuLoad])
|
|
#expect(target.alertCPULoad == 90)
|
|
#expect(target.hotKeys[.showProcesses]?.keyCode == 35)
|
|
// 当前页签属于本机状态,不随文档走
|
|
#expect(target.panelTab == .cleaner)
|
|
#expect(target.exportDocument() == document)
|
|
}
|
|
|
|
@Test func skipsUnknownAndInvalidValues() {
|
|
let settings = AppSettings(defaults: isolatedDefaults())
|
|
var document = SettingsDocument()
|
|
document.menuBarItems = ["cpu", "hologram"]
|
|
document.menuBarStyle = "neon"
|
|
document.refreshSeconds = 7
|
|
document.probeTarget = "mars"
|
|
document.language = "english"
|
|
settings.apply(document)
|
|
#expect(settings.menuBarItems == [.cpu])
|
|
#expect(settings.menuBarStyle == .stacked)
|
|
#expect(settings.refreshSeconds == 2)
|
|
#expect(settings.probeTarget == .cloudflare)
|
|
#expect(settings.language == .english)
|
|
settings.language = .system
|
|
}
|
|
|
|
@Test func documentSurvivesJSONWithMissingFields() throws {
|
|
let data = Data(#"{"schema":1,"refreshSeconds":3,"futureField":true}"#.utf8)
|
|
let document = try JSONDecoder().decode(SettingsDocument.self, from: data)
|
|
#expect(document.refreshSeconds == 3)
|
|
#expect(document.menuBarItems == nil)
|
|
let settings = AppSettings(defaults: isolatedDefaults())
|
|
settings.apply(document)
|
|
#expect(settings.refreshSeconds == 3)
|
|
#expect(settings.menuBarItems == [.cpu, .memory, .network])
|
|
}
|
|
|
|
@Test func freshSettingsEqualDefaultDocument() {
|
|
let settings = AppSettings(defaults: isolatedDefaults())
|
|
#expect(settings.exportDocument() == AppSettings.defaultDocument())
|
|
settings.colorizeHighLoad = true
|
|
#expect(settings.exportDocument() != AppSettings.defaultDocument())
|
|
}
|
|
}
|
|
|
|
@Suite struct ActivityRankingTests {
|
|
@Test func smoothsAndKeepsRecentlyActiveEntries() {
|
|
var ranking = ActivityRanking()
|
|
ranking.update(["a": 10_000, "b": 100_000])
|
|
#expect(ranking.ranked(limit: 5).map(\.id) == ["b", "a"])
|
|
// b 突然安静,a 继续:b 的分数衰减但仍在榜上,a 慢慢追上
|
|
for _ in 0..<3 { ranking.update(["a": 10_000]) }
|
|
let entries = ranking.ranked(limit: 5)
|
|
#expect(entries.map(\.id).contains("b"))
|
|
#expect(entries.first?.id == "b")
|
|
for _ in 0..<6 { ranking.update(["a": 10_000]) }
|
|
#expect(ranking.ranked(limit: 5).first?.id == "a")
|
|
// 长时间没有活动后从榜上移除
|
|
for _ in 0..<40 { ranking.update([:]) }
|
|
#expect(ranking.isEmpty)
|
|
}
|
|
|
|
@Test func limitsAndBreaksTiesByID() {
|
|
var ranking = ActivityRanking()
|
|
ranking.update(["x": 5_000, "y": 5_000, "z": 9_000])
|
|
#expect(ranking.ranked(limit: 2).map(\.id) == ["z", "x"])
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Suite struct SyncSignInTests {
|
|
/// 系统在后台的 XPC 队列上回调登录结果。回调闭包如果带着主线程隔离,Swift 6 会在入口处中止进程,
|
|
/// 表现为选完账号应用直接退出;这里在后台队列调用一次,能跑完并回到主线程处理就算通过
|
|
@Test func completionHandlerMayRunOffMainThread() async {
|
|
let controller = SyncController(settings: AppSettings(defaults: isolatedDefaults()), defaults: isolatedDefaults(),
|
|
tokens: MemoryTokenStore())
|
|
let handler = SyncController.completionHandler(for: controller, verifier: "verifier")
|
|
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
|
DispatchQueue.global().async {
|
|
handler(nil, nil)
|
|
continuation.resume()
|
|
}
|
|
}
|
|
// 没有回调地址按失败处理;处理发生在主线程的下一个任务里,稍等一下
|
|
for _ in 0..<100 where controller.phase == .idle {
|
|
try? await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
guard case .failed = controller.phase else {
|
|
Issue.record("回调之后 phase 应为 failed,实际是 \(controller.phase)")
|
|
return
|
|
}
|
|
}
|
|
}
|