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.
This commit is contained in:
Qiu Yuzhou
2026-10-24 02:22:39 +08:00
parent 2fdcb5ab93
commit c708cd8d19
11 changed files with 638 additions and 50 deletions
+14 -2
View File
@@ -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
+4 -2
View File
@@ -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)
}
@@ -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.
@@ -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)
@@ -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,
@@ -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)
}
@@ -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<AgentStatus>
private let systemProxyGate: TransientDelayGate<SystemProxyStatus>
private var cancellables: Set<AnyCancellable> = []
/// 当前排队的迟显任务;无排队为 nil。供测试等待迟显完成。
var scheduledAgentDisplayTask: Task<Void, Never>? { agentGate.scheduledDisplayTask }
var scheduledSystemProxyDisplayTask: Task<Void, Never>? {
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: Publisher>(
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: "正在修复系统代理配置…")
}
}
}
@@ -0,0 +1,90 @@
import Foundation
/// 单条状态显示流的瞬态延迟门控:稳定值立即上屏;瞬态值(启动中/应用中/
/// 修复中等短命中间态)延迟一个 interval 后才上屏,期间被新值取代则按新值
/// 重新裁定;已上屏的瞬态至少保持一个 interval,避免「应用中」只闪一瞬又跳
/// 终态的二次闪烁。首值直接上屏——初始绘制没有可被闪烁的上一状态。
///
/// 时间经 now/sleep 注入,测试用虚拟时钟驱动;不持有 SwiftUI 类型。
@MainActor
final class TransientDelayGate<Value: Equatable> {
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<Void, Never>?
/// 当前排队显示任务;无排队(已上屏或无待显值)为 nil。供测试等待迟显完成。
var scheduledDisplayTask: Task<Void, Never>? { 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)
}
}
@@ -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
}
}
@@ -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<ProxyControlSnapshot, Never>(
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<ProxyControlSnapshot, Never>(
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<ProxyControlSnapshot, Never>(
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<ProxyControlSnapshot, Never>(
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<ProxyControlSnapshot, Never>(
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<ProxyControlSnapshot, Never>(
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")))
}
}
@@ -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<String>(
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<String>(
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<String>(
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<String>(
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<String>(
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<String>(
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<String>(
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])
}
}