Files
msime-windows/windows/tests/statistics.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

216 lines
9.9 KiB
C++

#include "Statistics/char_classify.h"
#include "Statistics/stats_collector.h"
#include "Statistics/stats_passthrough.h"
#include <cstddef>
#include <cstdint>
#include <string>
namespace
{
int gFirstFailure = 0;
void Check(int caseId, bool condition)
{
// Do not use assert: Release builds must execute these checks too.
if (!condition && gFirstFailure == 0)
{
gFirstFailure = caseId;
}
}
void ExpectClassification(int caseId, const wchar_t *text, size_t length, uint16_t cjk, uint16_t latin, uint16_t digit,
uint16_t punct, uint16_t other)
{
const MsimeStats::CharClassCounts actual = MsimeStats::ClassifyText(text, length);
const bool matches = actual.cjk == cjk && actual.latin == latin && actual.digit == digit && actual.punct == punct &&
actual.other == other;
Check(caseId, matches);
}
FanyImeStatsEvent MakeEvent(uint64_t timestamp, uint16_t cjk)
{
FanyImeStatsEvent event;
event.timestamp_utc_ft = timestamp;
event.cjk = cjk;
return event;
}
void TestClassification()
{
using MsimeStats::ClassifyText;
Check(1, ClassifyText(nullptr, 8).Total() == 0);
Check(2, ClassifyText(L"ignored", 0).Total() == 0);
ExpectClassification(3, L"\u4F60\u597D", 2, 2, 0, 0, 0, 0); // ideographs
ExpectClassification(4, L"abcXYZ", 6, 0, 6, 0, 0, 0); // ASCII letters
ExpectClassification(5, L"0123456789", 10, 0, 0, 10, 0, 0); // ASCII digits
ExpectClassification(6, L"\uFF0C\u3002\uFF01\uFF1F", 4, 0, 0, 0, 4, 0); // fullwidth punctuation
ExpectClassification(7, L" ", 1, 0, 0, 0, 1, 0); // ASCII space
ExpectClassification(8, L"\u3000", 1, 0, 0, 0, 1, 0); // ideographic space
ExpectClassification(9, L"\uFF10\uFF11\uFF12", 3, 0, 0, 3, 0, 0); // fullwidth digits
ExpectClassification(10, L"\uFF21", 1, 0, 0, 0, 0, 1); // fullwidth Latin letter
ExpectClassification(11, L"\uF900", 1, 1, 0, 0, 0, 0); // compatibility ideograph
// U+20000 (CJK Extension B) is a surrogate pair and must count once as CJK.
const wchar_t extensionB[] = {static_cast<wchar_t>(0xD840), static_cast<wchar_t>(0xDC00)};
ExpectClassification(12, extensionB, 2, 1, 0, 0, 0, 0);
// Emoji: one user-perceived character, classified as other.
const wchar_t emoji[] = {static_cast<wchar_t>(0xD83D), static_cast<wchar_t>(0xDE00)};
ExpectClassification(13, emoji, 2, 0, 0, 0, 0, 1);
const wchar_t loneHigh[] = {static_cast<wchar_t>(0xD83D)};
const wchar_t loneLow[] = {static_cast<wchar_t>(0xDE00)};
ExpectClassification(14, loneHigh, 1, 0, 0, 0, 0, 1);
ExpectClassification(15, loneLow, 1, 0, 0, 0, 0, 1);
const wchar_t highThenAscii[] = {static_cast<wchar_t>(0xD83D), L'a'};
ExpectClassification(16, highThenAscii, 2, 0, 1, 0, 0, 1);
const wchar_t mixed[] = {L'\u4F60',
L'\u597D',
L'\uFF0C',
L'a',
L'b',
L'c',
L'1',
L'2',
L'3',
static_cast<wchar_t>(0xD83D),
static_cast<wchar_t>(0xDE00)};
const MsimeStats::CharClassCounts mixedCounts = ClassifyText(mixed, 11);
Check(17, mixedCounts.cjk == 2 && mixedCounts.latin == 3 && mixedCounts.digit == 3 && mixedCounts.punct == 1 &&
mixedCounts.other == 1);
Check(18, mixedCounts.Total() == 10);
// Counts are 16-bit on the wire: a run longer than 65535 characters must
// saturate instead of wrapping back to zero.
const std::wstring longRun(70000, L'\u4E2D');
const MsimeStats::CharClassCounts saturated = ClassifyText(longRun.data(), longRun.size());
Check(19, saturated.cjk == 0xFFFF && saturated.Total() == 0xFFFF);
MsimeStats::CharClassCounts sum;
sum.Add(mixedCounts);
sum.Add(mixedCounts);
Check(20, sum.Total() == 20 && sum.cjk == 4 && sum.other == 2);
}
void TestPassthroughPredicate()
{
using MsimeStats::ShouldCountPassthroughChar;
// A plain half-width digit in Chinese mode with no modifiers is the case
// this predicate exists for: the tip does not eat it, the host inserts it,
// and no commit exit ever sees it (digit stayed at zero before this hook).
Check(301, ShouldCountPassthroughChar(L'1', false, false, false, false, false, false));
// The printable range boundaries. Functional keys arrive with a control
// character from ToUnicode (Enter 0x0D, Tab 0x09, Backspace 0x08, Esc
// 0x1B) or with 0, so the range check -- not a key name list -- drops them.
Check(302, !ShouldCountPassthroughChar(L'\0', false, false, false, false, false, false));
Check(303, !ShouldCountPassthroughChar(static_cast<wchar_t>(0x1F), false, false, false, false, false, false));
Check(304, ShouldCountPassthroughChar(static_cast<wchar_t>(0x20), false, false, false, false, false, false));
Check(305, ShouldCountPassthroughChar(static_cast<wchar_t>(0x7E), false, false, false, false, false, false));
Check(306, !ShouldCountPassthroughChar(static_cast<wchar_t>(0x7F), false, false, false, false, false, false));
Check(307, !ShouldCountPassthroughChar(L'\r', false, false, false, false, false, false));
Check(308, !ShouldCountPassthroughChar(L'\t', false, false, false, false, false, false));
Check(309, !ShouldCountPassthroughChar(L'\b', false, false, false, false, false, false));
Check(310, !ShouldCountPassthroughChar(L'\x1B', false, false, false, false, false, false));
// Every single filter blocks the count on its own.
Check(311, !ShouldCountPassthroughChar(L'a', true, false, false, false, false, false)); // eaten
Check(312, !ShouldCountPassthroughChar(L'a', false, true, false, false, false, false)); // self-generated
Check(313, !ShouldCountPassthroughChar(L'a', false, false, true, false, false, false)); // keyboard disabled
Check(314, !ShouldCountPassthroughChar(L'a', false, false, false, true, false, false)); // Ctrl
Check(315, !ShouldCountPassthroughChar(L'a', false, false, false, false, true, false)); // Alt
Check(316, !ShouldCountPassthroughChar(L'a', false, false, false, false, false, true)); // Win
// Shift is deliberately not a parameter: uppercase letters and the
// shifted symbol row still count (the character already carries Shift).
Check(317, ShouldCountPassthroughChar(L'A', false, false, false, false, false, false));
Check(318, ShouldCountPassthroughChar(L'!', false, false, false, false, false, false));
Check(319, ShouldCountPassthroughChar(L' ', false, false, false, false, false, false));
Check(320, ShouldCountPassthroughChar(L'+', false, false, false, false, false, false));
// A non-ASCII keyboard layout still produces one countable character.
Check(321, ShouldCountPassthroughChar(L'\u4F60', false, false, false, false, false, false));
// The symbols this hook recovers outside the punctuation table classify as
// punct, which is what the digit fix must show in the aggregate.
ExpectClassification(322, L"+-=/|", 5, 0, 0, 0, 5, 0);
}
void TestEventQueue()
{
MsimeStats::StatsEventQueue queue;
FanyImeStatsEvent popped[MsimeStats::StatsEventQueue::kCapacity] = {};
Check(101, queue.PopBatch(popped, MsimeStats::StatsEventQueue::kCapacity) == 0);
Check(102, queue.PopBatch(nullptr, 4) == 0);
for (uint64_t index = 0; index < MsimeStats::StatsEventQueue::kCapacity; ++index)
{
Check(103, queue.Push(MakeEvent(index, 1)));
}
Check(104, queue.Size() == MsimeStats::StatsEventQueue::kCapacity);
// One event more than the capacity: the oldest is evicted, the newest is
// retained, and the loss is counted exactly once.
Check(105, !queue.Push(MakeEvent(1000, 5)));
Check(106, queue.Size() == MsimeStats::StatsEventQueue::kCapacity);
Check(107, queue.TakeDroppedCount() == 1);
Check(108, queue.TakeDroppedCount() == 0);
const size_t taken = queue.PopBatch(popped, MsimeStats::StatsEventQueue::kCapacity);
Check(109, taken == MsimeStats::StatsEventQueue::kCapacity);
Check(110, popped[0].timestamp_utc_ft == 1); // event 0 was evicted
Check(111, popped[taken - 1].timestamp_utc_ft == 1000);
Check(112, queue.Size() == 0);
for (uint64_t index = 0; index < 3; ++index)
{
queue.Push(MakeEvent(index + 10, 1));
}
Check(113, queue.PopBatch(popped, 2) == 2);
Check(114, popped[0].timestamp_utc_ft == 10 && popped[1].timestamp_utc_ft == 11);
Check(115, queue.Size() == 1);
Check(116, queue.PopBatch(popped, MsimeStats::StatsEventQueue::kCapacity) == 1);
Check(117, popped[0].timestamp_utc_ft == 12);
// The drop counter saturates instead of wrapping.
queue.AddDroppedCount(0xFFFFFFFFu);
queue.AddDroppedCount(7);
Check(118, queue.TakeDroppedCount() == 0xFFFFFFFFu);
}
void TestWireContract()
{
Check(201, sizeof(FanyImeStatsBatchHeader) == 28);
Check(202, sizeof(FanyImeStatsEvent) == 24);
Check(203, offsetof(FanyImeStatsEvent, timestamp_utc_ft) == 0);
Check(204, offsetof(FanyImeStatsEvent, cjk) == 8);
Check(205, FANY_IME_STATS_MAGIC == 0x54415453);
Check(206, FANY_IME_STATS_VERSION == 1);
const FanyImeStatsBatchHeader header;
Check(207, header.magic == FANY_IME_STATS_MAGIC && header.version == FANY_IME_STATS_VERSION);
Check(208, header.header_size == 28 && header.payload_bytes == 0 && header.event_count == 0 &&
header.dropped_count == 0 && header.source_process_id == 0);
// A completely full queue has to fit into one frame.
Check(209, sizeof(FanyImeStatsBatchHeader) + MsimeStats::StatsEventQueue::kCapacity * sizeof(FanyImeStatsEvent) <=
FANY_IME_STATS_MAX_FRAME_BYTES);
}
} // namespace
int main()
{
TestWireContract();
TestClassification();
TestPassthroughPredicate();
TestEventQueue();
return gFirstFailure;
}