- windows: 三上屏出口 + OnTestKeyDown 直通字符采集,修复半角数字恒零 - engine: 统计 Web 消息契约(statsRequest/statsResponse) - server: 统计管道接收、聚合、SQLite 存储、保留策略与每日自动清理 - ui-html: 设置页「统计」模块(卡片、热图、明细、开关、清理) - docs: PRIVACY / README 改为内置可选统计
251 lines
9.5 KiB
C++
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);
|
|
}
|