Files
msime-windows/server/tests/src/test_stats_store.cpp
T
fanlusky 94df9a7917 fix(stats): 修复统计功能审查发现的 8 个问题
- Server 退出时唤醒统计管道监听线程,避免从未有客户端连接时 join 永久阻塞
- DLL 发送批次时对 ERROR_FILE_NOT_FOUND 重试,覆盖服务端重建管道的窗口
- 新增 StatisticsEnabledChanged(26) 推送开关,DLL 侧在采集入口直接短路:
  关闭时不读 range、不分类、不开管道
- stats_meta 的 schema_version 写入正确的键名(此前键与值都是 "1")
- dropped_count 仅在实际发送的批次中消费,空批次不再吞掉丢弃计数
- _CaptureCompositionStats 改为遍历 composition range 的克隆,避免移动宿主的活动区间
- FanyImeStatsEvent 显式声明 reserved 尾部,不再把未初始化的填充字节写入管道
- 透传统计去重标记在抑制一次后即消费,避免同一 tick 内的连击被漏计
2026-09-20 20:34:50 +08:00

729 lines
25 KiB
C++

#include "tests/includes/test_framework.h"
#include "tests/includes/test_utf8_path.h"
#include "statistics/stats_overview.h"
#include "statistics/stats_store.h"
#include <sqlite3.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <filesystem>
#include <memory>
#include <stdexcept>
#include <string>
#include <thread>
#include <vector>
namespace
{
namespace fs = std::filesystem;
using MsimeStats::Batch;
using MsimeStats::DailyRow;
using MsimeStats::DayDelta;
using MsimeStats::HourDelta;
using MsimeStats::HourlyRow;
using MsimeStats::Retention;
using MsimeStats::Store;
// The directory name is deliberately non-ASCII: every store path crosses the
// wide/narrow boundary into SQLite, and the profile directory can contain
// Chinese on a real machine.
class ScopedTempDir
{
public:
ScopedTempDir()
{
root_ = fs::temp_directory_path() / L"msime-统计测试-存储";
std::error_code ec;
fs::remove_all(root_, ec);
fs::create_directories(root_, ec);
REQUIRE(!ec);
}
~ScopedTempDir()
{
std::error_code ec;
fs::remove_all(root_, ec);
}
ScopedTempDir(const ScopedTempDir &) = delete;
ScopedTempDir &operator=(const ScopedTempDir &) = delete;
const fs::path &Root() const
{
return root_;
}
private:
fs::path root_;
};
std::unique_ptr<Store> OpenStore(const ScopedTempDir &temp, fs::path &db_path)
{
db_path = temp.Root() / L"stats.db";
std::string error;
std::unique_ptr<Store> store = Store::Open(db_path, error);
REQUIRE(store != nullptr);
return store;
}
int DayKey(int year, int month, int day)
{
return year * 10000 + month * 100 + day;
}
bool HasDay(const std::vector<DailyRow> &rows, int day_key)
{
return std::any_of(rows.begin(), rows.end(), [day_key](const DailyRow &row) { return row.day_key == day_key; });
}
bool HasHour(const std::vector<HourlyRow> &rows, int day_key, int hour)
{
return std::any_of(rows.begin(), rows.end(),
[day_key, hour](const HourlyRow &row) { return row.day_key == day_key && row.hour == hour; });
}
class RawDb
{
public:
explicit RawDb(const fs::path &path)
{
if (sqlite3_open_v2(test::Utf8(path).c_str(), &db_, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK)
{
if (db_ != nullptr)
{
sqlite3_close(db_);
db_ = nullptr;
}
throw std::runtime_error("Requirement failed: raw sqlite connection");
}
}
~RawDb()
{
if (db_ != nullptr)
{
sqlite3_close(db_);
}
}
RawDb(const RawDb &) = delete;
RawDb &operator=(const RawDb &) = delete;
sqlite3 *get() const
{
return db_;
}
private:
sqlite3 *db_ = nullptr;
};
} // namespace
TEST_CASE(stats_store_upsert_accumulates_and_maintains_first_day)
{
ScopedTempDir temp;
fs::path db_path;
std::unique_ptr<Store> store = OpenStore(temp, db_path);
std::string error;
int first = 0;
bool present = false;
REQUIRE(store->FirstDay(first, present, error));
REQUIRE(!present);
Batch first_batch;
first_batch.days.push_back(DayDelta{20260920, 10, 2, 0, 0, 0, 5000});
first_batch.days.push_back(DayDelta{20260918, 1, 0, 1, 0, 0, 1000});
first_batch.hours.push_back(HourDelta{20260920, 9, 12, 5000});
REQUIRE(store->Upsert(first_batch, error));
Batch second_batch;
second_batch.days.push_back(DayDelta{20260920, 5, 0, 0, 1, 0, 2000});
second_batch.days.push_back(DayDelta{20260901, 0, 0, 0, 0, 3, 300});
second_batch.hours.push_back(HourDelta{20260920, 9, 6, 2000});
second_batch.hours.push_back(HourDelta{20260920, 10, 6, 2000});
REQUIRE(store->Upsert(second_batch, error));
std::vector<DailyRow> daily;
REQUIRE(store->DailyRows(daily, error));
REQUIRE_EQ(daily.size(), 3u);
// Rows come back ascending.
REQUIRE_EQ(daily[0].day_key, 20260901);
REQUIRE_EQ(daily[2].day_key, 20260920);
REQUIRE_EQ(daily[2].cjk, 15);
REQUIRE_EQ(daily[2].latin, 2);
REQUIRE_EQ(daily[2].punct, 1);
REQUIRE_EQ(daily[2].active_ms, 7000);
REQUIRE(store->FirstDay(first, present, error));
REQUIRE(present);
REQUIRE_EQ(first, 20260901);
std::vector<HourlyRow> hourly;
REQUIRE(store->HourlyRows(20260920, hourly, error));
REQUIRE_EQ(hourly.size(), 2u);
REQUIRE_EQ(hourly[0].hour, 9);
REQUIRE_EQ(hourly[0].chars, 18);
REQUIRE_EQ(hourly[1].hour, 10);
REQUIRE_EQ(hourly[1].chars, 6);
}
// SQLite must not invent the parent directory: a broken DataDir has to surface as
// a failed open with a message, so the caller can tell "no statistics yet" (absent
// file) apart from "the data directory is unusable" (absent parent).
TEST_CASE(stats_store_open_without_parent_directory_fails)
{
ScopedTempDir temp;
const fs::path missing = temp.Root() / L"absent" / L"stats.db";
std::string error;
std::unique_ptr<Store> store = Store::Open(missing, error);
REQUIRE(store == nullptr);
REQUIRE(!error.empty());
REQUIRE(!fs::exists(missing));
}
TEST_CASE(stats_store_upsert_empty_batch_is_noop)
{
ScopedTempDir temp;
fs::path db_path;
std::unique_ptr<Store> store = OpenStore(temp, db_path);
std::string error;
REQUIRE(store->Upsert(Batch{}, error));
int first = 0;
bool present = false;
REQUIRE(store->FirstDay(first, present, error));
REQUIRE(!present);
}
TEST_CASE(stats_store_persistence_across_reopen)
{
ScopedTempDir temp;
const fs::path db_path = temp.Root() / L"stats.db";
{
std::string error;
std::unique_ptr<Store> store = Store::Open(db_path, error);
REQUIRE(store != nullptr);
Batch batch;
batch.days.push_back(DayDelta{20260920, 42, 0, 0, 0, 0, 1234});
REQUIRE(store->Upsert(batch, error));
}
std::string error;
std::unique_ptr<Store> reopened = Store::Open(db_path, error);
REQUIRE(reopened != nullptr);
std::vector<DailyRow> daily;
REQUIRE(reopened->DailyRows(daily, error));
REQUIRE_EQ(daily.size(), 1u);
REQUIRE_EQ(daily[0].cjk, 42);
REQUIRE_EQ(daily[0].active_ms, 1234);
std::string value;
bool present = false;
REQUIRE(reopened->Meta(MsimeStats::kStoreMetaSchemaVersion, value, present, error));
REQUIRE(present);
REQUIRE_EQ(value, std::string(MsimeStats::kStoreSchemaVersion));
}
TEST_CASE(stats_store_clear_through_boundaries)
{
const int today = DayKey(2026, 9, 20);
struct Case
{
Retention retention;
int keep_days;
};
const std::vector<Case> cases = {
{Retention::Days30, 30},
{Retention::Days90, 90},
{Retention::Days180, 180},
{Retention::Days365, 365},
};
for (const Case &test_case : cases)
{
ScopedTempDir temp;
fs::path db_path;
std::unique_ptr<Store> store = OpenStore(temp, db_path);
// One row per day across a window wider than any retention, seeded in a
// single batch to keep the test quick.
Batch seed;
seed.days.reserve(400);
seed.hours.reserve(400);
for (int index = 0; index < 400; ++index)
{
const int key = MsimeStats::AddDays(today, -index);
seed.days.push_back(DayDelta{key, 1, 0, 0, 0, 0, 1000});
seed.hours.push_back(HourDelta{key, 12, 1, 1000});
}
std::string error;
REQUIRE(store->Upsert(seed, error));
int cutoff = 0;
REQUIRE(MsimeStats::RetentionCutoff(test_case.retention, today, cutoff));
int64_t removed = 0;
REQUIRE(store->ClearThrough(cutoff, removed, error));
REQUIRE_EQ(removed, static_cast<int64_t>(400 - test_case.keep_days));
const int kept = MsimeStats::AddDays(today, -(test_case.keep_days - 1));
int first = 0;
bool present = false;
REQUIRE(store->FirstDay(first, present, error));
REQUIRE(present);
REQUIRE_EQ(first, kept);
// The boundary day survives, the day before it does not.
std::vector<DailyRow> daily;
REQUIRE(store->DailyRows(daily, error));
REQUIRE(HasDay(daily, kept));
REQUIRE(!HasDay(daily, MsimeStats::AddDays(kept, -1)));
// Hour buckets follow the same boundary.
std::vector<HourlyRow> hourly;
REQUIRE(store->HourlyRows(kept, hourly, error));
REQUIRE(HasHour(hourly, kept, 12));
hourly.clear();
REQUIRE(store->HourlyRows(MsimeStats::AddDays(kept, -1), hourly, error));
REQUIRE(hourly.empty());
}
}
TEST_CASE(stats_store_clear_forever_deletes_nothing)
{
ScopedTempDir temp;
fs::path db_path;
std::unique_ptr<Store> store = OpenStore(temp, db_path);
const int today = DayKey(2026, 9, 20);
Batch seed;
seed.days.reserve(500);
for (int index = 0; index < 500; ++index)
{
seed.days.push_back(DayDelta{MsimeStats::AddDays(today, -index), 1, 0, 0, 0, 0, 0});
}
std::string error;
REQUIRE(store->Upsert(seed, error));
int cutoff = 0;
REQUIRE(!MsimeStats::RetentionCutoff(Retention::Forever, today, cutoff));
std::vector<DailyRow> rows;
REQUIRE(store->DailyRows(rows, error));
REQUIRE_EQ(rows.size(), 500u);
}
TEST_CASE(stats_store_clear_to_empty_resets_first_day)
{
ScopedTempDir temp;
fs::path db_path;
std::unique_ptr<Store> store = OpenStore(temp, db_path);
const int today = DayKey(2026, 9, 20);
Batch seed;
seed.days.push_back(DayDelta{today, 5, 0, 0, 0, 0, 0});
seed.hours.push_back(HourDelta{today, 8, 5, 0});
std::string error;
REQUIRE(store->Upsert(seed, error));
int cutoff = 0;
REQUIRE(MsimeStats::RetentionCutoff(Retention::Days30, today, cutoff));
int64_t removed = 0;
REQUIRE(store->ClearThrough(cutoff, removed, error));
REQUIRE_EQ(removed, 0);
REQUIRE(store->ClearThrough(MsimeStats::AddDays(today, 1), removed, error));
REQUIRE_EQ(removed, 1);
int first = 0;
bool present = false;
REQUIRE(store->FirstDay(first, present, error));
REQUIRE(!present);
std::vector<DailyRow> rows;
REQUIRE(store->DailyRows(rows, error));
REQUIRE(rows.empty());
std::vector<HourlyRow> hourly;
REQUIRE(store->HourlyRows(today, hourly, error));
REQUIRE(hourly.empty());
}
// The privacy contract: the on-disk schema must not be able to store text.
TEST_CASE(stats_store_schema_has_no_text_columns)
{
ScopedTempDir temp;
fs::path db_path;
std::unique_ptr<Store> store = OpenStore(temp, db_path);
RawDb raw(db_path);
sqlite3_stmt *statement = nullptr;
REQUIRE(sqlite3_prepare_v2(raw.get(),
"SELECT name FROM pragma_table_info('stats_daily') "
"UNION ALL SELECT name FROM pragma_table_info('stats_hourly')",
-1, &statement, nullptr) == SQLITE_OK);
const std::vector<std::string> allowed = {"day_key", "hour", "cjk", "latin", "digit",
"punct", "other", "chars", "active_ms"};
int step = SQLITE_OK;
while ((step = sqlite3_step(statement)) == SQLITE_ROW)
{
const char *name = reinterpret_cast<const char *>(sqlite3_column_text(statement, 0));
REQUIRE(name != nullptr);
REQUIRE(std::find(allowed.begin(), allowed.end(), std::string(name)) != allowed.end());
}
REQUIRE_EQ(step, SQLITE_DONE);
sqlite3_finalize(statement);
// WAL is a database property, so a second connection can assert it too.
statement = nullptr;
REQUIRE(sqlite3_prepare_v2(raw.get(), "PRAGMA journal_mode", -1, &statement, nullptr) == SQLITE_OK);
REQUIRE_EQ(sqlite3_step(statement), SQLITE_ROW);
const char *mode = reinterpret_cast<const char *>(sqlite3_column_text(statement, 0));
REQUIRE_EQ(std::string(mode != nullptr ? mode : ""), std::string("wal"));
sqlite3_finalize(statement);
}
// busy_timeout must make a write wait for a foreign writer instead of failing
// immediately; with WAL both processes can otherwise work concurrently.
TEST_CASE(stats_store_waits_for_a_cross_process_writer)
{
ScopedTempDir temp;
fs::path db_path;
std::unique_ptr<Store> store = OpenStore(temp, db_path);
sqlite3 *blocker = nullptr;
REQUIRE(sqlite3_open_v2(test::Utf8(db_path).c_str(), &blocker, SQLITE_OPEN_READWRITE, nullptr) == SQLITE_OK);
REQUIRE(sqlite3_exec(blocker, "BEGIN IMMEDIATE", nullptr, nullptr, nullptr) == SQLITE_OK);
std::atomic<bool> started{false};
bool upsert_ok = false;
std::string upsert_error;
int64_t waited_ms = 0;
std::thread writer([&]() {
started.store(true);
const auto begin = std::chrono::steady_clock::now();
Batch batch;
batch.days.push_back(DayDelta{DayKey(2026, 9, 20), 1, 0, 0, 0, 0, 0});
upsert_ok = store->Upsert(batch, upsert_error);
waited_ms =
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - begin).count();
});
while (!started.load())
{
std::this_thread::yield();
}
std::this_thread::sleep_for(std::chrono::milliseconds(300));
REQUIRE(sqlite3_exec(blocker, "COMMIT", nullptr, nullptr, nullptr) == SQLITE_OK);
writer.join();
REQUIRE(upsert_ok);
REQUIRE(waited_ms >= 200);
sqlite3_close(blocker);
std::vector<DailyRow> rows;
std::string error;
REQUIRE(store->DailyRows(rows, error));
REQUIRE_EQ(rows.size(), 1u);
}
TEST_CASE(stats_store_overview_reads_through_queries)
{
ScopedTempDir temp;
fs::path db_path;
std::unique_ptr<Store> store = OpenStore(temp, db_path);
const int today = DayKey(2026, 9, 20);
Batch batch;
batch.days.push_back(DayDelta{20260919, 10, 0, 0, 0, 0, 60'000});
batch.days.push_back(DayDelta{today, 20, 5, 0, 0, 0, 120'000});
batch.hours.push_back(HourDelta{today, 14, 25, 120'000});
std::string error;
REQUIRE(store->Upsert(batch, error));
std::vector<DailyRow> daily;
std::vector<HourlyRow> hourly;
REQUIRE(store->DailyRows(daily, error));
REQUIRE(store->HourlyRows(today, hourly, error));
const MsimeStats::Overview overview = MsimeStats::ComputeOverview(daily, hourly, today);
REQUIRE(overview.has_data);
REQUIRE_EQ(overview.days, 2);
REQUIRE_EQ(overview.total_chars, 35);
REQUIRE_EQ(overview.today_chars, 25);
REQUIRE_EQ(overview.current_streak, 2);
REQUIRE_EQ(overview.today_hourly[14], 25);
}
TEST_CASE(stats_store_parse_retention)
{
Retention retention = Retention::Forever;
REQUIRE(MsimeStats::ParseRetention("30d", retention));
REQUIRE(retention == Retention::Days30);
REQUIRE(MsimeStats::ParseRetention("90d", retention));
REQUIRE(retention == Retention::Days90);
REQUIRE(MsimeStats::ParseRetention("180d", retention));
REQUIRE(retention == Retention::Days180);
REQUIRE(MsimeStats::ParseRetention("365d", retention));
REQUIRE(retention == Retention::Days365);
REQUIRE(MsimeStats::ParseRetention("forever", retention));
REQUIRE(retention == Retention::Forever);
REQUIRE(!MsimeStats::ParseRetention("2d", retention));
REQUIRE(!MsimeStats::ParseRetention("", retention));
}
// The retention marker is the whole idempotence mechanism: the first call of a
// day runs the policy, every later call that day only reads the marker, and a
// new day runs it again. No timer is involved.
TEST_CASE(stats_store_retention_runs_once_per_local_day)
{
ScopedTempDir temp;
fs::path db_path;
std::unique_ptr<Store> store = OpenStore(temp, db_path);
const int today = DayKey(2026, 9, 20);
Batch seed;
seed.days.reserve(400);
for (int index = 0; index < 400; ++index)
{
seed.days.push_back(DayDelta{MsimeStats::AddDays(today, -index), 1, 0, 0, 0, 0, 1000});
}
std::string error;
REQUIRE(store->Upsert(seed, error));
// Yesterday's marker: today has not run yet.
REQUIRE(
store->SetMeta(MsimeStats::kStoreMetaLastRetentionDay, std::to_string(MsimeStats::AddDays(today, -1)), error));
MsimeStats::RetentionOutcome outcome;
REQUIRE(MsimeStats::RunDailyRetention(*store, Retention::Days30, today, outcome, error));
REQUIRE(!outcome.already_ran_today);
REQUIRE_EQ(outcome.removed_days, static_cast<int64_t>(400 - 30));
std::string marker;
bool present = false;
REQUIRE(store->Meta(MsimeStats::kStoreMetaLastRetentionDay, marker, present, error));
REQUIRE(present);
REQUIRE_EQ(marker, std::to_string(today));
// A second call on the same day must not delete anything - not even a row
// that just arrived with an expired day key.
Batch late;
late.days.push_back(DayDelta{MsimeStats::AddDays(today, -200), 1, 0, 0, 0, 0, 1000});
REQUIRE(store->Upsert(late, error));
REQUIRE(MsimeStats::RunDailyRetention(*store, Retention::Days30, today, outcome, error));
REQUIRE(outcome.already_ran_today);
REQUIRE_EQ(outcome.removed_days, 0);
std::vector<DailyRow> rows;
REQUIRE(store->DailyRows(rows, error));
REQUIRE(HasDay(rows, MsimeStats::AddDays(today, -200)));
// The next local day runs the policy again and clears the late row.
const int tomorrow = MsimeStats::AddDays(today, 1);
REQUIRE(MsimeStats::RunDailyRetention(*store, Retention::Days30, tomorrow, outcome, error));
REQUIRE(!outcome.already_ran_today);
rows.clear();
REQUIRE(store->DailyRows(rows, error));
REQUIRE(!HasDay(rows, MsimeStats::AddDays(today, -200)));
}
// Forever is not "skip the day": it still records the marker, so a later
// switch to a finite window starts cleaning from the next write.
TEST_CASE(stats_store_retention_forever_records_day_without_deleting)
{
ScopedTempDir temp;
fs::path db_path;
std::unique_ptr<Store> store = OpenStore(temp, db_path);
const int today = DayKey(2026, 9, 20);
Batch seed;
seed.days.reserve(500);
for (int index = 0; index < 500; ++index)
{
seed.days.push_back(DayDelta{MsimeStats::AddDays(today, -index), 1, 0, 0, 0, 0, 0});
}
std::string error;
REQUIRE(store->Upsert(seed, error));
MsimeStats::RetentionOutcome outcome;
REQUIRE(MsimeStats::RunDailyRetention(*store, Retention::Forever, today, outcome, error));
REQUIRE(!outcome.already_ran_today);
REQUIRE_EQ(outcome.removed_days, 0);
std::string marker;
bool present = false;
REQUIRE(store->Meta(MsimeStats::kStoreMetaLastRetentionDay, marker, present, error));
REQUIRE(present);
REQUIRE_EQ(marker, std::to_string(today));
std::vector<DailyRow> rows;
REQUIRE(store->DailyRows(rows, error));
REQUIRE_EQ(rows.size(), 500u);
REQUIRE(MsimeStats::RunDailyRetention(*store, Retention::Forever, today, outcome, error));
REQUIRE(outcome.already_ran_today);
REQUIRE_EQ(outcome.removed_days, 0);
}
// The window includes today and the days back to today-(days-1); the first day
// outside it is removed, together with its hour buckets and the first_day key.
TEST_CASE(stats_store_retention_boundary_keeps_today_and_window_start)
{
ScopedTempDir temp;
fs::path db_path;
std::unique_ptr<Store> store = OpenStore(temp, db_path);
const int today = DayKey(2026, 9, 20);
const int window_start = MsimeStats::AddDays(today, -29);
const int expired = MsimeStats::AddDays(today, -30);
Batch seed;
seed.days.push_back(DayDelta{expired, 1, 0, 0, 0, 0, 0});
seed.days.push_back(DayDelta{window_start, 1, 0, 0, 0, 0, 0});
seed.days.push_back(DayDelta{today, 1, 0, 0, 0, 0, 0});
seed.hours.push_back(HourDelta{expired, 9, 1, 0});
seed.hours.push_back(HourDelta{window_start, 9, 1, 0});
std::string error;
REQUIRE(store->Upsert(seed, error));
MsimeStats::RetentionOutcome outcome;
REQUIRE(MsimeStats::RunDailyRetention(*store, Retention::Days30, today, outcome, error));
REQUIRE_EQ(outcome.removed_days, 1);
std::vector<DailyRow> rows;
REQUIRE(store->DailyRows(rows, error));
REQUIRE(HasDay(rows, window_start));
REQUIRE(HasDay(rows, today));
REQUIRE(!HasDay(rows, expired));
std::vector<HourlyRow> hourly;
REQUIRE(store->HourlyRows(window_start, hourly, error));
REQUIRE(HasHour(hourly, window_start, 9));
hourly.clear();
REQUIRE(store->HourlyRows(expired, hourly, error));
REQUIRE(hourly.empty());
int first = 0;
bool present = false;
REQUIRE(store->FirstDay(first, present, error));
REQUIRE(present);
REQUIRE_EQ(first, window_start);
}
// A failed cleanup must not advance the marker, otherwise the stale rows would
// survive until the next calendar day before the retry.
TEST_CASE(stats_store_retention_failure_keeps_marker_stale)
{
ScopedTempDir temp;
fs::path db_path;
std::unique_ptr<Store> store = OpenStore(temp, db_path);
const int today = DayKey(2026, 9, 20);
const int yesterday = MsimeStats::AddDays(today, -1);
Batch seed;
seed.days.push_back(DayDelta{MsimeStats::AddDays(today, -100), 1, 0, 0, 0, 0, 0});
seed.days.push_back(DayDelta{today, 1, 0, 0, 0, 0, 0});
std::string error;
REQUIRE(store->Upsert(seed, error));
REQUIRE(store->SetMeta(MsimeStats::kStoreMetaLastRetentionDay, std::to_string(yesterday), error));
// Break the table the cleanup deletes from: ClearThrough fails and the
// marker must keep yesterday's value, so the next write retries.
{
RawDb raw(db_path);
REQUIRE(sqlite3_exec(raw.get(), "DROP TABLE stats_daily", nullptr, nullptr, nullptr) == SQLITE_OK);
}
MsimeStats::RetentionOutcome outcome;
REQUIRE(!MsimeStats::RunDailyRetention(*store, Retention::Days30, today, outcome, error));
REQUIRE(!error.empty());
std::string marker;
bool present = false;
REQUIRE(store->Meta(MsimeStats::kStoreMetaLastRetentionDay, marker, present, error));
REQUIRE(present);
REQUIRE_EQ(marker, std::to_string(yesterday));
}
// The explicit "clear all statistics" action wipes both tables and both meta
// keys, so the overview falls back to the empty state.
TEST_CASE(stats_store_clear_all_resets_rows_and_meta)
{
ScopedTempDir temp;
fs::path db_path;
std::unique_ptr<Store> store = OpenStore(temp, db_path);
const int today = DayKey(2026, 9, 20);
Batch seed;
seed.days.push_back(DayDelta{MsimeStats::AddDays(today, -2), 1, 0, 0, 0, 0, 0});
seed.days.push_back(DayDelta{MsimeStats::AddDays(today, -1), 1, 0, 0, 0, 0, 0});
seed.days.push_back(DayDelta{today, 1, 0, 0, 0, 0, 0});
seed.hours.push_back(HourDelta{today, 8, 1, 0});
std::string error;
REQUIRE(store->Upsert(seed, error));
REQUIRE(store->SetMeta(MsimeStats::kStoreMetaLastRetentionDay, std::to_string(today), error));
int64_t removed = 0;
REQUIRE(store->ClearAll(removed, error));
REQUIRE_EQ(removed, 3);
std::vector<DailyRow> daily;
REQUIRE(store->DailyRows(daily, error));
REQUIRE(daily.empty());
std::vector<HourlyRow> hourly;
REQUIRE(store->HourlyRows(today, hourly, error));
REQUIRE(hourly.empty());
int first = 0;
bool present = false;
REQUIRE(store->FirstDay(first, present, error));
REQUIRE(!present);
std::string marker;
REQUIRE(store->Meta(MsimeStats::kStoreMetaLastRetentionDay, marker, present, error));
REQUIRE(!present);
// schema_version is written on open and is not user data; it must survive.
REQUIRE(store->Meta(MsimeStats::kStoreMetaSchemaVersion, marker, present, error));
REQUIRE(present);
REQUIRE_EQ(marker, std::string(MsimeStats::kStoreSchemaVersion));
// Clearing an already empty database is a success that removed nothing.
REQUIRE(store->ClearAll(removed, error));
REQUIRE_EQ(removed, 0);
}
// A marker corrupted by a hand edit must not stall the policy forever: a value
// that is not a positive decimal day key is treated as "never ran", so the
// cleanup runs once and rewrites a valid key.
TEST_CASE(stats_store_retention_malformed_marker_self_repairs)
{
ScopedTempDir temp;
fs::path db_path;
std::unique_ptr<Store> store = OpenStore(temp, db_path);
const int today = DayKey(2026, 9, 20);
Batch seed;
seed.days.push_back(DayDelta{MsimeStats::AddDays(today, -100), 1, 0, 0, 0, 0, 0});
seed.days.push_back(DayDelta{today, 1, 0, 0, 0, 0, 0});
std::string error;
REQUIRE(store->Upsert(seed, error));
MsimeStats::RetentionOutcome outcome;
std::string marker;
bool present = false;
for (const char *bad : {"not-a-day", "-5", "20260920x", ""})
{
REQUIRE(store->SetMeta(MsimeStats::kStoreMetaLastRetentionDay, bad, error));
REQUIRE(MsimeStats::RunDailyRetention(*store, Retention::Days30, today, outcome, error));
REQUIRE(!outcome.already_ran_today);
REQUIRE(store->Meta(MsimeStats::kStoreMetaLastRetentionDay, marker, present, error));
REQUIRE(present);
REQUIRE_EQ(marker, std::to_string(today));
}
// The first repair run applied the policy; the day outside the window is gone.
std::vector<DailyRow> rows;
REQUIRE(store->DailyRows(rows, error));
REQUIRE(!HasDay(rows, MsimeStats::AddDays(today, -100)));
REQUIRE(HasDay(rows, today));
}