Files
ShadowsocksX-NG/ShadowsocksX-NG2/Tests/Workspace/WindowActivationPolicyTests.swift
Qiu Yuzhou 2241d3f7f9 fix(window): stop policy bouncing during launch activation
Keep regular policy while windows remain open to avoid repeated Dock registration. Share the activation retry budget across visibility events and stop permanently after exhaustion.
2026-10-25 17:50:18 +08:00

400 lines
15 KiB
Swift

import AppKit
import XCTest
@testable import ShadowsocksX_NG2
/// Anchored app windows share activation policy; foreign windows are ignored.
/// Notifications, an injected spy, and a manually driven tick queue keep these
/// tests independent of presentation and wall-clock timing.
@MainActor
final class WindowActivationPolicyTests: XCTestCase {
private var spy: SpyApplier!
private var tickScheduler: SpyTickScheduler!
private var coordinator: WindowActivationPolicyCoordinator!
private var window: NSWindow!
private var foreignWindow: NSWindow!
override func setUp() async throws {
try await super.setUp()
spy = SpyApplier()
tickScheduler = SpyTickScheduler()
coordinator = WindowActivationPolicyCoordinator(
applying: spy,
tickScheduler: tickScheduler)
// 离屏裸窗口仅作通知对象身份:不呈现、不抢 key,创建本身不触碰
// WindowServer 的窗口呈现。
window = Self.makeOffscreenWindow()
foreignWindow = Self.makeOffscreenWindow()
coordinator.anchorDidAttach(to: window)
// 消化首个不可见锚点安排的兜底重扫(此时无可见窗口,必然空转),
// 后续用例的 pending 队列保持确定。
tickScheduler.drainOne()
}
override func tearDown() async throws {
window = nil
foreignWindow = nil
try await super.tearDown()
}
/// 策略与激活落点 spy(ADR 0017):只记录调用,不触碰真实 NSApp。
/// `isActive`/`ownsMenuBar` 默认为真:启动激活重试首拍即短路,既有用例
/// 不受异步影响。
@MainActor
private final class SpyApplier: WindowActivationPolicyApplying {
var applied: [NSApplication.ActivationPolicy] = []
var isActive = true
var ownsMenuBar = true
var isPolicyRegular = true
var activationCalls = 0
/// 第 N 次 activate 后视为激活成功(模拟稍后才成功)。
var becomesActiveAfterCalls: Int?
func apply(_ policy: NSApplication.ActivationPolicy) {
applied.append(policy)
}
var isAppActive: Bool { isActive }
func activateApp() {
activationCalls += 1
if let becomesActiveAfterCalls, activationCalls >= becomesActiveAfterCalls {
isActive = true
}
}
}
/// 收集重试节拍的手动调度 spy:按需逐拍驱动,不依赖墙钟。
@MainActor
private final class SpyTickScheduler: ActivationTickScheduling {
var pending: [@MainActor @Sendable () -> Void] = []
func scheduleTick(
after interval: TimeInterval, _ tick: @escaping @MainActor @Sendable () -> Void
) {
pending.append(tick)
}
/// 手动驱动一拍已排定的重试;无待执行拍时返回 false。执行该拍可能
/// 在队尾续排下一拍。
@discardableResult
func drainOne() -> Bool {
guard !pending.isEmpty else { return false }
pending.removeFirst()()
return true
}
}
private static func makeOffscreenWindow() -> NSWindow {
NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 10, height: 10),
styleMask: [.titled],
backing: .buffered,
defer: false)
}
private func post(_ name: Notification.Name, on target: NSWindow) {
NotificationCenter.default.post(name: name, object: target)
}
// MARK: 随窗收敛
func testAttachToNotYetVisibleWindowAppliesNothing() {
XCTAssertTrue(
spy.applied.isEmpty,
"静默启动预建未呈现的窗口不得触发 regular:启动应停留在 accessory")
}
func testDidBecomeKeyAppliesRegular() {
post(NSWindow.didBecomeKeyNotification, on: window)
XCTAssertEqual(spy.applied, [.regular])
}
func testWillCloseAppliesAccessory() {
post(NSWindow.willCloseNotification, on: window)
XCTAssertEqual(spy.applied, [.accessory])
}
// MARK: 身份过滤
func testForeignWindowNotificationsAreIgnored() {
post(NSWindow.didBecomeKeyNotification, on: foreignWindow)
post(NSWindow.willCloseNotification, on: foreignWindow)
XCTAssertTrue(spy.applied.isEmpty, "非锚定窗口(sheet/辅助窗口)不得驱动策略")
}
func testClosingOneOfTwoWindowsKeepsRegularUntilLastCloses() {
coordinator.anchorDidAttach(to: foreignWindow)
post(NSWindow.didBecomeKeyNotification, on: window)
post(NSWindow.didBecomeKeyNotification, on: foreignWindow)
spy.applied.removeAll()
post(NSWindow.willCloseNotification, on: window)
XCTAssertFalse(spy.applied.contains(.accessory))
post(NSWindow.willCloseNotification, on: foreignWindow)
XCTAssertEqual(spy.applied.last, .accessory)
}
func testClosedWindowCanReopenWhileOtherWindowRemainsTracked() {
coordinator.anchorDidAttach(to: foreignWindow)
post(NSWindow.didBecomeKeyNotification, on: window)
post(NSWindow.willCloseNotification, on: window)
post(NSWindow.didBecomeKeyNotification, on: window)
post(NSWindow.didBecomeKeyNotification, on: foreignWindow)
spy.applied.removeAll()
post(NSWindow.willCloseNotification, on: foreignWindow)
XCTAssertFalse(spy.applied.contains(.accessory))
post(NSWindow.willCloseNotification, on: window)
XCTAssertEqual(spy.applied.last, .accessory)
}
func testWindowShownWithoutBecomingKeyParticipatesInLastClosePolicy() {
let visible = VisibilityWindow(
contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true)
coordinator.anchorDidAttach(to: visible)
visible.pretendVisible = true
post(NSWindow.didChangeOcclusionStateNotification, on: visible)
XCTAssertEqual(spy.applied.last, .regular)
XCTAssertEqual(visible.orderFrontCalls, 1, "首次可见化必须置前成 key")
post(NSWindow.didBecomeKeyNotification, on: window)
spy.applied.removeAll()
post(NSWindow.willCloseNotification, on: window)
XCTAssertFalse(spy.applied.contains(.accessory))
post(NSWindow.willCloseNotification, on: visible)
XCTAssertEqual(spy.applied.last, .accessory)
}
func testCloseIncludesVisibleSiblingWhenPresentationNotificationWasMissed() {
let visible = VisibilityWindow(
contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true)
coordinator.anchorDidAttach(to: visible)
visible.pretendVisible = true
post(NSWindow.didBecomeKeyNotification, on: window)
spy.applied.removeAll()
post(NSWindow.willCloseNotification, on: window)
XCTAssertFalse(spy.applied.contains(.accessory))
visible.pretendVisible = false
post(NSWindow.willCloseNotification, on: visible)
XCTAssertEqual(spy.applied.last, .accessory)
}
// MARK: 首次参与收敛置前
func testVisibleAttachAppliesRegularAndOrdersFront() {
let visible = VisibilityWindow(
contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true)
visible.pretendVisible = true
coordinator.anchorDidAttach(to: visible)
XCTAssertEqual(spy.applied, [.regular])
XCTAssertEqual(visible.orderFrontCalls, 1)
}
func testOcclusionInsertionOrdersFrontOnce() {
let visible = VisibilityWindow(
contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true)
coordinator.anchorDidAttach(to: visible)
visible.pretendVisible = true
post(NSWindow.didChangeOcclusionStateNotification, on: visible)
post(NSWindow.didChangeOcclusionStateNotification, on: visible)
XCTAssertEqual(visible.orderFrontCalls, 1, "重复可见化不得反复置前")
}
// MARK: 启动激活重试
func testLaunchRetryRunsOncePerProcess() {
spy.isActive = false
let first = VisibilityWindow(
contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true)
first.pretendVisible = true
let second = VisibilityWindow(
contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true)
second.pretendVisible = true
coordinator.anchorDidAttach(to: first)
coordinator.anchorDidAttach(to: second)
XCTAssertEqual(tickScheduler.pending.count, 1, "后续窗口参与不得再启动一轮重试")
}
/// 激活假象(实测失败场景):isActive 与 frontmost 均指向本应用、重试即停,
/// 但菜单栏仍归此前台应用——双信号不齐必须继续行动。
func testActivationIllusionKeepsRetryingUntilMenuBarReturns() {
spy.isActive = true
spy.ownsMenuBar = false
let visible = VisibilityWindow(
contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true)
visible.pretendVisible = true
coordinator.anchorDidAttach(to: visible)
tickScheduler.drainOne()
XCTAssertEqual(visible.orderRegardlessCalls, 1, "active=true 但菜单栏未归位也要抬窗")
spy.ownsMenuBar = true
while tickScheduler.drainOne() {}
XCTAssertEqual(visible.orderRegardlessCalls, 1, "菜单栏归位后不得再行动")
XCTAssertTrue(tickScheduler.pending.isEmpty)
}
// MARK: 启动期次序兜底
/// 锚点附着时窗口尚未可见、其可见化通知又早于附着被错过:延迟重扫补上
/// 参与与激活重试(实测失败场景启动期无任何事件即此序)。
func testDeferredRescanRecoversWindowVisibleAfterAttach() {
spy.isActive = false
let visible = VisibilityWindow(
contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true)
coordinator.anchorDidAttach(to: visible)
visible.pretendVisible = true
tickScheduler.drainOne()
XCTAssertEqual(spy.applied, [.regular], "重扫对已可见窗口补上参与并拉起重试")
XCTAssertEqual(visible.orderFrontCalls, 1)
while tickScheduler.drainOne() {}
}
func testDeferredRescanSkipsStillInvisibleWindow() {
let silent = VisibilityWindow(
contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true)
coordinator.anchorDidAttach(to: silent)
while tickScheduler.drainOne() {}
XCTAssertTrue(spy.applied.isEmpty, "静默启动的不可见窗口不得由重扫触发参与")
}
/// 链在窗口呈现完成前启动、首拍因不可见静默终结后,可见化事件必须能
/// 重新拉起(re-arm),直到真正收敛。
func testVisibilityEventRearmsChainEndedBeforeWindowVisible() {
spy.isActive = false
spy.becomesActiveAfterCalls = 1
let visible = VisibilityWindow(
contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true)
visible.pretendVisible = true
coordinator.anchorDidAttach(to: visible)
visible.pretendVisible = false
while tickScheduler.drainOne() {}
XCTAssertEqual(spy.activationCalls, 0, "首拍窗口不可见时链静默终结")
visible.pretendVisible = true
post(NSWindow.didChangeOcclusionStateNotification, on: visible)
tickScheduler.drainOne()
XCTAssertEqual(spy.activationCalls, 1, "可见化事件必须能重启已终结的链")
while tickScheduler.drainOne() {}
XCTAssertTrue(tickScheduler.pending.isEmpty)
}
}
private final class VisibilityWindow: NSWindow {
var pretendVisible = false
override var isVisible: Bool { pretendVisible }
/// 离屏拦截:真实置前会在测试宿主呈现窗口,只记录调用。
var orderFrontCalls = 0
override func makeKeyAndOrderFront(_ sender: Any?) {
orderFrontCalls += 1
}
var orderRegardlessCalls = 0
override func orderFrontRegardless() {
orderRegardlessCalls += 1
}
}
extension WindowActivationPolicyTests {
func testMenuBarMismatchNeverBouncesPolicyOrRestartsExhaustedRetries() {
spy.isActive = true
spy.ownsMenuBar = false
let visible = VisibilityWindow(
contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true)
visible.pretendVisible = true
coordinator.anchorDidAttach(to: visible)
while tickScheduler.drainOne() {}
XCTAssertFalse(spy.applied.contains(.accessory), "窗口仍打开时不得通过策略弹跳反复注册 Dock 图标")
let activationCalls = spy.activationCalls
for _ in 0..<3 {
post(NSWindow.didBecomeKeyNotification, on: visible)
post(NSWindow.didChangeOcclusionStateNotification, on: visible)
while tickScheduler.drainOne() {}
}
XCTAssertEqual(spy.activationCalls, activationCalls, "重试用尽后窗口事件不得重置预算并抢焦点")
}
func testLaunchRetryReactivatesWhileInactiveAndStopsAtLimit() {
spy.isActive = false
let visible = VisibilityWindow(
contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true)
visible.pretendVisible = true
coordinator.anchorDidAttach(to: visible)
XCTAssertEqual(tickScheduler.pending.count, 1)
tickScheduler.drainOne()
XCTAssertEqual(spy.activationCalls, 1)
while tickScheduler.drainOne() {}
XCTAssertEqual(spy.activationCalls, 5, "默认上限 5 次激活")
XCTAssertEqual(spy.applied, [.regular], "重试只激活,不改变随窗策略")
XCTAssertEqual(visible.orderFrontCalls, 6, "attach 置前一次,每次激活后再置前")
XCTAssertEqual(visible.orderRegardlessCalls, 5)
XCTAssertTrue(tickScheduler.pending.isEmpty, "终态检查拍后不得再排")
}
func testLaunchRetryStopsOnceAppActive() {
spy.isActive = false
spy.becomesActiveAfterCalls = 2
let visible = VisibilityWindow(
contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true)
visible.pretendVisible = true
coordinator.anchorDidAttach(to: visible)
while tickScheduler.drainOne() {}
XCTAssertEqual(spy.activationCalls, 2, "已激活后的检查不得再激活")
XCTAssertEqual(spy.applied, [.regular], "激活重试不得弹跳策略")
XCTAssertTrue(tickScheduler.pending.isEmpty, "已激活后不得再排下一拍")
}
func testLaunchRetryStopsWhenWindowCloses() {
spy.isActive = false
let visible = VisibilityWindow(
contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true)
visible.pretendVisible = true
coordinator.anchorDidAttach(to: visible)
visible.pretendVisible = false
tickScheduler.drainOne()
XCTAssertEqual(spy.activationCalls, 0, "窗口已不可见不得再激活")
XCTAssertTrue(tickScheduler.pending.isEmpty)
}
func testLaunchRetryPreservesBudgetAcrossVisibilityChanges() {
spy.isActive = false
let visible = VisibilityWindow(
contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true)
visible.pretendVisible = true
coordinator.anchorDidAttach(to: visible)
tickScheduler.drainOne()
tickScheduler.drainOne()
XCTAssertEqual(spy.activationCalls, 2)
visible.pretendVisible = false
while tickScheduler.drainOne() {}
XCTAssertEqual(spy.activationCalls, 2, "窗口不可见时不得再激活")
XCTAssertEqual(spy.applied, [.regular])
visible.pretendVisible = true
post(NSWindow.didChangeOcclusionStateNotification, on: visible)
while tickScheduler.drainOne() {}
XCTAssertEqual(spy.activationCalls, 5, "恢复可见后仅消耗剩余 3 次预算")
XCTAssertEqual(spy.applied, [.regular])
}
}