mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 10:44:50 +00:00
Add Mainland China proxy routing with independent rule workspaces. Preserve existing routing choices through equivalent preset mappings. BREAKING CHANGE: existing custom rules and enablement settings are not migrated. Recreate them in the appropriate preset.
159 lines
7.2 KiB
Swift
159 lines
7.2 KiB
Swift
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()
|
||
let 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,
|
||
rulePreset: .chinaDirect,
|
||
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")))
|
||
}
|
||
}
|