diff --git a/KeyStats.xcodeproj/project.pbxproj b/KeyStats.xcodeproj/project.pbxproj index 1701c6d..553e45a 100644 --- a/KeyStats.xcodeproj/project.pbxproj +++ b/KeyStats.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ A1000020 /* AllTimeStatsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000020 /* AllTimeStatsWindowController.swift */; }; A1000021 /* AllTimeStatsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000021 /* AllTimeStatsViewController.swift */; }; A1000022 /* AppStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000022 /* AppStats.swift */; }; + A1000032 /* StatsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000032 /* StatsModels.swift */; }; A1000023 /* AppActivityTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000023 /* AppActivityTracker.swift */; }; A1000024 /* AppStatsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000024 /* AppStatsWindowController.swift */; }; A1000025 /* AppStatsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000025 /* AppStatsViewController.swift */; }; @@ -58,6 +59,7 @@ A2000020 /* AllTimeStatsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllTimeStatsWindowController.swift; sourceTree = ""; }; A2000021 /* AllTimeStatsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllTimeStatsViewController.swift; sourceTree = ""; }; A2000022 /* AppStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStats.swift; sourceTree = ""; }; + A2000032 /* StatsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsModels.swift; sourceTree = ""; }; A2000023 /* AppActivityTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppActivityTracker.swift; sourceTree = ""; }; A2000024 /* AppStatsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStatsWindowController.swift; sourceTree = ""; }; A2000025 /* AppStatsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStatsViewController.swift; sourceTree = ""; }; @@ -101,6 +103,7 @@ A2000023 /* AppActivityTracker.swift */, A2000003 /* StatsManager.swift */, A2000022 /* AppStats.swift */, + A2000032 /* StatsModels.swift */, A2000004 /* MenuBarController.swift */, A2000005 /* StatsPopoverViewController.swift */, A2000024 /* AppStatsWindowController.swift */, @@ -222,6 +225,7 @@ A1000023 /* AppActivityTracker.swift in Sources */, A1000003 /* StatsManager.swift in Sources */, A1000022 /* AppStats.swift in Sources */, + A1000032 /* StatsModels.swift in Sources */, A1000004 /* MenuBarController.swift in Sources */, A1000005 /* StatsPopoverViewController.swift in Sources */, A1000024 /* AppStatsWindowController.swift in Sources */, diff --git a/KeyStats/StatsManager.swift b/KeyStats/StatsManager.swift index f13e83b..e3a10f6 100644 --- a/KeyStats/StatsManager.swift +++ b/KeyStats/StatsManager.swift @@ -2,143 +2,11 @@ import Foundation import Cocoa import UserNotifications -private let baseMetersPerPixel: Double = 0.000264583 - -private func baseKeyComponent(_ keyName: String) -> String { - let trimmed = keyName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return "" } - if let last = trimmed.split(separator: "+").last { - return String(last).trimmingCharacters(in: .whitespacesAndNewlines) - } - return trimmed -} - -/// 统计数据结构 -struct DailyStats: Codable { - var date: Date - var keyPresses: Int - var keyPressCounts: [String: Int] - var leftClicks: Int - var rightClicks: Int - var sideBackClicks: Int - var sideForwardClicks: Int - var mouseDistance: Double // 以像素为单位 - var scrollDistance: Double // 以像素为单位 - var appStats: [String: AppStats] - - init() { - self.date = Calendar.current.startOfDay(for: Date()) - self.keyPresses = 0 - self.keyPressCounts = [:] - self.leftClicks = 0 - self.rightClicks = 0 - self.sideBackClicks = 0 - self.sideForwardClicks = 0 - self.mouseDistance = 0 - self.scrollDistance = 0 - self.appStats = [:] - } - - init(date: Date) { - self.date = Calendar.current.startOfDay(for: date) - self.keyPresses = 0 - self.keyPressCounts = [:] - self.leftClicks = 0 - self.rightClicks = 0 - self.sideBackClicks = 0 - self.sideForwardClicks = 0 - self.mouseDistance = 0 - self.scrollDistance = 0 - self.appStats = [:] - } - - enum CodingKeys: String, CodingKey { - case date - case keyPresses - case keyPressCounts - case leftClicks - case rightClicks - case sideBackClicks - case sideForwardClicks - // legacy field - case otherClicks - case mouseDistance - case scrollDistance - case appStats - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - date = try container.decodeIfPresent(Date.self, forKey: .date) ?? Calendar.current.startOfDay(for: Date()) - keyPresses = try container.decodeIfPresent(Int.self, forKey: .keyPresses) ?? 0 - keyPressCounts = try container.decodeIfPresent([String: Int].self, forKey: .keyPressCounts) ?? [:] - leftClicks = try container.decodeIfPresent(Int.self, forKey: .leftClicks) ?? 0 - rightClicks = try container.decodeIfPresent(Int.self, forKey: .rightClicks) ?? 0 - sideBackClicks = try container.decodeIfPresent(Int.self, forKey: .sideBackClicks) ?? 0 - sideForwardClicks = try container.decodeIfPresent(Int.self, forKey: .sideForwardClicks) ?? 0 - // Backward compatibility: old builds stored all side clicks in `otherClicks`. - if !container.contains(.sideBackClicks) && !container.contains(.sideForwardClicks) { - sideBackClicks = try container.decodeIfPresent(Int.self, forKey: .otherClicks) ?? 0 - } - mouseDistance = try container.decodeIfPresent(Double.self, forKey: .mouseDistance) ?? 0 - scrollDistance = try container.decodeIfPresent(Double.self, forKey: .scrollDistance) ?? 0 - appStats = try container.decodeIfPresent([String: AppStats].self, forKey: .appStats) ?? [:] - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(date, forKey: .date) - try container.encode(keyPresses, forKey: .keyPresses) - try container.encode(keyPressCounts, forKey: .keyPressCounts) - try container.encode(leftClicks, forKey: .leftClicks) - try container.encode(rightClicks, forKey: .rightClicks) - try container.encode(sideBackClicks, forKey: .sideBackClicks) - try container.encode(sideForwardClicks, forKey: .sideForwardClicks) - try container.encode(mouseDistance, forKey: .mouseDistance) - try container.encode(scrollDistance, forKey: .scrollDistance) - try container.encode(appStats, forKey: .appStats) - } - - var totalClicks: Int { - return leftClicks + rightClicks + sideBackClicks + sideForwardClicks - } - - var hasAnyActivity: Bool { - return keyPresses > 0 || - leftClicks > 0 || - rightClicks > 0 || - sideBackClicks > 0 || - sideForwardClicks > 0 || - mouseDistance > 0 || - scrollDistance > 0 || - !keyPressCounts.isEmpty || - !appStats.isEmpty - } - - /// 纠错率 (Delete + ForwardDelete / Total Keys) - var correctionRate: Double { - guard keyPresses > 0 else { return 0 } - let deleteLikeCount = keyPressCounts.reduce(0) { partial, entry in - let base = baseKeyComponent(entry.key) - guard base == "Delete" || base == "ForwardDelete" else { return partial } - return partial + entry.value - } - return Double(deleteLikeCount) / Double(keyPresses) - } - - /// 键鼠比 (Keys / Clicks) - var inputRatio: Double { - let clicks = totalClicks - guard clicks > 0 else { return keyPresses > 0 ? Double.infinity : 0 } - return Double(keyPresses) / Double(clicks) - } - - /// 格式化鼠标移动距离 +extension DailyStats { var formattedMouseDistance: String { - return StatsManager.shared.formatMouseDistance(mouseDistance) + StatsManager.shared.formatMouseDistance(mouseDistance) } - - /// 格式化滚动距离 + var formattedScrollDistance: String { if scrollDistance >= 10000 { return String(format: "%.1f kPx", scrollDistance / 1000) @@ -148,55 +16,11 @@ struct DailyStats: Codable { } } -/// 有史以来统计数据结构 -struct AllTimeStats { - var totalKeyPresses: Int - var totalLeftClicks: Int - var totalRightClicks: Int - var totalSideBackClicks: Int - var totalSideForwardClicks: Int - var totalMouseDistance: Double - var totalScrollDistance: Double - var keyPressCounts: [String: Int] - var firstDate: Date? - var lastDate: Date? - var activeDays: Int - var maxDailyKeyPresses: Int - var maxDailyKeyPressesDate: Date? - var maxDailyClicks: Int - var maxDailyClicksDate: Date? - var mostActiveWeekday: Int? - var keyActiveDays: Int - var clickActiveDays: Int - - var totalClicks: Int { - return totalLeftClicks + totalRightClicks + totalSideBackClicks + totalSideForwardClicks - } - - /// 纠错率 (Delete + ForwardDelete / Total Keys) - var correctionRate: Double { - guard totalKeyPresses > 0 else { return 0 } - let deleteLikeCount = keyPressCounts.reduce(0) { partial, entry in - let base = baseKeyComponent(entry.key) - guard base == "Delete" || base == "ForwardDelete" else { return partial } - return partial + entry.value - } - return Double(deleteLikeCount) / Double(totalKeyPresses) - } - - /// 键鼠比 (Keys / Clicks) - var inputRatio: Double { - let clicks = totalClicks - guard clicks > 0 else { return totalKeyPresses > 0 ? Double.infinity : 0 } - return Double(totalKeyPresses) / Double(clicks) - } - - /// 格式化鼠标移动距离 +extension AllTimeStats { var formattedMouseDistance: String { - return StatsManager.shared.formatMouseDistance(totalMouseDistance) + StatsManager.shared.formatMouseDistance(totalMouseDistance) } - - /// 格式化滚动距离 + var formattedScrollDistance: String { if totalScrollDistance >= 10000 { return String(format: "%.1f kPx", totalScrollDistance / 1000) @@ -204,29 +28,6 @@ struct AllTimeStats { return String(format: "%.0f px", totalScrollDistance) } } - - static func initial() -> AllTimeStats { - return AllTimeStats( - totalKeyPresses: 0, - totalLeftClicks: 0, - totalRightClicks: 0, - totalSideBackClicks: 0, - totalSideForwardClicks: 0, - totalMouseDistance: 0, - totalScrollDistance: 0, - keyPressCounts: [:], - firstDate: nil, - lastDate: nil, - activeDays: 0, - maxDailyKeyPresses: 0, - maxDailyKeyPressesDate: nil, - maxDailyClicks: 0, - maxDailyClicksDate: nil, - mostActiveWeekday: nil, - keyActiveDays: 0, - clickActiveDays: 0 - ) - } } /// 统计数据管理器 - 单例模式 diff --git a/KeyStats/StatsModels.swift b/KeyStats/StatsModels.swift new file mode 100644 index 0000000..a4c94e3 --- /dev/null +++ b/KeyStats/StatsModels.swift @@ -0,0 +1,198 @@ +import Foundation + +let baseMetersPerPixel: Double = 0.000264583 + +func baseKeyComponent(_ keyName: String) -> String { + let trimmed = keyName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + if let last = trimmed.split(separator: "+").last { + return String(last).trimmingCharacters(in: .whitespacesAndNewlines) + } + return trimmed +} + +/// 统计数据结构 +struct DailyStats: Codable { + var date: Date + var keyPresses: Int + var keyPressCounts: [String: Int] + var leftClicks: Int + var rightClicks: Int + var sideBackClicks: Int + var sideForwardClicks: Int + var mouseDistance: Double + var scrollDistance: Double + var appStats: [String: AppStats] + + init() { + self.date = Calendar.current.startOfDay(for: Date()) + self.keyPresses = 0 + self.keyPressCounts = [:] + self.leftClicks = 0 + self.rightClicks = 0 + self.sideBackClicks = 0 + self.sideForwardClicks = 0 + self.mouseDistance = 0 + self.scrollDistance = 0 + self.appStats = [:] + } + + init(date: Date) { + self.date = Calendar.current.startOfDay(for: date) + self.keyPresses = 0 + self.keyPressCounts = [:] + self.leftClicks = 0 + self.rightClicks = 0 + self.sideBackClicks = 0 + self.sideForwardClicks = 0 + self.mouseDistance = 0 + self.scrollDistance = 0 + self.appStats = [:] + } + + enum CodingKeys: String, CodingKey { + case date + case keyPresses + case keyPressCounts + case leftClicks + case rightClicks + case sideBackClicks + case sideForwardClicks + case otherClicks + case mouseDistance + case scrollDistance + case appStats + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + date = try container.decodeIfPresent(Date.self, forKey: .date) ?? Calendar.current.startOfDay(for: Date()) + keyPresses = try container.decodeIfPresent(Int.self, forKey: .keyPresses) ?? 0 + keyPressCounts = try container.decodeIfPresent([String: Int].self, forKey: .keyPressCounts) ?? [:] + leftClicks = try container.decodeIfPresent(Int.self, forKey: .leftClicks) ?? 0 + rightClicks = try container.decodeIfPresent(Int.self, forKey: .rightClicks) ?? 0 + sideBackClicks = try container.decodeIfPresent(Int.self, forKey: .sideBackClicks) ?? 0 + sideForwardClicks = try container.decodeIfPresent(Int.self, forKey: .sideForwardClicks) ?? 0 + if !container.contains(.sideBackClicks) && !container.contains(.sideForwardClicks) { + sideBackClicks = try container.decodeIfPresent(Int.self, forKey: .otherClicks) ?? 0 + } + mouseDistance = try container.decodeIfPresent(Double.self, forKey: .mouseDistance) ?? 0 + scrollDistance = try container.decodeIfPresent(Double.self, forKey: .scrollDistance) ?? 0 + appStats = try container.decodeIfPresent([String: AppStats].self, forKey: .appStats) ?? [:] + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(date, forKey: .date) + try container.encode(keyPresses, forKey: .keyPresses) + try container.encode(keyPressCounts, forKey: .keyPressCounts) + try container.encode(leftClicks, forKey: .leftClicks) + try container.encode(rightClicks, forKey: .rightClicks) + try container.encode(sideBackClicks, forKey: .sideBackClicks) + try container.encode(sideForwardClicks, forKey: .sideForwardClicks) + try container.encode(mouseDistance, forKey: .mouseDistance) + try container.encode(scrollDistance, forKey: .scrollDistance) + try container.encode(appStats, forKey: .appStats) + } + + var totalClicks: Int { + leftClicks + rightClicks + sideBackClicks + sideForwardClicks + } + + var hasAnyActivity: Bool { + keyPresses > 0 || + leftClicks > 0 || + rightClicks > 0 || + sideBackClicks > 0 || + sideForwardClicks > 0 || + mouseDistance > 0 || + scrollDistance > 0 || + !keyPressCounts.isEmpty || + !appStats.isEmpty + } + + /// 纠错率 (Delete + ForwardDelete / Total Keys) + var correctionRate: Double { + guard keyPresses > 0 else { return 0 } + let deleteLikeCount = keyPressCounts.reduce(0) { partial, entry in + let base = baseKeyComponent(entry.key) + guard base == "Delete" || base == "ForwardDelete" else { return partial } + return partial + entry.value + } + return Double(deleteLikeCount) / Double(keyPresses) + } + + /// 键鼠比 (Keys / Clicks) + var inputRatio: Double { + let clicks = totalClicks + guard clicks > 0 else { return keyPresses > 0 ? Double.infinity : 0 } + return Double(keyPresses) / Double(clicks) + } +} + +/// 有史以来统计数据结构 +struct AllTimeStats { + var totalKeyPresses: Int + var totalLeftClicks: Int + var totalRightClicks: Int + var totalSideBackClicks: Int + var totalSideForwardClicks: Int + var totalMouseDistance: Double + var totalScrollDistance: Double + var keyPressCounts: [String: Int] + var firstDate: Date? + var lastDate: Date? + var activeDays: Int + var maxDailyKeyPresses: Int + var maxDailyKeyPressesDate: Date? + var maxDailyClicks: Int + var maxDailyClicksDate: Date? + var mostActiveWeekday: Int? + var keyActiveDays: Int + var clickActiveDays: Int + + var totalClicks: Int { + totalLeftClicks + totalRightClicks + totalSideBackClicks + totalSideForwardClicks + } + + /// 纠错率 (Delete + ForwardDelete / Total Keys) + var correctionRate: Double { + guard totalKeyPresses > 0 else { return 0 } + let deleteLikeCount = keyPressCounts.reduce(0) { partial, entry in + let base = baseKeyComponent(entry.key) + guard base == "Delete" || base == "ForwardDelete" else { return partial } + return partial + entry.value + } + return Double(deleteLikeCount) / Double(totalKeyPresses) + } + + /// 键鼠比 (Keys / Clicks) + var inputRatio: Double { + let clicks = totalClicks + guard clicks > 0 else { return totalKeyPresses > 0 ? Double.infinity : 0 } + return Double(totalKeyPresses) / Double(clicks) + } + + static func initial() -> AllTimeStats { + AllTimeStats( + totalKeyPresses: 0, + totalLeftClicks: 0, + totalRightClicks: 0, + totalSideBackClicks: 0, + totalSideForwardClicks: 0, + totalMouseDistance: 0, + totalScrollDistance: 0, + keyPressCounts: [:], + firstDate: nil, + lastDate: nil, + activeDays: 0, + maxDailyKeyPresses: 0, + maxDailyKeyPressesDate: nil, + maxDailyClicks: 0, + maxDailyClicksDate: nil, + mostActiveWeekday: nil, + keyActiveDays: 0, + clickActiveDays: 0 + ) + } +} diff --git a/KeyStatsTests/AppStatsTests.swift b/KeyStatsTests/AppStatsTests.swift new file mode 100644 index 0000000..e3a45c4 --- /dev/null +++ b/KeyStatsTests/AppStatsTests.swift @@ -0,0 +1,140 @@ +import XCTest +@testable import KeyStatsCore + +final class AppStatsTests: XCTestCase { + func testInitSetsExpectedDefaults() { + let stats = AppStats(bundleId: "com.test.app", displayName: "Test App") + + XCTAssertEqual(stats.bundleId, "com.test.app") + XCTAssertEqual(stats.displayName, "Test App") + XCTAssertEqual(stats.keyPresses, 0) + XCTAssertEqual(stats.leftClicks, 0) + XCTAssertEqual(stats.rightClicks, 0) + XCTAssertEqual(stats.sideBackClicks, 0) + XCTAssertEqual(stats.sideForwardClicks, 0) + XCTAssertEqual(stats.scrollDistance, 0) + XCTAssertEqual(stats.totalClicks, 0) + XCTAssertFalse(stats.hasActivity) + } + + func testRecordMethodsAccumulateCorrectly() { + var stats = AppStats(bundleId: "com.test.app", displayName: "Test App") + + stats.recordKeyPress() + stats.recordLeftClick() + stats.recordRightClick() + stats.recordSideBackClick() + stats.recordSideForwardClick() + + XCTAssertEqual(stats.keyPresses, 1) + XCTAssertEqual(stats.leftClicks, 1) + XCTAssertEqual(stats.rightClicks, 1) + XCTAssertEqual(stats.sideBackClicks, 1) + XCTAssertEqual(stats.sideForwardClicks, 1) + XCTAssertEqual(stats.totalClicks, 4) + XCTAssertTrue(stats.hasActivity) + } + + func testAddScrollDistanceUsesAbsoluteValue() { + var stats = AppStats(bundleId: "com.test.app", displayName: "Test App") + + stats.addScrollDistance(-12.5) + stats.addScrollDistance(7.5) + + XCTAssertEqual(stats.scrollDistance, 20.0, accuracy: 0.0001) + } + + func testUpdateDisplayNameIgnoresEmptyName() { + var stats = AppStats(bundleId: "com.test.app", displayName: "Original") + + stats.updateDisplayName("") + + XCTAssertEqual(stats.displayName, "Original") + } + + func testUpdateDisplayNameUpdatesWhenNotEmpty() { + var stats = AppStats(bundleId: "com.test.app", displayName: "Original") + + stats.updateDisplayName("Updated") + + XCTAssertEqual(stats.displayName, "Updated") + } + + func testCodableRoundTripPreservesFields() throws { + var original = AppStats(bundleId: "com.test.app", displayName: "Test App") + original.keyPresses = 12 + original.leftClicks = 3 + original.rightClicks = 4 + original.sideBackClicks = 5 + original.sideForwardClicks = 6 + original.scrollDistance = 18.2 + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(AppStats.self, from: data) + + XCTAssertEqual(decoded.bundleId, original.bundleId) + XCTAssertEqual(decoded.displayName, original.displayName) + XCTAssertEqual(decoded.keyPresses, 12) + XCTAssertEqual(decoded.leftClicks, 3) + XCTAssertEqual(decoded.rightClicks, 4) + XCTAssertEqual(decoded.sideBackClicks, 5) + XCTAssertEqual(decoded.sideForwardClicks, 6) + XCTAssertEqual(decoded.scrollDistance, 18.2, accuracy: 0.0001) + } + + func testDecodeLegacyOtherClicksBackfillsSideBackClicks() throws { + let json = """ + { + "bundleId": "com.test.app", + "displayName": "Test App", + "keyPresses": 2, + "leftClicks": 1, + "rightClicks": 1, + "otherClicks": 9, + "scrollDistance": 4.5 + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(AppStats.self, from: json) + + XCTAssertEqual(decoded.sideBackClicks, 9) + XCTAssertEqual(decoded.sideForwardClicks, 0) + XCTAssertEqual(decoded.totalClicks, 11) + } + + func testDecodeExplicitSideClicksDoesNotUseLegacyOtherClicks() throws { + let json = """ + { + "bundleId": "com.test.app", + "displayName": "Test App", + "leftClicks": 1, + "rightClicks": 1, + "sideBackClicks": 2, + "sideForwardClicks": 3, + "otherClicks": 100, + "scrollDistance": 0 + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(AppStats.self, from: json) + + XCTAssertEqual(decoded.sideBackClicks, 2) + XCTAssertEqual(decoded.sideForwardClicks, 3) + XCTAssertEqual(decoded.totalClicks, 7) + } + + func testDecodeMissingFieldsFallsBackToZeroAndEmpty() throws { + let json = "{}".data(using: .utf8)! + let decoded = try JSONDecoder().decode(AppStats.self, from: json) + + XCTAssertEqual(decoded.bundleId, "") + XCTAssertEqual(decoded.displayName, "") + XCTAssertEqual(decoded.keyPresses, 0) + XCTAssertEqual(decoded.leftClicks, 0) + XCTAssertEqual(decoded.rightClicks, 0) + XCTAssertEqual(decoded.sideBackClicks, 0) + XCTAssertEqual(decoded.sideForwardClicks, 0) + XCTAssertEqual(decoded.scrollDistance, 0) + XCTAssertFalse(decoded.hasActivity) + } +} diff --git a/KeyStatsTests/StatsModelsTests.swift b/KeyStatsTests/StatsModelsTests.swift new file mode 100644 index 0000000..2098a04 --- /dev/null +++ b/KeyStatsTests/StatsModelsTests.swift @@ -0,0 +1,100 @@ +import XCTest +@testable import KeyStatsCore + +final class StatsModelsTests: XCTestCase { + func testDailyStatsInitNormalizesDateToStartOfDay() { + let date = Date(timeIntervalSince1970: 1_710_099_123) + + let stats = DailyStats(date: date) + + XCTAssertEqual(stats.date, Calendar.current.startOfDay(for: date)) + } + + func testDailyStatsCorrectionRateCountsDeleteVariants() { + var stats = DailyStats(date: Date()) + stats.keyPresses = 20 + stats.keyPressCounts = [ + "Delete": 2, + "Shift + Delete": 3, + "Command+ForwardDelete": 1, + "Space": 9 + ] + + XCTAssertEqual(stats.correctionRate, 0.3, accuracy: 0.0001) + } + + func testDailyStatsInputRatioHandlesZeroClicks() { + var stats = DailyStats(date: Date()) + stats.keyPresses = 8 + + XCTAssertEqual(stats.inputRatio, .infinity) + } + + func testDailyStatsHasAnyActivityDetectsNestedAppStats() { + var stats = DailyStats(date: Date()) + stats.appStats["com.test.app"] = AppStats(bundleId: "com.test.app", displayName: "Test") + + XCTAssertTrue(stats.hasAnyActivity) + } + + func testDailyStatsCodableBackfillsLegacyOtherClicks() throws { + let json = """ + { + "date": 1710028800, + "keyPresses": 4, + "otherClicks": 7, + "mouseDistance": 15.5, + "scrollDistance": 9 + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(DailyStats.self, from: json) + + XCTAssertEqual(decoded.sideBackClicks, 7) + XCTAssertEqual(decoded.sideForwardClicks, 0) + XCTAssertEqual(decoded.totalClicks, 7) + XCTAssertEqual(decoded.mouseDistance, 15.5, accuracy: 0.0001) + } + + func testAllTimeStatsInitialStartsEmpty() { + let stats = AllTimeStats.initial() + + XCTAssertEqual(stats.totalKeyPresses, 0) + XCTAssertEqual(stats.totalClicks, 0) + XCTAssertEqual(stats.correctionRate, 0) + XCTAssertEqual(stats.inputRatio, 0) + XCTAssertNil(stats.firstDate) + XCTAssertNil(stats.lastDate) + } + + func testAllTimeStatsCorrectionRateAndInputRatioUseAggregates() { + let stats = AllTimeStats( + totalKeyPresses: 12, + totalLeftClicks: 2, + totalRightClicks: 1, + totalSideBackClicks: 1, + totalSideForwardClicks: 0, + totalMouseDistance: 0, + totalScrollDistance: 0, + keyPressCounts: [ + "Option + Delete": 2, + "ForwardDelete": 1, + "A": 9 + ], + firstDate: nil, + lastDate: nil, + activeDays: 0, + maxDailyKeyPresses: 0, + maxDailyKeyPressesDate: nil, + maxDailyClicks: 0, + maxDailyClicksDate: nil, + mostActiveWeekday: nil, + keyActiveDays: 0, + clickActiveDays: 0 + ) + + XCTAssertEqual(stats.totalClicks, 4) + XCTAssertEqual(stats.correctionRate, 0.25, accuracy: 0.0001) + XCTAssertEqual(stats.inputRatio, 3.0, accuracy: 0.0001) + } +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..b4d05d6 --- /dev/null +++ b/Package.swift @@ -0,0 +1,53 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "KeyStatsCoreTests", + products: [ + .library(name: "KeyStatsCore", targets: ["KeyStatsCore"]) + ], + targets: [ + .target( + name: "KeyStatsCore", + path: "KeyStats", + exclude: [ + "Assets.xcassets", + "Main.storyboard", + "Info.plist", + "en.lproj", + "zh-Hans.lproj", + "AppStatsViewController.swift", + "InputMonitor.swift", + "KeyStats.entitlements", + "NotificationManager.swift", + "StatsManager.swift", + "HoverIconButton.swift", + "MouseDistanceCalibrationViewController.swift", + "ActivityHeatmapView.swift", + "AllTimeStatsWindowController.swift", + "MouseDistanceCalibrationWindowController.swift", + "SettingsViewController.swift", + "AppActivityTracker.swift", + "AppStatsWindowController.swift", + "AppDelegate.swift", + "MenuBarController.swift", + "AllTimeStatsViewController.swift", + "MainWindowController.swift", + "KeyboardHeatmapViewController.swift", + "LaunchAtLoginManager.swift", + "UpdateManager.swift", + "KeyboardHeatmapWindowController.swift", + "StatsPopoverViewController.swift", + "SettingsWindowController.swift", + "MainWindowViewController.swift" + ], + sources: ["AppStats.swift", "StatsModels.swift"] + ), + .testTarget( + name: "KeyStatsCoreTests", + dependencies: ["KeyStatsCore"], + path: "KeyStatsTests", + sources: ["AppStatsTests.swift", "StatsModelsTests.swift"] + ) + ] +) diff --git a/README.md b/README.md index 1ba747f..729e172 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,29 @@ KeyStats.Windows/ - **UI Mode**: System tray application - **Advantages**: No runtime installation required, ready to use on Windows 10/11 out of the box, small app size (5-10 MB) + +## Testing (AppStats) + +To make `KeyStats/AppStats.swift` regression-testable, the repository includes XCTest-based tests in `KeyStatsTests/AppStatsTests.swift`, executed with `swift test`. + +Covered test cases: + +1. Default initialization values. +2. Counter accumulation for key/click recording methods. +3. Absolute-value behavior of `addScrollDistance(_:)`. +4. Empty-name guard in `updateDisplayName("")`. +5. Normal display name update. +6. Codable round-trip consistency. +7. Legacy `otherClicks` compatibility fallback. +8. New side-click fields taking precedence over legacy field. +9. Missing-field decode fallback to defaults. + +Run: + +```bash +swift test +``` + ## Privacy Statement KeyStats only tracks the **count** of keystrokes and clicks, and **does NOT record**: diff --git a/README_ZH.md b/README_ZH.md index 2121d8e..78b50d9 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -212,6 +212,29 @@ KeyStats.Windows/ - **UI 模式**:系统托盘应用 - **优势**:无需安装运行时,Windows 10/11 开箱即用,应用体积小(5-10 MB) + +## 测试(AppStats) + +为了保证 `KeyStats/AppStats.swift` 的核心逻辑可回归验证,仓库新增了基于 `XCTest` 的测试代码(见 `KeyStatsTests/AppStatsTests.swift`),并通过 `swift test` 运行。 + +当前覆盖的测试用例如下: + +1. 初始化默认值:验证 `init(bundleId:displayName:)` 后计数字段均为 0。 +2. 按键与点击累加:验证 `recordKeyPress/recordLeftClick/recordRightClick/recordSideBackClick/recordSideForwardClick` 的累加行为。 +3. 滚动距离绝对值:验证 `addScrollDistance(_:)` 对正负输入都按绝对值累加。 +4. 空名称保护:验证 `updateDisplayName("")` 不会覆盖旧名称。 +5. 名称更新:验证 `updateDisplayName("Updated")` 能正确更新。 +6. Codable 往返:编码再解码后字段保持一致。 +7. 旧字段兼容:仅存在 `otherClicks` 时回填 `sideBackClicks`。 +8. 新字段优先:当 `sideBackClicks/sideForwardClicks` 存在时忽略 `otherClicks`。 +9. 缺省字段容错:空 JSON 解码时回落到默认值。 + +运行方式: + +```bash +swift test +``` + ## 隐私说明 KeyStats 仅统计按键和点击的**次数**,**不会记录**: