Files
ShadowsocksX-NG/ShadowsocksX-NG2/Tests/ProxyControl/ProxyCommandAddressWorkflowTests.swift
T
Qiu Yuzhou 20a62117f2 refactor(tests): 按功能领域重组 Tests/ 目录
Tests/ 根层原有 138 个平铺测试文件,无法从目录读出测试覆盖的功能领域。

按功能领域重组为 17 个目录(Catalog/Workflow 与 Rules/Custom 两处嵌套,分别
对应 App/Application/CatalogWorkflow 与 Domain/Rules/Custom):Architecture /
Support / Agent / Activation / Catalog / Subscription / Rules / ProxyRuntime /
ProxyControl / Runtime / Settings / SystemProxy / Plugin / Diagnostics /
Credentials / Workspace / Servers;Smoke/ 保持独立 target 不动。138 个移动中
132 个是 100% 相似度的纯重命名,其余 6 个在重命名之上带路径反推改写(见下)。

本次不是纯搬移:测试代码比生产代码多一层位置耦合,有 7 处用固定层数的
deletingLastPathComponent() 从 #filePath 反推仓库路径(都假定文件就在 Tests/
根层)。这 7 处改走新增的 Tests/Support/TestSourceTree.swift——从调用点沿父
目录上溯找到含 project.yml 的目录,因此不再依赖父目录层数,以后移动测试文件
也不会静默指错。

配套改动:

- 新增 Tests 布局守卫 TestsLayoutTests,断言 Tests/ 根层无平铺 .swift。比
  Domain 那条弱一档——根层的 Info.plist(info.path 生成物)与
  Fixtures/RuleSnapshots 下的 JSON(folder resource)都必须保留,不能用
  「树下只有 .swift」覆盖。
- project.yml:Smoke target 按文件引用的 Tests/AppArtifact.swift 改指
  Tests/Support/;两处注释里写死的 Tests/SkeletonTests.swift 随移动更新。
  excludes 的裸名 "Smoke" / "Fixtures" 仍匹配 Tests/ 直属子目录,无需改动。
- README 的 Tests 条目展开为目录说明,并修掉 Domain 条目里写死的
  Tests/SkeletonTests.swift。
- 顺带删去 DomainLayoutTests 文档注释里「整理前 62 个文件」这类易漂移的数字。

验证:xcodegen generate 后单测 target 为 139 个源文件(全部非 Smoke 文件,0 个
Smoke 文件混入)且仍以 Fixtures/RuleSnapshots 为资源,Smoke target 为 8 个
Smoke 文件加 AppArtifact;依赖路径反推的 6 个测试类全绿(路径指错即失败);
task format 未改写任何被移动文件;task lint 与整理前同为 21 条内容型 warning;
build 0 error;task test 1025 用例全通过(整理前 1024,+1 为新守卫);
TestsLayoutTests 经变异验证(根层投放探针即失败);packaging-gate ALL CHECKS
PASSED。
2026-10-05 20:37:35 +08:00

384 lines
15 KiB
Swift

