Files
msime-windows/server/tests/src/test_engine_shuangpin_session.cpp
T
fanlusky 663f72301d fix(server): 独立上屏的整句候选也落库
上一个提交改的是 InputSession::commit(),而 Windows 前端根本不走那条路:选词由
ProcessSelectionKey 自己驱动。那里 CandidateSource::Generated 走提前返回,落库的
闸门 ShouldStoreEarlyReturnPhrase 要求造词前缀非空,也就是只认"先选前缀、再用整句
收尾"这一种形状;用户直接选一条完整的整句时 creating_word 没激活,条件为假,什么
也不做就返回了。Google 解码器那条整句(Fallback)不在提前返回的名单里,走普通
路径,但同样没有行可以调频。

因此拆出 ShouldStoreStandaloneSentence:整句候选独立上屏时就该落库,带 7 音节
上限(对标 quanpin::WordLatticeOptions::max_phrase_syllables)。接在造词前缀
后面的那一支由用户一段段选出来,维持原样不设限。

顺带修掉同一处的读音拼接:前缀为空时不能带上那个分隔符,'na'yi'tiao 这种前导
撇号会让整条读音作废。

原先有一条测试把这个缺陷钉住了(断言没有前缀就不落库),按新的意图翻过来。
2026-09-19 13:48:26 +08:00

