From 808da20f046ac99056a96c26043dd67de65ef41c Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 5 Aug 2025 10:39:49 -0400 Subject: [PATCH] Add MockStatsService and StatsDateAggregator --- .../Services/Mocks/MockStatsService.swift | 610 ++++++++++++++++++ .../Services/Mocks/StatsDataAggregator.swift | 127 ++++ .../MockStatsServiceTests.swift | 56 ++ .../StatsDataAggregationTests.swift | 421 ++++++++++++ 4 files changed, 1214 insertions(+) create mode 100644 Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift create mode 100644 Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift create mode 100644 Modules/Tests/JetpackStatsTests/MockStatsServiceTests.swift create mode 100644 Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift new file mode 100644 index 000000000000..601cc11ca8f8 --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -0,0 +1,610 @@ +import Foundation +import SwiftUI +@preconcurrency import WordPressKit + +actor MockStatsService: ObservableObject, StatsServiceProtocol { + private var hourlyData: [SiteMetric: [DataPoint]] = [:] + private var dailyTopListData: [TopListItemType: [Date: [any TopListItemProtocol]]] = [:] + private let calendar: Calendar + + let supportedMetrics = SiteMetric.allCases.filter { + $0 != .downloads && $0 != .bounceRate && $0 != .timeOnSite + } + let supportedItems = TopListItemType.allCases + + nonisolated func getSupportedMetrics(for item: TopListItemType) -> [SiteMetric] { + switch item { + case .postsAndPages: [.views, .visitors, .comments, .likes] + case .archive: [.views] + case .referrers: [.views, .visitors] + case .locations: [.views, .visitors] + case .authors: [.views, .comments, .likes] + case .externalLinks: [.views, .visitors] + case .fileDownloads: [.downloads] + case .searchTerms: [.views, .visitors] + case .videos: [.views, .likes] + } + } + + /// - parameter timeZone: The reporting time zone of a site. + init(timeZone: TimeZone = .current) { + var calendar = Calendar.current + calendar.timeZone = timeZone + self.calendar = calendar + } + + private func generateDataIfNeeded() async { + guard hourlyData.isEmpty else { + return + } + await generateChartMockData() + await generateTopListMockData() + } + + func getSiteStats(interval: DateInterval, granularity: DateRangeGranularity) async throws -> SiteMetricsResponse { + await generateDataIfNeeded() + + var total = SiteMetricsSet() + var output: [SiteMetric: [DataPoint]] = [:] + + let aggregator = StatsDataAggregator(calendar: calendar) + + for (metric, allDataPoints) in hourlyData { + // Filter data points for the period + let filteredDataPoints = allDataPoints.filter { + interval.start <= $0.date && $0.date < interval.end + } + + // Use processPeriod to aggregate and normalize the data + let periodData = aggregator.processPeriod( + dataPoints: filteredDataPoints, + dateInterval: interval, + granularity: granularity, + metric: metric + ) + output[metric] = periodData.dataPoints + total[metric] = periodData.total + } + + try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) + + return SiteMetricsResponse(total: total, metrics: output) + } + + func getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity, limit: Int?) async throws -> TopListResponse { + await generateDataIfNeeded() + + guard let typeData = dailyTopListData[item] else { + fatalError("data not configured for data type: \(item)") + } + + // Filter data within the date range + let filteredData = typeData.filter { date, _ in + interval.start <= date && date < interval.end + } + + // Aggregate all items across the date range + var aggregatedItems: [TopListItemID: (any TopListItemProtocol, Int)] = [:] // Store item and aggregated metrics + + for (_, dailyItems) in filteredData { + for item in dailyItems { + let key = item.id + if let (existingItem, existingValue) = aggregatedItems[key] { + // Aggregate based on metric + let metricValue = item.metrics[metric] ?? 0 + aggregatedItems[key] = (existingItem, existingValue + metricValue) + } else { + aggregatedItems[key] = (item, item.metrics[metric] ?? 0) + } + } + } + + // Convert to array with updated metric value and sort + let sortedItems = aggregatedItems.values + .map { (item, totalValue) -> any TopListItemProtocol in + // Create a mutable copy and update the aggregated metric value + var mutableItem = item + mutableItem.metrics[metric] = totalValue + return mutableItem + } + .sorted { ($0.metrics[metric] ?? 0) > ($1.metrics[metric] ?? 0) } + + try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) + + return TopListResponse(items: Array(sortedItems.prefix(limit ?? Int.max))) + } + + func getRealtimeTopListData(_ dataType: TopListItemType) async throws -> TopListResponse { + // Load base items from JSON + let baseItems = loadRealtimeBaseItems(for: dataType) + + // Add dynamic variations to simulate real-time changes + let realtimeItems = baseItems.map { item -> any TopListItemProtocol in + let baseViews = item.metrics.views ?? 0 + + // Use time-based seed for consistent gradual changes + let now = Date() + let timeInMinutes = now.timeIntervalSince1970 / 60.0 + + // Get item identifier for seeding + let itemId = item.id + let itemSeed = itemId.hashValue + + // Gradual oscillation (changes slowly over time) + let slowWave = sin(timeInMinutes / 5.0 + Double(itemSeed % 100) / 10.0) * 0.1 + 1.0 + + // Small random variation (±5%) + let smallVariation = Double.random(in: 0.95...1.05) + + // Very rare small spike (1% chance, max 20% increase) + let rareSpikeChance = Double.random(in: 0.0...1.0) + let rareSpike = rareSpikeChance < 0.01 ? Double.random(in: 1.1...1.2) : 1.0 + + let realtimeViews = Int(Double(baseViews) * slowWave * smallVariation * rareSpike) + let cappedViews = min(realtimeViews, 500) // Cap at 500 + + // Apply variations to create new item with updated values + var mutableItem = item + mutableItem.metrics.views = cappedViews + + if let comments = mutableItem.metrics.comments { + mutableItem.metrics.comments = Int(Double(comments) * slowWave * smallVariation * rareSpike * 0.8) + } + if let likes = mutableItem.metrics.likes { + mutableItem.metrics.likes = Int(Double(likes) * slowWave * smallVariation * rareSpike * 0.9) + } + if let visitors = mutableItem.metrics.visitors { + mutableItem.metrics.visitors = Int(Double(visitors) * slowWave * smallVariation * rareSpike) + } + if let bounceRate = mutableItem.metrics.bounceRate { + let bounceVariation = slowWave > 1.0 ? 0.95 : 1.05 + mutableItem.metrics.bounceRate = min(100, max(0, Int(Double(bounceRate) * bounceVariation * smallVariation))) + } + if let timeOnSite = mutableItem.metrics.timeOnSite { + let timeVariation = Double.random(in: 0.85...1.15) + mutableItem.metrics.timeOnSite = Int(Double(timeOnSite) * timeVariation) + } + if let downloads = mutableItem.metrics.downloads { + mutableItem.metrics.downloads = Int(Double(downloads) * slowWave * smallVariation * rareSpike) + } + + return mutableItem + } + + // Sort by views and take top 10 + let sortedItems = realtimeItems + .sorted { ($0.metrics.views ?? 0) > ($1.metrics.views ?? 0) } + + let topItems = Array(sortedItems.prefix(10)) + + return TopListResponse(items: topItems) + } + + private func loadRealtimeBaseItems(for dataType: TopListItemType) -> [any TopListItemProtocol] { + let fileName: String + switch dataType { + case .postsAndPages: + fileName = "postsAndPages" + case .archive: + fileName = "archive" + case .referrers: + fileName = "referrers" + case .locations: + fileName = "locations" + case .authors: + fileName = "authors" + case .externalLinks: + fileName = "external-links" + case .fileDownloads: + fileName = "file-downloads" + case .searchTerms: + fileName = "search-terms" + case .videos: + fileName = "videos" + } + + // Load from JSON file + guard let url = Bundle.module.url(forResource: "realtime-\(fileName)", withExtension: "json") else { + print("Failed to find \(fileName).json") + return [] + } + + do { + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + // Decode based on data type + switch dataType { + case .referrers: + let referrers = try decoder.decode([TopListItem.Referrer].self, from: data) + return referrers + case .locations: + let locations = try decoder.decode([TopListItem.Location].self, from: data) + return locations + case .authors: + let authors = try decoder.decode([TopListItem.Author].self, from: data) + return authors.map { + var copy = $0 + copy.avatarURL = Bundle.module.path(forResource: "author\($0.userId)", ofType: "jpg").map { + URL(filePath: $0) + } + return copy + } + case .externalLinks: + let links = try decoder.decode([TopListItem.ExternalLink].self, from: data) + return links + case .fileDownloads: + let downloads = try decoder.decode([TopListItem.FileDownload].self, from: data) + return downloads + case .searchTerms: + let terms = try decoder.decode([TopListItem.SearchTerm].self, from: data) + return terms + case .videos: + let videos = try decoder.decode([TopListItem.Video].self, from: data) + return videos + case .postsAndPages: + let posts = try decoder.decode([TopListItem.Post].self, from: data) + return posts + case .archive: + let sections = try decoder.decode([TopListItem.ArchiveSection].self, from: data) + return sections + } + } catch { + print("Failed to load \(fileName).json: \(error)") + return [] + } + } + + func getPostDetails(for postID: Int) async throws -> StatsPostDetails { + // Load from JSON file in Mocks/Misc directory + guard let url = Bundle.module.url(forResource: "post-details", withExtension: "json") else { + throw URLError(.fileDoesNotExist) + } + + let data = try Data(contentsOf: url) + let jsonObject = try JSONSerialization.jsonObject(with: data) as! [String: AnyObject] + + // Simulate network delay + try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) + + guard let details = StatsPostDetails(jsonDictionary: jsonObject) else { + throw URLError(.cannotParseResponse) + } + + return details + } + + func getPostLikes(for postID: Int, count: Int) async throws -> PostLikesData { + // Simulate network delay + try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) + + func makeUser(id: Int, name: String) -> PostLikesData.PostLikeUser { + PostLikesData.PostLikeUser( + id: id, + name: name, + avatarURL: Bundle.module.path(forResource: "author\(id)", ofType: "jpg").map { URL(filePath: $0) } + ) + } + + let mockUsers = [ + makeUser(id: 1, name: "Sarah Chen"), + makeUser(id: 2, name: "Marcus Johnson"), + makeUser(id: 3, name: "Emily Rodriguez"), + makeUser(id: 4, name: "Alex Thompson"), + makeUser(id: 5, name: "Nina Patel"), + makeUser(id: 6, name: "James Wilson") + ] + + let requestedCount = min(count, mockUsers.count) + let selectedUsers = Array(mockUsers.prefix(requestedCount)) + + return PostLikesData(users: selectedUsers, totalCount: 26) + } + + func toggleSpamState(for referrerDomain: String, currentValue: Bool) async throws { + // Simulate network delay + try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) + + // Mock implementation - randomly succeed or fail for testing + let shouldSucceed = Double.random(in: 0...1) > 0.1 // 90% success rate + if !shouldSucceed { + throw URLError(.networkConnectionLost) + } + } + + func getEmailOpens(for postID: Int) async throws -> StatsEmailOpensData { + // Simulate network delay + try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) + + // Generate realistic random data + let totalSends = Int.random(in: 500...5000) + let uniqueOpens = Int.random(in: 100...min(totalSends, 2000)) + let totalOpens = Int.random(in: uniqueOpens...min(totalSends * 2, uniqueOpens * 3)) + let opensRate = Double(uniqueOpens) / Double(totalSends) + + return StatsEmailOpensData( + totalSends: totalSends, + uniqueOpens: uniqueOpens, + totalOpens: totalOpens, + opensRate: opensRate + ) + } + + // MARK: - Data Loading + + /// Loads historical items from JSON files based on the data type + private func loadHistoricalItems(for dataType: TopListItemType) -> [any TopListItemProtocol] { + let fileName: String + switch dataType { + case .postsAndPages: + fileName = "historical-postsAndPages" + case .archive: + fileName = "historical-archive" + case .referrers: + fileName = "historical-referrers" + case .locations: + fileName = "historical-locations" + case .authors: + fileName = "historical-authors" + case .externalLinks: + fileName = "historical-external-links" + case .fileDownloads: + fileName = "historical-file-downloads" + case .searchTerms: + fileName = "historical-search-terms" + case .videos: + fileName = "historical-videos" + } + + // Load from JSON file + guard let url = Bundle.module.url(forResource: fileName, withExtension: "json") else { + print("Failed to find \(fileName).json") + return [] + } + + do { + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + // Decode based on data type + switch dataType { + case .referrers: + let referrers = try decoder.decode([TopListItem.Referrer].self, from: data) + return referrers + case .locations: + let locations = try decoder.decode([TopListItem.Location].self, from: data) + return locations + case .authors: + let authors = try decoder.decode([TopListItem.Author].self, from: data) + return authors.map { + var copy = $0 + copy.avatarURL = Bundle.module.path(forResource: "author\($0.userId)", ofType: "jpg").map { + URL(filePath: $0) + } + return copy + } + case .externalLinks: + let links = try decoder.decode([TopListItem.ExternalLink].self, from: data) + return links + case .fileDownloads: + let downloads = try decoder.decode([TopListItem.FileDownload].self, from: data) + return downloads + case .searchTerms: + let terms = try decoder.decode([TopListItem.SearchTerm].self, from: data) + return terms + case .videos: + let videos = try decoder.decode([TopListItem.Video].self, from: data) + return videos + case .postsAndPages: + let posts = try decoder.decode([TopListItem.Post].self, from: data) + return posts + case .archive: + let sections = try decoder.decode([TopListItem.ArchiveSection].self, from: data) + return sections + } + } catch { + print("Failed to load \(fileName).json: \(error)") + return [] + } + } + + // MARK: - Data Generation + + /// Mutates item metrics based on growth factors and variations + private func mutateItemMetrics(_ item: any TopListItemProtocol, growthFactor: Double, seasonalFactor: Double, weekendFactor: Double, randomFactor: Double) -> any TopListItemProtocol { + let combinedFactor = growthFactor * seasonalFactor * weekendFactor * randomFactor + + var item = item + if let views = item.metrics.views { + item.metrics.views = Int(Double(views) * combinedFactor) + } + if let comments = item.metrics.comments { + item.metrics.comments = Int(Double(comments) * combinedFactor * 0.8) + } + if let likes = item.metrics.likes { + item.metrics.likes = Int(Double(likes) * combinedFactor * 0.9) + } + if let visitors = item.metrics.visitors { + item.metrics.visitors = Int(Double(visitors) * combinedFactor) + } + if let bounceRate = item.metrics.bounceRate { + let bounceVariation = randomFactor > 1.0 ? 0.95 : 1.05 + item.metrics.bounceRate = min(100, max(0, Int(Double(bounceRate) * bounceVariation))) + } + if let timeOnSite = item.metrics.timeOnSite { + let timeVariation = Double.random(in: 0.85...1.15) + item.metrics.timeOnSite = Int(Double(timeOnSite) * timeVariation) + } + if let downloads = item.metrics.downloads { + item.metrics.downloads = Int(Double(downloads) * combinedFactor) + } + return item + } + + private func generateChartMockData() async { + let endDate = Date() + + // Create a date for Nov 1, 2011 + var dateComponents = DateComponents() + dateComponents.year = 2011 + dateComponents.month = 11 + dateComponents.day = 1 + + let startDate = calendar.date(from: dateComponents)! + + for dataType in SiteMetric.allCases { + var dataPoints: [DataPoint] = [] + + var currentDate = startDate + let nowDate = Date() + while currentDate <= endDate && currentDate <= nowDate { + let value = generateRealisticValue(for: dataType, at: currentDate) + let dataPoint = DataPoint(date: currentDate, value: value) + dataPoints.append(dataPoint) + currentDate = calendar.date(byAdding: .hour, value: 1, to: currentDate)! + } + + hourlyData[dataType] = dataPoints + } + } + + private func generateRealisticValue(for metric: SiteMetric, at date: Date) -> Int { + let hour = calendar.component(.hour, from: date) + let dayOfWeek = calendar.component(.weekday, from: date) + let month = calendar.component(.month, from: date) + let year = calendar.component(.year, from: date) + + // Base values and growth factors + let yearsSince2011 = year - 2011 + let growthFactor = 1.0 + (Double(yearsSince2011) * 0.15) // 15% yearly growth + + // Seasonal factor (higher in fall/winter) + let seasonalFactor = 1.0 + 0.2 * sin(2.0 * .pi * (Double(month - 3) / 12.0)) + + // Day of week factor (lower on weekends) + let weekendFactor = (dayOfWeek == 1 || dayOfWeek == 7) ? 0.7 : 1.0 + + // Hour of day factor (peak at 2pm, lowest at 3am) + let hourFactor = 0.5 + 0.5 * sin(2.0 * .pi * (Double(hour - 9) / 24.0)) + + // Random variation + let randomFactor = Double.random(in: 0.8...1.2) + + switch metric { + case .views: + let baseValue = 1000.0 + return Int(baseValue * growthFactor * seasonalFactor * weekendFactor * hourFactor * randomFactor) + + case .visitors: + let baseValue = 400.0 + return Int(baseValue * growthFactor * seasonalFactor * weekendFactor * hourFactor * randomFactor) + + case .likes: + let baseValue = 10.0 + return Int(baseValue * growthFactor * seasonalFactor * weekendFactor * randomFactor) + + case .comments: + let baseValue = 3.0 + return Int(baseValue * growthFactor * seasonalFactor * weekendFactor * randomFactor) + + case .posts: + let baseValue = 1.0 + return Int(baseValue * growthFactor * seasonalFactor * weekendFactor * randomFactor) + + case .timeOnSite: + // Time in seconds - doesn't follow same patterns + return Int(170 + Double.random(in: -40...40)) + + case .bounceRate: + // Percentage - inverse relationship with engagement + let engagementFactor = growthFactor * seasonalFactor + return Int(75 - (5 * engagementFactor) + Double.random(in: -5...5)) + + case .downloads: + let baseValue = 50.0 + return Int(baseValue * growthFactor * seasonalFactor * weekendFactor * randomFactor) + } + } + + private func generateTopListMockData() async { + let endDate = Date() + + var dateComponents = DateComponents() + dateComponents.year = 2011 + dateComponents.month = 11 + dateComponents.day = 1 + + let startDate = calendar.date(from: dateComponents)! + + // Generate daily data for each type + for dataType in TopListItemType.allCases { + var typeData: [Date: [any TopListItemProtocol]] = [:] + + // Load base items from JSON files + let baseItems = loadHistoricalItems(for: dataType) + + // Skip if no items to process + if baseItems.isEmpty { + dailyTopListData[dataType] = typeData + continue + } + + var currentDate = startDate + let nowDate = Date() + while currentDate <= endDate && currentDate <= nowDate { + let dayOfWeek = calendar.component(.weekday, from: currentDate) + let month = calendar.component(.month, from: currentDate) + let year = calendar.component(.year, from: currentDate) + + // Calculate daily variations + let yearsSince2011 = year - 2011 + let growthFactor = 1.0 + (Double(yearsSince2011) * 0.12) + let seasonalFactor = 1.0 + 0.15 * sin(2.0 * .pi * (Double(month - 3) / 12.0)) + let weekendFactor = (dayOfWeek == 1 || dayOfWeek == 7) ? 0.7 : 1.0 + let randomFactor = Double.random(in: 0.8...1.2) + + // Apply mutations to each item for this day + let dailyItems = baseItems.map { item in + var mutatedItem = mutateItemMetrics(item, growthFactor: growthFactor, seasonalFactor: seasonalFactor, weekendFactor: weekendFactor, randomFactor: randomFactor) + + // If it's an Author with posts, mutate the posts too + if let author = mutatedItem as? TopListItem.Author, let posts = author.posts { + var mutatedAuthor = author + mutatedAuthor.posts = posts.map { post in + var mutatedPost = post + // Apply similar mutation factors to post metrics + let postRandomFactor = Double.random(in: 0.9...1.1) // Slight variation per post + let postCombinedFactor = growthFactor * seasonalFactor * weekendFactor * randomFactor * postRandomFactor + + if let views = post.metrics.views { + mutatedPost.metrics.views = Int(Double(views) * postCombinedFactor) + } + if let comments = post.metrics.comments { + mutatedPost.metrics.comments = Int(Double(comments) * postCombinedFactor * 0.8) + } + if let likes = post.metrics.likes { + mutatedPost.metrics.likes = Int(Double(likes) * postCombinedFactor * 0.9) + } + if let visitors = post.metrics.visitors { + mutatedPost.metrics.visitors = Int(Double(visitors) * postCombinedFactor) + } + return mutatedPost + } + mutatedItem = mutatedAuthor + } + + return mutatedItem + } + + let startOfDay = calendar.startOfDay(for: currentDate) + typeData[startOfDay] = dailyItems + currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)! + } + + dailyTopListData[dataType] = typeData + } + } + +} diff --git a/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift b/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift new file mode 100644 index 000000000000..d2dedbc982d1 --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift @@ -0,0 +1,127 @@ +import Foundation + +/// Represents aggregated data with sum and count +struct AggregatedDataPoint { + let sum: Int + let count: Int +} + +/// Handles data aggregation and normalization for stats. +/// +/// Example usage: +/// ```swift +/// let aggregator = StatsDataAggregator(calendar: .current) +/// +/// // Raw hourly data points across multiple days +/// let hourlyData: [Date: Int] = [ +/// Date("2025-01-15T10:15:00Z"): 120, +/// Date("2025-01-15T14:30:00Z"): 200, +/// Date("2025-01-15T20:45:00Z"): 150, +/// Date("2025-01-16T11:20:00Z"): 300, +/// Date("2025-01-16T15:10:00Z"): 180 +/// ] +/// +/// // Aggregate by day with normalization for views (sum strategy) +/// let dailyViews = aggregator.aggregate(hourlyData, granularity: .day, metric: .views) +/// // Result: [ +/// // Date("2025-01-15T00:00:00Z"): 470, // sum of all views +/// // Date("2025-01-16T00:00:00Z"): 480 // sum of all views +/// // ] +/// +/// // Aggregate by day with normalization for bounce rate (average strategy) +/// let dailyBounceRate = aggregator.aggregate(hourlyData, granularity: .day, metric: .bounceRate) +/// // Result: [ +/// // Date("2025-01-15T00:00:00Z"): 156, // 470/3 (average) +/// // Date("2025-01-16T00:00:00Z"): 240 // 480/2 (average) +/// // ] +/// ``` +struct StatsDataAggregator { + var calendar: Calendar + + /// Aggregates data points based on the given granularity and normalizes for the specified metric. + /// This combines the previous aggregate and normalizeForMetric functions for efficiency. + func aggregate(_ dataPoints: [DataPoint], granularity: DateRangeGranularity, metric: SiteMetric) -> [Date: Int] { + var aggregatedData: [Date: AggregatedDataPoint] = [:] + + // First pass: aggregate data + for dataPoint in dataPoints { + if let aggregatedDate = makeAggegationDate(for: dataPoint.date, granularity: granularity) { + let existing = aggregatedData[aggregatedDate] + aggregatedData[aggregatedDate] = AggregatedDataPoint( + sum: (existing?.sum ?? 0) + dataPoint.value, + count: (existing?.count ?? 0) + 1 + ) + } + } + + // Second pass: normalize based on metric strategy + var normalizedData: [Date: Int] = [:] + for (date, dataPoint) in aggregatedData { + switch metric.aggregationStrategy { + case .sum: + normalizedData[date] = dataPoint.sum + case .average: + if dataPoint.count > 0 { + normalizedData[date] = dataPoint.sum / dataPoint.count + } + } + } + + return normalizedData + } + + private func makeAggegationDate(for date: Date, granularity: DateRangeGranularity) -> Date? { + let dateComponents = calendar.dateComponents(granularity.calendarComponents, from: date) + return calendar.date(from: dateComponents) + } + + /// Generates sequence of dates between start and end with the given component. + func generateDateSequence(dateInterval: DateInterval, by component: Calendar.Component, value: Int = 1) -> [Date] { + var dates: [Date] = [] + var currentDate = dateInterval.start + let now = Date() + // DateInterval.end is exclusive + while currentDate < dateInterval.end && currentDate <= now { + dates.append(currentDate) + currentDate = calendar.date(byAdding: component, value: value, to: currentDate) ?? currentDate + } + return dates + } + + /// Processes a period of data by aggregating and normalizing data points. + /// - Parameters: + /// - dataPoints: Data points already filtered for the period + /// - dateInterval: The date interval to process + /// - granularity: The aggregation granularity + /// - metric: The metric type for normalization + /// - Returns: Processed period data with aggregated data points and total + func processPeriod( + dataPoints: [DataPoint], + dateInterval: DateInterval, + granularity: DateRangeGranularity, + metric: SiteMetric + ) -> PeriodData { + // Aggregate and normalize data in one pass + let normalizedData = aggregate(dataPoints, granularity: granularity, metric: metric) + + // Generate complete date sequence for the range + let dateSequence = generateDateSequence(dateInterval: dateInterval, by: granularity.component) + + // Create data points for each date in the sequence + let periodDataPoints = dateSequence.map { date in + let aggregationDate = makeAggegationDate(for: date, granularity: granularity) + return DataPoint(date: date, value: normalizedData[aggregationDate ?? date] ?? 0) + } + + // Calculate total using DataPoint's getTotalValue method + let total = DataPoint.getTotalValue(for: periodDataPoints, metric: metric) ?? 0 + + return PeriodData(dataPoints: periodDataPoints, total: total) + } +} + +/// Represents processed data for a specific period +struct PeriodData { + let dataPoints: [DataPoint] + let total: Int +} diff --git a/Modules/Tests/JetpackStatsTests/MockStatsServiceTests.swift b/Modules/Tests/JetpackStatsTests/MockStatsServiceTests.swift new file mode 100644 index 000000000000..c2bab0f40d8b --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/MockStatsServiceTests.swift @@ -0,0 +1,56 @@ +import Testing +import Foundation +@testable import JetpackStats + +@Suite +struct MockStatsServiceTests { + let calendar = Calendar.mock(timeZone: .eastern) + + @Test("getTopListData returns valid data for posts") + func testGetTopListDataPosts() async throws { + // GIVEN + let service = MockStatsService(timeZone: .current) + let dateInterval = calendar.makeDateInterval(for: .today) + + // WHEN + let response = try await service.getTopListData( + .postsAndPages, + metric: .views, + interval: dateInterval, + granularity: dateInterval.preferredGranularity, + limit: nil + ) + + // THEN + #expect(response.items.count > 0) + #expect(response.items.count <= 40, "Should return maximum 40 items") + + // THEN all items are posts + for item in response.items { + if let post = item as? TopListItem.Post { + #expect(!post.title.isEmpty) + #expect((post.metrics.views ?? 0) > 0) + } else { + Issue.record("Expected post item but got \(type(of: item))") + } + } + + } + + @Test("Verify getChartData returns valid data for views metric with today range") + func testGetChartDataViewsToday() async throws { + // GIVEN + let service = MockStatsService(timeZone: .current) + let dateInterval = calendar.makeDateInterval(for: .today) + let granularity = dateInterval.preferredGranularity + + // WHEN + let response = try await service.getSiteStats( + interval: dateInterval, + granularity: granularity + ) + + // THEN - Basic validations + #expect(response.metrics.count > 0, "Should return at least one data point") + } +} diff --git a/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift b/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift new file mode 100644 index 000000000000..eb2c2c6ade4d --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift @@ -0,0 +1,421 @@ +import Testing +import Foundation +@testable import JetpackStats + +@Suite +struct StatsDataAggregationTests { + let calendar = Calendar.mock(timeZone: TimeZone(secondsFromGMT: 0)!) + + @Test + func hourlyAggregation() { + let aggregator = StatsDataAggregator(calendar: calendar) + + // Create test data with multiple values in the same hour + let date1 = Date("2025-01-15T14:15:00Z") + let date2 = Date("2025-01-15T14:30:00Z") + let date3 = Date("2025-01-15T14:45:00Z") + let date4 = Date("2025-01-15T15:10:00Z") + + let testData = [ + DataPoint(date: date1, value: 100), + DataPoint(date: date2, value: 200), + DataPoint(date: date3, value: 150), + DataPoint(date: date4, value: 300) + ] + + let aggregated = aggregator.aggregate(testData, granularity: .hour, metric: .views) + + // Should have 2 hours worth of data + #expect(aggregated.count == 2) + + // Check hour 14:00 + let hour14 = Date("2025-01-15T14:00:00Z") + #expect(aggregated[hour14] == 450) // 100 + 200 + 150 + + // Check hour 15:00 + let hour15 = Date("2025-01-15T15:00:00Z") + #expect(aggregated[hour15] == 300) + } + + @Test + func dailyAggregation() { + let aggregator = StatsDataAggregator(calendar: calendar) + + // Create test data across multiple days + let testData = [ + DataPoint(date: Date("2025-01-15T08:00:00Z"), value: 100), + DataPoint(date: Date("2025-01-15T14:00:00Z"), value: 200), + DataPoint(date: Date("2025-01-15T20:00:00Z"), value: 150), + DataPoint(date: Date("2025-01-16T10:00:00Z"), value: 300) + ] + + let aggregated = aggregator.aggregate(testData, granularity: .day, metric: .views) + + #expect(aggregated.count == 2) + + let day1 = Date("2025-01-15T00:00:00Z") + let day2 = Date("2025-01-16T00:00:00Z") + + #expect(aggregated[day1] == 450) + #expect(aggregated[day2] == 300) + } + + @Test + func monthlyAggregation() { + let aggregator = StatsDataAggregator(calendar: calendar) + + let testData = [ + DataPoint(date: Date("2025-01-15T08:00:00Z"), value: 100), + DataPoint(date: Date("2025-01-20T14:00:00Z"), value: 200), + DataPoint(date: Date("2025-02-10T10:00:00Z"), value: 300) + ] + + let aggregated = aggregator.aggregate(testData, granularity: .month, metric: .views) + + #expect(aggregated.count == 2) + + let jan = Date("2025-01-01T00:00:00Z") + let feb = Date("2025-02-01T00:00:00Z") + + #expect(aggregated[jan] == 300) + #expect(aggregated[feb] == 300) + } + + @Test + func yearlyAggregation() { + let aggregator = StatsDataAggregator(calendar: calendar) + + let testData = [ + DataPoint(date: Date("2025-01-15T08:00:00Z"), value: 100), + DataPoint(date: Date("2025-03-20T14:00:00Z"), value: 200), + DataPoint(date: Date("2025-05-10T10:00:00Z"), value: 300) + ] + + let aggregated = aggregator.aggregate(testData, granularity: .year, metric: .views) + + // Year granularity aggregates by month + #expect(aggregated.count == 1) + + let jan = Date("2025-01-01T00:00:00Z") + + #expect(aggregated[jan] == 600) + } + + // MARK: - Date Sequence Generation Tests + + @Test + func hourlyDateSequence() { + let aggregator = StatsDataAggregator(calendar: calendar) + let start = Date("2025-01-15T10:00:00Z") + let end = Date("2025-01-15T14:00:00Z") // Exclusive upper bound + + let sequence = aggregator.generateDateSequence(dateInterval: DateInterval(start: start, end: end), by: .hour) + + #expect(sequence.count == 4) // 10:00, 11:00, 12:00, 13:00 + #expect(sequence.first == start) + #expect(sequence.last == Date("2025-01-15T13:00:00Z")) + } + + @Test + func dailyDateSequence() { + let aggregator = StatsDataAggregator(calendar: calendar) + let start = Date("2025-01-15T00:00:00Z") // Already normalized + let end = Date("2025-01-17T00:00:00Z") // Exclusive upper bound + + let sequence = aggregator.generateDateSequence(dateInterval: DateInterval(start: start, end: end), by: .day) + + #expect(sequence.count == 2) // Jan 15, 16 (Jan 17 is excluded as end is exclusive) + #expect(sequence.first == Date("2025-01-15T00:00:00Z")) + #expect(sequence.last == Date("2025-01-16T00:00:00Z")) + } + + @Test + func monthlyDateSequence() { + let aggregator = StatsDataAggregator(calendar: calendar) + let start = Date("2025-01-01T00:00:00Z") // Already normalized + let end = Date("2025-03-01T00:00:00Z") // Exclusive upper bound + + let sequence = aggregator.generateDateSequence(dateInterval: DateInterval(start: start, end: end), by: .month) + + #expect(sequence.count == 2) // Jan, Feb (Mar is excluded as end is exclusive) + #expect(sequence.first == Date("2025-01-01T00:00:00Z")) + #expect(sequence.last == Date("2025-02-01T00:00:00Z")) + } + + @Test + func yearlyDateSequence() { + let aggregator = StatsDataAggregator(calendar: calendar) + let start = Date("2025-01-01T00:00:00Z") + let end = Date("2025-06-01T00:00:00Z") // Exclusive upper bound + + // Year granularity uses month increments + let sequence = aggregator.generateDateSequence(dateInterval: DateInterval(start: start, end: end), by: .month) + + #expect(sequence.count == 5) // Jan, Feb, Mar, Apr, May (Jun is excluded) + #expect(sequence.first == Date("2025-01-01T00:00:00Z")) + #expect(sequence[1] == Date("2025-02-01T00:00:00Z")) + #expect(sequence[2] == Date("2025-03-01T00:00:00Z")) + #expect(sequence[3] == Date("2025-04-01T00:00:00Z")) + #expect(sequence.last == Date("2025-05-01T00:00:00Z")) + } + + @Test + func dateSequenceExcludesEndDate() { + let aggregator = StatsDataAggregator(calendar: calendar) + let start = Date("2025-01-15T00:00:00Z") + let end = Date("2025-01-17T00:00:00Z") // Exclusive upper bound + + let sequence = aggregator.generateDateSequence(dateInterval: DateInterval(start: start, end: end), by: .day) + + // Should include Jan 15, 16 only (DateInterval end is exclusive) + #expect(sequence.count == 2) + #expect(sequence.contains(Date("2025-01-15T00:00:00Z"))) + #expect(sequence.contains(Date("2025-01-16T00:00:00Z"))) + #expect(!sequence.contains(Date("2025-01-17T00:00:00Z"))) + } + + @Test + func dateSequenceWithNonNormalizedStart() { + let aggregator = StatsDataAggregator(calendar: calendar) + // Test with non-normalized start times + let start = Date("2025-01-15T14:30:00Z") // Mid-day + let end = Date("2025-01-18T14:30:00Z") + + let sequence = aggregator.generateDateSequence(dateInterval: DateInterval(start: start, end: end), by: .day) + + // Should start from the given time and increment by days + #expect(sequence.count == 3) + #expect(sequence[0] == Date("2025-01-15T14:30:00Z")) + #expect(sequence[1] == Date("2025-01-16T14:30:00Z")) + #expect(sequence[2] == Date("2025-01-17T14:30:00Z")) + } + + // MARK: - Averaged Metrics Tests + + @Test + func aggregateWithAveragedMetric() { + let aggregator = StatsDataAggregator(calendar: calendar) + + let testData = [ + DataPoint(date: Date("2025-01-15T08:00:00Z"), value: 300), + DataPoint(date: Date("2025-01-15T14:00:00Z"), value: 600), + DataPoint(date: Date("2025-01-15T20:00:00Z"), value: 900), + DataPoint(date: Date("2025-01-16T10:00:00Z"), value: 400) + ] + + // Test with timeOnSite metric which uses average strategy + let aggregated = aggregator.aggregate(testData, granularity: .day, metric: .timeOnSite) + + #expect(aggregated.count == 2) + + let day1 = Date("2025-01-15T00:00:00Z") + let day2 = Date("2025-01-16T00:00:00Z") + + // Values should be averaged: (300 + 600 + 900) / 3 = 600 + #expect(aggregated[day1] == 600) + // Single value: 400 / 1 = 400 + #expect(aggregated[day2] == 400) + } + + // MARK: - Process Period Tests + + @Test + func processPeriodDailyGranularity() { + let aggregator = StatsDataAggregator(calendar: calendar) + + // Create test data spanning multiple days + let allDataPoints = [ + DataPoint(date: Date("2025-01-14T10:00:00Z"), value: 50), // Outside range + DataPoint(date: Date("2025-01-15T08:00:00Z"), value: 100), + DataPoint(date: Date("2025-01-15T14:00:00Z"), value: 200), + DataPoint(date: Date("2025-01-15T20:00:00Z"), value: 150), + DataPoint(date: Date("2025-01-16T10:00:00Z"), value: 300), + DataPoint(date: Date("2025-01-17T10:00:00Z"), value: 250), + DataPoint(date: Date("2025-01-18T10:00:00Z"), value: 75) // Outside range + ] + + // Create date interval for Jan 15-17 (exclusive end) + let dateInterval = DateInterval( + start: Date("2025-01-15T00:00:00Z"), + end: Date("2025-01-18T00:00:00Z") + ) + + // Filter data points for the period + let filteredDataPoints = allDataPoints.filter { dataPoint in + dateInterval.contains(dataPoint.date) + } + + let result = aggregator.processPeriod( + dataPoints: filteredDataPoints, + dateInterval: dateInterval, + granularity: .day, + metric: .views + ) + + // Should have 3 days of data + #expect(result.dataPoints.count == 3) + + // Check aggregated values + #expect(result.dataPoints[0].date == Date("2025-01-15T00:00:00Z")) + #expect(result.dataPoints[0].value == 450) // 100 + 200 + 150 + + #expect(result.dataPoints[1].date == Date("2025-01-16T00:00:00Z")) + #expect(result.dataPoints[1].value == 300) + + #expect(result.dataPoints[2].date == Date("2025-01-17T00:00:00Z")) + #expect(result.dataPoints[2].value == 250) + + // Check total + #expect(result.total == 1000) // 450 + 300 + 250 + } + + @Test + func processPeriodHourlyGranularity() { + let aggregator = StatsDataAggregator(calendar: calendar) + + // Create test data with multiple values per hour + let dataPoints = [ + DataPoint(date: Date("2025-01-15T14:15:00Z"), value: 100), + DataPoint(date: Date("2025-01-15T14:30:00Z"), value: 200), + DataPoint(date: Date("2025-01-15T14:45:00Z"), value: 150), + DataPoint(date: Date("2025-01-15T15:10:00Z"), value: 300), + DataPoint(date: Date("2025-01-15T16:20:00Z"), value: 250) + ] + + // Create date interval for 3 hours + let dateInterval = DateInterval( + start: Date("2025-01-15T14:00:00Z"), + end: Date("2025-01-15T17:00:00Z") + ) + + // Filter data points for the period + let filteredDataPoints = dataPoints.filter { dataPoint in + dateInterval.contains(dataPoint.date) + } + + let result = aggregator.processPeriod( + dataPoints: filteredDataPoints, + dateInterval: dateInterval, + granularity: .hour, + metric: .views + ) + + // Should have 3 hours of data + #expect(result.dataPoints.count == 3) + + // Check aggregated values + #expect(result.dataPoints[0].value == 450) // 14:00 hour: 100 + 200 + 150 + #expect(result.dataPoints[1].value == 300) // 15:00 hour + #expect(result.dataPoints[2].value == 250) // 16:00 hour + + #expect(result.total == 1000) + } + + @Test + func processPeriodWithAveragedMetric() { + let aggregator = StatsDataAggregator(calendar: calendar) + + // Create test data + let dataPoints = [ + DataPoint(date: Date("2025-01-15T08:00:00Z"), value: 300), + DataPoint(date: Date("2025-01-15T14:00:00Z"), value: 600), + DataPoint(date: Date("2025-01-15T20:00:00Z"), value: 900), + DataPoint(date: Date("2025-01-16T10:00:00Z"), value: 400) + ] + + let dateInterval = DateInterval( + start: Date("2025-01-15T00:00:00Z"), + end: Date("2025-01-17T00:00:00Z") + ) + + // Filter data points for the period + let filteredDataPoints = dataPoints.filter { dataPoint in + dateInterval.contains(dataPoint.date) + } + + // Use timeOnSite which requires averaging + let result = aggregator.processPeriod( + dataPoints: filteredDataPoints, + dateInterval: dateInterval, + granularity: .day, + metric: .timeOnSite + ) + + // Values should be averaged per day + #expect(result.dataPoints[0].value == 600) // (300 + 600 + 900) / 3 + #expect(result.dataPoints[1].value == 400) // 400 / 1 + + // Total for averaged metrics is the average of all period values + #expect(result.total == 500) // (600 + 400) / 2 + } + + @Test + func processPeriodWithEmptyDateRange() { + let aggregator = StatsDataAggregator(calendar: calendar) + + let dataPoints = [ + DataPoint(date: Date("2025-01-15T10:00:00Z"), value: 100), + DataPoint(date: Date("2025-01-16T10:00:00Z"), value: 200) + ] + + // Date interval with no matching data + let dateInterval = DateInterval( + start: Date("2025-01-20T00:00:00Z"), + end: Date("2025-01-22T00:00:00Z") + ) + + // Filter data points for the period (should be empty) + let filteredDataPoints = dataPoints.filter { dataPoint in + dateInterval.contains(dataPoint.date) + } + + let result = aggregator.processPeriod( + dataPoints: filteredDataPoints, + dateInterval: dateInterval, + granularity: .day, + metric: .views + ) + + // Should still have dates but with zero values + #expect(result.dataPoints.count == 2) + #expect(result.dataPoints[0].value == 0) + #expect(result.dataPoints[1].value == 0) + #expect(result.total == 0) + } + + @Test + func processPeriodMonthlyGranularity() { + let aggregator = StatsDataAggregator(calendar: calendar) + + let dataPoints = [ + DataPoint(date: Date("2025-01-15T10:00:00Z"), value: 100), + DataPoint(date: Date("2025-01-25T10:00:00Z"), value: 200), + DataPoint(date: Date("2025-02-10T10:00:00Z"), value: 300), + DataPoint(date: Date("2025-02-20T10:00:00Z"), value: 400), + DataPoint(date: Date("2025-03-05T10:00:00Z"), value: 500) + ] + + let dateInterval = DateInterval( + start: Date("2025-01-01T00:00:00Z"), + end: Date("2025-03-01T00:00:00Z") + ) + + // Filter data points for the period + let filteredDataPoints = dataPoints.filter { dataPoint in + dateInterval.contains(dataPoint.date) + } + + let result = aggregator.processPeriod( + dataPoints: filteredDataPoints, + dateInterval: dateInterval, + granularity: .month, + metric: .views + ) + + // Should have 2 months (Jan and Feb) + #expect(result.dataPoints.count == 2) + #expect(result.dataPoints[0].value == 300) // Jan: 100 + 200 + #expect(result.dataPoints[1].value == 700) // Feb: 300 + 400 + #expect(result.total == 1000) + } +}