From c708cd8d19c2d3a6f319a196b19efbd8702c2194 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Sat, 24 Oct 2026 02:22:39 +0800 Subject: [PATCH] feat(ui): debounce transient status rows via shared display gate The home run-control card and the menu-bar status card render the agent and system-proxy status rows straight from snapshot replacements, so fast operations (e.g. proxy mode switches) flash transient states like "applying" before settling. Add a presentation-layer debounce shared by both surfaces: - TransientDelayGate: per-stream gate where stable values display immediately, transient values (starting/applying/repairing) display after 400 ms and are dropped when superseded, and displayed transients are held for at least 400 ms. - StatusDisplayModel: app-level shared model deriving agent and system-proxy payloads (row + detail + hint) from workflow snapshots through two independent gates; domain facts keep their cadence. - Wire the model through the composition root into RuntimeControlCard and StatusCardView; toggles, approval actions, the difference view and target/mode rows keep reading the raw snapshot. --- .../App/Composition/MainApp.swift | 16 +- ShadowsocksX-NG2/App/UI/Home/HomeView.swift | 6 +- .../App/UI/Home/RuntimeControlCard.swift | 48 +---- .../App/UI/StatusMenu/ProxyStatusMenu.swift | 6 +- .../App/UI/Workspace/MainWindowView.swift | 6 + .../App/UI/Workspace/StatusCardView.swift | 13 +- .../App/UI/Workspace/StatusDisplayModel.swift | 142 ++++++++++++++ .../App/UI/Workspace/TransientDelayGate.swift | 90 +++++++++ .../Tests/Support/SleepGateRecorder.swift | 28 +++ .../Workspace/StatusDisplayModelTests.swift | 158 ++++++++++++++++ .../Workspace/TransientDelayGateTests.swift | 175 ++++++++++++++++++ 11 files changed, 638 insertions(+), 50 deletions(-) create mode 100644 ShadowsocksX-NG2/App/UI/Workspace/StatusDisplayModel.swift create mode 100644 ShadowsocksX-NG2/App/UI/Workspace/TransientDelayGate.swift create mode 100644 ShadowsocksX-NG2/Tests/Support/SleepGateRecorder.swift create mode 100644 ShadowsocksX-NG2/Tests/Workspace/StatusDisplayModelTests.swift create mode 100644 ShadowsocksX-NG2/Tests/Workspace/TransientDelayGateTests.swift diff --git a/ShadowsocksX-NG2/App/Composition/MainApp.swift b/ShadowsocksX-NG2/App/Composition/MainApp.swift index 745e8fe1..51070bf5 100644 --- a/ShadowsocksX-NG2/App/Composition/MainApp.swift +++ b/ShadowsocksX-NG2/App/Composition/MainApp.swift @@ -8,6 +8,9 @@ struct ShadowsocksXNG2App: App { @NSApplicationDelegateAdaptor(ConnectivityApplicationDelegate.self) private var appDelegate private let connectivityLifecycle: ConnectivityLifecycle private let proxyController: ProxyRuntimeController + /// 状态显示 debounce 模型:进程期强持有(与首页/菜单栏两个表面共享同一 + /// 门控状态机),body 不读它。 + private let statusDisplay: StatusDisplayModel @StateObject private var catalogWorkflow: CatalogWorkflow @StateObject private var proxyControl: ProxyControlWorkflow @StateObject private var loginController: LaunchAtLoginController @@ -32,6 +35,7 @@ struct ShadowsocksXNG2App: App { let composition = AppComposition.make() connectivityLifecycle = composition.connectivityLifecycle proxyController = composition.controller + statusDisplay = composition.statusDisplay _catalogWorkflow = StateObject(wrappedValue: composition.catalogWorkflow) _proxyControl = StateObject(wrappedValue: composition.proxyControl) _loginController = StateObject(wrappedValue: composition.loginController) @@ -55,7 +59,7 @@ struct ShadowsocksXNG2App: App { var body: some Scene { MenuBarExtra { ProxyStatusMenu( - control: proxyControl) + control: proxyControl, statusDisplay: statusDisplay) } label: { StatusMenuLabel(control: proxyControl) } @@ -169,6 +173,7 @@ private struct AppComposition { let controller: ProxyRuntimeController let catalogWorkflow: CatalogWorkflow let proxyControl: ProxyControlWorkflow + let statusDisplay: StatusDisplayModel let loginController: LaunchAtLoginController let settingsWorkflow: SettingsWorkflow let diagnosticsWorkflow: DiagnosticsWorkflow @@ -206,6 +211,9 @@ private struct AppComposition { runtime: ControllerProxyRuntimeAdapter(controller: controller), targetFacts: catalogWorkflow, interfaceFacts: SystemInterfaceFactsProvider()) + // 状态显示 debounce 模型:订阅代理控制工作流 snapshot,组合根接线一次, + // 首页运行控制卡与菜单栏状态卡共享同一门控状态机。 + let statusDisplay = StatusDisplayModel(control: proxyControl) // 设置工作流 module(Candidate 02):设置窗口的唯一 seam,组合根接线一次; // 写入侧经窄缝 SettingsCommitting,运行时控制器薄扩展即生产实现。 let settingsWorkflow = SettingsWorkflow( @@ -218,13 +226,15 @@ private struct AppComposition { let workspace = makeWorkspace( WorkspaceCompositionContext( dependencies: dependencies, controller: controller, workflow: catalogWorkflow, - control: proxyControl, plugins: plugins, settings: settingsWorkflow, + control: proxyControl, statusDisplay: statusDisplay, plugins: plugins, + settings: settingsWorkflow, login: loginController, clipboard: textClipboard)) return AppComposition( connectivityLifecycle: connectivityLifecycle, controller: controller, catalogWorkflow: catalogWorkflow, proxyControl: proxyControl, + statusDisplay: statusDisplay, loginController: loginController, settingsWorkflow: settingsWorkflow, diagnosticsWorkflow: diagnosticsWorkflow, @@ -297,6 +307,7 @@ private struct AppComposition { route: route, workflow: context.workflow, control: context.control, + statusDisplay: context.statusDisplay, plugins: PluginManagementModel(catalog: context.plugins), settingsWorkflow: context.settings, loginController: context.login, @@ -360,6 +371,7 @@ private struct WorkspaceCompositionContext { let controller: ProxyRuntimeController let workflow: CatalogWorkflow let control: ProxyControlWorkflow + let statusDisplay: StatusDisplayModel let plugins: PluginCatalog let settings: SettingsWorkflow let login: LaunchAtLoginController diff --git a/ShadowsocksX-NG2/App/UI/Home/HomeView.swift b/ShadowsocksX-NG2/App/UI/Home/HomeView.swift index f265e2e2..079f3368 100644 --- a/ShadowsocksX-NG2/App/UI/Home/HomeView.swift +++ b/ShadowsocksX-NG2/App/UI/Home/HomeView.swift @@ -2,11 +2,13 @@ import SwiftUI /// 首页分区:代理模式切换、运行控制、终端代理环境变量命令。 /// 模式与两个开关(agent/系统代理)走代理控制 -/// 工作流的整体 snapshot;命令复制是 UI 副作用。三种模式(规则/全局/直连) +/// 工作流的整体 snapshot;运行控制卡的状态行走 statusDisplay 的瞬态门控 +/// 呈现;命令复制是 UI 副作用。三种模式(规则/全局/直连) /// 与规则子选项共用同一快照。 struct HomeView: View { @ObservedObject var workflow: CatalogWorkflow @ObservedObject var control: ProxyControlWorkflow + let statusDisplay: StatusDisplayModel let browser: HomeTargetBrowser let activation: ActivationFeedbackState let showServers: () -> Void @@ -23,7 +25,7 @@ struct HomeView: View { } .frame(maxWidth: .infinity, alignment: .leading) VStack(spacing: 16) { - RuntimeControlCard(control: control) + RuntimeControlCard(control: control, statusDisplay: statusDisplay) ModeCard(control: control) TerminalProxyEnvironmentCard(control: control, clipboard: clipboard, errors: errors) } diff --git a/ShadowsocksX-NG2/App/UI/Home/RuntimeControlCard.swift b/ShadowsocksX-NG2/App/UI/Home/RuntimeControlCard.swift index 1f351645..bef3a7a0 100644 --- a/ShadowsocksX-NG2/App/UI/Home/RuntimeControlCard.swift +++ b/ShadowsocksX-NG2/App/UI/Home/RuntimeControlCard.swift @@ -1,14 +1,15 @@ import SwiftUI /// 「运行控制」卡:agent 与系统代理两个开关并排,绑定同一 -/// snapshot 的持久化意图;各自呈现运行状态/实际应用与点名原因。两个开关 +/// snapshot 的持久化意图;状态行经 StatusDisplayModel 的瞬态门控呈现—— +/// 快速切换模式这类瞬间成功的操作不再闪「应用中」等中间态。两个开关 /// 互不代替:关闭系统代理清除全部系统代理配置,关闭 agent 会级联关闭仍开启 /// 的系统代理意图并先清理再停止监听;helper 待批准时呈现登录项批准路径。 struct RuntimeControlCard: View { @ObservedObject var control: ProxyControlWorkflow + let statusDisplay: StatusDisplayModel var body: some View { - let card = StatusCardModel.card(from: control.snapshot) HomeCard( title: String(localized: "运行控制"), subtitle: nil, @@ -21,8 +22,8 @@ struct RuntimeControlCard: View { .frame(maxWidth: .infinity, alignment: .leading) } .toggleStyle(.switch) - statusRow(card.runtimeRow) - if let detail = card.runtimeDetail { + statusRow(statusDisplay.agent.row) + if let detail = statusDisplay.agent.detail { Text(detail.text) .font(.caption) .foregroundStyle(.orange) @@ -37,8 +38,8 @@ struct RuntimeControlCard: View { .frame(maxWidth: .infinity, alignment: .leading) } .toggleStyle(.switch) - statusRow(card.systemProxyRow) - Text(systemProxyHint) + statusRow(statusDisplay.systemProxy.row) + Text(statusDisplay.systemProxy.hint) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -49,10 +50,9 @@ struct RuntimeControlCard: View { .controlSize(.small) } SystemProxyDifferenceView(control: control) - if let systemProxyDetail = card.systemProxyDetail, - control.snapshot.systemProxyApplication != .paused + if let systemProxyDetail = statusDisplay.systemProxy.detail, + !statusDisplay.systemProxy.isPaused { - Text(systemProxyDetail.text) .font(.caption) .foregroundStyle(.orange) @@ -99,36 +99,6 @@ struct RuntimeControlCard: View { private var systemProxyApprovalNeeded: Bool { control.snapshot.systemProxyApprovalRequired } - - private var systemProxyHint: String { - let exitRequirement = - control.snapshot.proxyMode == .direct - ? String(localized: "直连模式不依赖活动服务器") - : String(localized: "规则/全局模式需要可用的活动服务器") - if systemProxyApprovalNeeded { - return String(localized: "系统代理助手等待批准。请在系统设置 → 登录项与扩展中允许。") - } - return switch control.snapshot.systemProxyApplication { - case .idle: - String(localized: "开启后让 macOS 系统代理指向本地入口;\(exitRequirement)") - case .pending: - String(localized: "已请求应用系统代理,等待本地入口就绪;\(exitRequirement)") - case .applied: - String(localized: "系统代理已指向本地入口") - case .failed, .repairFailed, .clearFailed: - String(localized: "操作失败,原因见下方") - case .paused: - AppPresentation.systemProxyPausedNotice - case .changed: - String(localized: "当前系统代理配置与预期存在差异") - case .unreadable: - String(localized: "无法检查当前系统代理配置") - case .applying: - String(localized: "正在应用系统代理配置…") - case .repairing: - String(localized: "正在修复系统代理配置…") - } - } } /// Names and commands come from current workflow facts; no configuration decisions in SwiftUI. diff --git a/ShadowsocksX-NG2/App/UI/StatusMenu/ProxyStatusMenu.swift b/ShadowsocksX-NG2/App/UI/StatusMenu/ProxyStatusMenu.swift index 5dabdc8f..f4ab5843 100644 --- a/ShadowsocksX-NG2/App/UI/StatusMenu/ProxyStatusMenu.swift +++ b/ShadowsocksX-NG2/App/UI/StatusMenu/ProxyStatusMenu.swift @@ -2,9 +2,11 @@ import AppKit import SwiftUI /// 菜单栏窗口式面板:重开主窗口、只读状态卡、无效服务器提示与退出。 -/// 控制与目标选择留在工作区;状态卡继续从 control 的整体 snapshot 派生。 +/// 控制与目标选择留在工作区;状态卡的状态行走 statusDisplay 的瞬态门控 +/// 呈现(与首页运行控制卡同一 debounce 口径)。 struct ProxyStatusMenu: View { @ObservedObject var control: ProxyControlWorkflow + let statusDisplay: StatusDisplayModel @Environment(\.openWindow) private var openWindow /// 只捕获本面板的宿主窗口,打开主窗口时不会误关其他窗口。 @State private var windowAnchor = NSView(frame: .zero) @@ -26,7 +28,7 @@ struct ProxyStatusMenu: View { .padding(12) Divider() - StatusCardView(control: control) + StatusCardView(control: control, statusDisplay: statusDisplay) Divider() Button("退出 ShadowsocksX-NG2(代理仍在后台运行)") { NSApp.terminate(nil) diff --git a/ShadowsocksX-NG2/App/UI/Workspace/MainWindowView.swift b/ShadowsocksX-NG2/App/UI/Workspace/MainWindowView.swift index f3b06660..43422b06 100644 --- a/ShadowsocksX-NG2/App/UI/Workspace/MainWindowView.swift +++ b/ShadowsocksX-NG2/App/UI/Workspace/MainWindowView.swift @@ -18,6 +18,9 @@ struct MainWindowView: View { @ObservedObject var route: WorkspaceRoute @ObservedObject var workflow: CatalogWorkflow @ObservedObject var control: ProxyControlWorkflow + /// 首页运行控制卡与菜单栏状态卡共享的状态显示 debounce 模型;壳 body + /// 不读它(分区视图自行观察),用 let 转发。 + let statusDisplay: StatusDisplayModel let plugins: PluginManagementModel let settingsWorkflow: SettingsWorkflow let loginController: LaunchAtLoginController @@ -127,6 +130,7 @@ struct MainWindowView: View { WorkspaceHomeView( workflow: workflow, control: control, + statusDisplay: statusDisplay, browser: homeBrowser, activation: activationFeedback, showServers: { route.navigate(to: .servers) }, @@ -183,6 +187,7 @@ extension WorkspaceDestination { private struct WorkspaceHomeView: View { @ObservedObject var workflow: CatalogWorkflow @ObservedObject var control: ProxyControlWorkflow + let statusDisplay: StatusDisplayModel let browser: HomeTargetBrowser let activation: ActivationFeedbackState let showServers: () -> Void @@ -193,6 +198,7 @@ private struct WorkspaceHomeView: View { HomeView( workflow: workflow, control: control, + statusDisplay: statusDisplay, browser: browser, activation: activation, showServers: showServers, diff --git a/ShadowsocksX-NG2/App/UI/Workspace/StatusCardView.swift b/ShadowsocksX-NG2/App/UI/Workspace/StatusCardView.swift index 43881595..ce52914d 100644 --- a/ShadowsocksX-NG2/App/UI/Workspace/StatusCardView.swift +++ b/ShadowsocksX-NG2/App/UI/Workspace/StatusCardView.swift @@ -1,17 +1,20 @@ import SwiftUI -/// 菜单栏面板代理状态卡 +/// 菜单栏面板代理状态卡。状态行经 StatusDisplayModel 的瞬态门控呈现 +/// (与首页运行控制卡同一 debounce 口径);活动目标与模式行仍从 control +/// 的整体 snapshot 直接派生。 struct StatusCardView: View { @ObservedObject var control: ProxyControlWorkflow + let statusDisplay: StatusDisplayModel var body: some View { let card = StatusCardModel.card(from: control.snapshot) - row(card.runtimeRow) - if let detail = card.runtimeDetail { + row(statusDisplay.agent.row) + if let detail = statusDisplay.agent.detail { detailLine(detail) } - row(card.systemProxyRow) - if let detail = card.systemProxyDetail { + row(statusDisplay.systemProxy.row) + if let detail = statusDisplay.systemProxy.detail { detailLine(detail) } diff --git a/ShadowsocksX-NG2/App/UI/Workspace/StatusDisplayModel.swift b/ShadowsocksX-NG2/App/UI/Workspace/StatusDisplayModel.swift new file mode 100644 index 00000000..12d07ffc --- /dev/null +++ b/ShadowsocksX-NG2/App/UI/Workspace/StatusDisplayModel.swift @@ -0,0 +1,142 @@ +import Combine +import Foundation +import Observation + +/// 状态显示 debounce 呈现模型:首页运行控制卡与菜单栏状态卡共用的两个意图 +/// (代理服务、系统代理)状态呈现,从代理控制工作流的整体 snapshot 派生 +/// payload 后经瞬态门控发布。稳定态(已应用/失败/待批准等)立即可见;瞬态 +/// (启动中/应用中/修复中)延迟上屏、短命即被吞掉,已上屏瞬态保持最短时 +/// 长——快速切换代理模式这类多数瞬间成功的操作不再让状态行闪中间态。 +/// 行、色调与点名 detail 复用 StatusCardModel 的既有口径,此处只做派生 +/// 捆绑(hint 一并随系统代理流更新,行与说明文案不会短暂不一致)。 +@Observable +@MainActor +final class StatusDisplayModel { + /// 代理服务意图的状态呈现:状态行与点名原因。 + struct AgentStatus: Equatable { + let row: StatusCardModel.Row + let detail: StatusCardModel.DetailLine? + } + + /// 系统代理意图的状态呈现:状态行、点名原因与运行控制卡说明文案。 + struct SystemProxyStatus: Equatable { + let row: StatusCardModel.Row + /// 点名原因不在此抑制:面板把它作为暂停说明行渲染,首页按 isPaused + /// 抑制(沿两侧原有口径)。 + let detail: StatusCardModel.DetailLine? + let hint: String + /// 已暂停时详情与提示文案重复(hint 已是暂停说明),首页据此不渲染 + /// detail;面板无 hint,仍渲染 detail 作为暂停说明。 + let isPaused: Bool + } + + private(set) var agent: AgentStatus + private(set) var systemProxy: SystemProxyStatus + + private let agentGate: TransientDelayGate + private let systemProxyGate: TransientDelayGate + private var cancellables: Set = [] + + /// 当前排队的迟显任务;无排队为 nil。供测试等待迟显完成。 + var scheduledAgentDisplayTask: Task? { agentGate.scheduledDisplayTask } + var scheduledSystemProxyDisplayTask: Task? { + systemProxyGate.scheduledDisplayTask + } + + /// 生产接线:订阅代理控制工作流的整体 snapshot 发布。 + convenience init( + control: ProxyControlWorkflow, + interval: TimeInterval = 0.4, + now: @escaping () -> TimeInterval = { Date().timeIntervalSinceReferenceDate }, + sleep: @escaping (TimeInterval) async throws -> Void = { + try await Task.sleep(for: .seconds($0)) + } + ) { + self.init(snapshots: control.$snapshot, interval: interval, now: now, sleep: sleep) + } + + /// 测试缝:直接注入 snapshot 流(订阅即送达当前值,两流首值立即上屏)。 + init( + snapshots: Snapshots, + interval: TimeInterval = 0.4, + now: @escaping () -> TimeInterval = { Date().timeIntervalSinceReferenceDate }, + sleep: @escaping (TimeInterval) async throws -> Void = { + try await Task.sleep(for: .seconds($0)) + } + ) where Snapshots.Output == ProxyControlSnapshot, Snapshots.Failure == Never { + agent = AgentStatus(row: .init(label: "", text: "", tone: .neutral), detail: nil) + systemProxy = SystemProxyStatus( + row: .init(label: "", text: "", tone: .neutral), detail: nil, hint: "", isPaused: false) + agentGate = TransientDelayGate(interval: interval, now: now, sleep: sleep) + systemProxyGate = TransientDelayGate(interval: interval, now: now, sleep: sleep) + // 存储属性至此完整初始化,显示回调才可捕获 self。 + agentGate.display = { [weak self] in self?.agent = $0 } + systemProxyGate.display = { [weak self] in self?.systemProxy = $0 } + // 订阅即送达当前 snapshot:两流首值立即上屏(门控首值语义)。 + snapshots + .sink { [weak self] snapshot in self?.handle(snapshot) } + .store(in: &cancellables) + } + + private func handle(_ snapshot: ProxyControlSnapshot) { + agentGate.update( + Self.agentStatus(from: snapshot), + isTransient: snapshot.runtime.status == .starting) + systemProxyGate.update( + Self.systemProxyStatus(from: snapshot), + isTransient: Self.isSystemProxyTransient(snapshot.systemProxyApplication)) + } + + // MARK: - payload 派生 + + static func agentStatus(from snapshot: ProxyControlSnapshot) -> AgentStatus { + let card = StatusCardModel.card(from: snapshot) + return AgentStatus(row: card.runtimeRow, detail: card.runtimeDetail) + } + + static func systemProxyStatus(from snapshot: ProxyControlSnapshot) -> SystemProxyStatus { + let card = StatusCardModel.card(from: snapshot) + return SystemProxyStatus( + row: card.systemProxyRow, + detail: card.systemProxyDetail, + hint: systemProxyHint(from: snapshot), + isPaused: snapshot.systemProxyApplication == .paused) + } + + /// 系统代理侧瞬态 = 正在写入/修复系统代理配置;pending(待批准/待应用)是 + /// 需要用户行动的 resting 态,不算瞬态。 + static func isSystemProxyTransient(_ application: SystemProxyApplicationFacts) -> Bool { + application == .applying || application == .repairing + } + + /// 运行控制卡的说明文案(原视图内派生移入,随系统代理流一起门控/去重)。 + private static func systemProxyHint(from snapshot: ProxyControlSnapshot) -> String { + let exitRequirement = + snapshot.proxyMode == .direct + ? String(localized: "直连模式不依赖活动服务器") + : String(localized: "规则/全局模式需要可用的活动服务器") + if snapshot.systemProxyApprovalRequired { + return String(localized: "系统代理助手等待批准。请在系统设置 → 登录项与扩展中允许。") + } + return switch snapshot.systemProxyApplication { + case .idle: + String(localized: "开启后让 macOS 系统代理指向本地入口;\(exitRequirement)") + case .pending: + String(localized: "已请求应用系统代理,等待本地入口就绪;\(exitRequirement)") + case .applied: + String(localized: "系统代理已指向本地入口") + case .failed, .repairFailed, .clearFailed: + String(localized: "操作失败,原因见下方") + case .paused: + AppPresentation.systemProxyPausedNotice + case .changed: + String(localized: "当前系统代理配置与预期存在差异") + case .unreadable: + String(localized: "无法检查当前系统代理配置") + case .applying: + String(localized: "正在应用系统代理配置…") + case .repairing: + String(localized: "正在修复系统代理配置…") + } + } +} diff --git a/ShadowsocksX-NG2/App/UI/Workspace/TransientDelayGate.swift b/ShadowsocksX-NG2/App/UI/Workspace/TransientDelayGate.swift new file mode 100644 index 00000000..8cc4b094 --- /dev/null +++ b/ShadowsocksX-NG2/App/UI/Workspace/TransientDelayGate.swift @@ -0,0 +1,90 @@ +import Foundation + +/// 单条状态显示流的瞬态延迟门控:稳定值立即上屏;瞬态值(启动中/应用中/ +/// 修复中等短命中间态)延迟一个 interval 后才上屏,期间被新值取代则按新值 +/// 重新裁定;已上屏的瞬态至少保持一个 interval,避免「应用中」只闪一瞬又跳 +/// 终态的二次闪烁。首值直接上屏——初始绘制没有可被闪烁的上一状态。 +/// +/// 时间经 now/sleep 注入,测试用虚拟时钟驱动;不持有 SwiftUI 类型。 +@MainActor +final class TransientDelayGate { + private let interval: TimeInterval + private let now: () -> TimeInterval + private let sleep: (TimeInterval) async throws -> Void + + /// 显示回调;构造后立即接线。持有方须在自身完成初始化后才能捕获 self, + /// 故不入构造参数。nil 时门控照常裁决但不外发。 + var display: ((Value) -> Void)? + + private var displayed: Value? + private var pending: (value: Value, isTransient: Bool)? + /// 已上屏瞬态的保持截止时刻;稳定值上屏时清除。窗口内的替换被排迟到 + /// 截止时刻,防止瞬态只闪一瞬。 + private var holdUntil: TimeInterval? + private var scheduleGeneration = 0 + private var pendingTask: Task? + + /// 当前排队显示任务;无排队(已上屏或无待显值)为 nil。供测试等待迟显完成。 + var scheduledDisplayTask: Task? { pendingTask } + + init( + interval: TimeInterval, + now: @escaping () -> TimeInterval = { Date().timeIntervalSinceReferenceDate }, + sleep: @escaping (TimeInterval) async throws -> Void = { + try await Task.sleep(for: .seconds($0)) + } + ) { + self.interval = interval + self.now = now + self.sleep = sleep + } + + func update(_ value: Value, isTransient: Bool) { + if displayed == nil { + pending = (value, isTransient) + displayPending() + return + } + if displayed == value { + pending = nil + pendingTask?.cancel() + return + } + // 重复 payload 不重排:沿用原定时刻,避免连续重发把瞬态无限推后。 + if pending?.value == value, pending?.isTransient == isTransient { return } + pending = (value, isTransient) + pendingTask?.cancel() + let arrival = now() + // 无保持窗口时用负无穷做中性值:稳定值的最早上屏时刻就是到达时刻, + // 立即同步上屏(不能用正的极小值——虚拟时钟 0 会被顶成 epsilon 延迟)。 + let earliest = max( + isTransient ? arrival + interval : arrival, + holdUntil ?? -.greatestFiniteMagnitude) + let delay = max(0, earliest - arrival) + if delay <= 0 { + displayPending() + return + } + scheduleGeneration += 1 + let generation = scheduleGeneration + pendingTask = Task { [weak self] in + await self?.displayPendingIfCurrent(generation, after: delay) + } + } + + private func displayPendingIfCurrent(_ generation: Int, after delay: TimeInterval) async { + guard generation == scheduleGeneration else { return } + try? await sleep(delay) + guard generation == scheduleGeneration else { return } + displayPending() + } + + private func displayPending() { + guard let current = pending else { return } + displayed = current.value + holdUntil = current.isTransient ? now() + interval : nil + pending = nil + pendingTask = nil + display?(current.value) + } +} diff --git a/ShadowsocksX-NG2/Tests/Support/SleepGateRecorder.swift b/ShadowsocksX-NG2/Tests/Support/SleepGateRecorder.swift new file mode 100644 index 00000000..4752cc8b --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Support/SleepGateRecorder.swift @@ -0,0 +1,28 @@ +import Foundation + +/// 顺序虚拟 sleep 记录器:调用前先 `armNextCall()` 预约一对门闩,下一次 +/// sleep 记录延迟、报到后挂起在预约门闩上;测试据此按调用序推进虚拟时钟, +/// 不轮询、不真实等待。 +@MainActor +final class SleepGateRecorder { + private(set) var delays: [TimeInterval] = [] + private var armedEntered: AsyncGate? + private var armedRelease: AsyncGate? + + func sleep(_ delay: TimeInterval) async { + delays.append(delay) + let entered = armedEntered ?? AsyncGate() + let release = armedRelease ?? AsyncGate() + armedEntered = nil + armedRelease = nil + entered.release() + await release.wait() + } + + func armNextCall() -> (entered: AsyncGate, release: AsyncGate) { + let pair = (entered: AsyncGate(), release: AsyncGate()) + armedEntered = pair.entered + armedRelease = pair.release + return pair + } +} diff --git a/ShadowsocksX-NG2/Tests/Workspace/StatusDisplayModelTests.swift b/ShadowsocksX-NG2/Tests/Workspace/StatusDisplayModelTests.swift new file mode 100644 index 00000000..7922e213 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Workspace/StatusDisplayModelTests.swift @@ -0,0 +1,158 @@ +import Combine +import Foundation +import Testing + +@testable import ShadowsocksX_NG2 + +/// 状态显示 debounce 呈现模型:代理服务与系统代理两个意图的呈现 payload +/// 从整体 snapshot 派生后经瞬态门控发布。断言用语义色调与结构(detail 空 +/// 非空、hint 变化),不断言具体本地化文案。 +@MainActor +struct StatusDisplayModelTests { + private let runningFacts = ProxyRuntimeFacts(status: .running, isOn: true) + + /// 快速模式切换:applying 短命即回 applied,「应用中」整段不得上屏。 + @Test func fastModeSwitchNeverShowsTransientSystemProxyState() async { + let snapshots = CurrentValueSubject( + makeSnapshot(systemProxyApplication: .applied)) + let clock = SleepGateRecorder() + var now: TimeInterval = 0 + let model = StatusDisplayModel( + snapshots: snapshots, interval: 0.4, now: { now }, + sleep: { delay in await clock.sleep(delay) }) + #expect(model.systemProxy.row.tone == .positive) + let armed = clock.armNextCall() + snapshots.send(makeSnapshot(systemProxyApplication: .applying)) + let transientTask = model.scheduledSystemProxyDisplayTask + #expect(model.systemProxy.row.tone == .positive, "瞬态「应用中」不得立即上屏") + snapshots.send(makeSnapshot(systemProxyApplication: .applied)) + #expect(model.systemProxy.row.tone == .positive, "快路径完成后瞬态从未上屏") + armed.release.release() + await transientTask?.value + #expect(clock.delays == [0.4]) + } + + /// applying 持续存在时(真正慢的写入)延迟后要上屏,不能永远藏住。 + @Test func persistentSystemProxyTransientDisplaysAfterDelay() async { + let snapshots = CurrentValueSubject( + makeSnapshot(systemProxyApplication: .applied)) + let clock = SleepGateRecorder() + var now: TimeInterval = 0 + let model = StatusDisplayModel( + snapshots: snapshots, interval: 0.4, now: { now }, + sleep: { delay in await clock.sleep(delay) }) + let armed = clock.armNextCall() + snapshots.send(makeSnapshot(systemProxyApplication: .applying)) + let task = model.scheduledSystemProxyDisplayTask + await armed.entered.wait() + #expect(model.systemProxy.row.tone == .positive) + now = 0.4 + armed.release.release() + await task?.value + #expect(model.systemProxy.row.tone == .attention) + } + + /// pending(待应用/待批准)是 resting 态:立即可见,不经门控。 + @Test func pendingApplicationStateIsNotGated() async { + let snapshots = CurrentValueSubject( + makeSnapshot(systemProxyApplication: .applied)) + let clock = SleepGateRecorder() + let model = StatusDisplayModel( + snapshots: snapshots, interval: 0.4, now: { 0 }, + sleep: { delay in await clock.sleep(delay) }) + let pending = makeSnapshot(systemProxyApplication: .pending) + snapshots.send(pending) + #expect( + StatusDisplayModel.systemProxyStatus(from: pending).row.tone == .attention, + "派生口径:待应用 = attention") + #expect(model.systemProxy.row.tone == .attention) + #expect(clock.delays.isEmpty) + #expect(model.scheduledSystemProxyDisplayTask == nil) + } + + /// 代理服务侧瞬态 = 启动中:同样延迟上屏。 + @Test func agentStartingStateIsGated() async { + let snapshots = CurrentValueSubject( + makeSnapshot(systemProxyApplication: .idle)) + let clock = SleepGateRecorder() + var now: TimeInterval = 0 + let model = StatusDisplayModel( + snapshots: snapshots, interval: 0.4, now: { now }, + sleep: { delay in await clock.sleep(delay) }) + let armed = clock.armNextCall() + snapshots.send( + makeSnapshot( + facts: ProxyRuntimeFacts(status: .starting, isOn: true), + systemProxyApplication: .idle)) + let task = model.scheduledAgentDisplayTask + #expect(model.agent.row.tone == .positive, "运行中保持可见") + await armed.entered.wait() + now = 0.4 + armed.release.release() + await task?.value + #expect(model.agent.row.tone == .neutral, "启动中延迟后上屏") + } + + /// 点名原因不随 payload 抑制:无法检查(面板与首页都渲染)与已暂停 + /// (payload 携带暂停事实,首页据此抑制、面板渲染作暂停说明)并存。 + @Test func pausedStateCarriesDetailAndPauseFlag() async { + let snapshots = CurrentValueSubject( + makeSnapshot(systemProxyApplication: .applied)) + let model = StatusDisplayModel( + snapshots: snapshots, interval: 0.4, now: { 0 }, sleep: { _ in }) + snapshots.send(makeSnapshot(systemProxyApplication: .unreadable(.unknown))) + #expect(model.systemProxy.detail != nil, "无法检查的点名原因立即可见") + #expect(model.systemProxy.row.tone == .failure) + #expect(!model.systemProxy.isPaused) + snapshots.send(makeSnapshot(systemProxyApplication: .paused)) + #expect(model.systemProxy.detail != nil, "暂停说明保留在 detail(面板渲染)") + #expect(model.systemProxy.isPaused, "首页据此抑制 detail(hint 已是暂停说明)") + #expect(model.systemProxy.row.tone == .attention) + } + + /// hint 文案随系统代理流派生:批准阻塞提示立即生效(稳定态)。 + @Test func approvalRequirementChangesHintImmediately() async { + let snapshots = CurrentValueSubject( + makeSnapshot(systemProxyApplication: .idle)) + let model = StatusDisplayModel( + snapshots: snapshots, interval: 0.4, now: { 0 }, sleep: { _ in }) + let idleHint = model.systemProxy.hint + snapshots.send(makeSnapshot(systemProxyApplication: .idle, approvalRequired: true)) + #expect(model.systemProxy.hint != idleHint) + #expect(model.systemProxy.row.tone == .neutral) + } + + /// 从运行时事实构造最小 snapshot(其余字段与本组用例无关;与 + /// StatusCardModelTests 的夹具同形状)。 + private func makeSnapshot( + facts: ProxyRuntimeFacts = ProxyRuntimeFacts(status: .running, isOn: true), + systemProxyApplication: SystemProxyApplicationFacts, + approvalRequired: Bool = false + ) -> ProxyControlSnapshot { + ProxyControlSnapshot( + runtime: facts, + agentIntentEnabled: true, + activationFailure: nil, + systemProxyIntentEnabled: false, + systemProxyApplication: systemProxyApplication, + systemProxyApprovalRequired: approvalRequired, + proxyMode: .rule, + ruleDefaultAction: .proxyWhenUnmatched, + availableModes: ProxyMode.availableModes, + activeTarget: nil, + skippedInvalidServerCount: 0, + httpExport: HTTPExportCapability( + copyableLine: + "export http_proxy=http://127.0.0.1:11087;export https_proxy=http://127.0.0.1:11087;"), + commandAddressPicker: TerminalCommandAddressPicker( + isVisible: false, + candidates: [ + TerminalCommandAddress(bsdName: "lo0", address: "127.0.0.1", displayName: "lo0") + ], + selected: TerminalCommandAddress(bsdName: "lo0", address: "127.0.0.1", displayName: "lo0")), + terminalProxyEnvironmentCommands: TerminalProxyEnvironmentCommands( + listen: RuntimeListenFacts(listen: ProxyListeningConfiguration()), + commandAddress: TerminalCommandAddress( + bsdName: "lo0", address: "127.0.0.1", displayName: "lo0"))) + } +} diff --git a/ShadowsocksX-NG2/Tests/Workspace/TransientDelayGateTests.swift b/ShadowsocksX-NG2/Tests/Workspace/TransientDelayGateTests.swift new file mode 100644 index 00000000..18a415e1 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Workspace/TransientDelayGateTests.swift @@ -0,0 +1,175 @@ +import Foundation +import Testing + +@testable import ShadowsocksX_NG2 + +/// 瞬态门控裁决表:稳定值立即可见、瞬态值延迟上屏、短命瞬态被吞、 +/// 已上屏瞬态保持最短时长。时间经注入的 now/sleep 虚拟化,不真实等待。 +@MainActor +struct TransientDelayGateTests { + @Test func firstValueDisplaysImmediatelyWithoutDelay() { + var displayed: [String] = [] + let gate = TransientDelayGate( + interval: 0.4, + now: { 0 }, + sleep: { _ in }) + gate.display = { displayed.append($0) } + gate.update("applied", isTransient: false) + #expect(displayed == ["applied"]) + } + + @Test func transientValueDisplaysOnlyAfterDelayInterval() async { + var now: TimeInterval = 0 + var displayed: [String] = [] + var delays: [TimeInterval] = [] + let sleepEntered = AsyncGate() + let sleepRelease = AsyncGate() + let gate = TransientDelayGate( + interval: 0.4, + now: { now }, + sleep: { delay in + delays.append(delay) + sleepEntered.release() + await sleepRelease.wait() + }) + gate.display = { displayed.append($0) } + gate.update("applied", isTransient: false) + gate.update("applying", isTransient: true) + let task = gate.scheduledDisplayTask + await sleepEntered.wait() + #expect(delays == [0.4]) + #expect(displayed == ["applied"]) + now = 0.4 + sleepRelease.release() + await task?.value + #expect(displayed == ["applied", "applying"]) + } + + @Test func shortLivedTransientIsSupersededAndNeverDisplayed() async { + let clock = SleepGateRecorder() + var now: TimeInterval = 0 + var displayed: [String] = [] + let gate = TransientDelayGate( + interval: 0.4, + now: { now }, + sleep: { delay in await clock.sleep(delay) }) + gate.display = { displayed.append($0) } + gate.update("applied", isTransient: false) + let transientSleep = clock.armNextCall() + gate.update("applying", isTransient: true) + let transientTask = gate.scheduledDisplayTask + await transientSleep.entered.wait() + now = 0.2 + gate.update("failed", isTransient: false) + #expect(displayed == ["applied", "failed"]) + transientSleep.release.release() + await transientTask?.value + #expect(displayed == ["applied", "failed"]) + } + + @Test func displayedTransientHoldsForMinimumInterval() async { + let clock = SleepGateRecorder() + var now: TimeInterval = 0 + var displayed: [String] = [] + let gate = TransientDelayGate( + interval: 0.4, + now: { now }, + sleep: { delay in await clock.sleep(delay) }) + gate.display = { displayed.append($0) } + gate.update("applied", isTransient: false) + let transientSleep = clock.armNextCall() + gate.update("applying", isTransient: true) + let transientTask = gate.scheduledDisplayTask + await transientSleep.entered.wait() + now = 0.4 + transientSleep.release.release() + await transientTask?.value + #expect(displayed == ["applied", "applying"]) + let heldSleep = clock.armNextCall() + gate.update("failed", isTransient: false) + let heldTask = gate.scheduledDisplayTask + #expect(displayed == ["applied", "applying"]) + await heldSleep.entered.wait() + #expect(clock.delays == [0.4, 0.4]) + now = 0.8 + heldSleep.release.release() + await heldTask?.value + #expect(displayed == ["applied", "applying", "failed"]) + } + + @Test func transientAfterDisplayedTransientWaitsForDelayAndHold() async { + let clock = SleepGateRecorder() + var now: TimeInterval = 0 + var displayed: [String] = [] + let gate = TransientDelayGate( + interval: 0.4, + now: { now }, + sleep: { delay in await clock.sleep(delay) }) + gate.display = { displayed.append($0) } + gate.update("applied", isTransient: false) + let applyingSleep = clock.armNextCall() + gate.update("applying", isTransient: true) + let applyingTask = gate.scheduledDisplayTask + await applyingSleep.entered.wait() + now = 0.4 + applyingSleep.release.release() + await applyingTask?.value + #expect(displayed == ["applied", "applying"]) + let repairingSleep = clock.armNextCall() + now = 0.5 + gate.update("repairing", isTransient: true) + let repairingTask = gate.scheduledDisplayTask + #expect(displayed == ["applied", "applying"]) + await repairingSleep.entered.wait() + #expect(clock.delays == [0.4, 0.4]) + now = 0.9 + repairingSleep.release.release() + await repairingTask?.value + #expect(displayed == ["applied", "applying", "repairing"]) + } + + @Test func duplicatePendingPayloadKeepsOriginalSchedule() async { + let clock = SleepGateRecorder() + var now: TimeInterval = 0 + var displayed: [String] = [] + let gate = TransientDelayGate( + interval: 0.4, + now: { now }, + sleep: { delay in await clock.sleep(delay) }) + gate.display = { displayed.append($0) } + gate.update("applied", isTransient: false) + let applyingSleep = clock.armNextCall() + gate.update("applying", isTransient: true) + let applyingTask = gate.scheduledDisplayTask + await applyingSleep.entered.wait() + now = 0.1 + gate.update("applying", isTransient: true) + now = 0.4 + applyingSleep.release.release() + await applyingTask?.value + #expect(displayed == ["applied", "applying"]) + #expect(clock.delays == [0.4]) + } + + @Test func valueEqualToDisplayedCancelsPendingDisplay() async { + let clock = SleepGateRecorder() + var now: TimeInterval = 0 + var displayed: [String] = [] + let gate = TransientDelayGate( + interval: 0.4, + now: { now }, + sleep: { delay in await clock.sleep(delay) }) + gate.display = { displayed.append($0) } + gate.update("applied", isTransient: false) + let applyingSleep = clock.armNextCall() + gate.update("applying", isTransient: true) + let applyingTask = gate.scheduledDisplayTask + await applyingSleep.entered.wait() + gate.update("applied", isTransient: false) + #expect(displayed == ["applied"]) + applyingSleep.release.release() + await applyingTask?.value + #expect(displayed == ["applied"]) + #expect(clock.delays == [0.4]) + } +}