Files
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 lines
24 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) {}
}