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("国内出口 IPv4:198.51.100.8")) XCTAssertTrue(report.contains("国际出口 IPv4:203.0.113.8")) XCTAssertTrue(report.contains("- 国内出口 Domestic Test IPv4:198.51.100.8")) XCTAssertTrue(report.contains("- 国际出口 International Test IPv4:203.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] = [] 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) {} }