diff --git a/KeyStats.Windows/KeyStats.Tests/DailyStatsTests.cs b/KeyStats.Windows/KeyStats.Tests/DailyStatsTests.cs new file mode 100644 index 0000000..a1bca73 --- /dev/null +++ b/KeyStats.Windows/KeyStats.Tests/DailyStatsTests.cs @@ -0,0 +1,54 @@ +using System; +using System.Text.Json; +using KeyStats.Models; +using Xunit; + +namespace KeyStats.Tests; + +public class DailyStatsTests +{ + [Fact] + public void Initialization_ShouldSetDefaultValues() + { + var stats = new DailyStats(); + Assert.Equal(0, stats.KeyPresses); + Assert.Equal(0, stats.TotalClicks); + Assert.Equal(0, stats.MouseDistance); + Assert.Equal(DateTime.Today, stats.Date.Date); + } + + [Fact] + public void TotalClicks_ShouldSumAllClicks() + { + var stats = new DailyStats + { + LeftClicks = 10, + RightClicks = 5, + SideBackClicks = 2, + SideForwardClicks = 1 + }; + Assert.Equal(18, stats.TotalClicks); + } + + [Fact] + public void Serialization_ShouldPreserveValues() + { + var original = new DailyStats + { + KeyPresses = 42, + LeftClicks = 10, + MouseDistance = 123.45 + }; + original.KeyPressCounts["Enter"] = 5; + + var json = JsonSerializer.Serialize(original); + var deserialized = JsonSerializer.Deserialize(json); + + Assert.NotNull(deserialized); + Assert.Equal(42, deserialized.KeyPresses); + Assert.Equal(10, deserialized.LeftClicks); + Assert.Equal(123.45, deserialized.MouseDistance); + Assert.True(deserialized.KeyPressCounts.ContainsKey("Enter")); + Assert.Equal(5, deserialized.KeyPressCounts["Enter"]); + } +} diff --git a/KeyStats.Windows/KeyStats.Tests/KeyStats.Tests.csproj b/KeyStats.Windows/KeyStats.Tests/KeyStats.Tests.csproj new file mode 100644 index 0000000..5c7d25f --- /dev/null +++ b/KeyStats.Windows/KeyStats.Tests/KeyStats.Tests.csproj @@ -0,0 +1,21 @@ + + + + net48 + enable + 10.0 + false + + + + + + + + + + + + + + diff --git a/KeyStats.Windows/KeyStats.Tests/StatsManagerTests.cs b/KeyStats.Windows/KeyStats.Tests/StatsManagerTests.cs new file mode 100644 index 0000000..d2e03af --- /dev/null +++ b/KeyStats.Windows/KeyStats.Tests/StatsManagerTests.cs @@ -0,0 +1,87 @@ +using System; +using System.IO; +using System.Threading; +using KeyStats.Services; +using KeyStats.Models; +using Xunit; + +namespace KeyStats.Tests; + +public class StatsManagerTests : IDisposable +{ + private readonly string _testDataFolder; + private readonly StatsManager _statsManager; + private readonly InputMonitorService _inputMonitor; + + public StatsManagerTests() + { + _testDataFolder = Path.Combine(Path.GetTempPath(), "KeyStatsTests_" + Guid.NewGuid()); + Directory.CreateDirectory(_testDataFolder); + + StatsManager.ResetInstanceForTesting(_testDataFolder); + _statsManager = StatsManager.Instance; + + // InputMonitorService is a singleton, so we get the same instance. + // We can't easily reset it, but we can use it to fire events. + _inputMonitor = InputMonitorService.Instance; + } + + public void Dispose() + { + // Cleanup + StatsManager.DisposeInstance(); + try + { + if (Directory.Exists(_testDataFolder)) + { + Directory.Delete(_testDataFolder, true); + } + } + catch { /* Ignore cleanup errors */ } + } + + [Fact] + public void Initialization_ShouldStartWithZeroStats() + { + Assert.Equal(0, _statsManager.CurrentStats.KeyPresses); + Assert.Equal(0, _statsManager.CurrentStats.TotalClicks); + } + + [Fact] + public void IncrementKeyPresses_ShouldIncreaseCount() + { + _inputMonitor.SimulateKeyPress("A", "TestApp", "Test App"); + + Assert.Equal(1, _statsManager.CurrentStats.KeyPresses); + Assert.True(_statsManager.CurrentStats.KeyPressCounts.ContainsKey("A")); + Assert.Equal(1, _statsManager.CurrentStats.KeyPressCounts["A"]); + } + + [Fact] + public void IncrementClicks_ShouldIncreaseCount() + { + _inputMonitor.SimulateLeftClick("TestApp", "Test App"); + Assert.Equal(1, _statsManager.CurrentStats.LeftClicks); + Assert.Equal(1, _statsManager.CurrentStats.TotalClicks); + + _inputMonitor.SimulateRightClick("TestApp", "Test App"); + Assert.Equal(1, _statsManager.CurrentStats.RightClicks); + Assert.Equal(2, _statsManager.CurrentStats.TotalClicks); + } + + [Fact] + public void Persistence_ShouldSaveAndLoadStats() + { + _inputMonitor.SimulateKeyPress("SaveTest", "TestApp", "Test App"); + + // Force save + _statsManager.FlushPendingSave(); + + // Reset instance to simulate restart + StatsManager.ResetInstanceForTesting(_testDataFolder); + var newManager = StatsManager.Instance; + + Assert.Equal(1, newManager.CurrentStats.KeyPresses); + Assert.True(newManager.CurrentStats.KeyPressCounts.ContainsKey("SaveTest")); + } +} diff --git a/KeyStats.Windows/KeyStats/GlobalUsings.cs b/KeyStats.Windows/KeyStats/GlobalUsings.cs index d5be946..b5ae4b9 100644 --- a/KeyStats.Windows/KeyStats/GlobalUsings.cs +++ b/KeyStats.Windows/KeyStats/GlobalUsings.cs @@ -1,3 +1,5 @@ +using System.Runtime.CompilerServices; + // Resolve WPF vs Windows Forms namespace conflicts // Alias WPF types to take precedence in this project // Note: .NET Framework 4.8 不支持 global using (C# 10.0 特性) @@ -8,3 +10,6 @@ // using Application = System.Windows.Application; // using MessageBox = System.Windows.MessageBox; // 等等... + +// Allow internal members to be accessed by test project +[assembly: InternalsVisibleTo("KeyStats.Tests")] diff --git a/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs b/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs index e13867b..03f2da9 100644 --- a/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs +++ b/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs @@ -33,6 +33,36 @@ public class InputMonitorService : IDisposable public event Action? MouseMoved; public event Action? MouseScrolled; + internal void SimulateKeyPress(string key, string appName, string displayName) + { + KeyPressed?.Invoke(key, appName, displayName); + } + + internal void SimulateLeftClick(string appName, string displayName) + { + LeftMouseClicked?.Invoke(appName, displayName); + } + + internal void SimulateRightClick(string appName, string displayName) + { + RightMouseClicked?.Invoke(appName, displayName); + } + + internal void SimulateSideBackClick(string appName, string displayName) + { + SideBackMouseClicked?.Invoke(appName, displayName); + } + + internal void SimulateSideForwardClick(string appName, string displayName) + { + SideForwardMouseClicked?.Invoke(appName, displayName); + } + + internal void SimulateMouseMove(double distance) + { + MouseMoved?.Invoke(distance); + } + private InputMonitorService() { } public void StartMonitoring() diff --git a/KeyStats.Windows/KeyStats/Services/StatsManager.cs b/KeyStats.Windows/KeyStats/Services/StatsManager.cs index daf44b5..c387e7a 100644 --- a/KeyStats.Windows/KeyStats/Services/StatsManager.cs +++ b/KeyStats.Windows/KeyStats/Services/StatsManager.cs @@ -16,6 +16,18 @@ public class StatsManager : IDisposable private static StatsManager? _instance; public static StatsManager Instance => _instance ??= new StatsManager(); + internal static void ResetInstanceForTesting(string? dataFolder = null) + { + DisposeInstance(); + _instance = new StatsManager(dataFolder); + } + + internal static void DisposeInstance() + { + _instance?.Dispose(); + _instance = null; + } + private const double DefaultMetersPerPixel = AppSettings.DefaultMouseMetersPerPixel; private readonly string _dataFolder; @@ -42,12 +54,20 @@ public class StatsManager : IDisposable public event Action? StatsUpdateRequested; - private StatsManager() + private StatsManager(string? dataFolder = null) { - _dataFolder = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "KeyStats"); - Directory.CreateDirectory(_dataFolder); + if (dataFolder != null) + { + _dataFolder = dataFolder; + Directory.CreateDirectory(_dataFolder); + } + else + { + _dataFolder = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "KeyStats"); + Directory.CreateDirectory(_dataFolder); + } _statsFilePath = Path.Combine(_dataFolder, "daily_stats.json"); _historyFilePath = Path.Combine(_dataFolder, "history.json"); diff --git a/KeyStats/StatsManager.swift b/KeyStats/StatsManager.swift index f13e83b..7399b9a 100644 --- a/KeyStats/StatsManager.swift +++ b/KeyStats/StatsManager.swift @@ -243,7 +243,7 @@ class StatsManager { case failure(pixels: Double) } - private let userDefaults = UserDefaults.standard + private let userDefaults: UserDefaults private let statsKey = "dailyStats" private let historyKey = "dailyStatsHistory" private let showKeyPressesKey = "showKeyPressesInMenuBar" @@ -435,7 +435,8 @@ class StatsManager { /// 上次鼠标位置(用于计算移动距离) var lastMousePosition: NSPoint? - private init() { + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" diff --git a/KeyStats/Tests/DailyStatsTests.swift b/KeyStats/Tests/DailyStatsTests.swift new file mode 100644 index 0000000..5556470 --- /dev/null +++ b/KeyStats/Tests/DailyStatsTests.swift @@ -0,0 +1,60 @@ +import XCTest +@testable import KeyStats + +class DailyStatsTests: XCTestCase { + + func testInitialization() { + let stats = DailyStats() + XCTAssertEqual(stats.keyPresses, 0) + XCTAssertEqual(stats.totalClicks, 0) + XCTAssertEqual(stats.mouseDistance, 0) + } + + func testTotalClicks() { + var stats = DailyStats() + stats.leftClicks = 10 + stats.rightClicks = 5 + stats.sideBackClicks = 2 + stats.sideForwardClicks = 1 + + XCTAssertEqual(stats.totalClicks, 18) + } + + func testCorrectionRate() { + var stats = DailyStats() + stats.keyPresses = 100 + stats.keyPressCounts["Delete"] = 5 + stats.keyPressCounts["ForwardDelete"] = 5 + stats.keyPressCounts["A"] = 90 + + // 10 deletes out of 100 keys + XCTAssertEqual(stats.correctionRate, 0.1, accuracy: 0.0001) + } + + func testInputRatio() { + var stats = DailyStats() + stats.keyPresses = 100 + stats.leftClicks = 50 + // total clicks = 50 + + XCTAssertEqual(stats.inputRatio, 2.0, accuracy: 0.0001) + } + + func testEncodingDecoding() { + var stats = DailyStats() + stats.keyPresses = 42 + stats.leftClicks = 10 + stats.keyPressCounts["Enter"] = 5 + + do { + let data = try JSONEncoder().encode(stats) + let decoded = try JSONDecoder().decode(DailyStats.self, from: data) + + XCTAssertEqual(decoded.keyPresses, 42) + XCTAssertEqual(decoded.leftClicks, 10) + XCTAssertEqual(decoded.keyPressCounts["Enter"], 5) + } catch { + XCTFail("Encoding/Decoding failed: \(error)") + } + } +} diff --git a/KeyStats/Tests/StatsManagerTests.swift b/KeyStats/Tests/StatsManagerTests.swift new file mode 100644 index 0000000..dc96845 --- /dev/null +++ b/KeyStats/Tests/StatsManagerTests.swift @@ -0,0 +1,88 @@ +import XCTest +@testable import KeyStats + +class StatsManagerTests: XCTestCase { + var statsManager: StatsManager! + var userDefaults: UserDefaults! + + override func setUp() { + super.setUp() + // Use a unique suite name to avoid conflicts and persistency between tests + userDefaults = UserDefaults(suiteName: "test.KeyStats.\(UUID().uuidString)") + userDefaults.removePersistentDomain(forName: userDefaults.suiteName!) + statsManager = StatsManager(userDefaults: userDefaults) + } + + override func tearDown() { + if let suiteName = userDefaults.suiteName { + userDefaults.removePersistentDomain(forName: suiteName) + } + super.tearDown() + } + + func testInitialization() { + XCTAssertEqual(statsManager.currentStats.keyPresses, 0) + XCTAssertEqual(statsManager.currentStats.totalClicks, 0) + } + + func testIncrementKeyPresses() { + statsManager.incrementKeyPresses(keyName: "A") + XCTAssertEqual(statsManager.currentStats.keyPresses, 1) + XCTAssertEqual(statsManager.currentStats.keyPressCounts["A"], 1) + + statsManager.incrementKeyPresses(keyName: "B") + XCTAssertEqual(statsManager.currentStats.keyPresses, 2) + XCTAssertEqual(statsManager.currentStats.keyPressCounts["B"], 1) + + statsManager.incrementKeyPresses(keyName: "A") + XCTAssertEqual(statsManager.currentStats.keyPresses, 3) + XCTAssertEqual(statsManager.currentStats.keyPressCounts["A"], 2) + } + + func testIncrementClicks() { + statsManager.incrementLeftClicks() + XCTAssertEqual(statsManager.currentStats.leftClicks, 1) + XCTAssertEqual(statsManager.currentStats.totalClicks, 1) + + statsManager.incrementRightClicks() + XCTAssertEqual(statsManager.currentStats.rightClicks, 1) + XCTAssertEqual(statsManager.currentStats.totalClicks, 2) + + statsManager.incrementSideBackClicks() + XCTAssertEqual(statsManager.currentStats.sideBackClicks, 1) + XCTAssertEqual(statsManager.currentStats.totalClicks, 3) + + statsManager.incrementSideForwardClicks() + XCTAssertEqual(statsManager.currentStats.sideForwardClicks, 1) + XCTAssertEqual(statsManager.currentStats.totalClicks, 4) + } + + func testMouseDistance() { + statsManager.addMouseDistance(100.0) + XCTAssertEqual(statsManager.currentStats.mouseDistance, 100.0) + + statsManager.addMouseDistance(50.0) + XCTAssertEqual(statsManager.currentStats.mouseDistance, 150.0) + } + + func testPersistence() { + statsManager.incrementKeyPresses(keyName: "Test") + statsManager.flushPendingSave() + + // Re-initialize with same defaults + let newManager = StatsManager(userDefaults: userDefaults) + XCTAssertEqual(newManager.currentStats.keyPresses, 1) + XCTAssertEqual(newManager.currentStats.keyPressCounts["Test"], 1) + } + + func testAppStats() { + // AppIdentity is internal, so we rely on @testable import KeyStats to access it. + // If not accessible, we might need to rely on public interfaces or skip this test. + // Assuming access. + let appIdentity = AppIdentity(bundleId: "com.test.app", displayName: "Test App") + statsManager.incrementKeyPresses(keyName: "Space", appIdentity: appIdentity) + + XCTAssertEqual(statsManager.currentStats.appStats["com.test.app"]?.keyPresses, 1) + XCTAssertEqual(statsManager.currentStats.appStats["com.test.app"]?.displayName, "Test App") + } +}