项目文件夹

文件
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

699 行
24 KiB
Swift

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import XCTest
import MacToolsPluginKit
@testable import IPOverviewPlugin
@MainActor
final class IPOverviewPluginTests: XCTestCase {
func testMetadataUsesStableIdentifier() {
let plugin = IPOverviewPlugin()
XCTAssertEqual(plugin.metadata.id, "ip-overview")
XCTAssertEqual(plugin.metadata.title, "IP 检测")
}
func testPluginUsesPrimaryPanelAndConfiguration() {
let plugin = IPOverviewPlugin()
XCTAssertNotNil(plugin.primaryPanel)
XCTAssertNil(plugin.componentPanel)
XCTAssertNotNil(plugin.configuration)
}
func testPrimaryPanelUsesButtonEntry() {
let plugin = IPOverviewPlugin()
XCTAssertEqual(plugin.primaryPanelDescriptor.controlStyle, .button)
XCTAssertEqual(plugin.primaryPanelDescriptor.buttonTitle, "检测")
XCTAssertEqual(plugin.primaryPanelDescriptor.menuActionBehavior, .dismissBeforeHandling)
XCTAssertFalse(plugin.primaryPanelState.isExpanded)
XCTAssertNil(plugin.primaryPanelState.detail)
}
func testPrimaryPanelButtonRequestsConfigurationPresentation() {
let viewModel = IPOverviewViewModel(storage: IPOverviewPluginTestStorage())
let plugin = IPOverviewPlugin(viewModel: viewModel)
var didRequestConfigurationPresentation = false
plugin.requestConfigurationPresentation = {
didRequestConfigurationPresentation = true
}
plugin.handleAction(.invokeAction(controlID: IPOverviewPlugin.ControlID.openSettings))
XCTAssertTrue(didRequestConfigurationPresentation)
}
func testRefreshIfNeededOnlyCollectsPublicIP() async throws {
let provider = IPOverviewProviderSpy(
publicSnapshot: IPOverviewSnapshot(
publicIPv4: IPOverviewPublicIPResult(
family: .ipv4,
ip: "203.0.113.8",
source: "Test"
),
publicIPv6: nil,
localAddresses: [],
geoInfoByIP: [:],
sourceResults: [],
lastUpdated: Date(),
errorMessage: nil,
isRefreshing: false
)
)
let viewModel = IPOverviewViewModel(
provider: provider,
storage: IPOverviewPluginTestStorage()
)
let refreshTask = try XCTUnwrap(viewModel.refreshIfNeeded())
await refreshTask.value
XCTAssertEqual(viewModel.snapshot.publicIPv4?.ip, "203.0.113.8")
let callCounts = await provider.callCounts()
XCTAssertEqual(callCounts.publicIP, 1)
XCTAssertEqual(callCounts.full, 0)
}
func testRefreshIfNeededUsesFreshCachedSnapshot() async throws {
let now = Date(timeIntervalSinceReferenceDate: 10_000)
let storage = IPOverviewPluginTestStorage()
let cachedSnapshot = testSnapshot(ip: "203.0.113.8", lastUpdated: now)
let cacheWriter = IPOverviewViewModel(
provider: IPOverviewProviderSpy(publicSnapshot: .empty, fullSnapshot: cachedSnapshot),
storage: storage,
currentDate: { now }
)
let cacheWriteTask = try XCTUnwrap(cacheWriter.refresh())
await cacheWriteTask.value
let provider = IPOverviewProviderSpy(
publicSnapshot: testSnapshot(ip: "198.51.100.24", lastUpdated: now)
)
let cachedViewModel = IPOverviewViewModel(
provider: provider,
storage: storage,
currentDate: { now.addingTimeInterval(60) }
)
XCTAssertEqual(cachedViewModel.snapshot.publicIPv4?.ip, "203.0.113.8")
XCTAssertNil(cachedViewModel.refreshIfNeeded())
let callCounts = await provider.callCounts()
XCTAssertEqual(callCounts.publicIP, 0)
XCTAssertEqual(callCounts.full, 0)
}
func testRefreshIfNeededIgnoresExpiredCache() async throws {
let now = Date(timeIntervalSinceReferenceDate: 10_000)
let storage = IPOverviewPluginTestStorage()
let cachedSnapshot = testSnapshot(ip: "203.0.113.8", lastUpdated: now)
let cacheWriter = IPOverviewViewModel(
provider: IPOverviewProviderSpy(publicSnapshot: .empty, fullSnapshot: cachedSnapshot),
storage: storage,
currentDate: { now }
)
let cacheWriteTask = try XCTUnwrap(cacheWriter.refresh())
await cacheWriteTask.value
let refreshedSnapshot = testSnapshot(
ip: "198.51.100.24",
lastUpdated: now.addingTimeInterval(25 * 60 * 60)
)
let provider = IPOverviewProviderSpy(publicSnapshot: refreshedSnapshot)
let cachedViewModel = IPOverviewViewModel(
provider: provider,
storage: storage,
currentDate: { now.addingTimeInterval(25 * 60 * 60) }
)
let refreshTask = try XCTUnwrap(cachedViewModel.refreshIfNeeded())
await refreshTask.value
XCTAssertEqual(cachedViewModel.snapshot.publicIPv4?.ip, "198.51.100.24")
let callCounts = await provider.callCounts()
XCTAssertEqual(callCounts.publicIP, 1)
XCTAssertEqual(callCounts.full, 0)
}
func testManualRefreshOverridesFreshCache() async throws {
let now = Date(timeIntervalSinceReferenceDate: 10_000)
let storage = IPOverviewPluginTestStorage()
let cachedSnapshot = testSnapshot(ip: "203.0.113.8", lastUpdated: now)
let cacheWriter = IPOverviewViewModel(
provider: IPOverviewProviderSpy(publicSnapshot: .empty, fullSnapshot: cachedSnapshot),
storage: storage,
currentDate: { now }
)
let cacheWriteTask = try XCTUnwrap(cacheWriter.refresh())
await cacheWriteTask.value
let provider = IPOverviewProviderSpy(
publicSnapshot: .empty,
fullSnapshot: testSnapshot(ip: "198.51.100.24", lastUpdated: now.addingTimeInterval(60))
)
let cachedViewModel = IPOverviewViewModel(
provider: provider,
storage: storage,
currentDate: { now.addingTimeInterval(60) }
)
let refreshTask = try XCTUnwrap(cachedViewModel.refresh())
await refreshTask.value
let callCounts = await provider.callCounts()
XCTAssertEqual(callCounts.publicIP, 0)
XCTAssertEqual(callCounts.full, 1)
}
func testPublicIPSourcesAreGroupedByEgressRoute() {
let domesticIPv4 = IPOverviewPublicIPSource.sources(route: .domestic, family: .ipv4)
let internationalIPv4 = IPOverviewPublicIPSource.sources(route: .international, family: .ipv4)
XCTAssertTrue(domesticIPv4.allSatisfy { $0.route == .domestic && $0.family == .ipv4 })
XCTAssertTrue(internationalIPv4.allSatisfy { $0.route == .international && $0.family == .ipv4 })
XCTAssertTrue(domesticIPv4.map(\.id).contains("bilibili-v4"))
XCTAssertTrue(internationalIPv4.map(\.id).contains("ipcheck-v4-json"))
}
func testReportTextLabelsDomesticAndInternationalEgress() {
let snapshot = IPOverviewSnapshot(
domesticIPv4: IPOverviewPublicIPResult(
family: .ipv4,
route: .domestic,
ip: "198.51.100.8",
source: "Domestic Test"
),
domesticIPv6: nil,
internationalIPv4: IPOverviewPublicIPResult(
family: .ipv4,
route: .international,
ip: "203.0.113.8",
source: "International Test"
),
internationalIPv6: nil,
localAddresses: [],
geoInfoByIP: [:],
sourceResults: [
IPOverviewSourceResult(
id: "domestic-test",
family: .ipv4,
route: .domestic,
source: "Domestic Test IPv4",
status: .success("198.51.100.8")
),
IPOverviewSourceResult(
id: "international-test",
family: .ipv4,
route: .international,
source: "International Test IPv4",
status: .success("203.0.113.8")
)
],
lastUpdated: nil,
errorMessage: nil,
isRefreshing: false
)
let report = snapshot.reportText
XCTAssertTrue(report.contains("国内出口 IPv4198.51.100.8"))
XCTAssertTrue(report.contains("国际出口 IPv4203.0.113.8"))
XCTAssertTrue(report.contains("- 国内出口 Domestic Test IPv4198.51.100.8"))
XCTAssertTrue(report.contains("- 国际出口 International Test IPv4203.0.113.8"))
}
func testMeasureNetworkQualityUpdatesState() async throws {
let measurement = IPOverviewNetworkQualityMeasurement(
baseRTTMilliseconds: 50,
downloadThroughputBitsPerSecond: 100_000_000,
uploadThroughputBitsPerSecond: 20_000_000,
downloadResponsivenessRPM: 80,
uploadResponsivenessRPM: 90,
downloadPhaseDuration: 3,
uploadPhaseDuration: 4,
interfaceName: "en0",
testEndpoint: "example.apple.com",
startDate: nil,
endDate: nil
)
let viewModel = IPOverviewViewModel(
provider: IPOverviewProviderSpy(publicSnapshot: .empty),
networkQualityMeasurer: IPOverviewNetworkQualityMeasurerSpy(result: .success(measurement)),
storage: IPOverviewPluginTestStorage()
)
viewModel.measureNetworkQuality()
try await Task.sleep(for: .milliseconds(50))
XCTAssertEqual(viewModel.networkQualityState.measurement, measurement)
XCTAssertFalse(viewModel.isMeasuringNetworkQuality)
}
func testRefreshAllStartsNetworkQualityMeasurement() async throws {
let measurement = IPOverviewNetworkQualityMeasurement(
baseRTTMilliseconds: 42,
downloadThroughputBitsPerSecond: 120_000_000,
uploadThroughputBitsPerSecond: 35_000_000,
downloadResponsivenessRPM: 120,
uploadResponsivenessRPM: 130,
downloadPhaseDuration: 3,
uploadPhaseDuration: 4,
interfaceName: "en0",
testEndpoint: "example.apple.com",
startDate: nil,
endDate: nil
)
let viewModel = IPOverviewViewModel(
provider: IPOverviewProviderSpy(
publicSnapshot: .empty,
fullSnapshot: testSnapshot(ip: "203.0.113.8", lastUpdated: Date())
),
connectivityChecker: IPOverviewConnectivityCheckerSpy(),
leakTester: IPOverviewLeakTesterSpy(),
networkQualityMeasurer: IPOverviewNetworkQualityMeasurerSpy(result: .success(measurement)),
storage: IPOverviewPluginTestStorage()
)
viewModel.refreshAll()
try await waitUntil {
viewModel.networkQualityState.measurement == measurement
}
}
func testSensitiveInfoVisibilityPersists() {
let storage = IPOverviewPluginTestStorage()
let viewModel = IPOverviewViewModel(storage: storage)
XCTAssertFalse(viewModel.hidesSensitiveInfo)
viewModel.toggleSensitiveInfoVisibility()
XCTAssertTrue(viewModel.hidesSensitiveInfo)
XCTAssertTrue(storage.bool(forKey: "hidesSensitiveInfo"))
}
func testMeasureNetworkQualityTracksProgressEvents() async throws {
let measurement = IPOverviewNetworkQualityMeasurement(
baseRTTMilliseconds: 50,
downloadThroughputBitsPerSecond: 90_000_000,
uploadThroughputBitsPerSecond: 30_000_000,
downloadResponsivenessRPM: 80,
uploadResponsivenessRPM: 90,
downloadPhaseDuration: 3,
uploadPhaseDuration: 4,
interfaceName: "en0",
testEndpoint: "example.apple.com",
startDate: nil,
endDate: nil
)
let measurer = IPOverviewNetworkQualityBlockingMeasurerSpy(
result: .success(measurement),
events: [.download(12), .download(24), .upload(8)]
)
let viewModel = IPOverviewViewModel(
provider: IPOverviewProviderSpy(publicSnapshot: .empty),
networkQualityMeasurer: measurer,
storage: IPOverviewPluginTestStorage()
)
viewModel.measureNetworkQuality()
await measurer.waitForProgressEvents()
guard case .running(let runningProgress) = viewModel.networkQualityState else {
XCTFail("Expected running state")
return
}
XCTAssertEqual(runningProgress.downloadSamples, [12, 24])
XCTAssertEqual(runningProgress.uploadSamples, [8])
XCTAssertEqual(runningProgress.phase, .measuringUpload)
await measurer.finish()
try await waitUntil {
if case .completed(let completedMeasurement, let progress) = viewModel.networkQualityState {
return completedMeasurement == measurement
&& progress.downloadSamples == [12, 24]
&& progress.uploadSamples == [8]
&& progress.phase == .measuringLatency
}
return false
}
guard case .completed = viewModel.networkQualityState else {
XCTFail("Expected completed state")
return
}
}
func testWebRTCWarnsWhenStunEndpointDiffersFromPublicIP() {
let snapshot = leakAssessmentSnapshot(publicIPv4: "203.0.113.8", country: "United States", countryCode: "US")
let assessment = IPOverviewLeakAssessment.evaluate(
kind: .webRTC,
results: [
leakResult(
id: "google",
ip: "198.51.100.24",
country: "Japan",
countryCode: "JP"
)
],
snapshot: snapshot,
isRunning: false
)
XCTAssertEqual(assessment.state, .warning)
XCTAssertEqual(assessment.reason, .webRTCDifferentIP)
XCTAssertEqual(assessment.issueEndpoint?.ip, "198.51.100.24")
}
func testWebRTCClearsWhenStunEndpointMatchesPublicIP() {
let snapshot = leakAssessmentSnapshot(publicIPv4: "203.0.113.8", country: "United States", countryCode: "US")
let assessment = IPOverviewLeakAssessment.evaluate(
kind: .webRTC,
results: [
leakResult(
id: "google",
ip: "203.0.113.8",
country: "United States",
countryCode: "US"
)
],
snapshot: snapshot,
isRunning: false
)
XCTAssertEqual(assessment.state, .clear)
XCTAssertEqual(assessment.reason, .webRTCMatchesPublicIP)
}
func testWebRTCClearsWhenNoVisibleEndpointIsReturned() {
let assessment = IPOverviewLeakAssessment.evaluate(
kind: .webRTC,
results: [
IPOverviewLeakTestResult(id: "google", name: "Google", status: .failure("超时")),
IPOverviewLeakTestResult(id: "twilio", name: "Twilio", status: .failure("超时"))
],
snapshot: leakAssessmentSnapshot(publicIPv4: "203.0.113.8", country: "United States", countryCode: "US"),
isRunning: false
)
XCTAssertEqual(assessment.state, .clear)
XCTAssertEqual(assessment.reason, .webRTCNoVisibleEndpoint)
}
func testDNSWarnsWhenResolverRegionDiffersFromPublicRegion() {
let assessment = IPOverviewLeakAssessment.evaluate(
kind: .dns,
results: [
leakResult(
id: "dns-1",
ip: "198.51.100.24",
country: "Japan",
countryCode: "JP"
)
],
snapshot: leakAssessmentSnapshot(publicIPv4: "203.0.113.8", country: "United States", countryCode: "US"),
isRunning: false
)
XCTAssertEqual(assessment.state, .warning)
XCTAssertEqual(assessment.reason, .dnsDifferentEgressRegion)
XCTAssertEqual(assessment.issueEndpoint?.countryCode, "JP")
}
func testDNSClearsWhenResolverRegionMatchesPublicRegion() {
let assessment = IPOverviewLeakAssessment.evaluate(
kind: .dns,
results: [
leakResult(
id: "dns-1",
ip: "198.51.100.24",
country: "United States",
countryCode: "US"
)
],
snapshot: leakAssessmentSnapshot(publicIPv4: "203.0.113.8", country: "United States", countryCode: "US"),
isRunning: false
)
XCTAssertEqual(assessment.state, .clear)
XCTAssertEqual(assessment.reason, .dnsMatchesEgressRegion)
}
}
private func leakResult(
id: String,
ip: String,
country: String?,
countryCode: String?
) -> IPOverviewLeakTestResult {
IPOverviewLeakTestResult(
id: id,
name: id,
status: .success(IPOverviewLeakEndpoint(
ip: ip,
natType: nil,
country: country,
countryCode: countryCode,
organization: "Example Network"
))
)
}
private func leakAssessmentSnapshot(
publicIPv4: String,
country: String?,
countryCode: String?
) -> IPOverviewSnapshot {
IPOverviewSnapshot(
publicIPv4: IPOverviewPublicIPResult(family: .ipv4, ip: publicIPv4, source: "Test"),
publicIPv6: nil,
localAddresses: [],
geoInfoByIP: [
publicIPv4: IPOverviewGeoInfo(
ip: publicIPv4,
country: country,
countryCode: countryCode,
region: nil,
city: nil,
isp: "Example ISP",
organization: "Example ISP",
asn: "AS64500",
timezone: nil,
networkType: .residential,
isProxy: nil,
isHosting: nil,
source: "Test"
)
],
sourceResults: [],
lastUpdated: Date(),
errorMessage: nil,
isRefreshing: false
)
}
private func testSnapshot(ip: String, lastUpdated: Date) -> IPOverviewSnapshot {
IPOverviewSnapshot(
publicIPv4: IPOverviewPublicIPResult(family: .ipv4, ip: ip, source: "Test"),
publicIPv6: nil,
localAddresses: [
IPOverviewLocalAddress(
id: "en0-192.168.1.10",
interfaceName: "en0",
address: "192.168.1.10",
family: .ipv4
)
],
geoInfoByIP: [
ip: IPOverviewGeoInfo(
ip: ip,
country: "United States",
countryCode: "US",
region: "California",
city: "Los Angeles",
isp: "Example ISP",
organization: "Example ISP",
asn: "AS64500",
timezone: "America/Los_Angeles",
networkType: .residential,
isProxy: false,
isHosting: false,
source: "Test"
)
],
sourceResults: [
IPOverviewSourceResult(
id: "test-source",
family: .ipv4,
route: .international,
source: "Test IPv4",
status: .success(ip)
)
],
lastUpdated: lastUpdated,
errorMessage: nil,
isRefreshing: false
)
}
private actor IPOverviewProviderSpy: IPOverviewProviding {
private(set) var collectSnapshotCallCount = 0
private(set) var collectPublicIPSnapshotCallCount = 0
private let publicSnapshot: IPOverviewSnapshot
private let fullSnapshot: IPOverviewSnapshot
init(publicSnapshot: IPOverviewSnapshot, fullSnapshot: IPOverviewSnapshot = .empty) {
self.publicSnapshot = publicSnapshot
self.fullSnapshot = fullSnapshot
}
func collectSnapshot() async -> IPOverviewSnapshot {
collectSnapshotCallCount += 1
return fullSnapshot
}
func collectPublicIPSnapshot(preserving snapshot: IPOverviewSnapshot) async -> IPOverviewSnapshot {
collectPublicIPSnapshotCallCount += 1
return publicSnapshot
}
func callCounts() -> (full: Int, publicIP: Int) {
(collectSnapshotCallCount, collectPublicIPSnapshotCallCount)
}
}
private struct IPOverviewConnectivityCheckerSpy: IPOverviewConnectivityChecking {
func check(target: IPOverviewConnectivityTarget) async -> IPOverviewConnectivityResult {
IPOverviewConnectivityResult(id: target.id, target: target, status: .reachable(milliseconds: 12))
}
}
private struct IPOverviewLeakTesterSpy: IPOverviewLeakTesting {
func checkWebRTC(target: IPOverviewWebRTCTarget) async -> IPOverviewLeakTestResult {
IPOverviewLeakTestResult(id: target.id, name: target.name, status: .failure("未获取到出口"))
}
func checkDNS(id: String, name: String) async -> IPOverviewLeakTestResult {
IPOverviewLeakTestResult(
id: id,
name: name,
status: .success(IPOverviewLeakEndpoint(
ip: "203.0.113.8",
natType: nil,
country: "United States",
countryCode: "US",
organization: "Example ISP"
))
)
}
}
private struct IPOverviewNetworkQualityMeasurerSpy: IPOverviewNetworkQualityMeasuring {
let result: IPOverviewNetworkQualityMeasurementResult
var events: [IPOverviewNetworkQualityProgressEvent] = []
func measure(
onProgress: @escaping @Sendable (IPOverviewNetworkQualityProgressEvent) async -> Void
) async -> IPOverviewNetworkQualityMeasurementResult {
for event in events {
await onProgress(event)
}
return result
}
}
private final class IPOverviewNetworkQualityBlockingMeasurerSpy: IPOverviewNetworkQualityMeasuring, @unchecked Sendable {
let result: IPOverviewNetworkQualityMeasurementResult
let events: [IPOverviewNetworkQualityProgressEvent]
private let progressGate = IPOverviewNetworkQualityCompletionGate()
private let completionGate = IPOverviewNetworkQualityCompletionGate()
init(
result: IPOverviewNetworkQualityMeasurementResult,
events: [IPOverviewNetworkQualityProgressEvent]
) {
self.result = result
self.events = events
}
func measure(
onProgress: @escaping @Sendable (IPOverviewNetworkQualityProgressEvent) async -> Void
) async -> IPOverviewNetworkQualityMeasurementResult {
for event in events {
await onProgress(event)
}
await progressGate.open()
await completionGate.wait()
return result
}
func waitForProgressEvents() async {
await progressGate.wait()
}
func finish() async {
await completionGate.open()
}
}
private actor IPOverviewNetworkQualityCompletionGate {
private var isOpen = false
private var continuations: [CheckedContinuation<Void, Never>] = []
func wait() async {
if isOpen {
return
}
await withCheckedContinuation { continuation in
continuations.append(continuation)
}
}
func open() {
isOpen = true
let waitingContinuations = continuations
continuations.removeAll()
for continuation in waitingContinuations {
continuation.resume()
}
}
}
@MainActor
private func waitUntil(
timeout: Duration = .seconds(1),
condition: @escaping @MainActor () -> Bool
) async throws {
let deadline = ContinuousClock.now + timeout
while !condition() {
if ContinuousClock.now >= deadline {
XCTFail("Timed out waiting for condition")
return
}
try await Task.sleep(for: .milliseconds(10))
}
}
@MainActor
private final class IPOverviewPluginTestStorage: PluginStorage {
private var values: [String: Any] = [:]
func object(forKey key: String) -> Any? { values[key] }
func data(forKey key: String) -> Data? { values[key] as? Data }
func string(forKey key: String) -> String? { values[key] as? String }
func stringArray(forKey key: String) -> [String]? { values[key] as? [String] }
func integer(forKey key: String) -> Int { values[key] as? Int ?? 0 }
func bool(forKey key: String) -> Bool { values[key] as? Bool ?? false }
func set(_ value: Any?, forKey key: String) { values[key] = value }
func removeObject(forKey key: String) { values.removeValue(forKey: key) }
func migrateValueIfNeeded(fromLegacyKey legacyKey: String, to key: String) {}
}