1229 lines
55 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "tests/includes/test_framework.h"
#include "src/session/engine_input_session.h"
#include "src/config/ime_config.h"
#include "engine/common/helpcode_utils.h"
#include "engine/quanpin/quanpin_query.h"
#include "engine/shuangpin/shuangpin_dictionary.h"
#include "src/ipc/candidate_selection_policy.h"
#include <windows.h>
#include <algorithm>
#include <filesystem>
#include <system_error>
namespace
{
void InputLetters(EngineInputSession &session, const std::string &keys)
{
for (const char ch : keys)
{
const bool is_upper = ch >= 'A' && ch <= 'Z';
const char upper = is_upper ? ch : static_cast<char>(ch - ('a' - 'A'));
session.handle_key(static_cast<UINT>(upper), 0, static_cast<WCHAR>(ch));
}
}
void InputSequence(EngineInputSession &session, const std::string &keys)
{
for (const char ch : keys)
{
if (ch == '\'')
{
session.handle_key(VK_OEM_7, 0, L'\'');
continue;
}
const bool is_upper = ch >= 'A' && ch <= 'Z';
const char upper = is_upper ? ch : static_cast<char>(ch - ('a' - 'A'));
session.handle_key(static_cast<UINT>(upper), 0, static_cast<WCHAR>(ch));
}
}
// 把 ime 配置重定向到一次性目录:SetConfiguredFuzzyPinyinRule 会真实落盘,
// 不能写进开发机的用户配置。用 METASEQUOIA_IME_CONFIG_DIR 而不是改 LOCALAPPDATA:
// 后者已经不是配置位置的权威(安装器会把数据目录写进 HKLM),而且它会把引擎词库
// 一起带走——本用例的候选断言正需要真实词库。
class ScopedConfigRoot
{
public:
ScopedConfigRoot()
{
wchar_t buffer[32768];
const DWORD length = GetEnvironmentVariableW(L"METASEQUOIA_IME_CONFIG_DIR", buffer, 32768);
had_previous_ = length != 0 || GetLastError() != ERROR_ENVVAR_NOT_FOUND;
previous_.assign(buffer, length);
root_ = std::filesystem::temp_directory_path() /
(L"msime-模糊音注入测试-" + std::to_wstring(GetCurrentProcessId()));
const auto data_dir = root_ / L"metasequoiaime";
std::error_code ec;
std::filesystem::remove_all(root_, ec);
std::filesystem::create_directories(data_dir, ec);
std::filesystem::copy_file(MSIME_DEFAULT_CONFIG_PATH, data_dir / L"config.default.toml",
std::filesystem::copy_options::overwrite_existing, ec);
// 拷贝失败(如仓库路径含非 ASCII 字符被 ACP 转换破坏)必须在源头报出来,
// 否则后面的 SetConfiguredFuzzyPinyinRule 会以无关断言的形式失败。
REQUIRE(!ec);
SetEnvironmentVariableW(L"METASEQUOIA_IME_CONFIG_DIR", data_dir.c_str());
}
~ScopedConfigRoot()
{
SetEnvironmentVariableW(L"METASEQUOIA_IME_CONFIG_DIR", had_previous_ ? previous_.c_str() : nullptr);
std::error_code ec;
std::filesystem::remove_all(root_, ec);
}
ScopedConfigRoot(const ScopedConfigRoot &) = delete;
ScopedConfigRoot &operator=(const ScopedConfigRoot &) = delete;
private:
std::wstring previous_;
bool had_previous_ = false;
std::filesystem::path root_;
};
bool HasCandidateWithCanonicalPrefix(const EngineInputSession &session, const char *prefix)
{
for (const auto &item : session.get_candidates())
{
if (item.canonical_pinyin.rfind(prefix, 0) == 0)
return true;
}
return false;
}
} // namespace
TEST_CASE(EngineSessionRejectsOnlineResponsesFromOtherSessionsAndPriorCompositions)
{
EngineInputSession first(SchemeType::Quanpin), second(SchemeType::Quanpin);
InputLetters(first, "nihao");
InputLetters(second, "nihao");
const auto query = first.online_query();
REQUIRE(query.has_value());
REQUIRE(!second.apply_online_candidate(*query, "跨会话测试候选", CandidateSource::CloudSuggestion));
first.reset_state();
InputLetters(first, "nihao");
REQUIRE(!first.apply_online_candidate(*query, "旧组合测试候选", CandidateSource::CloudSuggestion));
const auto current = first.online_query();
REQUIRE(current.has_value());
REQUIRE(first.apply_online_candidate(*current, "本次测试候选", CandidateSource::CloudSuggestion));
const auto &candidates = first.get_candidates();
const auto accepted = std::find_if(candidates.begin(), candidates.end(), [](const auto &item) {
return item.word == "本次测试候选" && item.source == CandidateSource::CloudSuggestion;
});
REQUIRE(accepted != candidates.end());
REQUIRE(!first.apply_online_candidate(*current, "本次测试候选", CandidateSource::CloudSuggestion));
}
TEST_CASE(EngineShuangpinSessionYoFindsDictionaryCandidate)
{
EngineInputSession session(SchemeType::Shuangpin, GetXiaoheShuangpinProfile());
InputLetters(session, "yo");
const auto &candidates = session.get_candidates();
REQUIRE(std::any_of(candidates.begin(), candidates.end(),
[](const auto &item) { return item.word == "哟" && item.canonical_pinyin == "yo"; }));
REQUIRE_EQ(session.get_quanpin(), std::string("yo"));
}
TEST_CASE(EngineShuangpinSessionContinuesCompositionWithoutHelpcode)
{
EngineInputSession session(SchemeType::Shuangpin);
InputLetters(session, "xitele");
const auto transition = session.advance_composition_after_selection("xi", "西", "xi");
REQUIRE(transition.continues_composition);
REQUIRE_EQ(transition.consumed_raw_input_with_cases, std::string("xi"));
REQUIRE_EQ(session.get_pinyin_sequence(), std::string("tele"));
}
TEST_CASE(EngineShuangpinMicrosoftSemicolonFinalStaysInConsumedInput)
{
EngineInputSession session(SchemeType::Shuangpin, GetMicrosoftShuangpinProfile());
// In the Microsoft profile ';' is the "ing" final: "b;" is bing, "ni" is ni.
session.handle_key('B', 0, L'b');
session.handle_key(VK_OEM_1, 0, L';');
session.handle_key('N', 0, L'n');
session.handle_key('I', 0, L'i');
const auto transition = session.advance_composition_after_selection("b;", "冰", "bing");
REQUIRE(transition.continues_composition);
REQUIRE_EQ(transition.consumed_raw_input_with_cases, std::string("b;"));
REQUIRE_EQ(session.get_pinyin_sequence(), std::string("ni"));
}
// 模糊音是会话级注入:总开关 + 规则位两层。总开关关 → 聚合 getter 全零,候选不出现;
// 开规则 + 开总开关 → zang 候选出现(全拼 zhang / 双拼 vh,AC1/AC2);仅关总开关(规则保留)→
// 候选消失;重开总开关 → 恢复(AC2b)。
TEST_CASE(EngineSessionAppliesFuzzyPinyinRulesForQuanpinAndShuangpin)
{
ScopedConfigRoot config_root;
InitImeConfig();
REQUIRE(!GetConfiguredFuzzyPinyinEnabled());
REQUIRE_EQ(GetConfiguredFuzzyPinyinOptions().rules, 0u);
// A:默认全关,zhang 只命中 zhang 读音。
{
EngineInputSession quanpin(SchemeType::Quanpin);
InputLetters(quanpin, "zhang");
REQUIRE(!HasCandidateWithCanonicalPrefix(quanpin, "zang"));
}
// 开规则、总开关仍关:getter 全零,行为不变。
REQUIRE(SetConfiguredFuzzyPinyinRule("fuzzy_z_zh", true));
REQUIRE_EQ(GetConfiguredFuzzyPinyinOptions().rules, 0u);
{
EngineInputSession quanpin(SchemeType::Quanpin);
InputLetters(quanpin, "zhang");
REQUIRE(!HasCandidateWithCanonicalPrefix(quanpin, "zang"));
}
// B:开总开关 —— 首次启用会播种全部规则(产品语义);本用例要的是「只有 z/zh 一条
// 规则」的纯净场景,播种后把其余规则关回去,此后总开关翻动不再碰规则位。
REQUIRE(SetConfiguredFuzzyPinyinEnabled(true));
for (const char *key : {"fuzzy_c_ch", "fuzzy_s_sh", "fuzzy_n_l", "fuzzy_f_h", "fuzzy_r_l", "fuzzy_an_ang",
"fuzzy_en_eng", "fuzzy_in_ing", "fuzzy_ian_iang", "fuzzy_uan_uang"})
REQUIRE(SetConfiguredFuzzyPinyinRule(key, false));
REQUIRE_EQ(GetConfiguredFuzzyPinyinRuleStates().rules,
static_cast<std::uint32_t>(metasequoia::FuzzyPinyinRule::Z_ZH));
{
EngineInputSession quanpin(SchemeType::Quanpin);
InputLetters(quanpin, "zhang");
REQUIRE(HasCandidateWithCanonicalPrefix(quanpin, "zang"));
}
{
EngineInputSession shuangpin(SchemeType::Shuangpin);
InputLetters(shuangpin, "vh");
REQUIRE(HasCandidateWithCanonicalPrefix(shuangpin, "zang"));
}
// A:仅关总开关(规则位保留)—— 候选消失。
REQUIRE(SetConfiguredFuzzyPinyinEnabled(false));
REQUIRE_EQ(GetConfiguredFuzzyPinyinRuleStates().rules,
static_cast<std::uint32_t>(metasequoia::FuzzyPinyinRule::Z_ZH));
{
EngineInputSession quanpin(SchemeType::Quanpin);
InputLetters(quanpin, "zhang");
REQUIRE(!HasCandidateWithCanonicalPrefix(quanpin, "zang"));
}
// B:重开总开关 —— 恢复。
REQUIRE(SetConfiguredFuzzyPinyinEnabled(true));
{
EngineInputSession quanpin(SchemeType::Quanpin);
InputLetters(quanpin, "zhang");
REQUIRE(HasCandidateWithCanonicalPrefix(quanpin, "zang"));
}
// 收尾:总开关与规则位归零,不留脏状态给进程内后续用例。
REQUIRE(SetConfiguredFuzzyPinyinEnabled(false));
REQUIRE(SetConfiguredFuzzyPinyinRule("fuzzy_z_zh", false));
REQUIRE_EQ(GetConfiguredFuzzyPinyinOptions().rules, 0u);
REQUIRE(!GetConfiguredFuzzyPinyinEnabled());
}
TEST_CASE(CloudCandidateNeverEntersCreatingWordMode)
{
REQUIRE(!FanyImeIpc::ShouldEnterCreatingWord(CandidateSource::CloudSuggestion, true));
REQUIRE(!FanyImeIpc::ShouldEnterCreatingWord(CandidateSource::CloudSuggestion, false));
REQUIRE(FanyImeIpc::ShouldEnterCreatingWord(CandidateSource::Database, true));
REQUIRE(!FanyImeIpc::ShouldEnterCreatingWord(CandidateSource::Database, false));
}
TEST_CASE(OnlyGeneratedSentencesWithBothCanonicalHalvesStoreAtTheEarlyReturn)
{
// Generated is the only special source that can end a creating-word session
// with a real reading; the other early-return sources are self-contained.
REQUIRE(FanyImeIpc::ShouldStoreEarlyReturnPhrase(CandidateSource::Generated, true, "xi", "ni'hao'zhong'guo"));
REQUIRE(!FanyImeIpc::ShouldStoreEarlyReturnPhrase(CandidateSource::Emoji, true, "xi", "ni'hao'zhong'guo"));
REQUIRE(!FanyImeIpc::ShouldStoreEarlyReturnPhrase(CandidateSource::QuickPhrase, true, "xi", "ni'hao'zhong'guo"));
// Fallback whole-sentence candidates take the normal path, where the
// creating-word completion block persists them.
REQUIRE(!FanyImeIpc::ShouldStoreEarlyReturnPhrase(CandidateSource::Fallback, true, "xi", "ni'hao'zhong'guo"));
// 没有造词前缀的整句也要落库:它自己就是完整的一条,而词库里没有它那一行,
// 调频改不到它。前缀为空时不看前缀读音。
REQUIRE(FanyImeIpc::ShouldStoreEarlyReturnPhrase(CandidateSource::Generated, false, "", "ni'hao'zhong'guo"));
REQUIRE(FanyImeIpc::ShouldStoreEarlyReturnPhrase(CandidateSource::Generated, false, "xi", "ni'hao'zhong'guo"));
// 造词中而前缀没有读音,或整句自己没有读音,都拼不出完整读音,不能落库。
REQUIRE(!FanyImeIpc::ShouldStoreEarlyReturnPhrase(CandidateSource::Generated, true, "", "ni'hao'zhong'guo"));
REQUIRE(!FanyImeIpc::ShouldStoreEarlyReturnPhrase(CandidateSource::Generated, true, "xi", ""));
REQUIRE(!FanyImeIpc::ShouldStoreEarlyReturnPhrase(CandidateSource::Generated, false, "", ""));
}
TEST_CASE(StandaloneWholeSentenceCandidatesAreLearnedWithinThePhraseLengthCap)
{
// 两条整句来源都要落库:词格走提前返回,Google 解码器走普通路径,同一条规则。
REQUIRE(FanyImeIpc::ShouldStoreStandaloneSentence(CandidateSource::Generated, "na'yi'tiao"));
REQUIRE(FanyImeIpc::ShouldStoreStandaloneSentence(CandidateSource::Fallback, "na'yi'tiao"));
REQUIRE(FanyImeIpc::ShouldStoreStandaloneSentence(CandidateSource::Generated, "na"));
// 词库里本来就有的候选不用落库,调频改得到它自己那一行。
REQUIRE(!FanyImeIpc::ShouldStoreStandaloneSentence(CandidateSource::Database, "na'yi'tiao"));
REQUIRE(!FanyImeIpc::ShouldStoreStandaloneSentence(CandidateSource::UserDatabase, "na'yi'tiao"));
REQUIRE(!FanyImeIpc::ShouldStoreStandaloneSentence(CandidateSource::CloudSuggestion, "na'yi'tiao"));
// 没有读音就拼不出词条。
REQUIRE(!FanyImeIpc::ShouldStoreStandaloneSentence(CandidateSource::Generated, ""));
// 长度上限:7 音节落库,8 音节不落。
REQUIRE(FanyImeIpc::CountCanonicalSyllables("a'a'a'a'a'a'a") == 7);
REQUIRE(FanyImeIpc::ShouldStoreStandaloneSentence(CandidateSource::Generated, "a'a'a'a'a'a'a"));
REQUIRE(!FanyImeIpc::ShouldStoreStandaloneSentence(CandidateSource::Generated, "a'a'a'a'a'a'a'a"));
}
TEST_CASE(MixedAsyncCandidatesKeepReservedSlotsForEveryArrivalOrder)
{
const auto local = [](std::string word) { return WordItem("ni", std::move(word), 100); };
const auto english = [] { return WordItem("ni", "nice", 1, CandidateSource::EnglishDictionary); };
const auto emoji = [] { return WordItem("ni", "\xF0\x9F\x98\x80", 1, CandidateSource::Emoji); };
const auto cloud = [] { return WordItem("ni", "云候选", 1, CandidateSource::CloudSuggestion); };
const auto ai = [] { return WordItem("ni", "AI联想", 1, CandidateSource::AiSuggestion); };
std::vector<WordItem> items = {local("你"), english(), local("呢"), ai(), cloud()};
FanyImeIpc::NormalizeMixedCandidateOrder(items);
REQUIRE_EQ(items[0].word, std::string("你"));
REQUIRE_EQ(items[1].source, CandidateSource::CloudSuggestion);
REQUIRE_EQ(items[2].source, CandidateSource::AiSuggestion);
REQUIRE_EQ(items[3].source, CandidateSource::EnglishDictionary);
items = {local("你"), ai(), local("呢"), english()};
FanyImeIpc::NormalizeMixedCandidateOrder(items);
REQUIRE_EQ(items[1].source, CandidateSource::EnglishDictionary);
REQUIRE_EQ(items[2].source, CandidateSource::AiSuggestion);
items = {local("你"), cloud(), local("呢"), english()};
FanyImeIpc::NormalizeMixedCandidateOrder(items);
REQUIRE_EQ(items[1].source, CandidateSource::CloudSuggestion);
REQUIRE_EQ(items[2].source, CandidateSource::EnglishDictionary);
}
TEST_CASE(PromotedEnglishCandidateCanBecomeTheFirstMixedCandidate)
{
const auto local = [](std::string word) { return WordItem("github", std::move(word), 100); };
const auto cloud = [] { return WordItem("github", "云候选", 1, CandidateSource::CloudSuggestion); };
const auto ai = [] { return WordItem("github", "AI联想", 1, CandidateSource::AiSuggestion); };
std::vector<WordItem> items = {
local("个"), cloud(), ai(), WordItem("github", "GitHub", 1100, CandidateSource::EnglishDictionary), local("给"),
};
FanyImeIpc::NormalizeMixedCandidateOrder(items);
REQUIRE_EQ(items[0].word, std::string("GitHub"));
REQUIRE_EQ(items[1].word, std::string("个"));
REQUIRE_EQ(items[2].source, CandidateSource::CloudSuggestion);
REQUIRE_EQ(items[3].source, CandidateSource::AiSuggestion);
}
TEST_CASE(FixedEnglishCandidateKeepsItsMixedCandidatePosition)
{
const auto local = [](std::string word) { return WordItem("github", std::move(word), 100); };
const auto cloud = [] { return WordItem("github", "云候选", 1, CandidateSource::CloudSuggestion); };
const auto ai = [] { return WordItem("github", "AI联想", 1, CandidateSource::AiSuggestion); };
WordItem english("github", "GitHub", 1100, CandidateSource::EnglishDictionary);
english.fixed_position = 1;
std::vector<WordItem> items = {local("个"), english, local("给")};
FanyImeIpc::NormalizeMixedCandidateOrder(items);
REQUIRE_EQ(items[0].word, std::string("GitHub"));
items.push_back(cloud());
FanyImeIpc::NormalizeMixedCandidateOrder(items);
REQUIRE_EQ(items[0].word, std::string("GitHub"));
items.push_back(ai());
FanyImeIpc::NormalizeMixedCandidateOrder(items);
REQUIRE_EQ(items[0].word, std::string("GitHub"));
english.fixed_position = 3;
items = {local("个"), english, ai(), cloud(), local("给")};
FanyImeIpc::NormalizeMixedCandidateOrder(items);
REQUIRE_EQ(items[2].word, std::string("GitHub"));
}
TEST_CASE(EmojiMixedCandidateFollowsEnglishAndShiftsWithCloudAndAi)
{
const auto local = [](std::string word) { return WordItem("ni", std::move(word), 100); };
const auto english = [] { return WordItem("ni", "nice", 1, CandidateSource::EnglishDictionary); };
const auto emoji = [] { return WordItem("ni", "\xF0\x9F\x98\x80", 1, CandidateSource::Emoji); };
const auto cloud = [] { return WordItem("ni", "云候选", 1, CandidateSource::CloudSuggestion); };
const auto ai = [] { return WordItem("ni", "AI联想", 1, CandidateSource::AiSuggestion); };
// Base case: English at slot 2, emoji at slot 3.
std::vector<WordItem> items = {local("你"), emoji(), local("呢"), english()};
FanyImeIpc::NormalizeMixedCandidateOrder(items);
REQUIRE_EQ(items[1].source, CandidateSource::EnglishDictionary);
REQUIRE_EQ(items[2].source, CandidateSource::Emoji);
// Cloud + AI occupy slots 2/3; English and emoji shift to slots 4/5.
items = {local("你"), emoji(), english(), ai(), cloud()};
FanyImeIpc::NormalizeMixedCandidateOrder(items);
REQUIRE_EQ(items[1].source, CandidateSource::CloudSuggestion);
REQUIRE_EQ(items[2].source, CandidateSource::AiSuggestion);
REQUIRE_EQ(items[3].source, CandidateSource::EnglishDictionary);
REQUIRE_EQ(items[4].source, CandidateSource::Emoji);
// Cloud only: English slot 3, emoji slot 4.
items = {local("你"), emoji(), local("呢"), english(), cloud()};
FanyImeIpc::NormalizeMixedCandidateOrder(items);
REQUIRE_EQ(items[1].source, CandidateSource::CloudSuggestion);
REQUIRE_EQ(items[2].source, CandidateSource::EnglishDictionary);
REQUIRE_EQ(items[3].source, CandidateSource::Emoji);
}
TEST_CASE(KaomojiMixedCandidateSitsRightAfterEmoji)
{
const auto local = [](std::string word) { return WordItem("ni", std::move(word), 100); };
const auto english = [] { return WordItem("ni", "nice", 1, CandidateSource::EnglishDictionary); };
const auto emoji = [] { return WordItem("ni", "\xF0\x9F\x98\x80", 1, CandidateSource::Emoji); };
const auto kaomoji = [] { return WordItem("ni", "(^_^)", 1, CandidateSource::Kaomoji); };
const auto cloud = [] { return WordItem("ni", "云候选", 1, CandidateSource::CloudSuggestion); };
const auto ai = [] { return WordItem("ni", "AI联想", 1, CandidateSource::AiSuggestion); };
// Base case: English slot 2, emoji slot 3, kaomoji slot 4.
std::vector<WordItem> items = {local("你"), kaomoji(), emoji(), local("呢"), english()};
FanyImeIpc::NormalizeMixedCandidateOrder(items);
REQUIRE_EQ(items[1].source, CandidateSource::EnglishDictionary);
REQUIRE_EQ(items[2].source, CandidateSource::Emoji);
REQUIRE_EQ(items[3].source, CandidateSource::Kaomoji);
// Cloud + AI occupy slots 2/3; emoji/kaomoji shift to slots 5/6.
items = {local("你"), kaomoji(), emoji(), english(), ai(), cloud()};
FanyImeIpc::NormalizeMixedCandidateOrder(items);
REQUIRE_EQ(items[1].source, CandidateSource::CloudSuggestion);
REQUIRE_EQ(items[2].source, CandidateSource::AiSuggestion);
REQUIRE_EQ(items[3].source, CandidateSource::EnglishDictionary);
REQUIRE_EQ(items[4].source, CandidateSource::Emoji);
REQUIRE_EQ(items[5].source, CandidateSource::Kaomoji);
}
TEST_CASE(JapaneseSingleKanaPairStaysAheadOfCloudCandidate)
{
std::vector<WordItem> items = {
WordItem("Ka", "か", 1000000, CandidateSource::Generated),
WordItem("Ka", "カ", 999999, CandidateSource::Generated),
WordItem("ka", "蚊", 1, CandidateSource::CloudSuggestion),
WordItem("ka", "科", 100, CandidateSource::Database),
};
FanyImeIpc::NormalizeMixedCandidateOrder(items, 2);
REQUIRE_EQ(items[0].word, std::string("か"));
REQUIRE_EQ(items[1].word, std::string("カ"));
REQUIRE_EQ(items[2].source, CandidateSource::CloudSuggestion);
}
TEST_CASE(EngineShuangpinAiCandidateConsumesFullRawInput)
{
EngineInputSession session(SchemeType::Shuangpin);
InputLetters(session, "geziaa");
const auto transition = session.advance_composition_after_selection("geziaa", "鸽子啊", "ge'zi'a");
REQUIRE(!transition.continues_composition);
REQUIRE_EQ(session.get_pinyin_sequence(), std::string("geziaa"));
}
TEST_CASE(EngineShuangpinSessionContinuesCompositionWithSingleHelpcode)
{
EngineInputSession session(SchemeType::Shuangpin);
InputLetters(session, "xitelea");
const auto transition = session.advance_composition_after_selection("xi", "西", "xi");
REQUIRE(transition.continues_composition);
// The active helpcode is not part of what the selection consumed.
REQUIRE_EQ(transition.consumed_raw_input_with_cases, std::string("xi"));
REQUIRE_EQ(session.get_pinyin_sequence(), std::string("tele"));
}
TEST_CASE(EngineShuangpinUppercaseSingleHelpcodePrefersSecondCodeMatches)
{
REQUIRE(HelpcodeUtils::select_helpcode_schema("lantian"));
EngineInputSession session(SchemeType::Shuangpin);
InputLetters(session, "nid");
session.reset_state();
InputLetters(session, "niD");
const auto &candidates = session.get_candidates();
const auto index_of = [&](const std::string &word) {
const auto found = std::find_if(candidates.begin(), candidates.end(),
[&](const IInputSession::WordItem &item) { return item.word == word; });
return static_cast<size_t>(std::distance(candidates.begin(), found));
};
const size_t ni = index_of("泥");
const size_t ni_second_code = index_of("溺");
const size_t ni_other_second_code = index_of("腻");
REQUIRE(ni < candidates.size());
REQUIRE(ni_second_code < candidates.size());
REQUIRE(ni_other_second_code < candidates.size());
REQUIRE(ni_second_code < ni);
REQUIRE(ni_other_second_code < ni);
}
TEST_CASE(EngineShuangpinSessionContinuesCompositionWithDoubleHelpcode)
{
EngineInputSession session(SchemeType::Shuangpin);
InputLetters(session, "xiteleaA");
const auto transition = session.advance_composition_after_selection("xi", "西", "xi");
REQUIRE(transition.continues_composition);
REQUIRE_EQ(session.get_pinyin_sequence(), std::string("tele"));
}
TEST_CASE(EngineShuangpinDoubleHelpcodesAreDisplayedAsOneSegment)
{
EngineInputSession session(SchemeType::Shuangpin);
InputLetters(session, "yakP");
REQUIRE_EQ(session.get_pinyin_segmentation_with_cases(), std::string("ya'kP"));
}
TEST_CASE(EngineShuangpinSessionCloudQueryMatchesLegacyTiming)
{
EngineInputSession session(SchemeType::Shuangpin);
InputLetters(session, "xi");
auto state = session.get_cloud_query_state();
REQUIRE(state.should_query);
session.reset_state();
InputLetters(session, "xia");
state = session.get_cloud_query_state();
REQUIRE(!state.should_query);
session.reset_state();
InputLetters(session, "xiA");
state = session.get_cloud_query_state();
REQUIRE(!state.should_query);
REQUIRE_EQ(state.cache_key, std::string("xi"));
session.reset_state();
InputLetters(session, "xI");
state = session.get_cloud_query_state();
REQUIRE(!state.should_query);
}
TEST_CASE(EngineShuangpinSessionCloudQueryDoesNotTriggerWhenHelpcodesApply)
{
EngineInputSession session(SchemeType::Shuangpin);
InputLetters(session, "xitelea");
auto state = session.get_cloud_query_state();
REQUIRE(!state.should_query);
REQUIRE_EQ(state.cache_key, std::string("xitele"));
session.reset_state();
InputLetters(session, "xiteleaA");
state = session.get_cloud_query_state();
REQUIRE(!state.should_query);
REQUIRE_EQ(state.cache_key, std::string("xitele"));
}
TEST_CASE(EngineShuangpinSessionCloudCommitUsesRawShuangpinSequence)
{
EngineInputSession session(SchemeType::Shuangpin);
InputLetters(session, "vh");
const auto state = session.get_cloud_query_state();
REQUIRE(state.should_query);
REQUIRE_EQ(state.query_text, std::string("zhang"));
REQUIRE_EQ(state.cache_key, std::string("vh"));
REQUIRE_EQ(state.committed_pinyin, std::string("vh"));
}
TEST_CASE(EngineShuangpinSessionCloudQueryUsesGoogleSpellingForU)
{
EngineInputSession session(SchemeType::Shuangpin);
// 小鹤的 nt 转成词库那侧的 canonical 写法是 nve,但云输入和本地 Google 解码器
// 的音节表里只有 nue:nve'dai'dong'wu 会被它们拆成 nv + e,「虐待动物」变成
// 「女蛾黛动物」。送出去的那一份必须是 nue'dai'dong'wu,撇号照旧带上。
InputLetters(session, "ntdddswu");
const auto state = session.get_cloud_query_state();
REQUIRE(state.should_query);
REQUIRE_EQ(state.query_text, std::string("nue'dai'dong'wu"));
REQUIRE_EQ(state.cache_key, std::string("ntdddswu"));
}
TEST_CASE(EngineShuangpinSessionStoresMultiSyllableDynamicPhrase)
{
EngineInputSession session(SchemeType::Shuangpin);
// Cloud and AI candidates are committed with their raw shuangpin sequence.
// "vsgo" must retain the converted boundary "zhong'guo" before entering
// the strict canonical-pinyin storage path. "中国" already exists in the
// shipped dictionary, so this verifies the route without mutating user data.
REQUIRE_EQ(session.store_user_phrase("vsgo", "中国"), 0);
}
TEST_CASE(EngineQuanpinSessionCloudQueryDoesNotTriggerWhenHelpcodesApply)
{
EngineInputSession session(SchemeType::Quanpin);
InputLetters(session, "xiteleA");
auto state = session.get_cloud_query_state();
REQUIRE(!state.should_query);
REQUIRE_EQ(state.cache_key, std::string("xitele"));
session.reset_state();
InputLetters(session, "xiteleAA");
state = session.get_cloud_query_state();
REQUIRE(!state.should_query);
REQUIRE_EQ(state.cache_key, std::string("xitele"));
session.reset_state();
InputLetters(session, "xitelR");
state = session.get_cloud_query_state();
REQUIRE(state.should_query);
}
TEST_CASE(EngineQuanpinSessionContinuesCompositionForCreatingWord)
{
EngineInputSession session(SchemeType::Quanpin);
InputLetters(session, "xitele");
const auto transition = session.advance_composition_after_selection("xi", "西", "xi");
REQUIRE(transition.continues_composition);
REQUIRE_EQ(transition.consumed_raw_input_with_cases, std::string("xi"));
REQUIRE_EQ(session.get_pinyin_sequence(), std::string("tele"));
REQUIRE_EQ(session.get_pinyin_segmentation_with_cases(), std::string("te'le"));
}
TEST_CASE(EngineQuanpinSessionCompletesCreatingWordProgress)
{
EngineInputSession session(SchemeType::Quanpin);
InputLetters(session, "xitele");
const auto first_transition = session.advance_composition_after_selection("xi", "西", "xi");
const auto first_progress = session.update_creating_word_progress("", "", "西", first_transition);
REQUIRE(!first_progress.completed);
REQUIRE_EQ(first_progress.pinyin, std::string("xi"));
REQUIRE_EQ(first_progress.word, std::string("西"));
REQUIRE_EQ(first_progress.preedit, std::string("西te'le"));
const auto second_transition = session.advance_composition_after_selection("te'le", "特乐", "te'le");
REQUIRE(!second_transition.continues_composition);
const auto second_progress =
session.update_creating_word_progress(first_progress.pinyin, first_progress.word, "特乐", second_transition);
REQUIRE(second_progress.completed);
REQUIRE(second_progress.can_store);
REQUIRE_EQ(second_progress.pinyin, std::string("xi'te'le"));
REQUIRE_EQ(second_progress.word, std::string("西特乐"));
}
TEST_CASE(EngineQuanpinAbbreviationsContinueAndCreateWithCanonicalPinyin)
{
EngineInputSession session(SchemeType::Quanpin);
InputLetters(session, "zgrm");
const auto first = session.advance_composition_after_selection("z'g", "中国", "zhong'guo");
REQUIRE(first.continues_composition);
REQUIRE_EQ(session.get_pinyin_sequence(), std::string("rm"));
REQUIRE_EQ(session.get_pinyin_segmentation_with_cases(), std::string("r'm"));
const auto first_progress = session.update_creating_word_progress("", "", "中国", first);
REQUIRE(!first_progress.completed);
REQUIRE_EQ(first_progress.pinyin, std::string("zhong'guo"));
REQUIRE_EQ(first_progress.word, std::string("中国"));
const auto second = session.advance_composition_after_selection("r'm", "人民", "ren'min");
REQUIRE(!second.continues_composition);
const auto completed =
session.update_creating_word_progress(first_progress.pinyin, first_progress.word, "人民", second);
REQUIRE(completed.completed);
REQUIRE(completed.can_store);
REQUIRE_EQ(completed.pinyin, std::string("zhong'guo'ren'min"));
REQUIRE_EQ(completed.word, std::string("中国人民"));
}
TEST_CASE(EngineQuanpinAbbreviationCandidatesRetainDatabaseCanonicalPinyin)
{
EngineInputSession session(SchemeType::Quanpin);
InputLetters(session, "zgrm");
const auto &candidates = session.get_candidates();
const auto candidate = std::find_if(candidates.begin(), candidates.end(), [](const IInputSession::WordItem &item) {
return item.source == CandidateSource::Database && item.pinyin != item.canonical_pinyin;
});
REQUIRE(candidate != candidates.end());
REQUIRE(!candidate->canonical_pinyin.empty());
REQUIRE_EQ(quanpin::split_segments(candidate->canonical_pinyin).size(),
HelpcodeUtils::count_han_chars(candidate->word));
}
TEST_CASE(EngineQuanpinSelectionPreservesManualSeparatorsInRemainingInput)
{
EngineInputSession session(SchemeType::Quanpin);
InputSequence(session, "zgrm'gh");
const auto first = session.advance_composition_after_selection("z'g", "中国", "zhong'guo");
REQUIRE(first.continues_composition);
REQUIRE_EQ(session.get_pinyin_sequence(), std::string("rm'gh"));
REQUIRE_EQ(session.get_pinyin_sequence_with_cases(), std::string("rm'gh"));
const auto second = session.advance_composition_after_selection("r'm", "人民", "ren'min");
REQUIRE(second.continues_composition);
REQUIRE_EQ(session.get_pinyin_sequence(), std::string("gh"));
}
TEST_CASE(EngineQuanpinSelectionConsumesOnlyTheLeadingManualBoundary)
{
EngineInputSession session(SchemeType::Quanpin);
InputSequence(session, "zg'rm'gh");
const auto transition = session.advance_composition_after_selection("z'g", "中国", "zhong'guo");
REQUIRE(transition.continues_composition);
REQUIRE_EQ(session.get_pinyin_sequence(), std::string("rm'gh"));
}
TEST_CASE(EngineShuangpinIncompleteManualSegmentsContinueCreatingWord)
{
EngineInputSession session(SchemeType::Shuangpin);
InputSequence(session, "v'x'r'm");
const auto first = session.advance_composition_after_selection("v", "中", "zhong");
REQUIRE(first.continues_composition);
REQUIRE_EQ(session.get_pinyin_sequence(), std::string("x'r'm"));
const auto first_progress = session.update_creating_word_progress("", "", "中", first);
REQUIRE(!first_progress.completed);
REQUIRE_EQ(first_progress.pinyin, std::string("zhong"));
REQUIRE_EQ(first_progress.word, std::string("中"));
const auto second = session.advance_composition_after_selection("x", "西", "xi");
REQUIRE(second.continues_composition);
REQUIRE_EQ(session.get_pinyin_sequence(), std::string("r'm"));
const auto second_progress =
session.update_creating_word_progress(first_progress.pinyin, first_progress.word, "西", second);
const auto third = session.advance_composition_after_selection("r", "人", "ren");
REQUIRE(third.continues_composition);
REQUIRE_EQ(session.get_pinyin_sequence(), std::string("m"));
const auto third_progress =
session.update_creating_word_progress(second_progress.pinyin, second_progress.word, "人", third);
const auto fourth = session.advance_composition_after_selection("m", "民", "min");
REQUIRE(!fourth.continues_composition);
const auto completed =
session.update_creating_word_progress(third_progress.pinyin, third_progress.word, "民", fourth);
REQUIRE(completed.completed);
REQUIRE(completed.can_store);
REQUIRE_EQ(completed.pinyin, std::string("zhong'xi'ren'min"));
REQUIRE_EQ(completed.word, std::string("中西人民"));
}
TEST_CASE(EngineShuangpinWholeSentenceCandidateCompletesCreatingWordWithCanonicalPinyin)
{
// 小鹤双拼 xi'ni'hc'vs'go:先选「西」,再用整句候选「你好中国」收尾。
// 整句候选(Google 整句 fallback / lattice 整句)只有带上 canonical quanpin,
// 造词才能拼出完整读音并落库。
EngineInputSession session(SchemeType::Shuangpin);
InputSequence(session, "xi'ni'hc'vs'go");
const auto first = session.advance_composition_after_selection("xi", "西", "xi");
REQUIRE(first.continues_composition);
const auto first_progress = session.update_creating_word_progress("", "", "西", first);
REQUIRE_EQ(first_progress.pinyin, std::string("xi"));
REQUIRE_EQ(first_progress.word, std::string("西"));
// 整句候选提交的是剩余的全部编码。
const std::string remaining = session.get_pinyin_sequence();
const auto second = session.advance_composition_after_selection(remaining, "你好中国", "ni'hao'zhong'guo");
REQUIRE(!second.continues_composition);
const auto completed =
session.update_creating_word_progress(first_progress.pinyin, first_progress.word, "你好中国", second);
REQUIRE(completed.completed);
REQUIRE(completed.can_store);
REQUIRE_EQ(completed.pinyin, std::string("xi'ni'hao'zhong'guo"));
REQUIRE_EQ(completed.word, std::string("西你好中国"));
}
TEST_CASE(EngineShuangpinWholeSentenceCandidateWithoutCanonicalPinyinCannotBeStored)
{
// 这条记录的是修复前的行为:整句 fallback 候选的 canonical_pinyin 为空时,
// 前缀 + 整句只能上屏,永远学不到词库里。
EngineInputSession session(SchemeType::Shuangpin);
InputSequence(session, "xi'ni'hc'vs'go");
const auto first = session.advance_composition_after_selection("xi", "西", "xi");
const auto first_progress = session.update_creating_word_progress("", "", "西", first);
const std::string remaining = session.get_pinyin_sequence();
const auto second = session.advance_composition_after_selection(remaining, "你好中国", "");
const auto completed =
session.update_creating_word_progress(first_progress.pinyin, first_progress.word, "你好中国", second);
REQUIRE(completed.completed);
REQUIRE(!completed.can_store);
REQUIRE(completed.pinyin.empty());
REQUIRE_EQ(completed.word, std::string("西你好中国"));
}
TEST_CASE(WholeSentenceCandidatesAlwaysCarryAStoreableCanonicalPinyin)
{
// 整句候选(lattice 的 Generated、Google 整句的 Fallback)是造词最后一段
// 最常选中的东西,必须带上完整的 canonical quanpin,否则 update_creating_word_progress
// 拼不出读音,前缀 + 整句只上屏、学不到词库里。
// 整句是否产出取决于装了哪套词库/模型,所以这里只校验产出的那些;
// 「双拼编码能转出完整 quanpin 读音」这一前提则无条件断言,
// 那正是修复里挂到整句候选上的那个字符串。
const auto check = [](EngineInputSession &session) {
for (const auto &item : session.get_candidates())
{
if (item.source != CandidateSource::Generated && item.source != CandidateSource::Fallback)
continue;
REQUIRE(!item.canonical_pinyin.empty());
REQUIRE_EQ(quanpin::split_segments(item.canonical_pinyin).size(),
HelpcodeUtils::count_han_chars(item.word));
}
};
EngineInputSession shuangpin(SchemeType::Shuangpin);
InputSequence(shuangpin, "ni'hc'vs'go");
REQUIRE_EQ(shuangpin.get_quanpin(), std::string("nihaozhongguo"));
check(shuangpin);
EngineInputSession quanpin(SchemeType::Quanpin);
InputSequence(quanpin, "ni'hao'zhong'guo");
check(quanpin);
}
TEST_CASE(EngineQuanpinIncompleteUppercaseSuffixIsNotConsumedAsHelpcode)
{
EngineInputSession session(SchemeType::Quanpin);
InputSequence(session, "zgR");
const auto transition = session.advance_composition_after_selection("z", "中", "zhong");
REQUIRE(transition.continues_composition);
REQUIRE_EQ(session.get_pinyin_sequence(), std::string("gr"));
REQUIRE_EQ(session.get_pinyin_sequence_with_cases(), std::string("gR"));
}
TEST_CASE(EngineQuanpinCompleteHelpcodeIsDiscardedButManualRemainderIsPreserved)
{
EngineInputSession session(SchemeType::Quanpin);
InputSequence(session, "ni'shuo'neNV");
const auto transition = session.advance_composition_after_selection("ni'shuo", "你说", "ni'shuo");
REQUIRE(transition.continues_composition);
REQUIRE_EQ(session.get_pinyin_sequence(), std::string("ne"));
REQUIRE_EQ(session.get_pinyin_sequence_with_cases(), std::string("ne"));
}
TEST_CASE(EngineCreatingWordDoesNotStoreWhenAnySelectedPartLacksCanonicalPinyin)
{
EngineInputSession session(SchemeType::Quanpin);
InputLetters(session, "zgrm");
const auto first = session.advance_composition_after_selection("z'g", "中国", "");
REQUIRE(first.continues_composition);
const auto first_progress = session.update_creating_word_progress("", "", "中国", first);
REQUIRE(first_progress.pinyin.empty());
const auto second = session.advance_composition_after_selection("r'm", "人民", "ren'min");
const auto completed =
session.update_creating_word_progress(first_progress.pinyin, first_progress.word, "人民", second);
REQUIRE(completed.completed);
REQUIRE(!completed.can_store);
REQUIRE(completed.pinyin.empty());
}
TEST_CASE(EngineQuanpinSessionContinuesCompositionAfterMultiSyllableSelection)
{
EngineInputSession session(SchemeType::Quanpin);
InputLetters(session, "zhengxianghuafen");
const auto transition = session.advance_composition_after_selection("zheng'xiang", "正向", "zheng'xiang");
REQUIRE(transition.continues_composition);
REQUIRE_EQ(session.get_pinyin_sequence(), std::string("huafen"));
REQUIRE_EQ(session.get_pinyin_segmentation_with_cases(), std::string("hua'fen"));
const auto progress = session.update_creating_word_progress("", "", "正向", transition);
REQUIRE_EQ(progress.pinyin, std::string("zheng'xiang"));
REQUIRE_EQ(progress.word, std::string("正向"));
REQUIRE_EQ(progress.preedit, std::string("正向hua'fen"));
}
TEST_CASE(EngineQuanpinSessionContinuesCompositionWithoutRetainingHelpcodes)
{
EngineInputSession session(SchemeType::Quanpin);
InputLetters(session, "nishuoneNV");
const auto transition = session.advance_composition_after_selection("ni'shuo", "你说", "ni'shuo");
REQUIRE(transition.continues_composition);
REQUIRE_EQ(session.get_pinyin_sequence(), std::string("ne"));
REQUIRE_EQ(session.get_pinyin_sequence_with_cases(), std::string("ne"));
REQUIRE_EQ(session.get_pinyin_segmentation_with_cases(), std::string("ne"));
const auto progress = session.update_creating_word_progress("", "", "你说", transition);
REQUIRE_EQ(progress.pinyin, std::string("ni'shuo"));
REQUIRE_EQ(progress.word, std::string("你说"));
REQUIRE_EQ(progress.preedit, std::string("你说ne"));
}
TEST_CASE(EngineShuangpinSessionDynamicCloudCandidateParticipatesInHelpcodesQuery)
{
EngineInputSession session(SchemeType::Shuangpin);
InputLetters(session, "xitele");
const auto state = session.get_cloud_query_state();
REQUIRE(state.should_query);
session.cache_dynamic_candidate(state.cache_key, "云词", CandidateSource::CloudSuggestion);
InputLetters(session, "a");
const auto &candidates = session.get_candidates();
const auto found = std::find_if(candidates.begin(), candidates.end(),
[](const IInputSession::WordItem &item) { return item.word == "云词"; });
REQUIRE(found != candidates.end());
REQUIRE(found->source == CandidateSource::CloudSuggestion);
}
TEST_CASE(EngineShuangpinSessionDynamicCandidateCacheDedupesRepeatedInserts)
{
EngineInputSession session(SchemeType::Shuangpin);
InputLetters(session, "xitele");
const auto state = session.get_cloud_query_state();
REQUIRE(state.should_query);
session.cache_dynamic_candidate(state.cache_key, "云词", CandidateSource::CloudSuggestion);
session.cache_dynamic_candidate(state.cache_key, "云词", CandidateSource::CloudSuggestion);
session.cache_dynamic_candidate(state.cache_key, "AI词", CandidateSource::AiSuggestion);
session.cache_dynamic_candidate(state.cache_key, "AI词", CandidateSource::AiSuggestion);
session.cache_dynamic_candidate(state.cache_key, "AI词2", CandidateSource::AiSuggestion);
// Force a fresh query so candidates are rebuilt from the series cache.
session.handle_key(VK_BACK, 0, 0);
InputLetters(session, "e");
const auto &candidates = session.get_candidates();
const auto cloud_count =
std::count_if(candidates.begin(), candidates.end(), [](const IInputSession::WordItem &item) {
return item.source == CandidateSource::CloudSuggestion && item.word == "云词";
});
const auto ai_count = std::count_if(candidates.begin(), candidates.end(), [](const IInputSession::WordItem &item) {
return item.source == CandidateSource::AiSuggestion;
});
const auto ai_latest = std::count_if(candidates.begin(), candidates.end(), [](const IInputSession::WordItem &item) {
return item.source == CandidateSource::AiSuggestion && item.word == "AI词2";
});
const auto ai_stale = std::count_if(candidates.begin(), candidates.end(), [](const IInputSession::WordItem &item) {
return item.source == CandidateSource::AiSuggestion && item.word == "AI词";
});
REQUIRE_EQ(cloud_count, 1);
REQUIRE_EQ(ai_count, 1);
REQUIRE_EQ(ai_latest, 1);
REQUIRE_EQ(ai_stale, 0);
}
TEST_CASE(EngineQuanpinSessionDynamicCloudCandidateParticipatesInHelpcodesQuery)
{
EngineInputSession session(SchemeType::Quanpin);
InputLetters(session, "xitele");
const auto state = session.get_cloud_query_state();
REQUIRE(state.should_query);
session.cache_dynamic_candidate(state.cache_key, "云词", CandidateSource::CloudSuggestion);
InputLetters(session, "A");
const auto &candidates = session.get_candidates();
const auto found = std::find_if(candidates.begin(), candidates.end(),
[](const IInputSession::WordItem &item) { return item.word == "云词"; });
REQUIRE(found != candidates.end());
REQUIRE(found->source == CandidateSource::CloudSuggestion);
}
TEST_CASE(EngineShuangpinSessionHasActiveHelpcodeTracksCloudQueryGate)
{
EngineInputSession session(SchemeType::Shuangpin);
InputLetters(session, "xitele");
REQUIRE(session.is_all_complete_pure_pinyin());
REQUIRE(!session.has_active_helpcode());
REQUIRE(session.get_cloud_query_state().should_query);
InputLetters(session, "a");
REQUIRE(session.is_all_complete_pure_pinyin());
REQUIRE(session.has_active_helpcode());
REQUIRE(!session.get_cloud_query_state().should_query);
}
TEST_CASE(EngineQuanpinSessionHasActiveHelpcodeTracksCloudQueryGate)
{
EngineInputSession session(SchemeType::Quanpin);
InputLetters(session, "xitele");
REQUIRE(session.is_all_complete_pure_pinyin());
REQUIRE(!session.has_active_helpcode());
REQUIRE(session.get_cloud_query_state().should_query);
InputLetters(session, "A");
REQUIRE(session.is_all_complete_pure_pinyin());
REQUIRE(session.has_active_helpcode());
REQUIRE(!session.get_cloud_query_state().should_query);
}
TEST_CASE(EngineShuangpinSessionUsesZiranmaProfileEndToEnd)
{
EngineInputSession session(SchemeType::Shuangpin, GetZiranmaShuangpinProfile());
InputLetters(session, "xd");
const auto state = session.get_cloud_query_state();
REQUIRE(state.should_query);
REQUIRE_EQ(state.query_text, std::string("xiang"));
REQUIRE_EQ(session.get_quanpin(), std::string("xiang"));
}
TEST_CASE(LegacyShuangpinDictionaryUsesZiranmaProfile)
{
ShuangpinDictionary dictionary(GetZiranmaShuangpinProfile());
dictionary.handleVkCode('X', 0, L'x');
dictionary.handleVkCode('D', 0, L'd');
REQUIRE_EQ(dictionary.get_quanpin(), std::string("xiang"));
}
TEST_CASE(EngineShuangpinInitialVQueriesZhCandidatesAndExpands)
{
EngineInputSession session(SchemeType::Shuangpin);
InputLetters(session, "v");
const auto &initial_candidates = session.get_candidates();
REQUIRE_EQ(initial_candidates.size(), static_cast<std::size_t>(24));
REQUIRE(std::all_of(initial_candidates.begin(), initial_candidates.end(), [](const IInputSession::WordItem &item) {
return item.pinyin == "v" && item.canonical_pinyin.rfind("zh", 0) == 0;
}));
const auto initial_count = initial_candidates.size();
REQUIRE(session.expand_initial_candidates());
const auto &expanded_candidates = session.get_candidates();
REQUIRE(expanded_candidates.size() > initial_count);
REQUIRE(
std::all_of(expanded_candidates.begin(), expanded_candidates.end(), [](const IInputSession::WordItem &item) {
return item.pinyin == "v" && item.canonical_pinyin.rfind("zh", 0) == 0;
}));
}
TEST_CASE(EngineQuanpinInitialCandidatesStartLimitedAndExpand)
{
EngineInputSession session(SchemeType::Quanpin);
InputLetters(session, "z");
const auto &initial_candidates = session.get_candidates();
REQUIRE_EQ(initial_candidates.size(), static_cast<std::size_t>(24));
REQUIRE(std::all_of(initial_candidates.begin(), initial_candidates.end(),
[](const IInputSession::WordItem &item) { return item.pinyin.rfind("z", 0) == 0; }));
const auto initial_count = initial_candidates.size();
REQUIRE(session.expand_initial_candidates());
const auto &expanded_candidates = session.get_candidates();
REQUIRE(expanded_candidates.size() > initial_count);
}
TEST_CASE(EngineQuanpinSegmentedInitialCandidatesExpandInPlace)
{
EngineInputSession session(SchemeType::Quanpin);
InputSequence(session, "l'shi");
const auto count_initials = [](const std::vector<IInputSession::WordItem> &items) {
return std::count_if(items.begin(), items.end(), [](const IInputSession::WordItem &item) {
return item.source == CandidateSource::Database && item.pinyin == "l";
});
};
const auto count_other_candidates = [](const std::vector<IInputSession::WordItem> &items) {
return std::count_if(items.begin(), items.end(), [](const IInputSession::WordItem &item) {
return item.source != CandidateSource::Database || item.pinyin != "l";
});
};
const auto initial_candidates = session.get_candidates();
REQUIRE_EQ(count_initials(initial_candidates), 24);
const auto other_count = count_other_candidates(initial_candidates);
REQUIRE(session.expand_initial_candidates());
const auto &expanded_candidates = session.get_candidates();
REQUIRE(count_initials(expanded_candidates) > 24);
REQUIRE_EQ(count_other_candidates(expanded_candidates), other_count);
}
TEST_CASE(EngineShuangpinSegmentedInitialCandidatesExpandInPlace)
{
EngineInputSession session(SchemeType::Shuangpin);
InputSequence(session, "v'ui");
const auto count_initials = [](const std::vector<IInputSession::WordItem> &items) {
return std::count_if(items.begin(), items.end(), [](const IInputSession::WordItem &item) {
return item.source == CandidateSource::Database && item.pinyin == "v";
});
};
const auto count_other_candidates = [](const std::vector<IInputSession::WordItem> &items) {
return std::count_if(items.begin(), items.end(), [](const IInputSession::WordItem &item) {
return item.source != CandidateSource::Database || item.pinyin != "v";
});
};
const auto initial_candidates = session.get_candidates();
REQUIRE_EQ(count_initials(initial_candidates), 24);
const auto other_count = count_other_candidates(initial_candidates);
REQUIRE(session.expand_initial_candidates());
const auto &expanded_candidates = session.get_candidates();
REQUIRE(count_initials(expanded_candidates) > 24);
REQUIRE_EQ(count_other_candidates(expanded_candidates), other_count);
}
TEST_CASE(EngineSessionsKeepHelpcodeFilteringAndAnnotationsTogether)
{
// The integration runner supplies an isolated config and the locked release dictionaries.
InitImeConfig();
struct RestoreConfiguration
{
std::string quanpin = GetConfiguredQuanpinHelpcodeSchema();
std::string shuangpin = GetConfiguredShuangpinHelpcodeSchema();
bool quanpin_enabled = GetConfiguredQuanpinHelpcodeEnabled();
bool shuangpin_enabled = GetConfiguredShuangpinHelpcodeEnabled();
~RestoreConfiguration()
{
SetConfiguredQuanpinHelpcodeSchema(quanpin);
SetConfiguredShuangpinHelpcodeSchema(shuangpin);
SetConfiguredQuanpinHelpcodeEnabled(quanpin_enabled);
SetConfiguredShuangpinHelpcodeEnabled(shuangpin_enabled);
}
} restore;
REQUIRE(SetConfiguredQuanpinHelpcodeEnabled(true));
REQUIRE(SetConfiguredShuangpinHelpcodeEnabled(true));
REQUIRE(SetConfiguredQuanpinHelpcodeSchema("lantian"));
REQUIRE(SetConfiguredShuangpinHelpcodeSchema("ziranma"));
EngineInputSession quanpin(SchemeType::Quanpin);
EngineInputSession shuangpin(SchemeType::Shuangpin);
REQUIRE_EQ(quanpin.get_helpcode_annotation("你", true), std::string("(RX)"));
REQUIRE_EQ(shuangpin.get_helpcode_annotation("你", false), std::string("(rE)"));
InputLetters(quanpin, "niRX");
REQUIRE(!quanpin.get_candidates().empty());
REQUIRE(std::any_of(quanpin.get_candidates().begin(), quanpin.get_candidates().end(),
[](const auto &item) { return item.word == "你"; }));
InputLetters(shuangpin, "ni");
shuangpin.recompute_candidates();
quanpin.recompute_candidates();
REQUIRE_EQ(quanpin.get_helpcode_annotation("你", true), std::string("(RX)"));
REQUIRE_EQ(shuangpin.get_helpcode_annotation("你", false), std::string("(rE)"));
REQUIRE(std::any_of(quanpin.get_candidates().begin(), quanpin.get_candidates().end(),
[](const auto &item) { return item.word == "你"; }));
// A settings change is adopted by the next refresh, including candidate annotations.
REQUIRE(SetConfiguredQuanpinHelpcodeSchema("ziranma"));
quanpin.reset_state();
InputLetters(quanpin, "niRE");
REQUIRE_EQ(quanpin.get_helpcode_annotation("你", true), std::string("(RE)"));
REQUIRE(std::any_of(quanpin.get_candidates().begin(), quanpin.get_candidates().end(),
[](const auto &item) { return item.word == "你"; }));
REQUIRE_EQ(shuangpin.get_helpcode_annotation("你", false), std::string("(rE)"));
quanpin.switch_scheme(SchemeType::Shuangpin);
REQUIRE_EQ(quanpin.get_helpcode_annotation("你", false), std::string("(rE)"));
}
TEST_CASE(SegmentBoundariesFollowTheDisplayedQuanpinSyllables)
{
EngineInputSession session(SchemeType::Quanpin);
InputLetters(session, "nihaoma");
REQUIRE_EQ(session.segment_raw_boundaries(), std::vector<std::size_t>({0, 2, 5, 7}));
// Manual delimiters are boundaries too, and every offset indexes the raw
// spelling (case included) rather than the displayed segmentation.
EngineInputSession delimited(SchemeType::Quanpin);
InputSequence(delimited, "ni'hao");
REQUIRE_EQ(delimited.segment_raw_boundaries(), std::vector<std::size_t>({0, 3, 6}));
EngineInputSession cased(SchemeType::Quanpin);
InputLetters(cased, "NiHaoMa");
REQUIRE_EQ(cased.segment_raw_boundaries(), std::vector<std::size_t>({0, 2, 5, 7}));
// A correction display that rewrites letters stays one unit when the cut
// reports one syllable, and an incomplete tail is a unit of its own so only
// the tail is deleted.
EngineInputSession corrected(SchemeType::Quanpin);
InputLetters(corrected, "sahng");
REQUIRE_EQ(corrected.segment_raw_boundaries(), std::vector<std::size_t>({0, 5}));
EngineInputSession partial(SchemeType::Quanpin);
InputLetters(partial, "nih");
REQUIRE_EQ(partial.segment_raw_boundaries(), std::vector<std::size_t>({0, 2, 3}));
EngineInputSession empty(SchemeType::Quanpin);
REQUIRE(empty.segment_raw_boundaries().empty());
}
TEST_CASE(SegmentBoundariesFollowShuangpinSyllableSegmentation)
{
EngineInputSession xiaohe(SchemeType::Shuangpin);
InputLetters(xiaohe, "nihaoma");
REQUIRE_EQ(xiaohe.segment_raw_boundaries(), std::vector<std::size_t>({0, 2, 4, 5, 7}));
// One complete syllable is one unit.
EngineInputSession single(SchemeType::Shuangpin);
InputLetters(single, "ni");
REQUIRE_EQ(single.segment_raw_boundaries(), std::vector<std::size_t>({0, 2}));
// The Microsoft ';' final key belongs to the syllable that consumes it.
EngineInputSession microsoft(SchemeType::Shuangpin, GetMicrosoftShuangpinProfile());
InputLetters(microsoft, "b");
microsoft.handle_key(VK_OEM_1, 0, L';');
InputLetters(microsoft, "ni");
REQUIRE_EQ(microsoft.segment_raw_boundaries(), std::vector<std::size_t>({0, 2, 4}));
// Same ';' final after several syllables: 你好病 = ni | hk | b; in the
// Microsoft layout (ao sits on 'k'), so the last unit spans 'b' and ';'.
EngineInputSession microsoft_long(SchemeType::Shuangpin, GetMicrosoftShuangpinProfile());
InputLetters(microsoft_long, "nihkb");
microsoft_long.handle_key(VK_OEM_1, 0, L';');
REQUIRE_EQ(microsoft_long.segment_raw_boundaries(), std::vector<std::size_t>({0, 2, 4, 6}));
// Greedy longest-pair parsing is the contract: 'cb' is a valid Microsoft
// code (cou), so it wins over pairing 'b' with the trailing ';'. The
// preedit shows the same cut, and the deletion follows the preedit.
EngineInputSession microsoft_greedy(SchemeType::Shuangpin, GetMicrosoftShuangpinProfile());
InputLetters(microsoft_greedy, "nihcb");
microsoft_greedy.handle_key(VK_OEM_1, 0, L';');
REQUIRE_EQ(microsoft_greedy.segment_raw_boundaries(), std::vector<std::size_t>({0, 2, 3, 5, 6}));
}
TEST_CASE(UnitlessSchemesReportNoSegmentBoundaries)
{
EngineInputSession wubi(SchemeType::Wubi);
InputLetters(wubi, "nihao");
REQUIRE(wubi.segment_raw_boundaries().empty());
EngineInputSession japanese(SchemeType::JapaneseRomaji);
InputLetters(japanese, "nihao");
REQUIRE(japanese.segment_raw_boundaries().empty());
}
TEST_CASE(SegmentBoundariesKeepHelpcodeAndJianpinTailAsTheirOwnUnits)
{
// The deletion follows the preedit, so the correction switches decide what
// the preedit looks like. Keep them off here so the test pins the plain
// syllable separators instead of the developer's own configuration.
ScopedConfigRoot config_root;
InitImeConfig();
REQUIRE(SetConfiguredQuanpinAutocorrectTransposition(false));
REQUIRE(SetConfiguredQuanpinAutocorrectNeighbor(false));
InitImeConfig();
REQUIRE(!GetConfiguredQuanpinAutocorrectTransposition());
REQUIRE(!GetConfiguredQuanpinAutocorrectNeighbor());
// A single trailing helpcode is one editable unit of its own, exactly as the
// preedit draws the separator before it.
EngineInputSession helpcode(SchemeType::Quanpin);
InputLetters(helpcode, "nihao");
InputLetters(helpcode, "V");
REQUIRE_EQ(helpcode.segment_raw_boundaries(), std::vector<std::size_t>({0, 2, 5, 6}));
// A jianpin tail after a complete syllable stays a unit of its own so only
// the tail is deleted.
EngineInputSession jianpin(SchemeType::Quanpin);
InputLetters(jianpin, "zheg");
REQUIRE_EQ(jianpin.segment_raw_boundaries(), std::vector<std::size_t>({0, 3, 4}));
}