Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions KeyStats.Windows/KeyStats.Sync.Tests/ActivityInsightsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
using System;
using System.Collections.Generic;
using System.Linq;
using KeyStats.Models;
using KeyStats.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace KeyStats.Sync.Tests;

[TestClass]
public sealed class ActivityInsightsTests
{
private static int[] Hours(params (int Hour, int Count)[] values)
{
var hours = new int[24];
foreach (var (hour, count) in values) hours[hour] = count;
return hours;
}

[TestMethod]
public void Hourly_NoDays_ReturnsEmptyResult()
{
var result = HourlyActivityAnalyzer.Analyze(Array.Empty<int[]>());

Assert.AreEqual(0, result.DayCount);
Assert.AreEqual(-1, result.PeakHour);
Assert.AreEqual(Chronotype.NotEnoughData, result.Chronotype);
}

[TestMethod]
public void Hourly_AveragesAcrossDaysAndFindsEarliestPeak()
{
var result = HourlyActivityAnalyzer.Analyze(new[]
{
Hours((9, 100), (14, 300)),
Hours((9, 300), (14, 100)),
new int[24]
});

Assert.AreEqual(3, result.DayCount);
Assert.AreEqual(400.0 / 3, result.HourlyAverages[9], 1e-9);
Assert.AreEqual(9, result.PeakHour);
}

[TestMethod]
public void Hourly_BelowMinimum_NoChronotypeButNightRatio()
{
var result = HourlyActivityAnalyzer.Analyze(new[] { Hours((23, 50), (10, 50)) });

Assert.AreEqual(Chronotype.NotEnoughData, result.Chronotype);
Assert.AreEqual(0.5, result.NightRatio, 1e-9);
}

[TestMethod]
[DataRow(23, 100, 14, 400, Chronotype.NightOwl)]
[DataRow(8, 300, 14, 200, Chronotype.EarlyBird)]
[DataRow(8, 100, 15, 300, Chronotype.AfternoonPerson)]
[DataRow(20, 300, 15, 100, Chronotype.EveningPerson)]
[DataRow(8, 200, 15, 200, Chronotype.EarlyBird)]
public void Hourly_Chronotype(int hourA, int countA, int hourB, int countB, Chronotype expected)
{
var result = HourlyActivityAnalyzer.Analyze(new[] { Hours((hourA, countA), (hourB, countB)) });

Assert.AreEqual(expected, result.Chronotype);
}

[TestMethod]
public void Hourly_HasHourlyData_UsesKeysAndClicks()
{
Assert.IsFalse(HourlyActivityAnalyzer.HasHourlyData(new DailyStats(DateTime.Today)));
var clicksOnly = new DailyStats(DateTime.Today) { HourlyClicks = Hours((3, 1)) };
Assert.IsTrue(HourlyActivityAnalyzer.HasHourlyData(clicksOnly));
var legacy = new DailyStats(DateTime.Today) { HourlyKeyPresses = null!, HourlyClicks = new int[5] };
Assert.IsFalse(HourlyActivityAnalyzer.HasHourlyData(legacy));
}

[TestMethod]
public void DailyStats_SumHourly_HandlesLegacyArrays()
{
var sum = DailyStats.SumHourly(null, new[] { 1, 2, -3 });

Assert.AreEqual(24, sum.Length);
CollectionAssert.AreEqual(new[] { 1, 2, 0 }, sum.Take(3).ToArray());
}

private static DailyStats Day(DateTime date, int keys, int clicks = 0, double kps = 0, double mouse = 0)
{
return new DailyStats(date) { KeyPresses = keys, LeftClicks = clicks, PeakKPS = kps, MouseDistance = mouse };
}

private static AchievementProgress Get(IReadOnlyList<AchievementProgress> list, string id)
{
return list.Single(p => p.Definition.Id == id);
}

[TestMethod]
public void Achievements_RecordFirstReachedDate()
{
var start = new DateTime(2026, 1, 1);
var days = Enumerable.Range(0, 12).Select(i => Day(start.AddDays(i), 10_000)).ToList();

var result = AchievementEvaluator.Evaluate(days, AppSettings.DefaultMouseMetersPerPixel);

Assert.AreEqual(start, Get(result, "day_10k").ReachedOn);
Assert.AreEqual(start.AddDays(9), Get(result, "keys_100k").ReachedOn);
Assert.AreEqual(start.AddDays(6), Get(result, "streak_7").ReachedOn);
Assert.IsFalse(Get(result, "keys_1m").IsUnlocked);
Assert.AreEqual(120_000, Get(result, "keys_100k").Current);
}

[TestMethod]
public void Achievements_StreakBreaksOnGapAndInactiveDay()
{
var start = new DateTime(2026, 3, 1);
var days = new List<DailyStats>();
days.AddRange(Enumerable.Range(0, 5).Select(i => Day(start.AddDays(i), 10)));
days.Add(Day(start.AddDays(5), 0)); // 不活跃
days.AddRange(Enumerable.Range(6, 4).Select(i => Day(start.AddDays(i), 10)));
days.AddRange(Enumerable.Range(12, 6).Select(i => Day(start.AddDays(i), 10))); // 中间缺两天

var result = AchievementEvaluator.Evaluate(days, AppSettings.DefaultMouseMetersPerPixel);

Assert.AreEqual(6, Get(result, "streak_7").Current);
Assert.IsFalse(Get(result, "streak_7").IsUnlocked);
Assert.AreEqual(15, Get(result, "active_100").Current);
}

[TestMethod]
public void Achievements_MarathonUsesCalibrationAndKpsUsesDailyPeak()
{
var date = new DateTime(2026, 5, 1);
// 0.5 米每像素:42,000 + 42,390 像素 = 42,195 米,恰好达到马拉松距离
var result = AchievementEvaluator.Evaluate(new[]
{
Day(date, 1, mouse: 42_000, kps: 9.9),
Day(date.AddDays(1), 1, mouse: 42_390, kps: 10)
}, 0.5);

Assert.AreEqual(date.AddDays(1), Get(result, "marathon").ReachedOn);
Assert.AreEqual(date.AddDays(1), Get(result, "kps_10").ReachedOn);
Assert.IsFalse(Get(result, "kps_15").IsUnlocked);
}

[TestMethod]
public void Achievements_CurrentStreakStartsFromYesterdayWhenTodayInactive()
{
var today = new DateTime(2026, 10, 23);
var byDate = new Dictionary<DateTime, DailyStats>
{
[today.AddDays(-1)] = Day(today.AddDays(-1), 5),
[today.AddDays(-2)] = Day(today.AddDays(-2), 0, clicks: 3),
[today.AddDays(-4)] = Day(today.AddDays(-4), 5)
};

Assert.AreEqual(2, AchievementEvaluator.CurrentStreak(byDate, today));
byDate[today] = Day(today, 1);
Assert.AreEqual(3, AchievementEvaluator.CurrentStreak(byDate, today));
}
}
129 changes: 129 additions & 0 deletions KeyStats.Windows/KeyStats.Sync.Tests/KeyboardPersonaAnalyzerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using System.Collections.Generic;
using System.Linq;
using KeyStats.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace KeyStats.Sync.Tests;

