From c6632e8d8553df4e46f2966442ab8a631e8316ec Mon Sep 17 00:00:00 2001 From: Jia Sui Date: Tue, 22 Sep 2026 08:38:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(engine):=20=E4=BA=94=E7=AC=94=E5=89=8D?= =?UTF-8?q?=E7=BC=80=E9=80=90=E9=94=AE=E6=8F=90=E7=A4=BA=E4=B8=8E=E8=B0=83?= =?UTF-8?q?=E9=A2=91=E6=8E=A5=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 码表查询从精确匹配改为前缀匹配:精确等长行优先(保住一级/二级简码), 前缀行按权重降序跟随,LIMIT 50 截断(引擎层不读 server 配置的分层边界) - WubiCandidateProvider 注入 RuntimePaths:接通 update_weight / delete_by_key 到 user_dictionary journal;新增 bump_wubi_weight(同码组 MAX+1、主库更新 与 journal upsert 同事务,行缺失不造行) - create_word / cache_dynamic_candidate 补「为何 no-op」注释(后续任务范畴) - 两个 toml 的 wubi_schema 注释与实现对齐:当前仅支持 wubi86 - server 测试:前缀语义、LIMIT 截断、上界构造、调频+journal 回放、删词墓碑 --- engine/providers/provider_registry.cpp | 2 +- engine/providers/wubi_candidate_provider.cpp | 62 +++++- engine/providers/wubi_candidate_provider.h | 8 +- .../user_dictionary_journal.cpp | 48 +++++ .../user_dictionary/user_dictionary_journal.h | 6 + installer/default_config/config.default.toml | 2 +- server/assets/config/config.toml | 2 +- server/tests/src/test_wubi.cpp | 200 ++++++++++++++++-- 8 files changed, 301 insertions(+), 29 deletions(-) diff --git a/engine/providers/provider_registry.cpp b/engine/providers/provider_registry.cpp index 53a377bb..3f1f136e 100644 --- a/engine/providers/provider_registry.cpp +++ b/engine/providers/provider_registry.cpp @@ -3,7 +3,7 @@ ProviderRegistry::ProviderRegistry(const ShuangpinProfile &shuangpin_profile, metasequoia::RuntimePaths paths) : pinyin_provider_(shuangpin_profile, paths), - wubi_provider_(metasequoia::path_to_utf8(paths.dictionary(metasequoia::assets::main_dictionary))), + wubi_provider_(metasequoia::path_to_utf8(paths.dictionary(metasequoia::assets::main_dictionary)), paths), japanese_provider_(metasequoia::path_to_utf8(paths.dictionary(metasequoia::assets::main_dictionary)), metasequoia::path_to_utf8(paths.resource(metasequoia::assets::japanese_model))) { diff --git a/engine/providers/wubi_candidate_provider.cpp b/engine/providers/wubi_candidate_provider.cpp index d9e78bf4..3be3c946 100644 --- a/engine/providers/wubi_candidate_provider.cpp +++ b/engine/providers/wubi_candidate_provider.cpp @@ -1,15 +1,24 @@ #include "wubi_candidate_provider.h" +#include "../contracts/assets/assets.h" +#include "../core/data_path.h" #include "../quanpin/quanpin_query.h" +#include "../user_dictionary/user_dictionary_journal.h" #include #include namespace { constexpr int kNoMutation = 0; -} +// 与 QuanpinDictionary::ERROR_CODE 同值:调用方(learn_candidate)丢弃返回值,这里只保持 +// 「成功 0 / 失败非 0」的既有约定。 +constexpr int kMutationFailed = -1; +// 前缀查询截断上限:候选窗每页最多 10 个,50 给固定位置/重排留足余量。1 码前缀在真实 +// 词库上可命中数千行,必须截断;provider 在引擎层,不读 server 配置(分层边界)。 +constexpr int kWubiQueryRowLimit = 50; +} // namespace -WubiCandidateProvider::WubiCandidateProvider(std::string db_path) - : db_path_(db_path.empty() ? quanpin::get_default_db_path() : std::move(db_path)) +WubiCandidateProvider::WubiCandidateProvider(std::string db_path, metasequoia::RuntimePaths paths) + : db_path_(db_path.empty() ? quanpin::get_default_db_path() : std::move(db_path)), paths_(std::move(paths)) { } @@ -28,7 +37,12 @@ std::vector WubiCandidateProvider::query(const QueryRequest &request) sqlite3_reset(query_statement_); sqlite3_clear_bindings(query_statement_); - if (sqlite3_bind_text(query_statement_, 1, request.normalized_input.c_str(), -1, SQLITE_TRANSIENT) != SQLITE_OK) + // 上界 = 前缀末字节 +1:五笔码字母域是 a–y(z 被方案丢弃,规范化后只剩小写字母), + // 末字节自增不会越过域边界,无需进位处理。 + std::string upper_bound = request.normalized_input; + ++upper_bound.back(); + if (sqlite3_bind_text(query_statement_, 1, request.normalized_input.c_str(), -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(query_statement_, 2, upper_bound.c_str(), -1, SQLITE_TRANSIENT) != SQLITE_OK) { return {}; } @@ -61,28 +75,47 @@ void WubiCandidateProvider::reset_cache() int WubiCandidateProvider::create_word(SchemeType, std::string, std::string) { + // 在线造词是后续任务(PRD Non-goals);设置页词典管理已覆盖手工造词。这里保持 no-op, + // 不伪造一条无法通过词组取码规则校验的插入。 return kNoMutation; } -int WubiCandidateProvider::update_weight_by_pinyin_and_word(SchemeType, std::string, std::string) +int WubiCandidateProvider::update_weight_by_pinyin_and_word(SchemeType, std::string code, std::string word) { + // 增量语义对齐全拼侧 build_sql_for_updating_word:新权重 = 同码组当前最高权重 + 1, + // 选中候选升到组内首位;主库更新与 journal upsert 同事务完成,升级回放可重建。 + if (!user_dictionary::bump_wubi_weight(db_path_, journal_db_path(), code, word)) + { + return kMutationFailed; + } + // 权重参与 ORDER BY,丢弃查询连接让下次查询按新权重重新排序。 + reset_cache(); return kNoMutation; } -int WubiCandidateProvider::delete_by_pinyin_and_word(SchemeType, std::string, std::string) +int WubiCandidateProvider::delete_by_pinyin_and_word(SchemeType, std::string code, std::string word) { + // user_dictionary 删词本就按 DictionaryKind 泛化,五笔此前只是没有接线。 + if (!user_dictionary::delete_dictionary_candidate(db_path_, journal_db_path(), + user_dictionary::DictionaryKind::Wubi, code, word)) + { + return kMutationFailed; + } + reset_cache(); return kNoMutation; } int WubiCandidateProvider::cache_dynamic_candidate(SchemeType, const std::string &, const std::string &, CandidateSource) { + // 五笔候选全部来自码表,没有云/AI 动态候选可缓存;no-op 是终态而非缺口。 return kNoMutation; } int WubiCandidateProvider::cache_dynamic_candidate_for_request(const QueryRequest &, const std::string &, CandidateSource) { + // 同 cache_dynamic_candidate:码表型方案没有动态候选来源。 return kNoMutation; } @@ -100,9 +133,15 @@ bool WubiCandidateProvider::ensure_query_statement() return false; } - constexpr const char *query_sql = "SELECT \"key\", \"value\", \"weight\" FROM wubi86 " - "WHERE \"key\" = ?1 ORDER BY \"weight\" DESC, rowid ASC"; - if (sqlite3_prepare_v2(db_, query_sql, -1, &query_statement_, nullptr) != SQLITE_OK) + const std::string query_sql = + "SELECT \"key\", \"value\", \"weight\" FROM wubi86 " + "WHERE \"key\" >= ?1 AND \"key\" < ?2 " + // 精确等长行优先(保住一级/二级简码),其余前缀行按权重降序;rowid 做稳定序,与现状一致。 + // key >= ?1 AND key < ?2 是范围扫描,受益于 key 上任何索引;LIMIT 只在截断处付出排序代价。 + "ORDER BY (\"key\" = ?1) DESC, \"weight\" DESC, \"key\" ASC, rowid ASC " + "LIMIT " + + std::to_string(kWubiQueryRowLimit); + if (sqlite3_prepare_v2(db_, query_sql.c_str(), -1, &query_statement_, nullptr) != SQLITE_OK) { (void)0; close_database(); @@ -111,6 +150,11 @@ bool WubiCandidateProvider::ensure_query_statement() return true; } +std::string WubiCandidateProvider::journal_db_path() const +{ + return metasequoia::path_to_utf8(paths_.user(metasequoia::assets::user_journal)); +} + void WubiCandidateProvider::close_database() { if (query_statement_ != nullptr) diff --git a/engine/providers/wubi_candidate_provider.h b/engine/providers/wubi_candidate_provider.h index e8bb893b..057a4232 100644 --- a/engine/providers/wubi_candidate_provider.h +++ b/engine/providers/wubi_candidate_provider.h @@ -1,13 +1,17 @@ #pragma once #include "candidate_provider.h" +#include "../core/runtime_paths.h" #include #include class WubiCandidateProvider : public ICandidateProvider { public: - explicit WubiCandidateProvider(std::string db_path = {}); + // paths 供调频/删词写 user journal 用;db_path 是测试注入口,生产路径由 ProviderRegistry + // 按 paths 解析后传入,两者指向同一份主库。 + explicit WubiCandidateProvider(std::string db_path = {}, + metasequoia::RuntimePaths paths = metasequoia::RuntimePaths::legacy()); ~WubiCandidateProvider() override; WubiCandidateProvider(const WubiCandidateProvider &) = delete; @@ -30,8 +34,10 @@ class WubiCandidateProvider : public ICandidateProvider private: bool ensure_query_statement(); void close_database(); + std::string journal_db_path() const; std::string db_path_; + metasequoia::RuntimePaths paths_; sqlite3 *db_ = nullptr; sqlite3_stmt *query_statement_ = nullptr; }; diff --git a/engine/user_dictionary/user_dictionary_journal.cpp b/engine/user_dictionary/user_dictionary_journal.cpp index a687aa07..cd0f04e6 100644 --- a/engine/user_dictionary/user_dictionary_journal.cpp +++ b/engine/user_dictionary/user_dictionary_journal.cpp @@ -735,6 +735,54 @@ bool delete_english_candidate(const std::string &english_db_path, const std::str return delete_dictionary_candidate(english_db_path, user_db_path, DictionaryKind::English, entry_key, value); } +bool bump_wubi_weight(const std::string &main_db_path, const std::string &user_db_path, const std::string &key, + const std::string &value) +{ + if (key.empty() || value.empty()) + return false; + // Initialize the journal before attaching it; every operation owns its connections. + if (!ensure_user_database(user_db_path)) + return false; + auto database = open_database(main_db_path, SQLITE_OPEN_READWRITE); + auto attach = database ? prepare(database.get(), "ATTACH DATABASE ?1 AS candidate_journal") : Stmt{}; + if (!attach || !bind_text(attach.get(), 1, user_db_path) || sqlite3_step(attach.get()) != SQLITE_DONE) + return false; + attach.reset(); + if (!execute_sql(database.get(), "BEGIN IMMEDIATE")) + return false; + const auto rollback = [&]() { + (void)execute_sql(database.get(), "ROLLBACK"); + return false; + }; + // 增量语义对齐全拼侧 build_sql_for_updating_word:新权重 = 同码组当前最高权重 + 1, + // 选中候选升到组内首位,而不是固定 +1(同码组有更高权重行时固定 +1 不会改变排序)。 + auto current = prepare(database.get(), "SELECT MAX(weight) FROM main.\"wubi86\" WHERE \"key\"=?1"); + if (!current || !bind_text(current.get(), 1, key) || sqlite3_step(current.get()) != SQLITE_ROW) + return rollback(); + const std::int64_t new_weight = clamp_managed_weight(sqlite3_column_int64(current.get(), 0) + 1); + current.reset(); + auto bump = prepare(database.get(), "UPDATE main.\"wubi86\" SET weight=?1 WHERE \"key\"=?2 AND \"value\"=?3"); + // changes()==0 表示目标行不存在:与 update_wubi_weight 一致,不造行。 + if (!bump || sqlite3_bind_int64(bump.get(), 1, new_weight) != SQLITE_OK || !bind_text(bump.get(), 2, key) || + !bind_text(bump.get(), 3, value) || sqlite3_step(bump.get()) != SQLITE_DONE || + sqlite3_changes(database.get()) == 0) + return rollback(); + bump.reset(); + auto journal = + prepare(database.get(), "INSERT INTO candidate_journal.user_dictionary_operations(dictionary,key,value," + "operation,weight,display) VALUES(?1,?2,?3,'upsert',?4,'')" + " ON CONFLICT(dictionary,key,value) DO UPDATE SET operation='upsert'," + "weight=excluded.weight,display='',updated_at=unixepoch()"); + if (!journal || !bind_text(journal.get(), 1, kind_name(DictionaryKind::Wubi)) || + !bind_text(journal.get(), 2, key) || !bind_text(journal.get(), 3, value) || + sqlite3_bind_int64(journal.get(), 4, new_weight) != SQLITE_OK || sqlite3_step(journal.get()) != SQLITE_DONE) + return rollback(); + journal.reset(); + if (!execute_sql(database.get(), "COMMIT")) + return rollback(); + return true; +} + bool learn_entered_english_word(const std::string &english_db_path, const std::string &user_db_path, const std::string &display, std::int64_t weight) { diff --git a/engine/user_dictionary/user_dictionary_journal.h b/engine/user_dictionary/user_dictionary_journal.h index cfdb624b..94a54526 100644 --- a/engine/user_dictionary/user_dictionary_journal.h +++ b/engine/user_dictionary/user_dictionary_journal.h @@ -32,6 +32,12 @@ bool ensure_user_database(const std::string &user_db_path); bool record_pinyin_upsert_from_database(const std::string &main_db_path, const std::string &key, const std::string &value, const std::string &user_db_path = default_user_db_path()); +// Raise the selected wubi candidate to the top of its own code group (max weight + 1, same +// semantics as the quanpin side) and persist the new weight plus a journal upsert in one +// attached-database transaction. Existing rows only: a missing (key,value) pair returns false +// without inserting. Row-missing behaviour matches update_wubi_weight. +bool bump_wubi_weight(const std::string &main_db_path, const std::string &user_db_path, const std::string &key, + const std::string &value); struct ReplayResult { diff --git a/installer/default_config/config.default.toml b/installer/default_config/config.default.toml index 3c437b65..a5f3f431 100644 --- a/installer/default_config/config.default.toml +++ b/installer/default_config/config.default.toml @@ -194,7 +194,7 @@ shuangpin_preedit_mode = "shuangpin" # 双拼方案,可选:"xiaohe", "ziranma", "shoudao", "microsoft" # shuangpin_schema = "xiaohe" shuangpin_schema = "xiaohe" -# 五笔方案,可选:"wubi86", "wubi98" +# 五笔方案,当前仅支持 "wubi86";98 版待词库发布后另开任务接线 wubi_schema = "wubi86" # 日语方案,当前支持:"romaji" japanese_schema = "romaji" diff --git a/server/assets/config/config.toml b/server/assets/config/config.toml index aafddaa7..0c7392e8 100644 --- a/server/assets/config/config.toml +++ b/server/assets/config/config.toml @@ -194,7 +194,7 @@ shuangpin_preedit_mode = "shuangpin" # 双拼方案,可选:"xiaohe", "ziranma", "shoudao", "microsoft" # shuangpin_schema = "xiaohe" shuangpin_schema = "xiaohe" -# 五笔方案,可选:"wubi86", "wubi98" +# 五笔方案,当前仅支持 "wubi86";98 版待词库发布后另开任务接线 wubi_schema = "wubi86" # 日语方案,当前支持:"romaji" japanese_schema = "romaji" diff --git a/server/tests/src/test_wubi.cpp b/server/tests/src/test_wubi.cpp index 2b0a83b6..1be6a6b3 100644 --- a/server/tests/src/test_wubi.cpp +++ b/server/tests/src/test_wubi.cpp @@ -1,10 +1,14 @@ #include "tests/includes/test_framework.h" #include "tests/includes/test_utf8_path.h" +#include "engine/contracts/assets/assets.h" +#include "engine/core/runtime_paths.h" #include "engine/providers/wubi_candidate_provider.h" #include "engine/schemes/wubi_scheme.h" #include #include #include +#include +#include namespace { @@ -17,6 +21,54 @@ void InputLetters(WubiScheme &scheme, const std::string &keys) } } +// 与 test_user_dictionary_journal.cpp 的同名助手同型:打开临时库跑语句、取标量。 +class TestDatabase +{ + public: + explicit TestDatabase(const std::filesystem::path &path) + { + REQUIRE_EQ(sqlite3_open(test::Utf8(path).c_str(), &db_), SQLITE_OK); + } + ~TestDatabase() + { + if (db_ != nullptr) + sqlite3_close(db_); + } + void exec(const std::string &sql) + { + REQUIRE_EQ(sqlite3_exec(db_, sql.c_str(), nullptr, nullptr, nullptr), SQLITE_OK); + } + std::int64_t scalar_int64(const std::string &sql) + { + sqlite3_stmt *stmt = nullptr; + REQUIRE_EQ(sqlite3_prepare_v2(db_, sql.c_str(), -1, &stmt, nullptr), SQLITE_OK); + REQUIRE_EQ(sqlite3_step(stmt), SQLITE_ROW); + const std::int64_t value = sqlite3_column_int64(stmt, 0); + sqlite3_finalize(stmt); + return value; + } + std::int64_t scalar_int(const std::string &sql) + { + return scalar_int64(sql); + } + + private: + sqlite3 *db_ = nullptr; +}; + +// journal 路径经 RuntimePaths 注入临时目录,避免测试写真实用户目录;目录须先存在, +// 与生产环境一致(RuntimePaths 管理的世代目录总是已建好)。 +metasequoia::RuntimePaths PathsIn(const std::filesystem::path &directory) +{ + metasequoia::RuntimePaths paths; + paths.resources = directory / "resources"; + paths.user_data = directory / "user"; + paths.cache = directory / "cache"; + paths.dictionaries = directory / "resources"; + std::filesystem::create_directories(paths.user_data); + return paths; +} + std::filesystem::path CreateWubiDatabase() { const auto path = std::filesystem::temp_directory_path() / "msime-wubi-provider-test.db"; @@ -31,7 +83,11 @@ std::filesystem::path CreateWubiDatabase() const char *sql = "CREATE TABLE wubi86 (\"key\" TEXT, \"value\" TEXT, \"weight\" INTEGER);" "INSERT INTO wubi86 VALUES ('a', '工', 20);" "INSERT INTO wubi86 VALUES ('a', '戈', 10);" - "INSERT INTO wubi86 VALUES ('aa', '式', 20);"; + "INSERT INTO wubi86 VALUES ('aa', '式', 20);" + // 上界邻居:'y' 前缀查询必须命中 'yh',且排除上界之外的 'z' 行。 + "INSERT INTO wubi86 VALUES ('y', '也', 5);" + "INSERT INTO wubi86 VALUES ('yh', '又', 6);" + "INSERT INTO wubi86 VALUES ('z', '越过上界', 999);"; const int result = sqlite3_exec(db, sql, nullptr, nullptr, nullptr); sqlite3_close(db); if (result != SQLITE_OK) @@ -41,6 +97,16 @@ std::filesystem::path CreateWubiDatabase() } return path; } + +QueryRequest WubiRequest(const std::string &code) +{ + QueryRequest request; + request.scheme = SchemeType::Wubi; + request.raw_input = code; + request.normalized_input = code; + request.valid = true; + return request; +} } // namespace TEST_CASE(WubiSchemeBuildsDirectCodeRequest) @@ -66,27 +132,129 @@ TEST_CASE(WubiSchemeAcceptsAtMostFourCodesAndDoesNotUseZ) REQUIRE_EQ(scheme.build_request().raw_input, std::string("abc")); } -TEST_CASE(WubiProviderUsesExactCodeAndFixedWeightOrder) +TEST_CASE(WubiProviderReturnsPrefixMatchesWithExactCodeFirst) { const auto db_path = CreateWubiDatabase(); { WubiCandidateProvider provider(test::Utf8(db_path)); - QueryRequest request; - request.scheme = SchemeType::Wubi; - request.raw_input = "a"; - request.normalized_input = "a"; - request.valid = true; - const auto candidates = provider.query(request); - REQUIRE_EQ(candidates.size(), static_cast(2)); - REQUIRE_EQ(candidates[0].pinyin, std::string("a")); - REQUIRE_EQ(candidates[0].word, std::string("工")); - REQUIRE_EQ(candidates[0].weight, 20); - REQUIRE_EQ(candidates[1].word, std::string("戈")); + // 1 码:精确等长行在前(按权重),更长前缀行按权重跟随——逐键提示的基础。 + const auto one_code = provider.query(WubiRequest("a")); + REQUIRE_EQ(one_code.size(), static_cast(3)); + REQUIRE_EQ(one_code[0].pinyin, std::string("a")); + REQUIRE_EQ(one_code[0].word, std::string("工")); + REQUIRE_EQ(one_code[0].weight, 20); + REQUIRE_EQ(one_code[1].word, std::string("戈")); + REQUIRE_EQ(one_code[2].pinyin, std::string("aa")); + REQUIRE_EQ(one_code[2].word, std::string("式")); - REQUIRE_EQ(provider.update_weight_by_pinyin_and_word(SchemeType::Wubi, "a", "戈"), 0); - const auto unchanged = provider.query(request); - REQUIRE_EQ(unchanged[0].word, std::string("工")); + // 每一码都是前缀查询:2 码精确命中只剩等长行。 + const auto two_code = provider.query(WubiRequest("aa")); + REQUIRE_EQ(two_code.size(), static_cast(1)); + REQUIRE_EQ(two_code[0].word, std::string("式")); + + // 精确优先压过权重:'y'(5) 排在前缀行 'yh'(6) 之前;上界构造排除 'z' 行。 + const auto upper_edge = provider.query(WubiRequest("y")); + REQUIRE_EQ(upper_edge.size(), static_cast(2)); + REQUIRE_EQ(upper_edge[0].word, std::string("也")); + REQUIRE_EQ(upper_edge[1].word, std::string("又")); } std::filesystem::remove(db_path); } + +TEST_CASE(WubiProviderLimitsPrefixScanToBoundedRows) +{ + const auto directory = + std::filesystem::temp_directory_path() / ("msime-wubi-provider-limit-" + std::to_string(GetCurrentProcessId())); + std::filesystem::create_directories(directory); + const auto db_path = directory / "limit.db"; + { + TestDatabase db(db_path); + std::string sql = "CREATE TABLE wubi86(\"key\" TEXT,\"value\" TEXT,\"weight\" INTEGER);"; + // 55 个精确行 + 5 个前缀行:LIMIT 截断后可见的只能是权重最高的 50 个精确行, + // 前缀行整体排在精确组之后,不应被返回。 + for (int weight = 1; weight <= 55; ++weight) + { + sql += "INSERT INTO wubi86 VALUES('a','字" + std::to_string(weight) + "'," + std::to_string(weight) + ");"; + } + for (int weight = 1; weight <= 5; ++weight) + { + sql += "INSERT INTO wubi86 VALUES('ab','前" + std::to_string(weight) + "'," + std::to_string(weight) + ");"; + } + db.exec(sql); + } + { + WubiCandidateProvider provider(test::Utf8(db_path)); + const auto candidates = provider.query(WubiRequest("a")); + REQUIRE_EQ(candidates.size(), static_cast(50)); + REQUIRE_EQ(candidates[0].weight, 55); + REQUIRE_EQ(candidates[49].weight, 6); + } + std::filesystem::remove_all(directory); +} + +TEST_CASE(WubiProviderLearnsSelectionIntoWeightAndJournal) +{ + const auto directory = + std::filesystem::temp_directory_path() / ("msime-wubi-provider-learn-" + std::to_string(GetCurrentProcessId())); + std::filesystem::create_directories(directory); + const auto db_path = directory / "main.db"; + const auto paths = PathsIn(directory); + { + TestDatabase db(db_path); + db.exec("CREATE TABLE wubi86(\"key\" TEXT,\"value\" TEXT,\"weight\" INTEGER);" + "INSERT INTO wubi86 VALUES('aa','式',20);" + "INSERT INTO wubi86 VALUES('aa','工',10);"); + } + { + WubiCandidateProvider provider(test::Utf8(db_path), paths); + // 选中次位候选:权重升到同码组最高(MAX+1),与全拼 build_sql_for_updating_word 语义一致。 + REQUIRE_EQ(provider.update_weight_by_pinyin_and_word(SchemeType::Wubi, "aa", "工"), 0); + const auto candidates = provider.query(WubiRequest("aa")); + REQUIRE_EQ(candidates.size(), static_cast(2)); + REQUIRE_EQ(candidates[0].word, std::string("工")); + REQUIRE_EQ(candidates[0].weight, 21); + + // 行不存在不造行。 + REQUIRE_EQ(provider.update_weight_by_pinyin_and_word(SchemeType::Wubi, "ab", "不存在"), -1); + } + { + TestDatabase journal(paths.user(metasequoia::assets::user_journal)); + REQUIRE_EQ(journal.scalar_int("SELECT COUNT(*) FROM user_dictionary_operations WHERE dictionary='wubi' " + "AND key='aa' AND value='工' AND operation='upsert' AND weight=21"), + 1); + REQUIRE_EQ(journal.scalar_int("SELECT COUNT(*) FROM user_dictionary_operations WHERE value='不存在'"), 0); + } + std::filesystem::remove_all(directory); +} + +TEST_CASE(WubiProviderDeletesWubiCandidateThroughJournal) +{ + const auto directory = std::filesystem::temp_directory_path() / + ("msime-wubi-provider-delete-" + std::to_string(GetCurrentProcessId())); + std::filesystem::create_directories(directory); + const auto db_path = directory / "main.db"; + const auto paths = PathsIn(directory); + { + TestDatabase db(db_path); + db.exec("CREATE TABLE wubi86(\"key\" TEXT,\"value\" TEXT,\"weight\" INTEGER);" + "INSERT INTO wubi86 VALUES('aa','式',20);" + "INSERT INTO wubi86 VALUES('aa','工',10);"); + } + { + WubiCandidateProvider provider(test::Utf8(db_path), paths); + REQUIRE_EQ(provider.delete_by_pinyin_and_word(SchemeType::Wubi, "aa", "式"), 0); + // 再删已不存在的行:changes()==0,失败且不写墓碑。 + REQUIRE_EQ(provider.delete_by_pinyin_and_word(SchemeType::Wubi, "aa", "式"), -1); + const auto remaining = provider.query(WubiRequest("aa")); + REQUIRE_EQ(remaining.size(), static_cast(1)); + REQUIRE_EQ(remaining[0].word, std::string("工")); + } + { + TestDatabase journal(paths.user(metasequoia::assets::user_journal)); + REQUIRE_EQ(journal.scalar_int("SELECT COUNT(*) FROM user_dictionary_operations WHERE dictionary='wubi' " + "AND key='aa' AND value='式' AND operation='delete'"), + 1); + } + std::filesystem::remove_all(directory); +}