mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-12 12:35:07 +00:00
221 lines
9.2 KiB
Swift
221 lines
9.2 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import SwiftUI
|
|
import Testing
|
|
|
|
@testable import ShadowsocksX_NG2
|
|
|
|
@MainActor
|
|
struct NodeDetailPresentationTests {
|
|
@Test
|
|
func groupCountsServerDescendantsAndShowsUnknownTimes() {
|
|
let detail = NodeDetailPresentation(
|
|
node: node(isGroup: true), server: nil,
|
|
eligibility: ActivationEligibility(
|
|
canActivate: true, candidateCount: 2, skippedInvalidCount: 1, ineligibility: nil),
|
|
isActiveTarget: true)
|
|
#expect(detail.title == "测试节点")
|
|
#expect(detail.subtitle.contains(String(localized: "活动目标")))
|
|
#expect(!detail.subtitle.contains("激活时将跳过"))
|
|
#expect(detail.activationMessages.count == 1)
|
|
#expect(detail.activationMessages[0].contains("1"))
|
|
#expect(detail.properties.count == 1)
|
|
#expect(detail.properties[0].items.map(\.value) == ["detail-id", "2", "1", "—", "—"])
|
|
}
|
|
|
|
@Test
|
|
func groupContainingActiveTargetMarksItInSubtitle() {
|
|
let marked = NodeDetailPresentation(
|
|
node: node(isGroup: true), server: nil, eligibility: nil,
|
|
isActiveTarget: false, containsActiveTarget: true)
|
|
#expect(marked.subtitle == "本地 · 含活动目标")
|
|
let unmarked = NodeDetailPresentation(
|
|
node: node(isGroup: true), server: nil, eligibility: nil,
|
|
isActiveTarget: false, containsActiveTarget: false)
|
|
#expect(unmarked.subtitle == "本地")
|
|
}
|
|
|
|
@Test
|
|
func parameterPropertiesPreserveFlagsEmptyValuesOrderAndDuplicates() {
|
|
var detail = NodeDetailPresentation(
|
|
node: node(isGroup: false), server: nil, eligibility: nil, isActiveTarget: false)
|
|
detail.appendPluginParameters("tls;host=one;host=two;empty=")
|
|
let items = detail.properties.last!.items
|
|
#expect(items.map(\.label) == ["tls", "host", "host", "empty"])
|
|
#expect(items.map(\.value) == [String(localized: "开关"), "one", "two", String(localized: "空值")])
|
|
}
|
|
|
|
@Test
|
|
func opaqueParametersRemainVerbatimAndEmptyParametersAddNoGroup() {
|
|
var detail = NodeDetailPresentation(
|
|
node: node(isGroup: false), server: nil, eligibility: nil, isActiveTarget: false)
|
|
detail.appendPluginParameters("")
|
|
#expect(detail.properties.count == 1)
|
|
detail.appendPluginParameters("opaque\\")
|
|
#expect(detail.properties.last?.items.map(\.value) == ["opaque\\"])
|
|
}
|
|
|
|
@Test
|
|
func parameterFailureLeavesConnectionInformationVisible() {
|
|
let server = ServerDetailPresentation(
|
|
name: "测试节点", address: "example.com", port: 8388, encryptionMethod: "aes-256-gcm",
|
|
plugin: PluginSectionState(
|
|
selection: .unknown(program: "unknown-plugin"), programs: [],
|
|
mappingsUnreadable: false, optionsPresent: true, options: ""))
|
|
var detail = NodeDetailPresentation(
|
|
node: node(isGroup: false), server: server, eligibility: nil, isActiveTarget: false)
|
|
detail.appendPluginParameterFailure()
|
|
#expect(
|
|
detail.properties[1].items.map(\.value) == [
|
|
"example.com", "8388", "aes-256-gcm", "unknown-plugin",
|
|
])
|
|
#expect(detail.properties[2].items.map(\.value) == [String(localized: "无法读取插件参数")])
|
|
#expect(detail.properties[0].items.map(\.value) == ["detail-id", "—", "—"])
|
|
}
|
|
|
|
@Test
|
|
func pluginAvailabilityFailureKeepsItsSpecificExplanation() {
|
|
let plugin = PluginSectionState(
|
|
selection: .named(program: "v2ray-plugin"),
|
|
programs: [.init(program: "v2ray-plugin", source: .user, availability: .notExecutable)],
|
|
mappingsUnreadable: false, optionsPresent: false, options: "")
|
|
let server = ServerDetailPresentation(
|
|
name: "测试节点", address: "example.com", port: 8388,
|
|
encryptionMethod: "aes-256-gcm", plugin: plugin)
|
|
let detail = NodeDetailPresentation(
|
|
node: node(isGroup: false, invalidReasons: [.pluginNotProvided(program: "v2ray-plugin")]),
|
|
server: server, eligibility: nil, isActiveTarget: false)
|
|
#expect(!detail.subtitle.contains("插件文件不可执行"))
|
|
#expect(detail.activationMessages == ["插件文件不可执行,请检查文件类型和执行权限。"])
|
|
}
|
|
|
|
@Test(arguments: [false, true])
|
|
func propertiesFooterRendersWithAndWithoutRows(hasRows: Bool) throws {
|
|
var detail = NodeDetailPresentation(
|
|
node: node(isGroup: true), server: nil, eligibility: nil, isActiveTarget: false)
|
|
detail.properties = [
|
|
.init(
|
|
id: .information, title: "Properties", items: hasRows ? [.init(label: "A", value: "B")] : []
|
|
)
|
|
]
|
|
let host = NSHostingView(
|
|
rootView: NodeDetailView(detail: detail) {
|
|
EmptyView()
|
|
} secondaryActions: {
|
|
EmptyView()
|
|
} feedback: {
|
|
EmptyView()
|
|
} propertiesFooter: { group in
|
|
group.id == .information ? FooterMarker() : nil
|
|
})
|
|
host.frame = NSRect(x: 0, y: 0, width: 480, height: 600)
|
|
host.layoutSubtreeIfNeeded()
|
|
#expect(containsFooter(in: host))
|
|
}
|
|
|
|
@Test
|
|
func connectivityHistoryUsesTimeOnlyAndPreservesRepeatedLabelsAndFailures() {
|
|
let date = Date(timeIntervalSince1970: 37_421)
|
|
let records = [
|
|
ServerConnectivity.Record(
|
|
finishedAt: date, outcome: .success(milliseconds: 42), networkExpired: true),
|
|
ServerConnectivity.Record(finishedAt: date, outcome: .requestTimeout),
|
|
ServerConnectivity.Record(finishedAt: date.addingTimeInterval(-60), outcome: .http(302)),
|
|
]
|
|
let properties = NodeDetailPresentation.connectivityHistory(
|
|
records, locale: Locale(identifier: "en_GB"), timeZone: TimeZone(secondsFromGMT: 3600)!)
|
|
#expect(properties.id == .connectivity)
|
|
#expect(properties.items.map(\.label) == ["11:23:41", "11:23:41", "11:22:41"])
|
|
#expect(properties.items.map(\.value) == ["42 ms", ConnectivityCopy.text("请求超时"), "HTTP 302"])
|
|
}
|
|
|
|
@Test
|
|
func connectivityEmptyStatesAreExplicitProperties() {
|
|
let locale = Locale(identifier: "en_GB")
|
|
let zone = TimeZone(secondsFromGMT: 0)!
|
|
let history = NodeDetailPresentation.connectivityHistory([], locale: locale, timeZone: zone)
|
|
#expect(
|
|
history.items == [
|
|
.init(label: ConnectivityCopy.text("状态"), value: ConnectivityCopy.text("尚未测试"))
|
|
])
|
|
for hasResults in [false, true] {
|
|
let summary = ServerConnectivity.Summary(
|
|
serverID: nil, record: nil, hasResults: hasResults, isTesting: true)
|
|
let properties = NodeDetailPresentation.connectivitySummary(
|
|
summary,
|
|
serverName: { _ in
|
|
Issue.record("No server should be resolved")
|
|
return ""
|
|
},
|
|
locale: locale, timeZone: zone)
|
|
#expect(
|
|
properties.items == [
|
|
.init(
|
|
label: ConnectivityCopy.text("状态"),
|
|
value: ConnectivityCopy.text(hasResults ? "无成功结果" : "尚未测试"))
|
|
])
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func connectivityGroupPropertiesFollowFreshnessNetworkChangesAndNames() async {
|
|
let ids = [NodeID.fresh(), NodeID.fresh()]
|
|
var now = Date(timeIntervalSince1970: 37_421)
|
|
var latency = 10
|
|
var name = "First"
|
|
let module = ServerConnectivity(
|
|
configuration: { _ in
|
|
SslocalServerDocument(
|
|
id: "test", server: "example.com", serverPort: 443, password: "secret",
|
|
method: "aes-256-gcm", plugin: nil, pluginOpts: nil)
|
|
}, now: { now },
|
|
measure: { _, _, _ in .success(milliseconds: latency) })
|
|
func properties(at date: Date) -> NodeDetailPresentation.Properties {
|
|
NodeDetailPresentation.connectivitySummary(
|
|
module.summary(for: ids, at: date), serverName: { $0 == ids[0] ? name : "Second" },
|
|
locale: Locale(identifier: "en_GB"), timeZone: TimeZone(secondsFromGMT: 0)!)
|
|
}
|
|
await module.wait(for: module.test([ids[0]]))
|
|
#expect(properties(at: now).items.map(\.value) == ["10 ms", "First", "10:23:41"])
|
|
name = "Renamed"
|
|
#expect(properties(at: now).items[1].value == "Renamed")
|
|
now.addTimeInterval(60)
|
|
latency = 20
|
|
await module.wait(for: module.test([ids[1]]))
|
|
#expect(properties(at: now).items[1].value == "Renamed")
|
|
// First expires while Second is still fresh, without another measurement.
|
|
#expect(
|
|
properties(at: now.addingTimeInterval(240)).items.map(\.value) == [
|
|
"20 ms", "Second", "10:24:41",
|
|
])
|
|
module.networkChanged()
|
|
#expect(properties(at: now.addingTimeInterval(240)).items[1].value == "Renamed")
|
|
module.setURL(URL(string: "http://example.com/check")!)
|
|
#expect(properties(at: now).items.map(\.value) == [ConnectivityCopy.text("尚未测试")])
|
|
}
|
|
|
|
private func containsFooter(in view: NSView) -> Bool {
|
|
view.identifier?.rawValue == "properties-footer"
|
|
|| view.subviews.contains { containsFooter(in: $0) }
|
|
}
|
|
|
|
private struct FooterMarker: NSViewRepresentable {
|
|
func makeNSView(context: Context) -> NSTextField {
|
|
let field = NSTextField(labelWithString: "Custom footer")
|
|
field.identifier = NSUserInterfaceItemIdentifier("properties-footer")
|
|
return field
|
|
}
|
|
|
|
func updateNSView(_ nsView: NSTextField, context: Context) {}
|
|
}
|
|
|
|
private func node(isGroup: Bool, invalidReasons: [LeafInvalidationReason] = []) -> CatalogTreeNode
|
|
{
|
|
CatalogTreeNode(
|
|
id: NodeID(rawValue: "detail-id"), name: "测试节点", isGroup: isGroup,
|
|
source: .manual, parentID: nil, createdAt: nil, updatedAt: nil,
|
|
invalidReasons: invalidReasons, childCount: 1, subtreeNodeCount: 4,
|
|
invalidDescendantCount: 1, children: isGroup ? [] : nil)
|
|
}
|
|
}
|