[TestClass]
public sealed class KeyboardPersonaAnalyzerTests
{
private static KeyboardPersonaResult Analyze(Dictionary<string, int> counts, int clicks)
{
return KeyboardPersonaAnalyzer.Analyze(counts, counts.Values.Sum(), clicks);
}

[TestMethod]
public void BelowMinimumKeyPresses_DoesNotAssignPersona()
{
var result = Analyze(new Dictionary<string, int> { ["A"] = 199 }, clicks: 50);

Assert.IsFalse(result.HasEnoughData);
Assert.AreEqual(0, result.SecondaryPersonas.Count);
}

[TestMethod]
public void ShortcutHeavy_IsShortcutMaster_AndShiftOnlyCombosAreNotShortcuts()
{
var result = Analyze(new Dictionary<string, int>
{
["A"] = 700,
["Shift+A"] = 150,
["Ctrl+S"] = 80,
["Alt+Tab"] = 40,
["Win+D"] = 30
}, clicks: 300);

Assert.AreEqual(150, result.ShortcutCount);
Assert.AreEqual(KeyboardPersona.ShortcutMaster, result.Persona);
}

[TestMethod]
public void PasteDominant_IsCopyPasteEngineer()
{
var result = Analyze(new Dictionary<string, int>
{
["A"] = 900,
["Ctrl+C"] = 10,
["Ctrl+V"] = 40,
["Ctrl+Z"] = 5
}, clicks: 400);

Assert.AreEqual(10, result.CopyCount);
Assert.AreEqual(40, result.PasteCount);
Assert.AreEqual(5, result.UndoCount);
// 快捷键占比 55/955 < 12%,因此主称号落到复制粘贴
Assert.AreEqual(KeyboardPersona.CopyPasteEngineer, result.Persona);
}

[TestMethod]
public void RegretKeysIncludeModifiedBackspaceAndDelete()
{
var result = Analyze(new Dictionary<string, int>
{
["A"] = 800,
["Backspace"] = 80,
["Ctrl+Backspace"] = 10,
["Delete"] = 20
}, clicks: 400);

Assert.AreEqual(110, result.RegretCount);
Assert.AreEqual(KeyboardPersona.Perfectionist, result.Persona);
}

[TestMethod]
public void ArrowHeavy_IsArrowKeyWalker()
{
var result = Analyze(new Dictionary<string, int>
{
["A"] = 900,
["Up"] = 50,
["Shift+Down"] = 50
}, clicks: 400);

Assert.AreEqual(100, result.ArrowCount);
Assert.AreEqual(KeyboardPersona.ArrowKeyWalker, result.Persona);
}

[TestMethod]
[DataRow(1000, KeyboardPersona.MousePerson)]
[DataRow(150, KeyboardPersona.KeyboardPurist)]
[DataRow(500, KeyboardPersona.BalancedPlayer)]
public void ClickRatio_DecidesMouseOrKeyboardOrBalanced(int clicks, KeyboardPersona expected)
{
var result = Analyze(new Dictionary<string, int> { ["A"] = 1000 }, clicks);

Assert.AreEqual(expected, result.Persona);
}

[TestMethod]
public void MultipleMatches_FirstIsPrimaryRestAreSecondary()
{
var result = Analyze(new Dictionary<string, int>
{
["A"] = 600,
["Ctrl+S"] = 200,
["Backspace"] = 200
}, clicks: 50);

Assert.AreEqual(KeyboardPersona.ShortcutMaster, result.Persona);
CollectionAssert.AreEqual(
new[] { KeyboardPersona.Perfectionist, KeyboardPersona.KeyboardPurist },
result.SecondaryPersonas.ToArray());
}

[TestMethod]
public void TopKeys_ExcludeSpace()
{
var result = Analyze(new Dictionary<string, int>
{
["Space"] = 500,
["E"] = 300,
["T"] = 200,
["A"] = 100,
["Q"] = 1
}, clicks: 400);

CollectionAssert.AreEqual(new[] { "E", "T", "A" }, result.TopKeys.Select(k => k.Key).ToArray());
}
}
25 changes: 25 additions & 0 deletions KeyStats.Windows/KeyStats/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public partial class App : System.Windows.Application
private AppStatsWindow? _appStatsWindow;
private KeyboardHeatmapWindow? _keyboardHeatmapWindow;
private KeyHistoryWindow? _keyHistoryWindow;
private YearReviewWindow? _yearReviewWindow;
private SyncSettingsWindow? _syncSettingsWindow;
private FloatingStatsWindow? _floatingStatsWindow;
private MenuItem? _floatingStatsMenuItem;
Expand Down Expand Up @@ -126,6 +127,7 @@ protected override void OnStartup(StartupEventArgs e)
_syncCoordinator.Start();
InitializeAnalytics(statsManager);
InputMonitorService.Instance.StartMonitoring();
AchievementService.Instance.Start();

