按笔顺拆前两个部件、各取读音首字母(妈 = 女 n + 马 m = nm),部件用加加俗称读音(亻=d、 扌=t、纟=j、忄=s、攵=f、夂=z、刂=l、彳=s);138 个独体/部首字取「本字声母 + 起笔」。码表 7968 条,覆盖《通用规范汉字表》7846 字,比小鹤少 422 个扩展区生僻字——缺码会让含这些字的 词组拿不到辅助码,是已知取舍。 编码对齐拼音加加 5.x 安装包内的 fzm.bin(双拼键位已换算为声母):6487 字取到两码,6349 字 逐字一致,138 字保留本表规则,覆盖外的 800 字按同一规则重建。来源与非官方性质写在 NOTICE.md 与随包的 THIRD_PARTY_NOTICES.txt。 - engine/contracts/assets 注册 helpcode_jiajia,assets.h 重新生成(5 → 6) - 设置页两个下拉显示名「加加」,两份配置模板注释同步 - 两侧测试补支持列表;README / README.en.md / server/AGENTS.md 改「六套」 默认值不变(双拼 lantian、全拼 ziranma)。
1469 lines
68 KiB
C++
1469 lines
68 KiB
C++
#include "tests/includes/test_framework.h"
|
||
#include "tests/includes/test_utf8_path.h"
|
||
#include "engine/common/helpcode_utils.h"
|
||
#include "engine/quanpin/autocorrect_table.h"
|
||
#include "engine/quanpin/quanpin_dictionary.h"
|
||
#include "engine/quanpin/quanpin_query.h"
|
||
#include "engine/quanpin/quanpin_utils.h"
|
||
#include "engine/schemes/quanpin_scheme.h"
|
||
#include <algorithm>
|
||
#include <cstring>
|
||
#include <filesystem>
|
||
#include <sqlite3.h>
|
||
#include <stdexcept>
|
||
#include <unordered_map>
|
||
#include <unordered_set>
|
||
#include <utility>
|
||
#include <vector>
|
||
|
||
namespace
|
||
{
|
||
void InputKey(QuanpinScheme &scheme, UINT vk, WCHAR wch, UINT modifiers_down = 0)
|
||
{
|
||
scheme.handle_key(vk, modifiers_down, wch);
|
||
}
|
||
|
||
std::filesystem::path CreatePinyinCacheDatabase()
|
||
{
|
||
const auto path = std::filesystem::temp_directory_path() / "msime-pinyin-cache-refresh-test.db";
|
||
std::filesystem::remove(path);
|
||
sqlite3 *db = nullptr;
|
||
if (sqlite3_open(test::Utf8(path).c_str(), &db) != SQLITE_OK)
|
||
{
|
||
throw std::runtime_error("Failed to create temporary pinyin database.");
|
||
}
|
||
const char *sql =
|
||
"PRAGMA journal_mode=WAL;"
|
||
"CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"CREATE TABLE tbl_1_a(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"CREATE TABLE tbl_1_x(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"CREATE TABLE tbl_2_a(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"CREATE TABLE tbl_2_x(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"CREATE TABLE tbl_3_a(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"CREATE TABLE tbl_3_x(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"CREATE TABLE tbl_4_x(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"CREATE TABLE tbl_5_x(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"CREATE TABLE tbl_6_x(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_3_a VALUES('ao''shi''ke','ask','奥湿克',1);"
|
||
"INSERT INTO tbl_1_x VALUES('xian','x','__primary_xian_1__',1000);"
|
||
"INSERT INTO tbl_1_x VALUES('xian','x','__primary_xian_2__',900);"
|
||
"INSERT INTO tbl_1_x VALUES('xian','x','__primary_xian_3__',800);"
|
||
// 4..8 只为把备选切分挤出首页:保护位只对首页之外的候选生效,夹具里若只有三条
|
||
// 主候选,__alternative_xi_an__ 自然就在第 4 位,测不到提升这条路径。
|
||
"INSERT INTO tbl_1_x VALUES('xian','x','__primary_xian_4__',700);"
|
||
"INSERT INTO tbl_1_x VALUES('xian','x','__primary_xian_5__',600);"
|
||
"INSERT INTO tbl_1_x VALUES('xian','x','__primary_xian_6__',500);"
|
||
"INSERT INTO tbl_1_x VALUES('xian','x','__primary_xian_7__',400);"
|
||
"INSERT INTO tbl_1_x VALUES('xian','x','__primary_xian_8__',300);"
|
||
"INSERT INTO tbl_2_x VALUES('xi''an','xa','__alternative_xi_an__',100);"
|
||
"INSERT INTO tbl_4_x VALUES('xi''an''xian''xian','xaxx','__three_syllable_alternative__',100);"
|
||
"INSERT INTO tbl_5_x VALUES('xi''an''xian''xian''xian','xaxxx','__four_syllable_alternative__',100);"
|
||
"INSERT INTO tbl_6_x VALUES('xi''an''xian''xian''xian''xian','xaxxxx','__five_syllable_alternative__',100);";
|
||
const int result = sqlite3_exec(db, sql, nullptr, nullptr, nullptr);
|
||
sqlite3_close(db);
|
||
if (result != SQLITE_OK)
|
||
{
|
||
std::filesystem::remove(path);
|
||
throw std::runtime_error("Failed to initialize temporary pinyin database.");
|
||
}
|
||
return path;
|
||
}
|
||
} // namespace
|
||
|
||
TEST_CASE(QuanpinSchemeSpaceDoesNotResetComposition)
|
||
{
|
||
QuanpinScheme scheme;
|
||
InputKey(scheme, 'N', L'n');
|
||
InputKey(scheme, 'I', L'i');
|
||
InputKey(scheme, VK_SPACE, L' ');
|
||
|
||
const QueryRequest request = scheme.build_request();
|
||
REQUIRE(request.valid);
|
||
REQUIRE_EQ(request.raw_input, std::string("ni"));
|
||
REQUIRE_EQ(request.normalized_input, std::string("ni"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinSchemeApostropheIsPreservedInRawInput)
|
||
{
|
||
QuanpinScheme scheme;
|
||
InputKey(scheme, 'X', L'x');
|
||
InputKey(scheme, VK_OEM_7, L'\'');
|
||
InputKey(scheme, 'I', L'i');
|
||
|
||
const QueryRequest request = scheme.build_request();
|
||
REQUIRE(request.valid);
|
||
REQUIRE_EQ(request.raw_input, std::string("x'i"));
|
||
REQUIRE_EQ(request.normalized_input, std::string("xi"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinSchemeTrailingApostropheIsPreservedInPreeditSegmentation)
|
||
{
|
||
QuanpinScheme scheme;
|
||
InputKey(scheme, 'F', L'f');
|
||
InputKey(scheme, 'A', L'a');
|
||
InputKey(scheme, 'N', L'n');
|
||
InputKey(scheme, 'G', L'g');
|
||
InputKey(scheme, VK_OEM_7, L'\'');
|
||
InputKey(scheme, VK_OEM_7, L'\'');
|
||
InputKey(scheme, VK_OEM_7, L'\'');
|
||
|
||
const QueryRequest request = scheme.build_request();
|
||
REQUIRE(request.valid);
|
||
REQUIRE_EQ(request.raw_input, std::string("fang'"));
|
||
REQUIRE_EQ(request.normalized_segmentation, std::string("fang"));
|
||
REQUIRE_EQ(request.raw_segmentation, std::string("fang'"));
|
||
REQUIRE_EQ(request.key_strokes.size(), static_cast<size_t>(5));
|
||
}
|
||
|
||
TEST_CASE(HelpcodeSchemaSelectionLoadsAllSupportedSchemas)
|
||
{
|
||
const std::vector<std::pair<std::string, std::string>> schemas{
|
||
{"lantian", "(KK)"}, {"ziranma", "(KA)"}, {"shouyou2_0", "(KV)"},
|
||
{"shouyouplus", "(KE)"}, {"xiaohe", "(KK)"}, {"jiajia", "(KA)"},
|
||
};
|
||
|
||
for (const auto &[schema, expected] : schemas)
|
||
{
|
||
REQUIRE(HelpcodeUtils::is_supported_helpcode_schema(schema));
|
||
REQUIRE(HelpcodeUtils::select_helpcode_schema(schema));
|
||
REQUIRE_EQ(HelpcodeUtils::compute_helpcodes("啊", true), expected);
|
||
}
|
||
|
||
REQUIRE(!HelpcodeUtils::is_supported_helpcode_schema("unknown"));
|
||
REQUIRE(!HelpcodeUtils::select_helpcode_schema("unknown"));
|
||
REQUIRE(HelpcodeUtils::select_helpcode_schema("lantian"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinSchemeResegmentsEachManualApostrophePart)
|
||
{
|
||
QuanpinScheme scheme;
|
||
InputKey(scheme, 'X', L'x');
|
||
InputKey(scheme, 'S', L's');
|
||
InputKey(scheme, VK_OEM_7, L'\'');
|
||
InputKey(scheme, 'H', L'h');
|
||
|
||
const QueryRequest request = scheme.build_request();
|
||
REQUIRE(request.valid);
|
||
REQUIRE_EQ(request.normalized_segmentation, std::string("x's'h"));
|
||
REQUIRE_EQ(request.raw_segmentation, std::string("x's'h"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinCandidateCacheKeepsManualSegmentationBoundariesDistinct)
|
||
{
|
||
QuanpinDictionary dictionary;
|
||
const std::string automatic_only_candidate = "__automatic_fan_gan__";
|
||
REQUIRE_EQ(
|
||
dictionary.insert_word_to_series_cache("fangan", automatic_only_candidate, CandidateSource::CloudSuggestion),
|
||
QuanpinDictionary::OK);
|
||
|
||
const auto automatic_candidates = dictionary.query("fangan", "fan'gan");
|
||
REQUIRE(std::any_of(automatic_candidates.begin(), automatic_candidates.end(),
|
||
[&](const WordItem &item) { return item.word == automatic_only_candidate; }));
|
||
|
||
const auto manual_candidates = dictionary.query("fang'an", "fang'an");
|
||
REQUIRE(std::none_of(manual_candidates.begin(), manual_candidates.end(),
|
||
[&](const WordItem &item) { return item.word == automatic_only_candidate; }));
|
||
|
||
const auto same_segmentation_manual_candidates = dictionary.query("fan'gan", "fan'gan");
|
||
REQUIRE(std::none_of(same_segmentation_manual_candidates.begin(), same_segmentation_manual_candidates.end(),
|
||
[&](const WordItem &item) { return item.word == automatic_only_candidate; }));
|
||
}
|
||
|
||
TEST_CASE(QuanpinSyllableGraphKeepsEveryCompleteSegmentation)
|
||
{
|
||
REQUIRE(quanpin::has_only_complete_pinyin_segments(quanpin::Segments{"xi", "an"}));
|
||
REQUIRE(!quanpin::has_only_complete_pinyin_segments(quanpin::Segments{"x", "ian"}));
|
||
|
||
const auto graph = quanpin::build_syllable_graph("xian");
|
||
const auto segmentations = quanpin::enumerate_complete_segmentations(graph);
|
||
REQUIRE_EQ(segmentations.size(), static_cast<size_t>(2));
|
||
REQUIRE(std::find(segmentations.begin(), segmentations.end(), quanpin::Segments{"xian"}) != segmentations.end());
|
||
REQUIRE(std::find(segmentations.begin(), segmentations.end(), quanpin::Segments{"xi", "an"}) !=
|
||
segmentations.end());
|
||
}
|
||
|
||
TEST_CASE(QuanpinDictionaryUsesSyllableGraphAlternativeSegmentations)
|
||
{
|
||
QuanpinDictionary dictionary;
|
||
|
||
const auto fangan_candidates = dictionary.query("fangan", "fan'gan");
|
||
const auto fangan_solution = std::find_if(fangan_candidates.begin(), fangan_candidates.end(),
|
||
[](const WordItem &item) { return item.word == "方案"; });
|
||
REQUIRE(fangan_solution != fangan_candidates.end());
|
||
REQUIRE_EQ(fangan_solution->canonical_pinyin, std::string("fang'an"));
|
||
REQUIRE_EQ(fangan_candidates.front().word, std::string("方案"));
|
||
|
||
const auto qinai_candidates = dictionary.query("qinai", "qi'nai");
|
||
const auto qinai_solution = std::find_if(qinai_candidates.begin(), qinai_candidates.end(),
|
||
[](const WordItem &item) { return item.word == "亲爱"; });
|
||
REQUIRE(qinai_solution != qinai_candidates.end());
|
||
REQUIRE_EQ(qinai_solution->canonical_pinyin, std::string("qin'ai"));
|
||
REQUIRE_EQ(qinai_candidates.front().word, std::string("亲爱"));
|
||
|
||
const auto xian_candidates = dictionary.query("xian", "xian");
|
||
const auto xian_solution = std::find_if(xian_candidates.begin(), xian_candidates.end(),
|
||
[](const WordItem &item) { return item.word == "西安"; });
|
||
REQUIRE(xian_solution != xian_candidates.end());
|
||
REQUIRE_EQ(xian_solution->canonical_pinyin, std::string("xi'an"));
|
||
REQUIRE(static_cast<size_t>(std::distance(xian_candidates.begin(), xian_solution)) <= static_cast<size_t>(1));
|
||
}
|
||
|
||
TEST_CASE(QuanpinDictionaryRequiresCompletePrimarySegmentsBeforeTryingAlternatives)
|
||
{
|
||
QuanpinDictionary dictionary;
|
||
const auto candidates = dictionary.query("xian", "x'ian");
|
||
REQUIRE(
|
||
std::none_of(candidates.begin(), candidates.end(), [](const WordItem &item) { return item.word == "西安"; }));
|
||
}
|
||
|
||
// The alternative must stay within two orders of magnitude of the primary top (1000) for the
|
||
// protected slot; alternatives dwarfed by it are rejected (pinned in engine test_fuzzy_pinyin).
|
||
TEST_CASE(QuanpinDictionaryKeepsPlausibleAlternativeSegmentationNearTheFront)
|
||
{
|
||
const auto db_path = CreatePinyinCacheDatabase();
|
||
{
|
||
QuanpinDictionary dictionary(test::Utf8(db_path));
|
||
const auto candidates = dictionary.query("xian", "xian");
|
||
const auto alternative = std::find_if(candidates.begin(), candidates.end(), [](const WordItem &item) {
|
||
return item.word == "__alternative_xi_an__";
|
||
});
|
||
REQUIRE(alternative != candidates.end());
|
||
REQUIRE(static_cast<size_t>(std::distance(candidates.begin(), alternative)) <= static_cast<size_t>(1));
|
||
}
|
||
std::filesystem::remove(db_path);
|
||
}
|
||
|
||
// 保护位只把首页之外的备选切分拉进来,不给已经在首页的候选重排座次。调频写的就是权重,
|
||
// 一个被用户调到第 5 位的备选切分(吉安)若再被钉回第 2 位,用户就再也调不动它了。
|
||
TEST_CASE(QuanpinDictionaryLeavesAnAlternativeSegmentationAtItsEarnedRankOnTheFirstPage)
|
||
{
|
||
const auto db_path = CreatePinyinCacheDatabase();
|
||
{
|
||
sqlite3 *db = nullptr;
|
||
REQUIRE_EQ(sqlite3_open(test::Utf8(db_path).c_str(), &db), SQLITE_OK);
|
||
// 权重 650 让它落在 700 和 600 之间,也就是自然第 5 位,仍然满足 1/100 的提升门槛。
|
||
REQUIRE_EQ(sqlite3_exec(db, "UPDATE tbl_2_x SET weight=650 WHERE value='__alternative_xi_an__';", nullptr,
|
||
nullptr, nullptr),
|
||
SQLITE_OK);
|
||
sqlite3_close(db);
|
||
|
||
QuanpinDictionary dictionary(test::Utf8(db_path));
|
||
const auto candidates = dictionary.query("xian", "xian");
|
||
const auto alternative = std::find_if(candidates.begin(), candidates.end(), [](const WordItem &item) {
|
||
return item.word == "__alternative_xi_an__";
|
||
});
|
||
REQUIRE(alternative != candidates.end());
|
||
REQUIRE_EQ(static_cast<size_t>(std::distance(candidates.begin(), alternative)), static_cast<size_t>(4));
|
||
}
|
||
std::filesystem::remove(db_path);
|
||
}
|
||
|
||
TEST_CASE(QuanpinDictionaryTriesFourSyllablesButBoundsFiveSyllableAmbiguity)
|
||
{
|
||
const auto db_path = CreatePinyinCacheDatabase();
|
||
{
|
||
QuanpinDictionary dictionary(test::Utf8(db_path));
|
||
|
||
const auto three_syllable_candidates = dictionary.query("xianxianxian", "xian'xian'xian");
|
||
REQUIRE(std::any_of(three_syllable_candidates.begin(), three_syllable_candidates.end(),
|
||
[](const WordItem &item) { return item.word == "__three_syllable_alternative__"; }));
|
||
|
||
const auto four_syllable_candidates = dictionary.query("xianxianxianxian", "xian'xian'xian'xian");
|
||
REQUIRE(std::any_of(four_syllable_candidates.begin(), four_syllable_candidates.end(),
|
||
[](const WordItem &item) { return item.word == "__four_syllable_alternative__"; }));
|
||
|
||
const auto five_syllable_candidates = dictionary.query("xianxianxianxianxian", "xian'xian'xian'xian'xian");
|
||
REQUIRE(std::none_of(five_syllable_candidates.begin(), five_syllable_candidates.end(),
|
||
[](const WordItem &item) { return item.word == "__five_syllable_alternative__"; }));
|
||
}
|
||
std::filesystem::remove(db_path);
|
||
}
|
||
|
||
TEST_CASE(QuanpinCandidateCacheDetectsExternalDictionaryWrites)
|
||
{
|
||
const auto db_path = CreatePinyinCacheDatabase();
|
||
{
|
||
QuanpinDictionary dictionary(test::Utf8(db_path));
|
||
const auto before = dictionary.query("aoshike", "ao'shi'ke");
|
||
REQUIRE(std::none_of(before.begin(), before.end(), [](const WordItem &item) { return item.word == "澳鳾科"; }));
|
||
|
||
sqlite3 *writer = nullptr;
|
||
REQUIRE_EQ(sqlite3_open(test::Utf8(db_path).c_str(), &writer), SQLITE_OK);
|
||
REQUIRE_EQ(sqlite3_exec(writer, "INSERT INTO tbl_3_a VALUES('ao''shi''ke','ask','澳鳾科',1)", nullptr, nullptr,
|
||
nullptr),
|
||
SQLITE_OK);
|
||
sqlite3_close(writer);
|
||
|
||
const auto after = dictionary.query("aoshike", "ao'shi'ke");
|
||
REQUIRE(std::any_of(after.begin(), after.end(), [](const WordItem &item) { return item.word == "澳鳾科"; }));
|
||
}
|
||
std::filesystem::remove(db_path);
|
||
}
|
||
|
||
TEST_CASE(QuanpinSchemePreservesUppercaseRawInputForHelpcodes)
|
||
{
|
||
QuanpinScheme scheme;
|
||
InputKey(scheme, 'X', L'x');
|
||
InputKey(scheme, 'I', L'i');
|
||
InputKey(scheme, 'T', L't');
|
||
InputKey(scheme, 'E', L'e');
|
||
InputKey(scheme, 'L', L'l');
|
||
InputKey(scheme, 'E', L'e');
|
||
InputKey(scheme, 'A', L'A', 1);
|
||
InputKey(scheme, 'A', L'A', 1);
|
||
|
||
const QueryRequest request = scheme.build_request();
|
||
REQUIRE(request.valid);
|
||
REQUIRE_EQ(request.raw_input_with_cases, std::string("xiteleAA"));
|
||
REQUIRE_EQ(request.raw_input, std::string("xiteleaa"));
|
||
REQUIRE_EQ(request.normalized_input, std::string("xitele"));
|
||
REQUIRE_EQ(request.raw_segmentation, std::string("xi'te'le'AA"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinDoubleHelpModeRecognizesTrailingUppercaseLetters)
|
||
{
|
||
REQUIRE(HelpcodeUtils::is_quanpin_double_help_mode("xiteleAA"));
|
||
REQUIRE(!HelpcodeUtils::is_quanpin_double_help_mode("xiteleaA"));
|
||
REQUIRE(!HelpcodeUtils::is_quanpin_double_help_mode("xiteleaa"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinSchemePreservesUppercaseRawInputForSingleHelpcode)
|
||
{
|
||
QuanpinScheme scheme;
|
||
InputKey(scheme, 'X', L'x');
|
||
InputKey(scheme, 'I', L'i');
|
||
InputKey(scheme, 'T', L't');
|
||
InputKey(scheme, 'E', L'e');
|
||
InputKey(scheme, 'L', L'l');
|
||
InputKey(scheme, 'E', L'e');
|
||
InputKey(scheme, 'A', L'A', 1);
|
||
|
||
const QueryRequest request = scheme.build_request();
|
||
REQUIRE(request.valid);
|
||
REQUIRE_EQ(request.raw_input_with_cases, std::string("xiteleA"));
|
||
REQUIRE_EQ(request.raw_input, std::string("xitelea"));
|
||
REQUIRE_EQ(request.normalized_input, std::string("xitele"));
|
||
REQUIRE_EQ(request.raw_segmentation, std::string("xi'te'le'A"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinSingleHelpModeRecognizesTrailingUppercaseLetter)
|
||
{
|
||
REQUIRE(HelpcodeUtils::is_quanpin_single_help_mode("xiteleA"));
|
||
REQUIRE(!HelpcodeUtils::is_quanpin_single_help_mode("xiteleAA"));
|
||
REQUIRE(!HelpcodeUtils::is_quanpin_single_help_mode("xitelea"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinHelpcodeRequiresCompleteBasePinyin)
|
||
{
|
||
QuanpinScheme scheme;
|
||
InputKey(scheme, 'X', L'x');
|
||
InputKey(scheme, 'I', L'i');
|
||
InputKey(scheme, 'T', L't');
|
||
InputKey(scheme, 'E', L'e');
|
||
InputKey(scheme, 'L', L'l');
|
||
InputKey(scheme, 'A', L'A', 1);
|
||
|
||
const QueryRequest request = scheme.build_request();
|
||
REQUIRE(request.valid);
|
||
REQUIRE_EQ(request.raw_input_with_cases, std::string("xitelA"));
|
||
REQUIRE_EQ(request.raw_input, std::string("xitela"));
|
||
REQUIRE_EQ(request.normalized_input, std::string("xitela"));
|
||
REQUIRE_EQ(request.raw_segmentation, std::string("xi'te'lA"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinCompletePinyinInputDetectionMatchesHelpcodesRequirement)
|
||
{
|
||
REQUIRE(quanpin::is_complete_pinyin_input("xitele"));
|
||
REQUIRE(quanpin::is_complete_pinyin_input("xi'te'le"));
|
||
REQUIRE(!quanpin::is_complete_pinyin_input("xitel"));
|
||
REQUIRE(!quanpin::is_complete_pinyin_input("xi'tel"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinHelpcodeDetectionUsesSharedUtilsRules)
|
||
{
|
||
REQUIRE_EQ(quanpin::detect_active_helpcode_length("xitelea", "xiteleA"), static_cast<size_t>(1));
|
||
REQUIRE_EQ(quanpin::detect_active_helpcode_length("xiteleaa", "xiteleAA"), static_cast<size_t>(2));
|
||
REQUIRE_EQ(quanpin::detect_active_helpcode_length("xitelr", "xitelR"), static_cast<size_t>(0));
|
||
REQUIRE_EQ(quanpin::strip_active_helpcodes("xitelea", "xiteleA"), std::string("xitele"));
|
||
REQUIRE_EQ(quanpin::strip_active_helpcodes("xiteleaa", "xiteleAA"), std::string("xitele"));
|
||
REQUIRE_EQ(quanpin::strip_active_helpcodes("xitelr", "xitelR"), std::string("xitelr"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinCorrectionPrefersFewerSegments)
|
||
{
|
||
const auto cuts = quanpin::cut_pinyin_by_mode("keneng", "correction");
|
||
REQUIRE(!cuts.empty());
|
||
REQUIRE_EQ(quanpin::join_segments(cuts.front()), std::string("ke'neng"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinCorrectionNormalizesTransposedPinyinLetters)
|
||
{
|
||
const std::vector<std::pair<std::string, std::string>> cases = {
|
||
{"laing", "liang"}, {"haung", "huang"}, {"bain", "bian"}, {"haun", "huan"}, {"laio", "liao"},
|
||
{"ahng", "hang"}, {"cehng", "cheng"}, {"zehng", "zheng"}, {"zhehng", "zheng"}, {"mihng", "ming"},
|
||
{"agn", "ang"}, {"bagn", "bang"}, {"chagn", "chang"}, {"zagn", "zang"}, {"egn", "eng"},
|
||
{"begn", "beng"}, {"chegn", "cheng"}, {"zhegn", "zheng"}, {"jv", "ju"},
|
||
};
|
||
|
||
for (const auto &[typed, expected] : cases)
|
||
{
|
||
const auto cuts = quanpin::cut_pinyin_by_mode(typed, "correction");
|
||
REQUIRE(!cuts.empty());
|
||
const auto actual = quanpin::join_segments(cuts.front());
|
||
if (actual != expected)
|
||
{
|
||
throw std::runtime_error("Expected '" + typed + "' to normalize to '" + expected + "', got '" + actual +
|
||
"'.");
|
||
}
|
||
}
|
||
}
|
||
|
||
TEST_CASE(QuanpinCorrectionHandlesContinuousAndManuallySegmentedInput)
|
||
{
|
||
const auto continuous = quanpin::cut_pinyin_by_mode("woxainxin", "correction");
|
||
REQUIRE(!continuous.empty());
|
||
REQUIRE_EQ(quanpin::join_segments(continuous.front()), std::string("wo'xian'xin"));
|
||
|
||
const auto manual = quanpin::cut_pinyin_by_mode("wo'xain'xin", "correction");
|
||
REQUIRE(!manual.empty());
|
||
REQUIRE_EQ(quanpin::join_segments(manual.front()), std::string("wo'xian'xin"));
|
||
|
||
const auto continuous_jv = quanpin::cut_pinyin_by_mode("wojv", "correction");
|
||
REQUIRE(!continuous_jv.empty());
|
||
REQUIRE_EQ(quanpin::join_segments(continuous_jv.front()), std::string("wo'ju"));
|
||
|
||
const auto manual_jv = quanpin::cut_pinyin_by_mode("wo'jv", "correction");
|
||
REQUIRE(!manual_jv.empty());
|
||
REQUIRE_EQ(quanpin::join_segments(manual_jv.front()), std::string("wo'ju"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinCorrectionAppliesSuffixRulesToEveryValidPinyinPrefix)
|
||
{
|
||
const std::vector<std::pair<std::string, std::string>> cases = {
|
||
{"daun", "duan"}, {"jaun", "juan"}, {"laun", "luan"}, {"naun", "nuan"}, {"qaun", "quan"}, {"raun", "ruan"},
|
||
{"saun", "suan"}, {"taun", "tuan"}, {"xaun", "xuan"}, {"yaun", "yuan"}, {"zaun", "zuan"},
|
||
};
|
||
|
||
for (const auto &[typed, expected] : cases)
|
||
{
|
||
const auto cuts = quanpin::cut_pinyin_by_mode(typed, "correction");
|
||
REQUIRE(!cuts.empty());
|
||
REQUIRE_EQ(quanpin::join_segments(cuts.front()), expected);
|
||
}
|
||
}
|
||
|
||
TEST_CASE(QuanpinCorrectionRetainsAmbiguousEhngInterpretations)
|
||
{
|
||
const auto cuts = quanpin::cut_pinyin_by_mode("cehng", "correction");
|
||
REQUIRE_EQ(cuts.size(), static_cast<size_t>(2));
|
||
REQUIRE_EQ(quanpin::join_segments(cuts[0]), std::string("cheng"));
|
||
REQUIRE_EQ(quanpin::join_segments(cuts[1]), std::string("ceng"));
|
||
|
||
const auto bare = quanpin::cut_pinyin_by_mode("ehng", "correction");
|
||
REQUIRE_EQ(bare.size(), static_cast<size_t>(1));
|
||
REQUIRE_EQ(quanpin::join_segments(bare[0]), std::string("heng"));
|
||
|
||
const auto extra_h = quanpin::cut_pinyin_by_mode("behng", "correction");
|
||
REQUIRE(!extra_h.empty());
|
||
REQUIRE_EQ(quanpin::join_segments(extra_h.front()), std::string("beng"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinCorrectionRetainsAmbiguousAhngInterpretations)
|
||
{
|
||
const auto cuts = quanpin::cut_pinyin_by_mode("ahng", "correction");
|
||
REQUIRE_EQ(cuts.size(), static_cast<size_t>(2));
|
||
REQUIRE_EQ(quanpin::join_segments(cuts[0]), std::string("hang"));
|
||
REQUIRE_EQ(quanpin::join_segments(cuts[1]), std::string("ang"));
|
||
|
||
const auto prefixed = quanpin::cut_pinyin_by_mode("zahng", "correction");
|
||
REQUIRE_EQ(prefixed.size(), static_cast<size_t>(2));
|
||
REQUIRE_EQ(quanpin::join_segments(prefixed[0]), std::string("zhang"));
|
||
REQUIRE_EQ(quanpin::join_segments(prefixed[1]), std::string("zang"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinCorrectionCarriesAmbiguousPathsThroughContinuousInput)
|
||
{
|
||
const auto cuts = quanpin::cut_pinyin_by_mode("wocehng", "correction");
|
||
REQUIRE_EQ(cuts.size(), static_cast<size_t>(2));
|
||
REQUIRE_EQ(quanpin::join_segments(cuts[0]), std::string("wo'cheng"));
|
||
REQUIRE_EQ(quanpin::join_segments(cuts[1]), std::string("wo'ceng"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinGreedyModeDoesNotApplyLetterCorrections)
|
||
{
|
||
const auto cuts = quanpin::cut_pinyin_by_mode("haun", "greedy");
|
||
REQUIRE(cuts.empty());
|
||
}
|
||
|
||
TEST_CASE(QuanpinPreeditPreservesTypedCaseEvenWhenHelpcodesDoNotApply)
|
||
{
|
||
QuanpinScheme scheme;
|
||
InputKey(scheme, 'X', L'x');
|
||
InputKey(scheme, 'I', L'i');
|
||
InputKey(scheme, 'T', L't');
|
||
InputKey(scheme, 'E', L'e');
|
||
InputKey(scheme, 'L', L'l');
|
||
InputKey(scheme, 'R', L'R', 1);
|
||
|
||
const QueryRequest request = scheme.build_request();
|
||
REQUIRE(request.valid);
|
||
REQUIRE_EQ(request.raw_input_with_cases, std::string("xitelR"));
|
||
REQUIRE_EQ(request.raw_input, std::string("xitelr"));
|
||
REQUIRE_EQ(request.raw_segmentation, std::string("xi'te'l'R"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinSparsePinyinFallbackSegmentsAreGenerated)
|
||
{
|
||
{
|
||
const auto fallbacks = quanpin::sparse_pinyin_fallback_segments(quanpin::Segments{"dia"});
|
||
REQUIRE_EQ(fallbacks.size(), static_cast<size_t>(2));
|
||
REQUIRE_EQ(quanpin::join_segments(fallbacks[0]), std::string("di'a"));
|
||
REQUIRE_EQ(quanpin::join_segments(fallbacks[1]), std::string("di"));
|
||
}
|
||
|
||
{
|
||
const auto fallbacks = quanpin::sparse_pinyin_fallback_segments(quanpin::Segments{"biang"});
|
||
REQUIRE_EQ(fallbacks.size(), static_cast<size_t>(2));
|
||
REQUIRE_EQ(quanpin::join_segments(fallbacks[0]), std::string("bi'ang"));
|
||
REQUIRE_EQ(quanpin::join_segments(fallbacks[1]), std::string("bi"));
|
||
}
|
||
|
||
{
|
||
const auto fallbacks = quanpin::sparse_pinyin_fallback_segments(quanpin::Segments{"gei"});
|
||
REQUIRE_EQ(fallbacks.size(), static_cast<size_t>(1));
|
||
REQUIRE_EQ(quanpin::join_segments(fallbacks[0]), std::string("ge"));
|
||
}
|
||
|
||
{
|
||
const auto fallbacks = quanpin::sparse_pinyin_fallback_segments(quanpin::Segments{"yo"});
|
||
REQUIRE_EQ(fallbacks.size(), static_cast<size_t>(1));
|
||
REQUIRE_EQ(quanpin::join_segments(fallbacks[0]), std::string("y"));
|
||
}
|
||
}
|
||
|
||
TEST_CASE(QuanpinSparsePinyinFallbackSegmentsPreserveSuffixSegments)
|
||
{
|
||
const auto fallbacks = quanpin::sparse_pinyin_fallback_segments(quanpin::Segments{"gei", "wo"});
|
||
REQUIRE_EQ(fallbacks.size(), static_cast<size_t>(1));
|
||
REQUIRE_EQ(quanpin::join_segments(fallbacks[0]), std::string("ge"));
|
||
|
||
const auto dia_fallbacks = quanpin::sparse_pinyin_fallback_segments(quanpin::Segments{"dia", "wo"});
|
||
REQUIRE_EQ(dia_fallbacks.size(), static_cast<size_t>(2));
|
||
REQUIRE_EQ(quanpin::join_segments(dia_fallbacks[0]), std::string("di'a'wo"));
|
||
REQUIRE_EQ(quanpin::join_segments(dia_fallbacks[1]), std::string("di"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinAutocorrectTableHasNoCollisionsWithLegalPinyin)
|
||
{
|
||
const auto &legal = quanpin::intact_pinyin_set();
|
||
// Ambiguity is kept: one wrong key may map to several syllables and the
|
||
// runtime disambiguates with k-best cuts + word frequency (CN 101133411 B),
|
||
// so uniqueness is asserted per (wrong, correct) pair instead of per key.
|
||
std::unordered_set<std::string> seen_pairs;
|
||
size_t total_entries = 0;
|
||
// 三张表的数组长度不同,初始化列表推导不出共同的指针类型(VS2022 严格报错);
|
||
// 用边界对遍历代替指针到数组的推导。
|
||
const auto require_valid_entries = [&](const quanpin::autocorrect::Entry *entries, std::size_t count) {
|
||
for (std::size_t i = 0; i < count; ++i)
|
||
{
|
||
const std::string wrong(entries[i].wrong);
|
||
// correct 是生成音节表的 16-bit 下标,断言前先解引用。
|
||
const std::string correct(quanpin::autocorrect::kCorrectSyllables[entries[i].correct]);
|
||
REQUIRE(!wrong.empty());
|
||
// 2-letter strings belong to the jianpin space: a correction key there
|
||
// would shadow abbreviations such as wj -> 文件.
|
||
REQUIRE(wrong.size() >= 3);
|
||
REQUIRE(!legal.count(wrong)); // a key must never shadow a legal syllable
|
||
REQUIRE(legal.count(correct)); // the correction must be a legal syllable
|
||
REQUIRE(seen_pairs.insert(wrong + '>' + correct).second); // pairs unique across all tables
|
||
++total_entries;
|
||
}
|
||
};
|
||
require_valid_entries(quanpin::autocorrect::kTranspositionEntries, quanpin::autocorrect::kTranspositionCount);
|
||
require_valid_entries(quanpin::autocorrect::kNeighborEntries, quanpin::autocorrect::kNeighborCount);
|
||
require_valid_entries(quanpin::autocorrect::kDeletionEntries, quanpin::autocorrect::kDeletionCount);
|
||
require_valid_entries(quanpin::autocorrect::kInsertionEntries, quanpin::autocorrect::kInsertionCount);
|
||
// Coverage gate (task AC1): transposition + neighbor + deletion entries together.
|
||
REQUIRE(total_entries >= 4700);
|
||
REQUIRE(seen_pairs.size() == total_entries);
|
||
}
|
||
|
||
TEST_CASE(QuanpinAutocorrectTableKeepsAmbiguousAndDeletionVariants)
|
||
{
|
||
std::unordered_map<std::string, std::unordered_set<std::string>> targets;
|
||
const auto collect = [&](const quanpin::autocorrect::Entry *entries, std::size_t count) {
|
||
for (std::size_t i = 0; i < count; ++i)
|
||
{
|
||
targets[entries[i].wrong].insert(std::string(quanpin::autocorrect::kCorrectSyllables[entries[i].correct]));
|
||
}
|
||
};
|
||
collect(quanpin::autocorrect::kTranspositionEntries, quanpin::autocorrect::kTranspositionCount);
|
||
collect(quanpin::autocorrect::kNeighborEntries, quanpin::autocorrect::kNeighborCount);
|
||
collect(quanpin::autocorrect::kDeletionEntries, quanpin::autocorrect::kDeletionCount);
|
||
collect(quanpin::autocorrect::kInsertionEntries, quanpin::autocorrect::kInsertionCount);
|
||
|
||
const auto maps_to = [&](const char *wrong, const char *correct) {
|
||
const auto found = targets.find(wrong);
|
||
return found != targets.end() && found->second.count(correct) != 0;
|
||
};
|
||
|
||
// Ambiguous neighbor keys in the z/zh confusion zone used to be dropped at
|
||
// generation time; they must now survive with every candidate syllable.
|
||
REQUIRE(maps_to("ahan", "shan"));
|
||
REQUIRE(maps_to("ahan", "zhan"));
|
||
REQUIRE(maps_to("aang", "sang"));
|
||
REQUIRE(maps_to("aang", "wang"));
|
||
// Deletion variants: one dropped letter, ambiguous targets kept together.
|
||
REQUIRE(maps_to("shng", "shang"));
|
||
REQUIRE(maps_to("shng", "sheng"));
|
||
REQUIRE(maps_to("chn", "chan"));
|
||
REQUIRE(maps_to("chn", "chen"));
|
||
REQUIRE(maps_to("bng", "bang"));
|
||
REQUIRE(maps_to("bng", "beng"));
|
||
REQUIRE(maps_to("zhng", "zhang"));
|
||
REQUIRE(maps_to("zhng", "zheng"));
|
||
// Insertion variants keep ambiguous targets too: baio = bai/bao + one
|
||
// inserted letter inside the ai->ao confusion zone.
|
||
REQUIRE(maps_to("baio", "bai"));
|
||
REQUIRE(maps_to("baio", "bao"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinAutocorrectDeletionTableEntriesAreLegal)
|
||
{
|
||
// Shape pin for the deletion table: exactly one dropped letter per key,
|
||
// never a legal syllable, always a legal target. The runtime builds its
|
||
// multi-value index from this table (quanpin_utils.cpp correction_index).
|
||
const auto &legal = quanpin::intact_pinyin_set();
|
||
REQUIRE(quanpin::autocorrect::kDeletionCount > 0);
|
||
for (std::size_t i = 0; i < quanpin::autocorrect::kDeletionCount; ++i)
|
||
{
|
||
const auto &entry = quanpin::autocorrect::kDeletionEntries[i];
|
||
const std::string wrong(entry.wrong);
|
||
const std::string correct(quanpin::autocorrect::kCorrectSyllables[entry.correct]);
|
||
// A deletion key is exactly one letter shorter than its target.
|
||
REQUIRE_EQ(wrong.size() + 1, correct.size());
|
||
REQUIRE(wrong.size() >= 3);
|
||
REQUIRE(!legal.count(wrong));
|
||
REQUIRE(legal.count(correct));
|
||
}
|
||
}
|
||
|
||
TEST_CASE(QuanpinAutocorrectInsertionTableEntriesAreLegal)
|
||
{
|
||
// Shape pin for the insertion table: exactly one inserted letter per key,
|
||
// never a legal syllable, always a legal target. The 6-letter cap means
|
||
// 6-letter syllables (zhuang family) have no insertion coverage on
|
||
// purpose -- their variants would be 7 letters.
|
||
const auto &legal = quanpin::intact_pinyin_set();
|
||
REQUIRE(quanpin::autocorrect::kInsertionCount > 0);
|
||
for (std::size_t i = 0; i < quanpin::autocorrect::kInsertionCount; ++i)
|
||
{
|
||
const auto &entry = quanpin::autocorrect::kInsertionEntries[i];
|
||
const std::string wrong(entry.wrong);
|
||
const std::string correct(quanpin::autocorrect::kCorrectSyllables[entry.correct]);
|
||
// An insertion key is exactly one letter longer than its target.
|
||
REQUIRE_EQ(wrong.size(), correct.size() + 1);
|
||
REQUIRE(wrong.size() >= 3);
|
||
REQUIRE(!legal.count(wrong));
|
||
REQUIRE(legal.count(correct));
|
||
}
|
||
}
|
||
|
||
TEST_CASE(QuanpinAutocorrectCutGatesEachTypeIndependently)
|
||
{
|
||
const unsigned none = 0;
|
||
const unsigned transposition_only = quanpin::kAutocorrectTransposition;
|
||
const unsigned neighbor_only = quanpin::kAutocorrectNeighbor;
|
||
const unsigned both = transposition_only | neighbor_only;
|
||
|
||
// "sahng" is a transposition fix; only that bit may correct it.
|
||
REQUIRE(quanpin::autocorrect_cut("sahng", none).empty());
|
||
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("sahng", transposition_only)), std::string("shang"));
|
||
REQUIRE(quanpin::autocorrect_cut("sahng", neighbor_only).empty());
|
||
// "shabg" is a neighbor fix; only that bit may correct it.
|
||
REQUIRE(quanpin::autocorrect_cut("shabg", none).empty());
|
||
REQUIRE(quanpin::autocorrect_cut("shabg", transposition_only).empty());
|
||
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("shabg", neighbor_only)), std::string("shang"));
|
||
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("shabg", both)), std::string("shang"));
|
||
|
||
// "sshang" (doubled s) is an insertion fix; only that bit may correct it.
|
||
REQUIRE(quanpin::autocorrect_cut("sshang", none).empty());
|
||
REQUIRE(quanpin::autocorrect_cut("sshang", transposition_only).empty());
|
||
REQUIRE(quanpin::autocorrect_cut("sshang", neighbor_only).empty());
|
||
REQUIRE(quanpin::autocorrect_cut("sshang", quanpin::kAutocorrectDeletion).empty());
|
||
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("sshang", quanpin::kAutocorrectInsertion)),
|
||
std::string("shang"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinJianpinShapeGuardBlocksCorrection)
|
||
{
|
||
// One or more legal syllables plus at most one trailing letter is jianpin
|
||
// intent (zheg = zhe + g), never a typo the tables may rewrite.
|
||
REQUIRE(quanpin::looks_like_syllable_with_jianpin_tail("zheg"));
|
||
REQUIRE(quanpin::looks_like_syllable_with_jianpin_tail("keneng"));
|
||
REQUIRE(quanpin::looks_like_syllable_with_jianpin_tail("shang"));
|
||
REQUIRE(!quanpin::looks_like_syllable_with_jianpin_tail("sahng"));
|
||
REQUIRE(!quanpin::looks_like_syllable_with_jianpin_tail("shabg"));
|
||
REQUIRE(!quanpin::looks_like_syllable_with_jianpin_tail("xi'an"));
|
||
// All-consonant strings stay correctable: the engine has no multi-letter
|
||
// jianpin, so correction is the only useful reading of e.g. bqng -> bang.
|
||
REQUIRE(!quanpin::looks_like_syllable_with_jianpin_tail("bqng"));
|
||
REQUIRE(!quanpin::looks_like_syllable_with_jianpin_tail("wj"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinAutocorrectCutSingleSyllableTranspositions)
|
||
{
|
||
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
|
||
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("sahng", both)), std::string("shang"));
|
||
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("hsang", both)), std::string("shang"));
|
||
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("shagn", both)), std::string("shang"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinAutocorrectCutNeighborKeySubstitutions)
|
||
{
|
||
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
|
||
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("shabg", both)), std::string("shang")); // n -> b
|
||
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("sgang", both)), std::string("shang")); // h -> g
|
||
}
|
||
|
||
TEST_CASE(QuanpinAutocorrectCutMixedMultisyllableInput)
|
||
{
|
||
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
|
||
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("sahngzhi", both)), std::string("shang'zhi"));
|
||
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("sahngzhk", both)), std::string("shang'zhi")); // i -> k
|
||
}
|
||
|
||
TEST_CASE(QuanpinAutocorrectCutPrefersFewestCorrections)
|
||
{
|
||
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
|
||
// sahnguai has two plausible readings; the one with fewer corrected edges
|
||
// (shan + guai, one mistake) wins over shang + hai (two mistakes).
|
||
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("sahnguai", both)), std::string("shan'guai"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinAutocorrectCutRejectsInputsOutOfScope)
|
||
{
|
||
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
|
||
// The mask short-circuits before any table lookup.
|
||
REQUIRE(quanpin::autocorrect_cut("shang", 0u).empty());
|
||
// Legal input: the caller must only invoke autocorrect when the correction
|
||
// cut already failed, so a fully legal spelling yields no correction.
|
||
REQUIRE(quanpin::autocorrect_cut("shang", both).empty());
|
||
REQUIRE(quanpin::autocorrect_cut("keneng", both).empty());
|
||
// Manual delimiter: user-intended boundary, never rewritten.
|
||
REQUIRE(quanpin::autocorrect_cut("xi'an", both).empty());
|
||
// No correction path: unresolvable garbage.
|
||
REQUIRE(quanpin::autocorrect_cut("xxxxx", both).empty());
|
||
REQUIRE(quanpin::autocorrect_cut("qzzvv", both).empty());
|
||
// Beyond the per-input correction edge budget.
|
||
REQUIRE(quanpin::autocorrect_cut("sahngsahngsahngsahng", both).empty());
|
||
}
|
||
|
||
TEST_CASE(QuanpinAutocorrectCutRespectsEdgeBudget)
|
||
{
|
||
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
|
||
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("sahngsahngsahng", both)),
|
||
std::string("shang'shang'shang"));
|
||
}
|
||
|
||
namespace
|
||
{
|
||
std::string JoinCut(const quanpin::AutocorrectCut &cut)
|
||
{
|
||
quanpin::Segments syllables;
|
||
syllables.reserve(cut.segments.size());
|
||
for (const auto &segment : cut.segments)
|
||
{
|
||
syllables.push_back(segment.syllable);
|
||
}
|
||
return quanpin::join_segments(syllables);
|
||
}
|
||
} // namespace
|
||
|
||
TEST_CASE(QuanpinAutocorrectCutDeletionVariants)
|
||
{
|
||
const unsigned all =
|
||
quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor | quanpin::kAutocorrectDeletion;
|
||
|
||
// "shng" drops the "a" of shang/sheng: an unequal-length deletion edge.
|
||
// Both targets weigh kAutocorrectDeletionWeight, so the generated table
|
||
// order (shang before sheng) fixes the first cut deterministically.
|
||
const auto cuts = quanpin::autocorrect_cut_kbest("shng", all);
|
||
REQUIRE(cuts.size() >= 2);
|
||
REQUIRE_EQ(JoinCut(cuts[0]), std::string("shang"));
|
||
std::unordered_set<std::string> readings;
|
||
for (const auto &cut : cuts)
|
||
{
|
||
readings.insert(JoinCut(cut));
|
||
}
|
||
REQUIRE(readings.count("sheng") != 0);
|
||
|
||
// The detail cut is the k = 1 projection; its raw span still covers the
|
||
// four typed letters even though the syllable is five letters long.
|
||
const auto detail = quanpin::autocorrect_cut_detail("shng", all);
|
||
REQUIRE_EQ(detail.segments.size(), static_cast<size_t>(1));
|
||
REQUIRE_EQ(detail.segments[0].syllable, std::string("shang"));
|
||
REQUIRE_EQ(detail.segments[0].raw_text, std::string("shng"));
|
||
REQUIRE_EQ(detail.segments[0].start, static_cast<size_t>(0));
|
||
REQUIRE(detail.segments[0].corrected);
|
||
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("shng", all)), std::string("shang"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinAutocorrectCutDeletionKeepsRawSpans)
|
||
{
|
||
const unsigned all =
|
||
quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor | quanpin::kAutocorrectDeletion;
|
||
|
||
// "zhngu": the deletion key "zhn" consumes three letters, the untouched
|
||
// "gu" starts at raw offset 3 -- position advance follows raw_length,
|
||
// not syllable.size().
|
||
const auto cuts = quanpin::autocorrect_cut_kbest("zhngu", all);
|
||
REQUIRE(!cuts.empty());
|
||
REQUIRE_EQ(cuts[0].segments.size(), static_cast<size_t>(2));
|
||
REQUIRE_EQ(cuts[0].segments[0].syllable, std::string("zhan"));
|
||
REQUIRE_EQ(cuts[0].segments[0].raw_text, std::string("zhn"));
|
||
REQUIRE_EQ(cuts[0].segments[0].start, static_cast<size_t>(0));
|
||
REQUIRE(cuts[0].segments[0].corrected);
|
||
REQUIRE_EQ(cuts[0].segments[1].syllable, std::string("gu"));
|
||
REQUIRE_EQ(cuts[0].segments[1].raw_text, std::string("gu"));
|
||
REQUIRE_EQ(cuts[0].segments[1].start, static_cast<size_t>(3));
|
||
REQUIRE(!cuts[0].segments[1].corrected);
|
||
|
||
// "zhnggu": the 4-letter deletion key "zhng" -> zhang followed by a legal
|
||
// "gu" at raw offset 4.
|
||
const auto zhang_gu = quanpin::autocorrect_cut_detail("zhnggu", all);
|
||
REQUIRE_EQ(zhang_gu.segments.size(), static_cast<size_t>(2));
|
||
REQUIRE_EQ(zhang_gu.segments[0].syllable, std::string("zhang"));
|
||
REQUIRE_EQ(zhang_gu.segments[0].raw_text, std::string("zhng"));
|
||
REQUIRE_EQ(zhang_gu.segments[0].start, static_cast<size_t>(0));
|
||
REQUIRE(zhang_gu.segments[0].corrected);
|
||
REQUIRE_EQ(zhang_gu.segments[1].syllable, std::string("gu"));
|
||
REQUIRE_EQ(zhang_gu.segments[1].raw_text, std::string("gu"));
|
||
REQUIRE_EQ(zhang_gu.segments[1].start, static_cast<size_t>(4));
|
||
REQUIRE(!zhang_gu.segments[1].corrected);
|
||
}
|
||
|
||
TEST_CASE(QuanpinAutocorrectCutMixedDeletionAndNeighbor)
|
||
{
|
||
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
|
||
const unsigned all = both | quanpin::kAutocorrectDeletion;
|
||
|
||
// "shngzhk" mixes a deletion edge (shng -> shang) with a neighbor edge
|
||
// (zhk -> zhi): two corrected edges, weights 11 + 13.
|
||
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("shngzhk", all)), std::string("shang'zhi"));
|
||
// Without the deletion bit the same input stays unexplained.
|
||
REQUIRE(quanpin::autocorrect_cut("shngzhk", both).empty());
|
||
|
||
// A two-letter jianpin leftover ("zh") stays out of correction scope even
|
||
// with deletion on: the tables hold no 2-letter keys.
|
||
REQUIRE(quanpin::autocorrect_cut("sahngzh", all).empty());
|
||
}
|
||
|
||
TEST_CASE(QuanpinAutocorrectCutGatesDeletionIndependently)
|
||
{
|
||
const unsigned none = 0;
|
||
const unsigned transposition_only = quanpin::kAutocorrectTransposition;
|
||
const unsigned neighbor_only = quanpin::kAutocorrectNeighbor;
|
||
const unsigned deletion_only = quanpin::kAutocorrectDeletion;
|
||
const unsigned all = transposition_only | neighbor_only | deletion_only;
|
||
|
||
// "shng" is a deletion fix; only that bit may correct it.
|
||
REQUIRE(quanpin::autocorrect_cut("shng", none).empty());
|
||
REQUIRE(quanpin::autocorrect_cut("shng", transposition_only).empty());
|
||
REQUIRE(quanpin::autocorrect_cut("shng", neighbor_only).empty());
|
||
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("shng", deletion_only)), std::string("shang"));
|
||
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("shng", all)), std::string("shang"));
|
||
|
||
// The legacy switches keep their own families for the direct reading; note
|
||
// that bits gate tables, not typo intents: with deletion only, "sahng" is
|
||
// still explained through a DIFFERENT split (sa + hng -> hang), which is
|
||
// exactly the added coverage the deletion table provides.
|
||
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("sahng", deletion_only)), std::string("sa'hang"));
|
||
REQUIRE(quanpin::autocorrect_cut("shabg", deletion_only).empty());
|
||
}
|
||
|
||
TEST_CASE(QuanpinAutocorrectCutKbestRanksAmbiguousKeys)
|
||
{
|
||
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
|
||
const unsigned all = both | quanpin::kAutocorrectDeletion;
|
||
|
||
// "ahan" maps to both shan and zhan in the neighbor table. Both readings
|
||
// survive as parallel hypotheses (query-time disambiguation per CN
|
||
// 101133411 B), ordered by (edge count, weight, table order).
|
||
const auto cuts = quanpin::autocorrect_cut_kbest("ahan", both);
|
||
REQUIRE(cuts.size() >= 2);
|
||
REQUIRE_EQ(JoinCut(cuts[0]), std::string("shan"));
|
||
REQUIRE_EQ(JoinCut(cuts[1]), std::string("zhan"));
|
||
|
||
// Same edge count, different weights: the direct transposition (weight
|
||
// 10) outranks the split deletion reading (weight 11) within one edge.
|
||
const auto sahng = quanpin::autocorrect_cut_kbest("sahng", all);
|
||
REQUIRE(sahng.size() >= 2);
|
||
REQUIRE_EQ(JoinCut(sahng[0]), std::string("shang"));
|
||
REQUIRE_EQ(JoinCut(sahng[1]), std::string("sa'hang"));
|
||
|
||
// Edge count stays the primary key: one transposition (weight 10) beats
|
||
// transposition + deletion (21) for the same input.
|
||
REQUIRE_EQ(quanpin::join_segments(quanpin::autocorrect_cut("sahnguai", all)), std::string("shan'guai"));
|
||
|
||
// k = 1 agrees with the single-cut projection on every input, including
|
||
// the ones with no correction reading at all.
|
||
const std::pair<const char *, unsigned> inputs[] = {
|
||
{"sahng", both}, {"shabg", both}, {"sahnguai", both}, {"ahan", both}, {"zheg", both},
|
||
{"keneng", both}, {"shng", all}, {"zhngu", all}, {"sahngzhk", all},
|
||
};
|
||
for (const auto &entry : inputs)
|
||
{
|
||
const auto single = quanpin::autocorrect_cut(entry.first, entry.second);
|
||
const auto top1 = quanpin::autocorrect_cut_kbest(entry.first, entry.second, 1);
|
||
REQUIRE_EQ(top1.size(), single.empty() ? static_cast<size_t>(0) : static_cast<size_t>(1));
|
||
if (!single.empty())
|
||
{
|
||
REQUIRE_EQ(JoinCut(top1.front()), quanpin::join_segments(single));
|
||
}
|
||
}
|
||
}
|
||
|
||
namespace
|
||
{
|
||
std::filesystem::path CreateAutocorrectDatabase()
|
||
{
|
||
const auto path = std::filesystem::temp_directory_path() / "msime-quanpin-autocorrect-test.db";
|
||
std::filesystem::remove(path);
|
||
sqlite3 *db = nullptr;
|
||
if (sqlite3_open(test::Utf8(path).c_str(), &db) != SQLITE_OK)
|
||
{
|
||
throw std::runtime_error("Failed to create temporary autocorrect database.");
|
||
}
|
||
const char *sql = "CREATE TABLE tbl_1_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"CREATE TABLE tbl_2_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"CREATE TABLE tbl_2_g(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"CREATE TABLE tbl_2_j(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"CREATE TABLE tbl_2_q(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"CREATE TABLE tbl_4_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_s VALUES('shang','s','上',100);"
|
||
"INSERT INTO tbl_2_s VALUES('shang''zhi','sz','上至',100);"
|
||
"INSERT INTO tbl_2_g VALUES('guan''li','gl','管理',100);"
|
||
"INSERT INTO tbl_2_j VALUES('jian''du','jd','监督',100);"
|
||
"INSERT INTO tbl_2_q VALUES('quan''li','ql','权利',100);"
|
||
"INSERT INTO tbl_4_s VALUES('sa''huang''na''ge','shng','撒谎那个',1000);";
|
||
const int result = sqlite3_exec(db, sql, nullptr, nullptr, nullptr);
|
||
sqlite3_close(db);
|
||
if (result != SQLITE_OK)
|
||
{
|
||
std::filesystem::remove(path);
|
||
throw std::runtime_error("Failed to initialize temporary autocorrect database.");
|
||
}
|
||
return path;
|
||
}
|
||
} // namespace
|
||
|
||
TEST_CASE(QuanpinDictionaryAutocorrectPutsCorrectedCandidateFirst)
|
||
{
|
||
const auto db_path = CreateAutocorrectDatabase();
|
||
QuanpinDictionary dictionary(test::Utf8(db_path));
|
||
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
|
||
|
||
const auto candidates = dictionary.query("sahng", "sa'h'n'g", both);
|
||
REQUIRE(!candidates.empty());
|
||
// The corrected candidate leads, and its pinyin is the corrected key so
|
||
// that selection/weight updates land on the right dictionary entry.
|
||
REQUIRE_EQ(candidates.front().word, std::string("上"));
|
||
REQUIRE_EQ(candidates.front().pinyin, std::string("shang"));
|
||
REQUIRE_EQ(candidates.front().canonical_pinyin, std::string("shang"));
|
||
// The original greedy candidates stay behind as a fallback tail.
|
||
REQUIRE(std::any_of(candidates.begin(), candidates.end(),
|
||
[](const WordItem &item) { return item.word == "撒谎那个"; }));
|
||
REQUIRE(candidates.front().word != "撒谎那个");
|
||
}
|
||
|
||
TEST_CASE(QuanpinDictionaryAutocorrectNeighborKeySubstitution)
|
||
{
|
||
const auto db_path = CreateAutocorrectDatabase();
|
||
QuanpinDictionary dictionary(test::Utf8(db_path));
|
||
|
||
const auto candidates = dictionary.query("shabg", "sha'b'g", quanpin::kAutocorrectNeighbor);
|
||
REQUIRE(!candidates.empty());
|
||
REQUIRE_EQ(candidates.front().word, std::string("上"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinDictionaryAutocorrectDeletionTypo)
|
||
{
|
||
const auto db_path = CreateAutocorrectDatabase();
|
||
QuanpinDictionary dictionary(test::Utf8(db_path));
|
||
const unsigned all =
|
||
quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor | quanpin::kAutocorrectDeletion;
|
||
|
||
// 阶段 2 生效信号:漏字输入经纠错键命中目标词,标记 corrected_from,
|
||
// 关掉 deletion 位则回到现状(乱码后备尾)。缓存隔离:纠错结果不污染
|
||
// 后续对同键的其他查询。
|
||
const auto candidates = dictionary.query("shng", "sh'n'g", all);
|
||
REQUIRE(!candidates.empty());
|
||
REQUIRE_EQ(candidates.front().word, std::string("上"));
|
||
REQUIRE_EQ(candidates.front().pinyin, std::string("shang"));
|
||
REQUIRE_EQ(candidates.front().corrected_from, std::string("shng"));
|
||
|
||
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
|
||
const auto legacy = dictionary.query("shng", "sh'n'g", both);
|
||
REQUIRE(
|
||
std::none_of(legacy.begin(), legacy.end(), [](const WordItem &item) { return item.corrected_from == "shng"; }));
|
||
}
|
||
|
||
TEST_CASE(QuanpinDictionaryAutocorrectInsertionTypo)
|
||
{
|
||
const auto db_path = CreateAutocorrectDatabase();
|
||
QuanpinDictionary dictionary(test::Utf8(db_path));
|
||
const unsigned all = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor |
|
||
quanpin::kAutocorrectDeletion | quanpin::kAutocorrectInsertion;
|
||
|
||
// 阶段 4 生效信号:开头双打(sshang)经插入纠错键命中目标词,标记
|
||
// corrected_from,canonical 读音落在纠错后的 shang 上(造词/调频不学习
|
||
// 错拼)。关掉 insertion 位则回到无纠错标记的现状。
|
||
const auto candidates = dictionary.query("sshang", "", all);
|
||
REQUIRE(!candidates.empty());
|
||
REQUIRE_EQ(candidates.front().word, std::string("上"));
|
||
REQUIRE_EQ(candidates.front().pinyin, std::string("shang"));
|
||
REQUIRE_EQ(candidates.front().corrected_from, std::string("sshang"));
|
||
|
||
const unsigned legacy_bits =
|
||
quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor | quanpin::kAutocorrectDeletion;
|
||
const auto legacy = dictionary.query("sshang", "", legacy_bits);
|
||
REQUIRE(std::none_of(legacy.begin(), legacy.end(),
|
||
[](const WordItem &item) { return item.corrected_from == "sshang"; }));
|
||
}
|
||
|
||
TEST_CASE(QuanpinDictionaryAutocorrectAmbiguousDisambiguation)
|
||
{
|
||
// k-best 进查询管线(阶段 3):"chn" 同时映射 chan/chen,主切分查 chan,
|
||
// 备选切分经 merge_alternative_segmentations 与主切分同台竞争,词频
|
||
// 高者胜出(专利 M3 查询期消歧)。分两个权重布局验证顺序跟随词频。
|
||
// 不用派发指令中的 ahan:ahan = a + han 是全合法拼读,被简拼形状守卫
|
||
// (AC3 既有语义)挡在纠错门外;chn 无任何合法切分,是真实可达的歧义键。
|
||
const auto create_database = [](long chan_weight, long chen_weight) {
|
||
const auto path = std::filesystem::temp_directory_path() / "msime-quanpin-autocorrect-ambiguous-test.db";
|
||
std::filesystem::remove(path);
|
||
sqlite3 *db = nullptr;
|
||
if (sqlite3_open(test::Utf8(path).c_str(), &db) != SQLITE_OK)
|
||
{
|
||
throw std::runtime_error("Failed to create the ambiguous autocorrect database.");
|
||
}
|
||
const std::string sql = std::string("CREATE TABLE tbl_1_c(key TEXT,jp TEXT,value TEXT,weight INTEGER);") +
|
||
"INSERT INTO tbl_1_c VALUES('chan','c','产'," + std::to_string(chan_weight) + ");" +
|
||
"INSERT INTO tbl_1_c VALUES('chen','c','陈'," + std::to_string(chen_weight) + ");";
|
||
const int result = sqlite3_exec(db, sql.c_str(), nullptr, nullptr, nullptr);
|
||
sqlite3_close(db);
|
||
if (result != SQLITE_OK)
|
||
{
|
||
std::filesystem::remove(path);
|
||
throw std::runtime_error("Failed to initialize the ambiguous autocorrect database.");
|
||
}
|
||
return path;
|
||
};
|
||
const unsigned all =
|
||
quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor | quanpin::kAutocorrectDeletion;
|
||
|
||
{
|
||
const auto db_path = create_database(100000, 1000);
|
||
QuanpinDictionary dictionary(test::Utf8(db_path));
|
||
const auto candidates = dictionary.query("chn", "", all);
|
||
const auto chan =
|
||
std::find_if(candidates.begin(), candidates.end(), [](const WordItem &item) { return item.word == "产"; });
|
||
const auto chen =
|
||
std::find_if(candidates.begin(), candidates.end(), [](const WordItem &item) { return item.word == "陈"; });
|
||
REQUIRE(chan != candidates.end());
|
||
REQUIRE(chen != candidates.end());
|
||
// Both readings survive (query-time disambiguation) and the higher
|
||
// weight wins the order.
|
||
REQUIRE(chan < chen);
|
||
// AC5: candidates from the alternative cut carry corrected_from too.
|
||
REQUIRE_EQ(chan->corrected_from, std::string("chn"));
|
||
REQUIRE_EQ(chen->corrected_from, std::string("chn"));
|
||
}
|
||
{
|
||
// Weight layout reversed: the order must follow the dictionary
|
||
// frequency, not the k-best ranking of the cuts.
|
||
const auto db_path = create_database(1000, 100000);
|
||
QuanpinDictionary dictionary(test::Utf8(db_path));
|
||
const auto candidates = dictionary.query("chn", "", all);
|
||
const auto chan =
|
||
std::find_if(candidates.begin(), candidates.end(), [](const WordItem &item) { return item.word == "产"; });
|
||
const auto chen =
|
||
std::find_if(candidates.begin(), candidates.end(), [](const WordItem &item) { return item.word == "陈"; });
|
||
REQUIRE(chan != candidates.end());
|
||
REQUIRE(chen != candidates.end());
|
||
REQUIRE(chen < chan);
|
||
}
|
||
}
|
||
|
||
TEST_CASE(QuanpinDictionaryAutocorrectEmptyBaseSegmentationReachesCorrection)
|
||
{
|
||
// 丢声母类错拼(quan -> uan、jian -> ian)在 correction 模式下完全切不出
|
||
// 段:prefix 切词器连前缀都匹配不上,segments 为空。旧 eligible 门的
|
||
// !segments.empty() 会把这类输入整体挡在纠错之外(候选为空);BFS 工作在
|
||
// 原始字母上,不依赖基础切分,空切分必须进入纠错路径。
|
||
const auto db_path = CreateAutocorrectDatabase();
|
||
QuanpinDictionary dictionary(test::Utf8(db_path));
|
||
const unsigned all =
|
||
quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor | quanpin::kAutocorrectDeletion;
|
||
|
||
// ian 的漏字目标里 jian 排在 k-best 前 3(bian/dian/jian),备选切分
|
||
// jian'du 经查询期词频合并成为首位。
|
||
const auto iandu = dictionary.query("iandu", "", all);
|
||
REQUIRE(!iandu.empty());
|
||
REQUIRE_EQ(iandu.front().word, std::string("监督"));
|
||
REQUIRE_EQ(iandu.front().corrected_from, std::string("iandu"));
|
||
|
||
// 关掉纠错(types=0)回到不纠错的现状:纠错词不出现。
|
||
const auto off = dictionary.query("iandu", "", 0u);
|
||
REQUIRE(std::none_of(off.begin(), off.end(), [](const WordItem &item) { return item.word == "监督"; }));
|
||
|
||
// uan 键有 15 个等权漏字目标(cuan/duan/guan/.../zuan),quan 按表序排
|
||
// 第 9。k-best 的 k=9 是评测定的截断线:quan'li 恰好进入本轮查询,权利
|
||
// 可达并带 corrected_from;k=3 时 quan 超出截断线,权利不可达(评测阶段
|
||
// 4 的边界结论)。本 fixture 里管理/权利同权重 100,quan'li 表序靠后,
|
||
// 因此权利排第二而非首位(阶段 4 验证时真实 msime.db 上权利进入首选)。
|
||
const auto uanli = dictionary.query("uanli", "", all);
|
||
REQUIRE(!uanli.empty());
|
||
REQUIRE_EQ(uanli.front().word, std::string("管理"));
|
||
const auto quanli =
|
||
std::find_if(uanli.begin(), uanli.end(), [](const WordItem &item) { return item.word == "权利"; });
|
||
REQUIRE(quanli != uanli.end());
|
||
REQUIRE_EQ(quanli->corrected_from, std::string("uanli"));
|
||
|
||
// 无可纠错解读的垃圾串:不产生任何纠错标记(BFS 无路径,走既有后备尾)。
|
||
const auto garbage = dictionary.query("qqqq", "", all);
|
||
REQUIRE(std::none_of(garbage.begin(), garbage.end(),
|
||
[](const WordItem &item) { return !item.corrected_from.empty(); }));
|
||
}
|
||
|
||
TEST_CASE(QuanpinDictionaryAutocorrectMultisyllableInput)
|
||
{
|
||
const auto db_path = CreateAutocorrectDatabase();
|
||
QuanpinDictionary dictionary(test::Utf8(db_path));
|
||
|
||
const auto candidates = dictionary.query("sahngzhi", "sa'h'n'g'zhi",
|
||
quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor);
|
||
REQUIRE(!candidates.empty());
|
||
REQUIRE_EQ(candidates.front().word, std::string("上至"));
|
||
}
|
||
|
||
TEST_CASE(QuanpinDictionaryAutocorrectDisabledKeepsLegacyBehavior)
|
||
{
|
||
const auto db_path = CreateAutocorrectDatabase();
|
||
QuanpinDictionary dictionary(test::Utf8(db_path));
|
||
|
||
const auto candidates = dictionary.query("sahng", "sa'h'n'g", 0u);
|
||
REQUIRE(!candidates.empty());
|
||
REQUIRE(std::none_of(candidates.begin(), candidates.end(), [](const WordItem &item) { return item.word == "上"; }));
|
||
REQUIRE(std::any_of(candidates.begin(), candidates.end(),
|
||
[](const WordItem &item) { return item.word == "撒谎那个"; }));
|
||
}
|
||
|
||
TEST_CASE(QuanpinDictionaryAutocorrectLeavesLegalInputsUntouched)
|
||
{
|
||
const auto db_path = CreateAutocorrectDatabase();
|
||
QuanpinDictionary dictionary(test::Utf8(db_path));
|
||
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
|
||
|
||
// Corrected results live in their own cache slot; a plain spelling never
|
||
// sees the fallback tail produced for the corrected spelling.
|
||
(void)dictionary.query("sahng", "sa'h'n'g", both);
|
||
const auto plain = dictionary.query("shang", "shang", both);
|
||
REQUIRE(!plain.empty());
|
||
REQUIRE_EQ(plain.front().word, std::string("上"));
|
||
REQUIRE(std::none_of(plain.begin(), plain.end(), [](const WordItem &item) { return item.word == "撒谎那个"; }));
|
||
|
||
const auto manual = dictionary.query("xi'an", "xi'an", both);
|
||
const auto manual_off = dictionary.query("xi'an", "xi'an", 0u);
|
||
REQUIRE_EQ(manual.size(), manual_off.size());
|
||
}
|
||
|
||
namespace
|
||
{ // ü 系别名的字典级 fixture:词库正键全部用标准拼写(v 系 nve/lve/nv/lv、省鱼眼系 jue),
|
||
// 同表另放真实音节 nu/lu 供隔离断言。
|
||
std::filesystem::path CreateUmlautAliasDatabase()
|
||
{
|
||
const auto path = std::filesystem::temp_directory_path() / "msime-quanpin-umlaut-alias-test.db";
|
||
std::filesystem::remove(path);
|
||
sqlite3 *db = nullptr;
|
||
if (sqlite3_open(test::Utf8(path).c_str(), &db) != SQLITE_OK)
|
||
{
|
||
throw std::runtime_error("Failed to create temporary umlaut alias database.");
|
||
}
|
||
const char *sql = "CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"CREATE TABLE tbl_1_l(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"CREATE TABLE tbl_1_j(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"CREATE TABLE tbl_1_e(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_n VALUES('nve','n','虐',100);"
|
||
"INSERT INTO tbl_1_n VALUES('nv','n','女',90);"
|
||
"INSERT INTO tbl_1_n VALUES('nu','n','怒',80);"
|
||
"INSERT INTO tbl_1_l VALUES('lve','l','略',100);"
|
||
"INSERT INTO tbl_1_l VALUES('lv','l','绿',90);"
|
||
"INSERT INTO tbl_1_l VALUES('lu','l','路',80);"
|
||
"INSERT INTO tbl_1_j VALUES('jue','j','决',100);"
|
||
"INSERT INTO tbl_1_e VALUES('e','e','鹅',50);"
|
||
"INSERT INTO tbl_2_n VALUES('nu''e','ne','怒鹅',30);";
|
||
const int result = sqlite3_exec(db, sql, nullptr, nullptr, nullptr);
|
||
sqlite3_close(db);
|
||
if (result != SQLITE_OK)
|
||
{
|
||
std::filesystem::remove(path);
|
||
throw std::runtime_error("Failed to initialize temporary umlaut alias database.");
|
||
}
|
||
return path;
|
||
}
|
||
|
||
std::int64_t NveRowWeight(const std::filesystem::path &db_path)
|
||
{
|
||
sqlite3 *db = nullptr;
|
||
if (sqlite3_open(test::Utf8(db_path).c_str(), &db) != SQLITE_OK)
|
||
{
|
||
throw std::runtime_error("Failed to open the umlaut alias database.");
|
||
}
|
||
sqlite3_stmt *stmt = nullptr;
|
||
if (sqlite3_prepare_v2(db, "SELECT weight FROM tbl_1_n WHERE key='nve' AND value='虐'", -1, &stmt, nullptr) !=
|
||
SQLITE_OK ||
|
||
sqlite3_step(stmt) != SQLITE_ROW)
|
||
{
|
||
sqlite3_finalize(stmt);
|
||
sqlite3_close(db);
|
||
throw std::runtime_error("Failed to read the nve row weight.");
|
||
}
|
||
const std::int64_t weight = sqlite3_column_int64(stmt, 0);
|
||
sqlite3_finalize(stmt);
|
||
sqlite3_close(db);
|
||
return weight;
|
||
}
|
||
|
||
const WordItem *FindWord(const std::vector<WordItem> &candidates, const std::string &word)
|
||
{
|
||
const auto found =
|
||
std::find_if(candidates.begin(), candidates.end(), [&](const WordItem &item) { return item.word == word; });
|
||
return found == candidates.end() ? nullptr : &*found;
|
||
}
|
||
} // namespace
|
||
|
||
TEST_CASE(QuanpinDictionaryUmlautAliasNormalisesQueryAndMarksNonStandardSpelling)
|
||
{
|
||
const auto db_path = CreateUmlautAliasDatabase();
|
||
{
|
||
QuanpinDictionary dictionary(test::Utf8(db_path));
|
||
|
||
// AC1:混拼 nue 命中 nve 行并带轻标记,候选 pinyin 落标准键;标准拼法无标记。
|
||
const auto nue = dictionary.query("nue");
|
||
const auto *nue_row = FindWord(nue, "虐");
|
||
REQUIRE(nue_row != nullptr);
|
||
REQUIRE_EQ(nue_row->corrected_from, std::string("nue"));
|
||
REQUIRE_EQ(nue_row->pinyin, std::string("nve"));
|
||
|
||
const auto nve = dictionary.query("nve");
|
||
const auto *nve_row = FindWord(nve, "虐");
|
||
REQUIRE(nve_row != nullptr);
|
||
REQUIRE(nve_row->corrected_from.empty());
|
||
REQUIRE_EQ(nve_row->pinyin, std::string("nve"));
|
||
|
||
// AC2:lue/lve 同理。
|
||
const auto lue = dictionary.query("lue");
|
||
const auto *lue_row = FindWord(lue, "略");
|
||
REQUIRE(lue_row != nullptr);
|
||
REQUIRE_EQ(lue_row->corrected_from, std::string("lue"));
|
||
|
||
const auto lve = dictionary.query("lve");
|
||
const auto *lve_row = FindWord(lve, "略");
|
||
REQUIRE(lve_row != nullptr);
|
||
REQUIRE(lve_row->corrected_from.empty());
|
||
|
||
// AC3:jqxy 系 v→u 既有归一保持,标记行为从无标变带标;标准拼法无标。
|
||
const auto jve = dictionary.query("jve");
|
||
const auto *jve_row = FindWord(jve, "决");
|
||
REQUIRE(jve_row != nullptr);
|
||
REQUIRE_EQ(jve_row->corrected_from, std::string("jve"));
|
||
|
||
const auto jue = dictionary.query("jue");
|
||
const auto *jue_row = FindWord(jue, "决");
|
||
REQUIRE(jue_row != nullptr);
|
||
REQUIRE(jue_row->corrected_from.empty());
|
||
|
||
// AC4:nu/nv/lu/lv 是真实音节,任何输入互不串。
|
||
const auto nu = dictionary.query("nu");
|
||
REQUIRE(FindWord(nu, "怒") != nullptr);
|
||
REQUIRE(FindWord(nu, "女") == nullptr);
|
||
REQUIRE(FindWord(nu, "虐") == nullptr);
|
||
const auto nv = dictionary.query("nv");
|
||
const auto *nv_row = FindWord(nv, "女");
|
||
REQUIRE(nv_row != nullptr);
|
||
REQUIRE(FindWord(nv, "怒") == nullptr);
|
||
REQUIRE(FindWord(nv, "虐") == nullptr);
|
||
REQUIRE(nv_row->corrected_from.empty());
|
||
const auto lu = dictionary.query("lu");
|
||
REQUIRE(FindWord(lu, "路") != nullptr);
|
||
REQUIRE(FindWord(lu, "绿") == nullptr);
|
||
REQUIRE(FindWord(lu, "略") == nullptr);
|
||
const auto lv = dictionary.query("lv");
|
||
const auto *lv_row = FindWord(lv, "绿");
|
||
REQUIRE(lv_row != nullptr);
|
||
REQUIRE(FindWord(lv, "路") == nullptr);
|
||
REQUIRE(FindWord(lv, "略") == nullptr);
|
||
REQUIRE(lv_row->corrected_from.empty());
|
||
|
||
// AC4(模糊音全开):归一只精确匹配 [nl]ue 段,与模糊音正交,真实音节仍互不串。
|
||
metasequoia::FuzzyPinyinOptions fuzzy_all;
|
||
fuzzy_all.rules = 0xFFFFFFFFu;
|
||
const auto nu_fuzzy = dictionary.query("nu", "", 0, fuzzy_all);
|
||
REQUIRE(FindWord(nu_fuzzy, "怒") != nullptr);
|
||
REQUIRE(FindWord(nu_fuzzy, "女") == nullptr);
|
||
REQUIRE(FindWord(nu_fuzzy, "虐") == nullptr);
|
||
const auto nv_fuzzy = dictionary.query("nv", "", 0, fuzzy_all);
|
||
REQUIRE(FindWord(nv_fuzzy, "女") != nullptr);
|
||
REQUIRE(FindWord(nv_fuzzy, "怒") == nullptr);
|
||
REQUIRE(FindWord(nv_fuzzy, "虐") == nullptr);
|
||
|
||
// AC5:手动分隔符 nu'e 切分为 怒+鹅,不触发别名(虐不可见),无标记。
|
||
const auto nue_delimited = dictionary.query("nu'e");
|
||
REQUIRE(FindWord(nue_delimited, "怒鹅") != nullptr);
|
||
REQUIRE(FindWord(nue_delimited, "怒") != nullptr);
|
||
REQUIRE(FindWord(nue_delimited, "虐") == nullptr);
|
||
REQUIRE(std::none_of(nue_delimited.begin(), nue_delimited.end(),
|
||
[](const WordItem &item) { return !item.corrected_from.empty(); }));
|
||
|
||
// AC6:别名命中的调频数据落在标准拼法键。会话主路径(pinyin_segmentation_
|
||
// 已是标准键)与显式别名键直调都不得在 nue 行积累数据。
|
||
REQUIRE_EQ(NveRowWeight(db_path), 100);
|
||
(void)dictionary.query("nue");
|
||
REQUIRE_EQ(dictionary.update_weight_by_word("虐"), QuanpinDictionary::OK);
|
||
REQUIRE_EQ(NveRowWeight(db_path), 101);
|
||
|
||
REQUIRE_EQ(dictionary.update_weight_by_pinyin_and_word("nue", "虐"), QuanpinDictionary::OK);
|
||
REQUIRE_EQ(NveRowWeight(db_path), 102);
|
||
}
|
||
std::filesystem::remove(db_path);
|
||
}
|
||
|
||
TEST_CASE(QuanpinGoogleSpellingRewritesUmlautForDecoders)
|
||
{
|
||
// 词库那侧的 canonical 是 nve/lve,Google 的两个解码器(本地
|
||
// googlepinyinime-rev 与 inputtools 云输入)只认 nue/lue:送出去之前必须换过来,
|
||
// 否则 nve 会被它们拆成 nv + e。撇号是它们认的音节分隔符,要原样留住。
|
||
REQUIRE_EQ(quanpin::to_google_spelling("nve'dai'dong'wu"), std::string("nue'dai'dong'wu"));
|
||
REQUIRE_EQ(quanpin::to_google_spelling("wo'men'lve'de"), std::string("wo'men'lue'de"));
|
||
REQUIRE_EQ(quanpin::to_google_spelling("jve'qve'xve'yve"), std::string("jue'que'xue'yue"));
|
||
REQUIRE_EQ(quanpin::to_google_spelling("jv'qv'xv'yv"), std::string("ju'qu'xu'yu"));
|
||
|
||
// nv/lv 本身是解码器认的写法,不动;不成音节的块原样透传。
|
||
REQUIRE_EQ(quanpin::to_google_spelling("nv'er"), std::string("nv'er"));
|
||
REQUIRE_EQ(quanpin::to_google_spelling("lv'se"), std::string("lv'se"));
|
||
REQUIRE_EQ(quanpin::to_google_spelling("nvedaidongwu"), std::string("nvedaidongwu"));
|
||
REQUIRE_EQ(quanpin::to_google_spelling(""), std::string(""));
|
||
}
|
||
|
||
namespace
|
||
{
|
||
struct HelpcodeSample
|
||
{
|
||
char help_code = 0;
|
||
std::string first_matched_a;
|
||
std::string first_matched_b;
|
||
std::string unmatched;
|
||
};
|
||
|
||
// Picks a helpcode letter that has at least two candidates matching on the
|
||
// leading part plus one candidate that does not match at all, which is enough
|
||
// to observe how the single-helpcode reordering treats the buckets.
|
||
HelpcodeSample FindSplitBucketHelpcode()
|
||
{
|
||
const auto &keymap = HelpcodeUtils::helpcode_keymap();
|
||
for (char letter = 'a'; letter <= 'z'; ++letter)
|
||
{
|
||
std::vector<std::string> firsts;
|
||
std::vector<std::string> unmatched;
|
||
for (const auto &entry : keymap)
|
||
{
|
||
if (entry.second.size() < 2 || HelpcodeUtils::count_han_chars(entry.first) != 1)
|
||
{
|
||
continue;
|
||
}
|
||
if (entry.second[0] == letter)
|
||
{
|
||
firsts.push_back(entry.first);
|
||
}
|
||
else if (entry.second[1] != letter)
|
||
{
|
||
unmatched.push_back(entry.first);
|
||
}
|
||
}
|
||
if (firsts.size() < 2 || unmatched.empty())
|
||
{
|
||
continue;
|
||
}
|
||
std::sort(firsts.begin(), firsts.end());
|
||
std::sort(unmatched.begin(), unmatched.end());
|
||
return {letter, firsts[0], firsts[1], unmatched[0]};
|
||
}
|
||
return {};
|
||
}
|
||
} // namespace
|
||
|
||
// Mirrors typing a helpcode that filters out the candidates above the AI
|
||
// suggestion: the suggestion has to move up with them instead of staying pinned
|
||
// to its old slot.
|
||
TEST_CASE(SingleHelpcodeReorderKeepsAiSuggestionAheadOfLaterCandidates)
|
||
{
|
||
const HelpcodeSample sample = FindSplitBucketHelpcode();
|
||
REQUIRE(sample.help_code != 0);
|
||
|
||
const std::vector<WordItem> base{
|
||
WordItem("py", sample.unmatched, 10, CandidateSource::Database),
|
||
WordItem("py", sample.first_matched_a, 1, CandidateSource::AiSuggestion),
|
||
WordItem("py", sample.first_matched_b, 8, CandidateSource::Database),
|
||
};
|
||
|
||
const auto result = HelpcodeUtils::reorder_candidates_with_single_helpcode(base, std::string(1, sample.help_code));
|
||
|
||
REQUIRE_EQ(result.size(), base.size());
|
||
REQUIRE(result[0].source == CandidateSource::AiSuggestion);
|
||
REQUIRE_EQ(result[1].word, sample.first_matched_b);
|
||
REQUIRE_EQ(result[2].word, sample.unmatched);
|
||
}
|
||
|
||
TEST_CASE(DoubleHelpcodeFilterPreservesCloudAndAiRelativeOrder)
|
||
{
|
||
const auto &keymap = HelpcodeUtils::helpcode_keymap();
|
||
std::unordered_map<std::string, std::vector<std::string>> words_by_helpcode;
|
||
for (const auto &entry : keymap)
|
||
{
|
||
if (entry.second.size() < 2 || HelpcodeUtils::count_han_chars(entry.first) != 1)
|
||
{
|
||
continue;
|
||
}
|
||
words_by_helpcode[entry.second].push_back(entry.first);
|
||
}
|
||
|
||
std::string help_codes;
|
||
std::vector<std::string> words;
|
||
for (const auto &entry : words_by_helpcode)
|
||
{
|
||
if (entry.second.size() >= 3 && (help_codes.empty() || entry.first < help_codes))
|
||
{
|
||
help_codes = entry.first;
|
||
words = entry.second;
|
||
}
|
||
}
|
||
REQUIRE(!help_codes.empty());
|
||
std::sort(words.begin(), words.end());
|
||
words.resize(3);
|
||
|
||
const std::vector<WordItem> base{
|
||
WordItem("py", words[0], 10, CandidateSource::Database),
|
||
WordItem("py", words[1], 1, CandidateSource::CloudSuggestion),
|
||
WordItem("py", words[2], 1, CandidateSource::AiSuggestion),
|
||
};
|
||
|
||
const auto result = HelpcodeUtils::filter_candidates_with_double_helpcodes(base, help_codes);
|
||
|
||
REQUIRE_EQ(result.size(), base.size());
|
||
REQUIRE_EQ(result[0].word, words[0]);
|
||
REQUIRE(result[1].source == CandidateSource::CloudSuggestion);
|
||
REQUIRE(result[2].source == CandidateSource::AiSuggestion);
|
||
}
|