Files
msime-windows/server/tests/src/test_stats_overview.cpp
T
Jia Sui 6b4f8e5398 fix(server): 速度分母加下限避免零散输入日算出离谱速率 (#614)
活跃时间只累加相邻两次上屏之间的间隔,首键之前与末键之后的时间不在其中。零散输入的日子分母可能只剩几秒,按它外推会得到没人打过的速率:本机 20261004 记录 81 个可读字符 / 1.9 秒活跃,页面显示 2503 字/分。

CharsPerMinute 在 active_ms > 0 时改用 max(active_ms, kMinSpeedActiveMs) 作分母。active_ms <= 0 仍返回 0——那是没有测量,不是测量小,不该被下限救活。

kMinSpeedActiveMs 与 kFastestSpeedMinActiveMs 保持为两个常量、两个理由:下限管数值,排名门槛管资格。没有门槛的话,被下限压到「每分钟字数」量级的爆发日仍会拿下最快日。

前端 speedOf 是同一公式的手写副本,一并改,并把运算顺序对齐 C++ 的 chars / (ms / 60000),否则两侧会差一个 ulp。落库口径不变,无需迁移历史数据。
2026-10-04 14:24:53 +08:00

292 lines
11 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "tests/includes/test_framework.h"
#include "statistics/stats_overview.h"
#include <cmath>
#include <cstdint>
#include <initializer_list>
#include <vector>
namespace
{
using MsimeStats::DailyRow;
using MsimeStats::HourlyRow;
using MsimeStats::Overview;
int DayKey(int year, int month, int day)
{
return year * 10000 + month * 100 + day;
}
DailyRow Row(int day_key, int64_t cjk = 0, int64_t latin = 0, int64_t digit = 0, int64_t punct = 0, int64_t other = 0,
int64_t active_ms = 0)
{
DailyRow row;
row.day_key = day_key;
row.cjk = cjk;
row.latin = latin;
row.digit = digit;
row.punct = punct;
row.other = other;
row.active_ms = active_ms;
return row;
}
HourlyRow Hour(int day_key, int hour, int64_t chars)
{
HourlyRow row;
row.day_key = day_key;
row.hour = hour;
row.chars = chars;
return row;
}
std::vector<DailyRow> Rows(std::initializer_list<int> day_keys)
{
std::vector<DailyRow> rows;
rows.reserve(day_keys.size());
for (const int day_key : day_keys)
{
rows.push_back(Row(day_key, 1));
}
return rows;
}
bool NearlyEqual(double left, double right)
{
return std::abs(left - right) <= 0.001;
}
constexpr int kToday = 20260920;
} // namespace
TEST_CASE(stats_overview_empty_state)
{
const Overview overview = MsimeStats::ComputeOverview({}, {}, kToday);
REQUIRE(!overview.has_data);
REQUIRE_EQ(overview.total_chars, 0);
REQUIRE_EQ(overview.days, 0);
REQUIRE_EQ(overview.current_streak, 0);
REQUIRE_EQ(overview.longest_streak, 0);
REQUIRE_EQ(overview.today_day_key, kToday);
REQUIRE_EQ(overview.today_hourly.size(), 24u);
REQUIRE_EQ(overview.average_per_day, 0.0);
REQUIRE_EQ(overview.today_speed, 0.0);
REQUIRE_EQ(overview.average_speed, 0.0);
REQUIRE_EQ(overview.fastest_speed, 0.0);
}
TEST_CASE(stats_overview_single_day)
{
const std::vector<DailyRow> rows = {Row(kToday, 100, 20, 5, 10, 1, 120'000)};
const std::vector<HourlyRow> hourly = {Hour(kToday, 9, 50), Hour(kToday, 10, 86)};
const Overview overview = MsimeStats::ComputeOverview(rows, hourly, kToday);
REQUIRE(overview.has_data);
REQUIRE_EQ(overview.days, 1);
REQUIRE_EQ(overview.total_chars, 136);
REQUIRE_EQ(overview.today_chars, 136);
REQUIRE_EQ(overview.today_active_ms, 120'000);
REQUIRE_EQ(overview.average_per_day, 136.0);
REQUIRE_EQ(overview.current_streak, 1);
REQUIRE_EQ(overview.longest_streak, 1);
REQUIRE_EQ(overview.best_day_key, kToday);
REQUIRE_EQ(overview.best_day_chars, 136);
// 速度分子只含 cjk + latin:120 readable chars / 2 min
REQUIRE(NearlyEqual(overview.today_speed, 60.0));
REQUIRE(NearlyEqual(overview.average_speed, overview.today_speed));
REQUIRE(NearlyEqual(overview.fastest_speed, 60.0));
REQUIRE_EQ(overview.fastest_day_key, kToday);
REQUIRE_EQ(overview.categories.Total(), 136);
REQUIRE_EQ(overview.categories.cjk, 100);
REQUIRE_EQ(overview.today_hourly[9], 50);
REQUIRE_EQ(overview.today_hourly[10], 86);
REQUIRE_EQ(overview.today_hourly[11], 0);
}
TEST_CASE(stats_overview_excludes_other_days_from_hourly)
{
const std::vector<DailyRow> rows = {Row(20260919, 1), Row(kToday, 2)};
const std::vector<HourlyRow> hourly = {Hour(20260919, 9, 100), Hour(kToday, 9, 7)};
const Overview overview = MsimeStats::ComputeOverview(rows, hourly, kToday);
REQUIRE_EQ(overview.today_hourly[9], 7);
}
// Zero-record days are not rows, so they must not enter the average
// denominator: 10 characters over two recorded days is 5/day even when the
// calendar span is twenty days.
TEST_CASE(stats_overview_average_per_day_excludes_days_without_records)
{
const std::vector<DailyRow> rows = {Row(20260901, 4), Row(kToday, 6)};
const Overview overview = MsimeStats::ComputeOverview(rows, {}, kToday);
REQUIRE_EQ(overview.average_per_day, 5.0);
REQUIRE_EQ(overview.days, 2);
}
TEST_CASE(stats_overview_current_streak)
{
struct Case
{
const char *name;
std::vector<DailyRow> rows;
int want;
};
const std::vector<Case> cases = {
{"no rows", {}, 0},
{"today only", Rows({kToday}), 1},
{"today and yesterday", Rows({kToday, 20260919}), 2},
{"today missing, streak alive through yesterday", Rows({20260919, 20260918}), 2},
{"gap before yesterday stops the streak", Rows({20260918, 20260917}), 0},
{"gap inside the run", Rows({kToday, 20260919, 20260917}), 2},
{"future rows do not count", Rows({20260925}), 0},
};
for (const Case &test_case : cases)
{
REQUIRE_EQ(MsimeStats::CurrentStreak(test_case.rows, kToday), test_case.want);
}
}
TEST_CASE(stats_overview_longest_streak)
{
struct Case
{
const char *name;
std::vector<DailyRow> rows;
int want;
};
const std::vector<Case> cases = {
{"empty", {}, 0},
{"single", Rows({kToday}), 1},
{"across month boundary", Rows({20260929, 20260930, 20261001}), 3},
{"longest in the middle", Rows({20260901, 20260910, 20260911, 20260912, 20260920}), 3},
{"duplicates do not break a run", Rows({20260910, 20260910, 20260911}), 2},
};
for (const Case &test_case : cases)
{
REQUIRE_EQ(MsimeStats::LongestStreak(test_case.rows), test_case.want);
}
}
TEST_CASE(stats_overview_best_day)
{
const std::vector<DailyRow> rows = {Row(20260918, 10), Row(20260919, 30, 5), Row(kToday, 20)};
const Overview overview = MsimeStats::ComputeOverview(rows, {}, kToday);
REQUIRE_EQ(overview.best_day_key, 20260919);
REQUIRE_EQ(overview.best_day_chars, 35);
}
TEST_CASE(stats_overview_speeds)
{
const std::vector<DailyRow> rows = {
// 120 readable chars in 2 min = 60/min; candidate for fastest.
Row(20260918, 120, 0, 0, 0, 0, 120'000),
// 600 readable chars in 5 min = 120/min; wins.
Row(20260919, 600, 0, 0, 0, 0, 300'000),
// 50 readable chars in 30s: too little active time to compete in the
// ranking, floored to a minute for today's own speed, and still counted
// in the overall average at its real duration.
Row(kToday, 50, 0, 0, 0, 0, 30'000),
};
const Overview overview = MsimeStats::ComputeOverview(rows, {}, kToday);
REQUIRE(NearlyEqual(overview.today_speed, 50.0)); // 50 / floored 1 min
REQUIRE(NearlyEqual(overview.average_speed, 770.0 / 7.5)); // total 7.5 active minutes
REQUIRE(NearlyEqual(overview.fastest_speed, 120.0));
REQUIRE_EQ(overview.fastest_day_key, 20260919);
}
// 零散输入日:分母只按相邻上屏之间的间隔累加,活跃时间可能只有几秒,按它外推会得出
// 没人打过的速率(真实记录:81 个可读字符 / 1.9 秒活跃 = 2503 字/分)。这组数字取自
// 本机 stats.db 的 20261004。
TEST_CASE(stats_overview_today_speed_survives_sparse_typing)
{
const std::vector<DailyRow> rows = {
// 10-02 是真实记录里速度最高的一天(约 121 字/分);10-03 有 31 个可读字符
// 但完全没有活跃时间,速度是 0——它进不了最快日,也拉不动平均值。
Row(20261002, 747, 473, 1, 81, 0, 603'977),
Row(20261003, 0, 31, 0, 0, 0, 0),
// 今天零星敲了几个键:2/7/10 点的间隔都超过 kActiveGapLimitMs 被丢掉,
// 只有 8 点那 1.9 秒被计入。
Row(kToday, 19, 62, 10, 0, 0, 1942),
};
const Overview overview = MsimeStats::ComputeOverview(rows, {}, kToday);
REQUIRE_EQ(overview.today_active_ms, 1942);
REQUIRE(NearlyEqual(overview.today_speed, 81.0));
// 今日速度有了合理值,但这一天仍然不够资格拿下最快日:门槛管的是排名,
// 下限管的是数值,两者不能互相替代。
REQUIRE_EQ(overview.fastest_day_key, 20261002);
REQUIRE(NearlyEqual(overview.fastest_speed, 1220.0 / (603'977 / 60000.0)));
// 平均速度用全期累计活跃时长,本来就远离下限,不受今日这 1.9 秒影响。
REQUIRE(NearlyEqual(overview.average_speed, 1332.0 / ((603'977 + 1942) / 60000.0)));
}
// 速度分子只数可读字符(cjk + latin):标点、数字、其他加量都不提速。
TEST_CASE(stats_overview_speed_ignores_digits_punct_and_other)
{
const std::vector<DailyRow> quiet = {Row(kToday, 30, 30, 0, 0, 0, 60'000)};
const std::vector<DailyRow> noisy = {Row(kToday, 30, 30, 500, 500, 500, 60'000)};
const Overview quiet_overview = MsimeStats::ComputeOverview(quiet, {}, kToday);
const Overview noisy_overview = MsimeStats::ComputeOverview(noisy, {}, kToday);
REQUIRE(NearlyEqual(noisy_overview.today_speed, 60.0));
REQUIRE(NearlyEqual(noisy_overview.today_speed, quiet_overview.today_speed));
REQUIRE(NearlyEqual(noisy_overview.average_speed, quiet_overview.average_speed));
REQUIRE(NearlyEqual(noisy_overview.fastest_speed, quiet_overview.fastest_speed));
// 卡片与最高日仍按总字符,不受速度口径影响。
REQUIRE_EQ(noisy_overview.today_chars, 30 + 30 + 500 * 3);
REQUIRE_EQ(noisy_overview.best_day_chars, 30 + 30 + 500 * 3);
// 中文/英文加量才让速度上升:120 readable / 1 min = 120/min。
const std::vector<DailyRow> more = {Row(kToday, 60, 60, 500, 500, 500, 60'000)};
const Overview more_overview = MsimeStats::ComputeOverview(more, {}, kToday);
REQUIRE(NearlyEqual(more_overview.today_speed, 120.0));
REQUIRE(NearlyEqual(more_overview.average_speed, 120.0));
REQUIRE(NearlyEqual(more_overview.fastest_speed, 120.0));
}
// 最高日是产量指标(按总字符),与速度指标(按 cjk + latin)是两个口径。
TEST_CASE(stats_overview_best_day_is_production_not_speed)
{
const std::vector<DailyRow> rows = {
// 9/18:标点多、总字多,拿下最高日。
Row(20260918, 10, 0, 0, 200, 0, 60'000),
// 9/19:可读字符多,拿下最快日。
Row(20260919, 100, 0, 0, 0, 0, 60'000),
};
const Overview overview = MsimeStats::ComputeOverview(rows, {}, kToday);
REQUIRE_EQ(overview.best_day_key, 20260918);
REQUIRE_EQ(overview.best_day_chars, 210);
REQUIRE_EQ(overview.fastest_day_key, 20260919);
REQUIRE(NearlyEqual(overview.fastest_speed, 100.0));
}
TEST_CASE(stats_overview_speeds_with_no_active_time)
{
const std::vector<DailyRow> rows = {Row(kToday, 42)};
const Overview overview = MsimeStats::ComputeOverview(rows, {}, kToday);
REQUIRE_EQ(overview.today_speed, 0.0);
REQUIRE_EQ(overview.average_speed, 0.0);
REQUIRE_EQ(overview.fastest_speed, 0.0);
REQUIRE_EQ(overview.today_chars, 42);
}
TEST_CASE(stats_overview_daily_is_sorted_and_copied)
{
std::vector<DailyRow> input = {Row(kToday, 1), Row(20260918, 2), Row(20260919, 3)};
const Overview overview = MsimeStats::ComputeOverview(input, {}, kToday);
REQUIRE_EQ(input[0].day_key, kToday);
REQUIRE_EQ(overview.daily[0].day_key, 20260918);
REQUIRE_EQ(overview.daily[2].day_key, kToday);
}
TEST_CASE(stats_overview_history_ends_before_today)
{
const std::vector<DailyRow> rows = {Row(20260901, 10), Row(20260902, 10)};
const Overview overview = MsimeStats::ComputeOverview(rows, {}, kToday);
REQUIRE_EQ(overview.today_chars, 0);
REQUIRE_EQ(overview.current_streak, 0);
REQUIRE_EQ(overview.longest_streak, 2);
}