Console.WriteLine("Creating tray icon...");
_trayIconViewModel = new TrayIconViewModel();
Expand Down Expand Up @@ -227,6 +229,14 @@ private System.Windows.Controls.ContextMenu CreateContextMenu()
};
menu.Items.Add(keyHistoryItem);

var yearReviewItem = new System.Windows.Controls.MenuItem { Header = KeyStats.Properties.Strings.Tray_YearReview };
yearReviewItem.Click += (s, e) =>
{
TrackClick("context_menu_year_review");
ShowYearReviewWindow();
};
menu.Items.Add(yearReviewItem);

menu.Items.Add(new System.Windows.Controls.Separator());

var quitItem = new System.Windows.Controls.MenuItem { Header = KeyStats.Properties.Strings.Tray_Quit };
Expand Down Expand Up @@ -475,6 +485,20 @@ public void ShowKeyHistoryWindow()
_keyHistoryWindow.Activate();
}

public void ShowYearReviewWindow()
{
if (_yearReviewWindow != null && _yearReviewWindow.IsVisible)
{
_yearReviewWindow.Activate();
return;
}

_yearReviewWindow = new YearReviewWindow();
_yearReviewWindow.Closed += (_, _) => _yearReviewWindow = null;
_yearReviewWindow.Show();
_yearReviewWindow.Activate();
}

public void ExportData()
{
// 确保在 UI 线程上执行
Expand Down Expand Up @@ -649,6 +673,7 @@ protected override void OnExit(ExitEventArgs e)
_floatingStatsWindow?.Close();
_floatingStatsWindow = null;
InputMonitorService.Instance.StopMonitoring();
AchievementService.Instance.Stop();
_syncCoordinator?.Dispose();
_syncCoordinator = null;
StatsManager.Instance.Dispose();
Expand Down
12 changes: 12 additions & 0 deletions KeyStats.Windows/KeyStats/Models/AppSettings.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;

namespace KeyStats.Models;
Expand Down Expand Up @@ -118,6 +119,17 @@ public int FloatingStatsFontSize
[JsonPropertyName("heatmapCustomHighColor")]
public string HeatmapCustomHighColor { get; set; } = "#C62828";

// 已解锁的成就:id → 首次达成日期(yyyy-MM-dd)
[JsonPropertyName("unlockedAchievements")]
public Dictionary<string, string> UnlockedAchievements { get; set; } = new();

// 首次评估时静默补记已达成的成就,之后新解锁的才发通知
[JsonPropertyName("achievementsInitialized")]
public bool AchievementsInitialized { get; set; }

[JsonPropertyName("achievementNotificationsEnabled")]
public bool AchievementNotificationsEnabled { get; set; } = true;

[JsonPropertyName("languagePreference")]
public string LanguagePreference { get; set; } = "system"; // "system" | "zh-Hans" | "zh-Hant" | "en"
}
Loading
Loading