import XCTest
@testable import ShadowsocksX_NG2
/// 首页命令地址选择器 contract(issue #72)。候选派生:四种监听方式的地址族
/// 过滤与默认值、严格接口类型过滤、名称回退、排序合并与网络变化刷新。选择
/// 生命周期与命令生成见 `ProxyCommandAddressLifecycleTests`。复用
/// ProxyControlWorkflowTests 的可编程替身。
@MainActor
final class ProxyCommandAddressWorkflowTests: XCTestCase {
private var runtime: FakeProxyRuntime!
private var interfaceFacts: FakeLocalInterfaceFacts!
private var workflow: ProxyControlWorkflow!
override func setUp() async throws {
try await super.setUp()
runtime = FakeProxyRuntime()
interfaceFacts = FakeLocalInterfaceFacts()
workflow = ProxyControlWorkflow(
runtime: runtime, targetFacts: FakeTargetFacts(), interfaceFacts: interfaceFacts)
}
/// 把已保存监听事实切到指定方式并触发重观察。
private func setListenerMode(_ mode: ListenerMode) {
runtime.listenFacts = RuntimeListenFacts(
listenerMode: mode, socksPort: 11086, httpPort: 11087)
runtime.emitChange()
}
func testLocalhostModeHidesPickerWithLoopbackOnlyCandidates() {
setListenerMode(.localhost)
interfaceFacts.interfaces = [
FakeLocalInterfaceFacts.wifi(addresses: [.ipv4("192.168.1.10")]),
FakeLocalInterfaceFacts.loopback(),
]
interfaceFacts.emitChange()
let picker = workflow.snapshot.commandAddressPicker
XCTAssertFalse(picker.isVisible, "仅本机监听方式隐藏下拉框")
XCTAssertEqual(
picker.candidates,
[TerminalCommandAddress(bsdName: "lo0", address: "127.0.0.1", displayName: "lo0")],
"仅本机方式候选只有 IPv4 回环")
XCTAssertEqual(picker.selected.address, "127.0.0.1")
}
func testIPv4ModeFiltersFamiliesAndAllowedInterfaceTypes() {
setListenerMode(.allIPv4Interfaces)
interfaceFacts.interfaces = [
FakeLocalInterfaceFacts.wifi(
addresses: [.ipv4("192.168.1.10"), .ipv6("2001:db8::1"), .ipv6("fe80::1", linkLocal: true)]
),
FakeLocalInterfaceFacts.ethernet(addresses: [.ipv4("10.0.0.5")]),
FakeLocalInterfaceFacts.vpn(),
FakeLocalInterfaceFacts.virtual(),
FakeLocalInterfaceFacts.loopback(),
]
interfaceFacts.emitChange()
let picker = workflow.snapshot.commandAddressPicker
XCTAssertTrue(picker.isVisible)
XCTAssertEqual(
picker.candidates.map(\.address),
["127.0.0.1", "10.0.0.5", "192.168.1.10"],
"回环置顶,其余按接口名称排序;IPv6、VPN、其他类型与回环事实不进候选")
XCTAssertEqual(picker.selected.address, "127.0.0.1", "默认兼容回环地址")
}
func testDualStackModeIncludesBothLoopbacksAndBothFamilies() {
setListenerMode(.allIPv4AndIPv6Interfaces)
interfaceFacts.interfaces = [
FakeLocalInterfaceFacts.wifi(
addresses: [
.ipv6("2001:db8::1"), .ipv4("192.168.1.10"), .ipv4("169.254.9.9"),
.ipv6("fe80::1", linkLocal: true),
])
]
interfaceFacts.emitChange()
let picker = workflow.snapshot.commandAddressPicker
XCTAssertEqual(
picker.candidates.map(\.address),
["127.0.0.1", "::1", "169.254.9.9", "192.168.1.10", "2001:db8::1"],
"双栈提供两个回环(127.0.0.1 在前)、允许 IPv4 链路本地、排除 IPv6 链路本地,同接口 IPv4 先于 IPv6")
XCTAssertEqual(picker.selected.address, "127.0.0.1")
}
func testTemporaryIPv6ExcludedAndAnnotationsKeptAsSeparateFacts() {
setListenerMode(.allIPv4AndIPv6Interfaces)
interfaceFacts.interfaces = [
FakeLocalInterfaceFacts.wifi(
addresses: [
.ipv6("240e::8150", flags: [.autoconf, .secured]),
.ipv6("240e::855", flags: [.autoconf, .temporary]),
.ipv6("240e::17", flags: [.dynamic]),
])
]
interfaceFacts.emitChange()
let picker = workflow.snapshot.commandAddressPicker
XCTAssertEqual(
picker.candidates.map(\.address),
["127.0.0.1", "::1", "240e::17", "240e::8150"],
"RFC 4941 隐私临时地址被过滤(会轮换);其余按地址稳定排序")
let secured = picker.candidates.first { $0.address == "240e::8150" }
XCTAssertEqual(secured?.displayName, "Wi-Fi")
XCTAssertEqual(secured?.annotation, "autoconf secured", "注记作为独立呈现事实")
XCTAssertEqual(
picker.candidates.first { $0.address == "240e::17" }?.annotation,
"dynamic", "DHCPv6 地址注记 dynamic")
}
func testIPv6OnlyModeDefaultsToV1AndFiltersFamilies() {
setListenerMode(.allIPv6Interfaces)
interfaceFacts.interfaces = [
FakeLocalInterfaceFacts.wifi(
addresses: [.ipv4("192.168.1.10"), .ipv6("2001:db8::1")])
]
interfaceFacts.emitChange()
let picker = workflow.snapshot.commandAddressPicker
XCTAssertEqual(
picker.candidates.map(\.address), ["::1", "2001:db8::1"],
"仅 IPv6 方式默认 ::1 且候选只有 IPv6")
XCTAssertEqual(picker.selected.address, "::1")
}
func testDisplayNamesUseLocalizedNameWithBSDNameFallback() {
setListenerMode(.allIPv4Interfaces)
interfaceFacts.interfaces = [
FakeLocalInterfaceFacts.wifi(localizedName: "Wi-Fi", addresses: [.ipv4("192.168.1.10")]),
FakeLocalInterfaceFacts.ethernet(localizedName: nil, addresses: [.ipv4("10.0.0.5")]),
]
interfaceFacts.emitChange()
XCTAssertEqual(
workflow.snapshot.commandAddressPicker.candidates.map(\.displayName),
["lo0", "en1", "Wi-Fi"],
"名称缺失回退 BSD 名,回环显示 lo0")
}
func testSameInterfaceDuplicateIPMergesAcrossInterfacesKept() {
setListenerMode(.allIPv4Interfaces)
interfaceFacts.interfaces = [
FakeLocalInterfaceFacts.wifi(
localizedName: "Wi-Fi",
addresses: [.ipv4("192.168.1.10"), .ipv4("192.168.1.10")]),
FakeLocalInterfaceFacts.ethernet(
bsdName: "en2", localizedName: "Ethernet 2", addresses: [.ipv4("192.168.1.10")]),
]
interfaceFacts.emitChange()
let picker = workflow.snapshot.commandAddressPicker
XCTAssertEqual(
picker.candidates.map(\.identity),
[
TerminalCommandAddressIdentity(bsdName: "lo0", address: "127.0.0.1"),
TerminalCommandAddressIdentity(bsdName: "en2", address: "192.168.1.10"),
TerminalCommandAddressIdentity(bsdName: "en0", address: "192.168.1.10"),
],
"同接口重复 IP 合并;不同接口同 IP 保留接口身份")
}
func testInterfaceChangeNotificationRepublishesCandidates() {
setListenerMode(.allIPv4Interfaces)
interfaceFacts.interfaces = [
FakeLocalInterfaceFacts.wifi(addresses: [.ipv4("192.168.1.10")])
]
interfaceFacts.emitChange()
XCTAssertEqual(
workflow.snapshot.commandAddressPicker.candidates.map(\.address),
["127.0.0.1", "192.168.1.10"], "接口事实变化后候选随网络刷新")
}
}
/// 选择生命周期与命令生成(issue #72):两种 shell 共用选中地址(端口取已
/// 保存值、IPv6 方括号、no_proxy 不变)、会话保留与新会话重置、失效回退
/// (地址消失/监听方式切换/复制前刷新/枚举失败)与命令边界。
@MainActor
final class ProxyCommandAddressLifecycleTests: XCTestCase {
private var runtime: FakeProxyRuntime!
private var interfaceFacts: FakeLocalInterfaceFacts!
private var workflow: ProxyControlWorkflow!
override func setUp() async throws {
try await super.setUp()
runtime = FakeProxyRuntime()
interfaceFacts = FakeLocalInterfaceFacts()
workflow = ProxyControlWorkflow(
runtime: runtime, targetFacts: FakeTargetFacts(), interfaceFacts: interfaceFacts)
}
private func setListenerMode(_ mode: ListenerMode) {
runtime.listenFacts = RuntimeListenFacts(
listenerMode: mode, socksPort: 11086, httpPort: 11087)
runtime.emitChange()
}
func testSelectionDrivesBothShellsAndSavedPorts() {
runtime.listenFacts = RuntimeListenFacts(
listenerMode: .allIPv4Interfaces, socksPort: 12086, httpPort: 12087)
runtime.emitChange()
interfaceFacts.interfaces = [
FakeLocalInterfaceFacts.wifi(addresses: [.ipv4("192.168.1.10")])
]
interfaceFacts.emitChange()
let wifi = workflow.snapshot.commandAddressPicker.candidates[1]
_ = workflow.selectCommandAddress(wifi)
let commands = workflow.snapshot.terminalProxyEnvironmentCommands
XCTAssertEqual(
commands.zshBash,
"export http_proxy='http://192.168.1.10:12087'; "
+ "export https_proxy='http://192.168.1.10:12087'; "
+ "export all_proxy='socks5://192.168.1.10:12086'; "
+ "export no_proxy='localhost,127.0.0.1,::1,.local';",
"HTTP/HTTPS/SOCKS 端点共用选中地址,端口取已保存值,no_proxy 保持不变")
XCTAssertEqual(
commands.fish,
"set -gx http_proxy 'http://192.168.1.10:12087'; "
+ "set -gx https_proxy 'http://192.168.1.10:12087'; "
+ "set -gx all_proxy 'socks5://192.168.1.10:12086'; "
+ "set -gx no_proxy 'localhost,127.0.0.1,::1,.local';",
"fish 语法保持 set -gx 形式且端点共用同一地址")
XCTAssertTrue(runtime.agentCommands.isEmpty, "命令选址不触发运行时命令")
XCTAssertTrue(runtime.systemProxyCommands.isEmpty)
XCTAssertTrue(runtime.modeCommands.isEmpty)
}
func testIPv6SelectionProducesBracketedURLs() {
setListenerMode(.allIPv4AndIPv6Interfaces)
interfaceFacts.interfaces = [
FakeLocalInterfaceFacts.wifi(
addresses: [.ipv6("2001:db8::1", flags: [.autoconf, .secured])])
]
interfaceFacts.emitChange()
let v6Candidate = workflow.snapshot.commandAddressPicker.candidates[2]
_ = workflow.selectCommandAddress(v6Candidate)
let commands = workflow.snapshot.terminalProxyEnvironmentCommands
XCTAssertTrue(
commands.zshBash.contains("export http_proxy='http://[2001:db8::1]:11087'"),
"IPv6 URL 使用方括号与已保存 HTTP 端口")
XCTAssertTrue(
commands.fish.contains("set -gx all_proxy 'socks5://[2001:db8::1]:11086'"))
XCTAssertFalse(
commands.zshBash.contains("autoconf"), "类型注记只进标签,不进命令")
}
func testSelectionSurvivesLeavingAndReenteringHome() {
setListenerMode(.allIPv4Interfaces)
interfaceFacts.interfaces = [
FakeLocalInterfaceFacts.wifi(addresses: [.ipv4("192.168.1.10")])
]
interfaceFacts.emitChange()
let wifi = workflow.snapshot.commandAddressPicker.candidates[1]
_ = workflow.selectCommandAddress(wifi)
_ = workflow.refreshTerminalCommands() // 离开再进入首页
XCTAssertEqual(
workflow.snapshot.commandAddressPicker.selected.identity, wifi.identity,
"同一会话内选择跨首页离开/返回保留")
}
func testNewSessionResetsSelectionToDefaultLoopback() {
setListenerMode(.allIPv4Interfaces)
interfaceFacts.interfaces = [
FakeLocalInterfaceFacts.wifi(addresses: [.ipv4("192.168.1.10")])
]
interfaceFacts.emitChange()
let wifi = workflow.snapshot.commandAddressPicker.candidates[1]
_ = workflow.selectCommandAddress(wifi)
let freshWorkflow = ProxyControlWorkflow(
runtime: runtime,
targetFacts: FakeTargetFacts(),
interfaceFacts: interfaceFacts)
XCTAssertEqual(
freshWorkflow.snapshot.commandAddressPicker.selected.address, "127.0.0.1",
"新会话恢复默认回环地址,不沿用先前选择")
}
func testDisappearedAddressFallsBackToLoopback() {
setListenerMode(.allIPv4Interfaces)
interfaceFacts.interfaces = [
FakeLocalInterfaceFacts.wifi(addresses: [.ipv4("192.168.1.10")])
]
interfaceFacts.emitChange()
let wifi = workflow.snapshot.commandAddressPicker.candidates[1]
_ = workflow.selectCommandAddress(wifi)
interfaceFacts.interfaces = []
interfaceFacts.emitChange()
XCTAssertEqual(
workflow.snapshot.commandAddressPicker.selected.address, "127.0.0.1",
"地址消失回退默认回环")
}
func testListenerModeSwitchToIncompatibleFamilyFallsBack() {
setListenerMode(.allIPv4AndIPv6Interfaces)
interfaceFacts.interfaces = [
FakeLocalInterfaceFacts.wifi(addresses: [.ipv6("2001:db8::1")])
]
interfaceFacts.emitChange()
let v6Candidate = workflow.snapshot.commandAddressPicker.candidates[2]
_ = workflow.selectCommandAddress(v6Candidate)
setListenerMode(.allIPv4Interfaces)
XCTAssertEqual(
workflow.snapshot.commandAddressPicker.selected.address, "127.0.0.1",
"监听方式不再支持该地址族时回退")
XCTAssertEqual(
workflow.snapshot.commandAddressPicker.candidates.map(\.address),
["127.0.0.1"],
"切换监听方式立即重新过滤候选")
}
func testEnumerationFailureKeepsLoopbackCandidatesAndCopy() {
setListenerMode(.allIPv4Interfaces)
interfaceFacts.interfaces = [
FakeLocalInterfaceFacts.wifi(addresses: [.ipv4("192.168.1.10")])
]
interfaceFacts.emitChange()
let wifi = workflow.snapshot.commandAddressPicker.candidates[1]
_ = workflow.selectCommandAddress(wifi)
interfaceFacts.interfaces = nil
interfaceFacts.emitChange()
let picker = workflow.snapshot.commandAddressPicker
XCTAssertEqual(
picker.candidates.map(\.address), ["127.0.0.1"], "枚举失败仅提供兼容回环候选")
XCTAssertEqual(picker.selected.address, "127.0.0.1")
XCTAssertTrue(
workflow.snapshot.terminalProxyEnvironmentCommands.zshBash.contains("127.0.0.1"),
"枚举失败仍可复制回环命令")
}
func testCopyPreparationRefreshesFallsBackAndMatchesProjection() {
runtime.listenFacts = RuntimeListenFacts(
listenerMode: .allIPv4Interfaces, socksPort: 11086, httpPort: 11087)
runtime.emitChange()
interfaceFacts.interfaces = [
FakeLocalInterfaceFacts.wifi(addresses: [.ipv4("192.168.1.10")])
]
interfaceFacts.emitChange()
let wifi = workflow.snapshot.commandAddressPicker.candidates[1]
_ = workflow.selectCommandAddress(wifi)
// 复制前地址消失且变化通知丢失:复制前刷新发现失效。
interfaceFacts.interfaces = []
let prepared = workflow.refreshTerminalCommands()
XCTAssertFalse(
prepared.zshBash.contains("192.168.1.10"), "复制内容不包含已消失的地址")
XCTAssertTrue(prepared.zshBash.contains("127.0.0.1"))
XCTAssertEqual(
prepared, workflow.snapshot.terminalProxyEnvironmentCommands,
"复制前刷新产生的变化同步至提示投影")
XCTAssertEqual(
workflow.snapshot.commandAddressPicker.selected.address, "127.0.0.1",
"回退结果更新界面选择")
}
func testProxyOffStillPreparesCommands() {
setListenerMode(.allIPv4Interfaces)
runtime.runtimeFacts = ProxyRuntimeFacts(status: .off, isOn: false)
runtime.emitChange()
let prepared = workflow.refreshTerminalCommands()
XCTAssertTrue(prepared.zshBash.contains("127.0.0.1"), "代理未运行时命令仍可准备")
XCTAssertEqual(runtime.resyncCount, 0, "刷新不触碰运行时生命周期")
}
}