Files
msime-windows/server/tests/src/test_caret_prefix_input_session.cpp
T
Jia Suiandfanlusky 1499027f04 fix(engine,server): 光标落在拼音首单元内部不再收起候选窗 (#940)
* fix(engine,server): 光标落在拼音首单元内部不再收起候选窗

quantized_prefix_end() 把光标 floor 到完整单元边界后,prefix_end==0 同时表示「真串首」与「首单元内部」。后者被 Server 的 IsCaretPrefixEmpty 当成串首,候选窗整窗收起(全拼 nihaoya 选你好后剩 ya 左移即消失)。

floor 结果为 0 且 caret>0(首单元内部)时改返回 raw.size(),落回整串解码;prefix_end()==0 当且仅当 caret==0,只有真串首才隐藏。segment_raw_boundaries() 与 #187「前缀只切完整单元」的契约不变。

* fix(engine,server): 首单元内部的光标向上取整到首单元末尾

上一提交让首单元内部的光标落回整串解码,prefix_end() 因此报告整串已解码,
与实际光标位置不符:Server 选词时 caret_prefix_selection 为假,不复位组合
光标,造词后剩余串带着旧光标位置;首单元与后续单元的取整方向也不一致。

改为向上取整到首单元末尾(只有一个单元时即整串)。prefix_end() == 0 仍当且
仅当 caret == 0,前缀选词走正常的光标复位路径。同步更新引擎与
input_key_policy.h 中描述旧规则的注释和相应测试。

---------

Co-authored-by: fanlusky <1730976608@qq.com>
2026-10-15 01:37:58 +08:00

457 lines
20 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.
// 光标驱动的前缀解码(PRD R2–R7/R9,引擎 Stage 1)的 CI 回归。
// 同一组断言已先落在 engine/tests/src/test_input_session.cpp 的
// run_caret_prefix_session_tests,但 engine/tests 不进 CI(见 engine spec 的 helpcode
// 接入契约),PRD AC9 要求单测 CI 可跑,因此按 server 测试框架移植一份等价实现;
// 两侧语义以引擎侧为准,这里只消费 set_caret / recompute_candidates / prefix_end /
// pending_suffix 这些公开访问器。
#include "tests/includes/test_framework.h"
#include "tests/includes/test_utf8_path.h"
#include "engine/core/input_session.h"
#include "engine/shuangpin/shuangpin_profile.h"
#include <sqlite3.h>
#include <windows.h>
#include <algorithm>
#include <filesystem>
#include <stdexcept>
#include <string>
#include <vector>
namespace
{
// 隔离词库夹具:caret 前缀断言需要确定的候选集与权重次序,绝不依赖开发机或 CI
// 机器上的全局 msime.db。表内容与 engine 侧同名夹具逐字一致,权重决定候选顺序。
std::filesystem::path CreateCaretPrefixFixture()
{
// PID 后缀:目录开场会被 remove_all,固定名在并行 ctest 或多工作树同时跑时会删掉
// 另一个进程正在用的夹具。
const auto directory = std::filesystem::temp_directory_path() /
("msime-caret-prefix-input-session-test-" + std::to_string(GetCurrentProcessId()));
std::error_code ec;
std::filesystem::remove_all(directory, ec);
std::filesystem::create_directories(directory);
sqlite3 *database = nullptr;
if (sqlite3_open(test::Utf8(directory / "msime.db").c_str(), &database) != SQLITE_OK)
{
throw std::runtime_error("Failed to create the caret-prefix fixture dictionary.");
}
const auto execute = [database](const char *sql) {
char *error = nullptr;
if (sqlite3_exec(database, sql, nullptr, nullptr, &error) != SQLITE_OK)
{
const std::string message = error == nullptr ? "SQLite operation failed." : error;
sqlite3_free(error);
sqlite3_close(database);
throw std::runtime_error(message);
}
};
execute("CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_1_n VALUES('ni','n','你',100);"
"INSERT INTO tbl_1_n VALUES('ni','n','拟',90);");
execute("CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_2_n VALUES('ni''hao','nh','你好',200);"
"INSERT INTO tbl_2_n VALUES('ni''hao','nh','拟好',100);");
execute("CREATE TABLE tbl_1_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_1_s VALUES('shi','sh','是',100);");
execute("CREATE TABLE tbl_1_j(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_1_j VALUES('jie','j','接',100);");
execute("CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);"
"INSERT INTO wubi86 VALUES('aaaa','工',100);"
"INSERT INTO wubi86 VALUES('aaaa','或',50);");
sqlite3_close(database);
return directory;
}
metasequoia::RuntimePaths FixturePaths(const std::filesystem::path &directory)
{
metasequoia::RuntimePaths paths;
paths.resources = directory;
paths.user_data = directory;
paths.cache = directory;
paths.dictionaries = directory;
return paths;
}
// 把夹具里「拟」的权重抬到「你」之上,模拟组合进行中的词库重排:用户词典调频、
// 配置热更新都会触发 reset_cache,前缀候选必须随之刷新。
void RaiseNiCandidateWeight(const std::filesystem::path &directory)
{
sqlite3 *database = nullptr;
if (sqlite3_open(test::Utf8(directory / "msime.db").c_str(), &database) != SQLITE_OK)
{
throw std::runtime_error("Failed to open the caret-prefix fixture dictionary.");
}
// 会话的连接同时开着,写入可能短暂拿不到锁;等它释放而不是直接失败。
sqlite3_busy_timeout(database, 5000);
char *error = nullptr;
if (sqlite3_exec(database, "UPDATE tbl_1_n SET weight=150 WHERE key='ni' AND value='拟';", nullptr, nullptr,
&error) != SQLITE_OK)
{
const std::string message = error == nullptr ? "SQLite weight update failed." : error;
sqlite3_free(error);
sqlite3_close(database);
throw std::runtime_error(message);
}
sqlite3_close(database);
}
void TypeText(metasequoia::InputSession &session, const std::string &text)
{
for (const char character : text)
{
REQUIRE(session.handle_character(character).handled);
}
}
std::vector<std::string> CandidateWords(const metasequoia::InputSession &session)
{
std::vector<std::string> words;
words.reserve(session.candidates().size());
for (const WordItem &item : session.candidates())
{
words.push_back(item.word);
}
return words;
}
bool SameCandidateWords(const metasequoia::InputSession &session, const std::vector<std::string> &expected)
{
if (session.candidates().size() != expected.size())
{
return false;
}
return std::equal(session.candidates().begin(), session.candidates().end(), expected.begin(),
[](const WordItem &left, const std::string &right) { return left.word == right; });
}
std::size_t CandidateWordIndex(const metasequoia::InputSession &session, const std::string &word)
{
const auto &candidates = session.candidates();
const auto found =
std::find_if(candidates.begin(), candidates.end(), [&word](const WordItem &item) { return item.word == word; });
return static_cast<std::size_t>(found - candidates.begin());
}
// 前缀等价断言的基准:同一拼写独立键入得到的候选列表。
std::vector<std::string> QuanpinCandidateWordsFor(const std::string &typed, const metasequoia::RuntimePaths &paths)
{
metasequoia::InputSession session(SchemeType::Quanpin, 0, true, true, false, paths);
TypeText(session, typed);
return CandidateWords(session);
}
std::vector<std::string> ShuangpinCandidateWordsFor(const std::string &typed, const metasequoia::RuntimePaths &paths)
{
metasequoia::InputSession session(SchemeType::Shuangpin, GetMicrosoftShuangpinProfile(), paths);
TypeText(session, typed);
return CandidateWords(session);
}
} // namespace
// R2/R3/R7:音节内 caret 向下取整到最后一个完整单元边界,候选等于独立键入该前缀的
// 候选;caret 未设置或落在末尾时与整串解码零差异;R4:前缀为空时无候选且组合不动。
TEST_CASE(CaretPrefixDecodesQuanpinByCompleteSyllablePrefix)
{
const auto directory = CreateCaretPrefixFixture();
const auto paths = FixturePaths(directory);
// 会话必须先于 remove_all 析构:它持有词库句柄,Windows 上删不掉打开的文件。
{
const std::string sentence = "ni'hao'shi'jie";
metasequoia::InputSession session(SchemeType::Quanpin, 0, true, true, false, paths);
TypeText(session, sentence);
REQUIRE(session.has_composition());
REQUIRE_EQ(session.prefix_end(), sentence.size());
REQUIRE(session.pending_suffix().empty());
const auto full_words = CandidateWords(session);
REQUIRE(SameCandidateWords(session, full_words));
for (const std::size_t caret : {std::size_t{5}, std::size_t{6}})
{
session.set_caret(caret);
session.recompute_candidates();
REQUIRE_EQ(session.prefix_end(), std::size_t{3});
REQUIRE_EQ(session.pending_suffix(), std::string("hao'shi'jie"));
REQUIRE(CandidateWordIndex(session, "你") < session.candidates().size());
REQUIRE(SameCandidateWords(session, QuanpinCandidateWordsFor("ni", paths)));
}
session.set_caret(7);
session.recompute_candidates();
REQUIRE_EQ(session.prefix_end(), std::size_t{7});
REQUIRE_EQ(session.pending_suffix(), std::string("shi'jie"));
REQUIRE(CandidateWordIndex(session, "你好") < session.candidates().size());
REQUIRE(SameCandidateWords(session, QuanpinCandidateWordsFor("ni'hao", paths)));
const auto at_hao = CandidateWords(session);
session.set_caret(9);
session.recompute_candidates();
REQUIRE_EQ(session.prefix_end(), std::size_t{7});
REQUIRE(SameCandidateWords(session, at_hao));
session.set_caret(13);
session.recompute_candidates();
REQUIRE_EQ(session.prefix_end(), std::size_t{11});
REQUIRE_EQ(session.pending_suffix(), std::string("jie"));
REQUIRE(!session.candidates().empty());
REQUIRE(SameCandidateWords(session, QuanpinCandidateWordsFor("ni'hao'shi", paths)));
session.set_caret(0);
session.recompute_candidates();
REQUIRE(session.candidates().empty());
REQUIRE_EQ(session.prefix_end(), std::size_t{0});
REQUIRE_EQ(session.pending_suffix(), sentence);
REQUIRE_EQ(session.editing_text(), sentence);
REQUIRE_EQ(session.caret_position(), std::size_t{0});
REQUIRE_EQ(session.preedit(), sentence);
// 越界 caret 被夹到串尾,退化为整串解码;nullopt 恢复默认。
session.set_caret(sentence.size() + 10);
session.recompute_candidates();
REQUIRE_EQ(session.caret_position(), sentence.size());
REQUIRE_EQ(session.prefix_end(), sentence.size());
REQUIRE(SameCandidateWords(session, full_words));
session.set_caret(std::nullopt);
session.recompute_candidates();
REQUIRE(SameCandidateWords(session, full_words));
}
std::filesystem::remove_all(directory);
}
// 回归:前缀候选按前缀文本缓存,修复前只有文本变化才重查——caret 停在内点时
// 外部重排词库(用户词典调频、配置热更新都走 reset_cache),重算后候选仍停在
// 旧权重上。reset_cache 必须连带失效前缀缓存,caret 与后缀不受影响。
TEST_CASE(PrefixCandidatesRefreshAfterResetCache)
{
const auto directory = CreateCaretPrefixFixture();
const auto paths = FixturePaths(directory);
{
metasequoia::InputSession session(SchemeType::Quanpin, 0, true, true, false, paths);
TypeText(session, "ni'hao");
session.set_caret(4);
session.recompute_candidates();
REQUIRE_EQ(session.prefix_end(), std::size_t{3});
REQUIRE_EQ(session.pending_suffix(), std::string("hao"));
REQUIRE(CandidateWordIndex(session, "你") < CandidateWordIndex(session, "拟"));
RaiseNiCandidateWeight(directory);
session.reset_cache();
session.recompute_candidates();
// caret 不动、后缀不变,只有前缀候选按新权重重查。
REQUIRE_EQ(session.prefix_end(), std::size_t{3});
REQUIRE_EQ(session.pending_suffix(), std::string("hao"));
REQUIRE(CandidateWordIndex(session, "拟") < CandidateWordIndex(session, "你"));
}
std::filesystem::remove_all(directory);
}
// pending_suffix 保留原始大小写:光标处插入的大写字母原样留在后缀里。
TEST_CASE(CaretPrefixSuffixKeepsTheTypedCasing)
{
const auto directory = CreateCaretPrefixFixture();
const auto paths = FixturePaths(directory);
{
metasequoia::InputSession session(SchemeType::Quanpin, 0, true, true, false, paths);
TypeText(session, "ni'hao");
session.handle_command(metasequoia::Command::MoveHome);
REQUIRE(session.handle_character('H').handled);
REQUIRE_EQ(session.editing_text(), std::string("Hni'hao"));
REQUIRE_EQ(session.caret_position(), std::size_t{1});
session.set_caret(0);
session.recompute_candidates();
REQUIRE_EQ(session.prefix_end(), std::size_t{0});
REQUIRE_EQ(session.pending_suffix(), std::string("Hni'hao"));
session.set_caret(std::nullopt);
session.recompute_candidates();
REQUIRE_EQ(session.editing_text(), std::string("Hni'hao"));
}
std::filesystem::remove_all(directory);
}
// R9:五笔等无单元模型(segment_raw_boundaries 为空)的方案,caret 移动不量化、
// 候选零变化,维持整串解码的现状。
TEST_CASE(CaretPrefixLeavesSchemesWithoutUnitModelUntouched)
{
const auto directory = CreateCaretPrefixFixture();
const auto paths = FixturePaths(directory);
{
metasequoia::InputSession session(SchemeType::Wubi, 0, true, true, false, paths);
TypeText(session, "aaaa");
REQUIRE(session.has_composition());
REQUIRE(CandidateWordIndex(session, "工") < session.candidates().size());
const auto native = CandidateWords(session);
for (const std::size_t caret : {std::size_t{0}, std::size_t{2}})
{
session.set_caret(caret);
session.recompute_candidates();
REQUIRE(SameCandidateWords(session, native));
REQUIRE_EQ(session.prefix_end(), std::size_t{4});
REQUIRE(session.pending_suffix().empty());
}
}
std::filesystem::remove_all(directory);
}
// 双拼贪心配对(engine spec #187):nihkb; → {0,2,4,6}、nihcb; → {0,2,3,5,6},
// caret 落在段中间时同样 floor 到完整段边界;首单元内侧向上取整到首单元末尾。
TEST_CASE(CaretPrefixFloorsInsideShuangpinGreedyUnits)
{
const auto directory = CreateCaretPrefixFixture();
const auto paths = FixturePaths(directory);
{
metasequoia::InputSession session(SchemeType::Shuangpin, GetMicrosoftShuangpinProfile(), paths);
TypeText(session, "nihkb;");
REQUIRE(CandidateWordIndex(session, "你好") < session.candidates().size());
REQUIRE_EQ(session.prefix_end(), std::size_t{6});
REQUIRE(session.pending_suffix().empty());
session.set_caret(1);
session.recompute_candidates();
REQUIRE_EQ(session.prefix_end(), std::size_t{2});
REQUIRE_EQ(session.pending_suffix(), std::string("hkb;"));
REQUIRE(SameCandidateWords(session, ShuangpinCandidateWordsFor("ni", paths)));
session.set_caret(3);
session.recompute_candidates();
REQUIRE_EQ(session.prefix_end(), std::size_t{2});
REQUIRE_EQ(session.pending_suffix(), std::string("hkb;"));
REQUIRE(SameCandidateWords(session, ShuangpinCandidateWordsFor("ni", paths)));
session.set_caret(5);
session.recompute_candidates();
REQUIRE_EQ(session.prefix_end(), std::size_t{4});
REQUIRE_EQ(session.pending_suffix(), std::string("b;"));
REQUIRE(CandidateWordIndex(session, "你好") < session.candidates().size());
REQUIRE(SameCandidateWords(session, ShuangpinCandidateWordsFor("nihk", paths)));
session.set_caret(6);
session.recompute_candidates();
REQUIRE_EQ(session.prefix_end(), std::size_t{6});
REQUIRE(!session.candidates().empty());
session.handle_command(metasequoia::Command::Cancel);
TypeText(session, "nihcb;");
session.set_caret(4);
session.recompute_candidates();
REQUIRE_EQ(session.prefix_end(), std::size_t{3});
REQUIRE_EQ(session.pending_suffix(), std::string("cb;"));
REQUIRE(SameCandidateWords(session, ShuangpinCandidateWordsFor("nih", paths)));
}
std::filesystem::remove_all(directory);
}
// R5 前缀选词结算序列(Server ProcessSelectionKey 对会话的确切调用序):advance 消
// 耗的正是前缀本身、剩余 raw 就是后缀;结算把光标复位为 nullopt 后,后缀按全新整串
// 解码(AC2)。同时钉住「选中候选短于前缀」的部分消费:剩余 raw 从被消费的那段之后
// 开始,而不是从旧边界之后开始。
TEST_CASE(PrefixSelectionSettlementLeavesFreshFullStringSuffix)
{
const auto directory = CreateCaretPrefixFixture();
const auto paths = FixturePaths(directory);
{
const std::string sentence = "ni'hao'shi'jie";
metasequoia::InputSession session(SchemeType::Quanpin, 0, true, true, false, paths);
TypeText(session, sentence);
// 光标在 ni'hao 之后:候选等于前缀 ni'hao 的候选,选中「你好」。
session.set_caret(7);
session.recompute_candidates();
REQUIRE_EQ(session.prefix_end(), std::size_t{7});
const std::size_t you_hao = CandidateWordIndex(session, "你好");
REQUIRE(you_hao < session.candidates().size());
const WordItem selected = session.candidates()[you_hao];
const auto transition =
session.advance_composition_after_selection(selected.pinyin, selected.word, selected.canonical_pinyin);
REQUIRE(transition.continues_composition);
REQUIRE_EQ(transition.consumed_raw_input_with_cases, std::string("ni'hao"));
REQUIRE_EQ(session.get_pinyin_sequence_with_cases(), std::string("shi'jie"));
// 结算收尾(Server 侧的确切序列):复位会话光标后,后缀按整串解码,
// 与独立键入 shi'jie 的候选一致。
session.set_caret(std::nullopt);
session.recompute_candidates();
REQUIRE(session.has_composition());
REQUIRE(session.pending_suffix().empty());
REQUIRE(SameCandidateWords(session, QuanpinCandidateWordsFor("shi'jie", paths)));
}
{
// 部分消费:光标在 ni 之后(floor 到边界 2),选中的「你」只消耗 ni,
// 剩余 raw 从 hao 重新开始;hao 此时不完整,属于新的待转后缀。
metasequoia::InputSession session(SchemeType::Quanpin, 0, true, true, false, paths);
TypeText(session, "ni'hao'shi'jie");
session.set_caret(4);
session.recompute_candidates();
REQUIRE_EQ(session.prefix_end(), std::size_t{3});
const std::size_t ni_index = CandidateWordIndex(session, "你");
REQUIRE(ni_index < session.candidates().size());
const WordItem selected = session.candidates()[ni_index];
const auto transition =
session.advance_composition_after_selection(selected.pinyin, selected.word, selected.canonical_pinyin);
REQUIRE(transition.continues_composition);
REQUIRE_EQ(transition.consumed_raw_input_with_cases, std::string("ni"));
REQUIRE_EQ(session.get_pinyin_sequence_with_cases(), std::string("hao'shi'jie"));
session.set_caret(std::nullopt);
session.recompute_candidates();
REQUIRE(SameCandidateWords(session, QuanpinCandidateWordsFor("hao'shi'jie", paths)));
}
std::filesystem::remove_all(directory);
}
// R7 回归(Stage 2 质检):前缀激活后光标移回串尾,会话必须完全回到整串态,
// 否则空格结算会错误走前缀选词路径。ApplyCompositionEditKey 的 resegment_by_caret
// 在串尾执行的就是 set_caret(nullopt) + recompute_candidates();接线本身依赖
// GlobalIme/管道全局态,event_listener.cpp 不参与测试链接,CI 只能在这层钉住
// 同一调用序的契约:从激活的内点 caret_ 直接喂 nullopt 后,prefix_end 与候选
// 和全新整串会话零差异,空格结算据 prefix_end == raw.size() 走整串路径。
TEST_CASE(CaretReturnToTailAfterPrefixActivationRestoresFreshFullStringDecode)
{
const auto directory = CreateCaretPrefixFixture();
const auto paths = FixturePaths(directory);
{
const std::string sentence = "ni'hao'shi'jie";
metasequoia::InputSession session(SchemeType::Quanpin, 0, true, true, false, paths);
TypeText(session, sentence);
const auto full_words = CandidateWords(session);
// 光标移到中间:前缀激活,引擎 caret_ 停在内点(修复前串尾不喂时残留的状态)。
session.set_caret(7);
session.recompute_candidates();
REQUIRE_EQ(session.prefix_end(), std::size_t{7});
REQUIRE_EQ(session.pending_suffix(), std::string("shi'jie"));
REQUIRE(SameCandidateWords(session, QuanpinCandidateWordsFor("ni'hao", paths)));
// 光标移回串尾:与 Server 侧修复后相同的确切调用序,结果必须与全新整串零差异。
session.set_caret(std::nullopt);
session.recompute_candidates();
REQUIRE_EQ(session.caret_position(), sentence.size());
REQUIRE_EQ(session.prefix_end(), sentence.size());
REQUIRE(session.pending_suffix().empty());
REQUIRE(SameCandidateWords(session, full_words));
REQUIRE(SameCandidateWords(session, QuanpinCandidateWordsFor(sentence, paths)));
}
std::filesystem::remove_all(directory);
}