Files
msime-windows/server/tests/src/test_stats_aggregate.cpp
T
Jia Sui ffcd1aee28 feat: 输入统计(DLL 采集、Server 聚合存储与设置页展示)
- windows: 三上屏出口 + OnTestKeyDown 直通字符采集,修复半角数字恒零
- engine: 统计 Web 消息契约(statsRequest/statsResponse)
- server: 统计管道接收、聚合、SQLite 存储、保留策略与每日自动清理
- ui-html: 设置页「统计」模块(卡片、热图、明细、开关、清理)
- docs: PRIVACY / README 改为内置可选统计
2026-09-20 19:08:16 +08:00

251 lines
9.5 KiB
C++

#include "tests/includes/test_framework.h"
#include "statistics/stats_aggregate.h"
#include "statistics/stats_frames.h"
#include <chrono>
#include <cstdint>
#include <vector>
namespace
{
using ::FanyImeStatsEvent;
// The reference tool's tests pin the bucketing in a fixed UTC+8 zone; the
// aggregator accepts the resolver as a parameter precisely so these assertions
// do not depend on the machine's timezone.
MsimeStats::LocalTimeParts Utc8(MsimeStats::TimePoint utc)
{
return MsimeStats::UtcPartsOf(utc + std::chrono::hours(8));
}
// At8 builds the instant that reads as the given UTC+8 wall-clock time.
MsimeStats::TimePoint At8(int year, int month, int day, int hour, int minute, int second)
{
return MsimeStats::TimeFromUtcParts(year, month, day, hour - 8, minute, second);
}
uint64_t FiletimeAt8(int year, int month, int day, int hour, int minute, int second)
{
return MsimeStats::TimeToFiletime(At8(year, month, day, hour, minute, second));
}
FanyImeStatsEvent MakeEvent(uint64_t filetime)
{
FanyImeStatsEvent event;
event.timestamp_utc_ft = filetime;
return event;
}
} // namespace
TEST_CASE(stats_active_delta_ms_boundaries)
{
const MsimeStats::TimePoint base = At8(2026, 9, 20, 10, 0, 0);
const auto ms = [](int64_t value) { return std::chrono::milliseconds(value); };
REQUIRE_EQ(MsimeStats::ActiveDeltaMs(MsimeStats::TimePoint{}, base), 0);
REQUIRE_EQ(MsimeStats::ActiveDeltaMs(base, base), 0);
REQUIRE_EQ(MsimeStats::ActiveDeltaMs(base, base + ms(1)), 1);
REQUIRE_EQ(MsimeStats::ActiveDeltaMs(base, base + ms(MsimeStats::kActiveGapLimitMs - 1)), 9999);
REQUIRE_EQ(MsimeStats::ActiveDeltaMs(base, base + ms(MsimeStats::kActiveGapLimitMs)), 10000);
REQUIRE_EQ(MsimeStats::ActiveDeltaMs(base, base + ms(MsimeStats::kActiveGapLimitMs + 1)), 0);
REQUIRE_EQ(MsimeStats::ActiveDeltaMs(base, base - std::chrono::seconds(1)), 0);
REQUIRE_EQ(MsimeStats::ActiveDeltaMs(base, MsimeStats::TimePoint{}), 0);
}
TEST_CASE(stats_day_key_and_hour_follow_the_requested_zone)
{
// 2026-09-20 23:30 in UTC+8 is still 2026-09-20 locally.
const MsimeStats::LocalTimeParts local = Utc8(At8(2026, 9, 20, 23, 30, 0));
REQUIRE_EQ(MsimeStats::DayKeyOf(local), 20260920);
REQUIRE_EQ(local.hour, 23);
// The same instant is still 2026-09-20 in UTC.
const MsimeStats::TimePoint instant = At8(2026, 9, 20, 23, 30, 0);
REQUIRE_EQ(MsimeStats::DayKeyOf(MsimeStats::UtcPartsOf(instant)), 20260920);
// 17:00 UTC is already the next day in UTC+8; the requested zone decides
// the bucket, not the timestamp's own.
const MsimeStats::TimePoint late = MsimeStats::TimeFromUtcParts(2026, 9, 20, 17, 0, 0);
REQUIRE_EQ(MsimeStats::DayKeyOf(Utc8(late)), 20260921);
REQUIRE_EQ(Utc8(late).hour, 1);
}
TEST_CASE(stats_add_days_across_month_and_year)
{
REQUIRE_EQ(MsimeStats::AddDays(20260920, -1), 20260919);
REQUIRE_EQ(MsimeStats::AddDays(20261001, -1), 20260930);
REQUIRE_EQ(MsimeStats::AddDays(20260101, -1), 20251231);
REQUIRE_EQ(MsimeStats::AddDays(20260228, 1), 20260301);
REQUIRE_EQ(MsimeStats::AddDays(20240228, 1), 20240229); // leap year
REQUIRE_EQ(MsimeStats::AddDays(20261231, 1), 20270101);
}
TEST_CASE(stats_day_key_to_time_is_utc_midnight)
{
REQUIRE(MsimeStats::DayKeyToTime(20260920) == MsimeStats::TimeFromUtcParts(2026, 9, 20, 0, 0, 0));
}
TEST_CASE(stats_accumulator_batches_and_active_time)
{
MsimeStats::Accumulator accumulator(Utc8);
FanyImeStatsEvent first = MakeEvent(FiletimeAt8(2026, 9, 20, 10, 0, 0));
first.cjk = 2;
FanyImeStatsEvent second = MakeEvent(FiletimeAt8(2026, 9, 20, 10, 0, 3));
second.cjk = 1;
second.punct = 1;
FanyImeStatsEvent third = MakeEvent(FiletimeAt8(2026, 9, 20, 10, 0, 8));
third.cjk = 1;
third.other = 1;
MsimeStats::Batch batch = accumulator.Add({first, second, third});
REQUIRE_EQ(batch.days.size(), 1u);
REQUIRE_EQ(batch.hours.size(), 1u);
REQUIRE_EQ(batch.days[0].day_key, 20260920);
REQUIRE_EQ(batch.days[0].cjk, 4);
REQUIRE_EQ(batch.days[0].punct, 1);
REQUIRE_EQ(batch.days[0].other, 1);
REQUIRE_EQ(batch.days[0].active_ms, 8000);
REQUIRE_EQ(batch.hours[0].hour, 10);
REQUIRE_EQ(batch.hours[0].chars, 6);
REQUIRE_EQ(batch.hours[0].active_ms, 8000);
// A pause longer than the threshold contributes no active time.
FanyImeStatsEvent after_pause = MakeEvent(FiletimeAt8(2026, 9, 20, 10, 1, 0));
after_pause.latin = 3;
batch = accumulator.Add({after_pause});
REQUIRE_EQ(batch.days[0].active_ms, 0);
REQUIRE_EQ(batch.days[0].latin, 3);
}
TEST_CASE(stats_accumulator_is_global_across_day_and_hour_boundaries)
{
MsimeStats::Accumulator accumulator(Utc8);
FanyImeStatsEvent first = MakeEvent(FiletimeAt8(2026, 9, 20, 23, 59, 58));
first.cjk = 1;
MsimeStats::Batch batch = accumulator.Add({first});
REQUIRE_EQ(batch.days[0].day_key, 20260920);
REQUIRE_EQ(batch.hours[0].hour, 23);
// Two seconds later it is a new day and a new hour, but still active.
FanyImeStatsEvent second = MakeEvent(FiletimeAt8(2026, 9, 21, 0, 0, 0));
second.cjk = 1;
batch = accumulator.Add({second});
REQUIRE_EQ(batch.days[0].day_key, 20260921);
REQUIRE_EQ(batch.hours[0].hour, 0);
REQUIRE_EQ(batch.days[0].active_ms, 2000);
REQUIRE_EQ(batch.hours[0].active_ms, 2000);
}
TEST_CASE(stats_accumulator_out_of_order_and_clock_rollback)
{
MsimeStats::Accumulator accumulator(Utc8);
FanyImeStatsEvent reference = MakeEvent(FiletimeAt8(2026, 9, 20, 10, 0, 10));
reference.cjk = 1;
accumulator.Add({reference});
// Out-of-order event from a slower process: characters counted, no time.
FanyImeStatsEvent slow = MakeEvent(FiletimeAt8(2026, 9, 20, 10, 0, 5));
slow.cjk = 2;
MsimeStats::Batch batch = accumulator.Add({slow});
REQUIRE_EQ(batch.days[0].active_ms, 0);
REQUIRE_EQ(batch.days[0].cjk, 2);
// The accumulator clock must not have moved backwards.
REQUIRE(accumulator.HasLastEvent());
REQUIRE(accumulator.LastEventTime() == At8(2026, 9, 20, 10, 0, 10));
// The next in-order event continues from the furthest point seen.
FanyImeStatsEvent next = MakeEvent(FiletimeAt8(2026, 9, 20, 10, 0, 14));
next.cjk = 1;
batch = accumulator.Add({next});
REQUIRE_EQ(batch.days[0].active_ms, 4000);
}
TEST_CASE(stats_accumulator_merges_multiple_events_into_one_day_delta)
{
MsimeStats::Accumulator accumulator(Utc8);
FanyImeStatsEvent first = MakeEvent(FiletimeAt8(2026, 9, 20, 9, 0, 0));
first.cjk = 1;
FanyImeStatsEvent second = MakeEvent(FiletimeAt8(2026, 9, 20, 9, 0, 1));
second.cjk = 1;
FanyImeStatsEvent third = MakeEvent(FiletimeAt8(2026, 9, 20, 20, 0, 0));
third.latin = 1;
const MsimeStats::Batch batch = accumulator.Add({first, second, third});
REQUIRE_EQ(batch.days.size(), 1u);
REQUIRE_EQ(batch.days[0].cjk, 2);
REQUIRE_EQ(batch.days[0].latin, 1);
REQUIRE_EQ(batch.days[0].active_ms, 1000);
REQUIRE_EQ(batch.hours.size(), 2u);
REQUIRE_EQ(batch.hours[0].hour, 9);
REQUIRE_EQ(batch.hours[1].hour, 20);
}
TEST_CASE(stats_accumulator_zero_timestamp)
{
MsimeStats::Accumulator accumulator(Utc8);
FanyImeStatsEvent reference = MakeEvent(FiletimeAt8(2026, 9, 20, 10, 0, 0));
reference.cjk = 1;
accumulator.Add({reference});
FanyImeStatsEvent broken = MakeEvent(0);
broken.cjk = 3;
const MsimeStats::Batch batch = accumulator.Add({broken});
REQUIRE_EQ(batch.days.size(), 1u);
REQUIRE_EQ(batch.days[0].day_key, 20260920);
REQUIRE_EQ(batch.days[0].active_ms, 0);
REQUIRE_EQ(batch.days[0].cjk, 3);
}
TEST_CASE(stats_accumulator_reset_clears_the_active_clock)
{
MsimeStats::Accumulator accumulator(Utc8);
FanyImeStatsEvent reference = MakeEvent(FiletimeAt8(2026, 9, 20, 10, 0, 0));
reference.cjk = 1;
accumulator.Add({reference});
REQUIRE(accumulator.HasLastEvent());
accumulator.Reset();
REQUIRE(!accumulator.HasLastEvent());
REQUIRE(accumulator.LastEventTime() == MsimeStats::TimePoint{});
// After a reset the next event starts a fresh chain: no active time even
// though it arrives one second after the previous (discarded) event.
FanyImeStatsEvent next = MakeEvent(FiletimeAt8(2026, 9, 20, 10, 0, 1));
next.cjk = 1;
const MsimeStats::Batch batch = accumulator.Add({next});
REQUIRE_EQ(batch.days[0].active_ms, 0);
}
TEST_CASE(stats_accumulator_batch_is_sorted)
{
// Events belonging to several days in one frame must still come back sorted
// by day key and by (day, hour), so the caller can write them deterministically.
MsimeStats::Accumulator accumulator(Utc8);
FanyImeStatsEvent next_day = MakeEvent(FiletimeAt8(2026, 9, 21, 9, 0, 0));
next_day.cjk = 1;
FanyImeStatsEvent evening = MakeEvent(FiletimeAt8(2026, 9, 20, 20, 0, 0));
evening.latin = 1;
FanyImeStatsEvent morning = MakeEvent(FiletimeAt8(2026, 9, 20, 8, 0, 0));
morning.digit = 1;
const MsimeStats::Batch batch = accumulator.Add({next_day, evening, morning});
REQUIRE_EQ(batch.days.size(), 2u);
REQUIRE_EQ(batch.days[0].day_key, 20260920);
REQUIRE_EQ(batch.days[1].day_key, 20260921);
REQUIRE_EQ(batch.hours.size(), 3u);
REQUIRE_EQ(batch.hours[0].day_key, 20260920);
REQUIRE_EQ(batch.hours[0].hour, 8);
REQUIRE_EQ(batch.hours[1].hour, 20);
REQUIRE_EQ(batch.hours[2].day_key, 20260921);
REQUIRE_EQ(batch.hours[2].hour, 9);
}
TEST_CASE(stats_chars_per_minute_guard)
{
REQUIRE_EQ(MsimeStats::CharsPerMinute(100, 0), 0.0);
REQUIRE_EQ(MsimeStats::CharsPerMinute(120, 60'000), 120.0);
REQUIRE_EQ(MsimeStats::CharsPerMinute(0, 60'000), 0.0);
}