From 1cfba58eba2b4f0ec6cdf8c38ef81712fc2eb194 Mon Sep 17 00:00:00 2001 From: Jia Sui Date: Tue, 22 Sep 2026 14:56:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(engine):=20=E5=85=89=E6=A0=87=E9=A9=B1?= =?UTF-8?q?=E5=8A=A8=E7=9A=84=E6=8B=BC=E9=9F=B3=E5=89=8D=E7=BC=80=E8=A7=A3?= =?UTF-8?q?=E7=A0=81=E4=B8=8E=E5=80=99=E9=80=89=E9=87=8D=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit caret 从纯显示状态升级为查询管线输入:set_caret 量化到最后一个完整 音节单元边界(只消费 segment_raw_boundaries,#187 契约),前缀候选 独立解码、preedit 保持全串拼音风格;纯移动光标同样重算候选。 IInputScheme::set_raw_input 提升为接口方法以支持不动现场组合的 独立前缀查询。runtime_isolation 两处按 R3/R4 新语义改写(caret 在 音节中间 floor 到空前缀、候选为空是预期状态);新增 caret 前缀 会话单测(engine/tests + server CI 目标,隔离词库夹具,覆盖 全拼 floor、双拼贪心配对、五笔无单元模型、boundary=0 空候选)。 --- engine/core/ime_session.cpp | 42 ++- engine/core/ime_session.h | 9 + engine/core/input_session.cpp | 98 +++++- engine/core/input_session.h | 25 ++ engine/core/input_session_editing.cpp | 14 + engine/schemes/input_scheme.h | 4 + engine/schemes/japanese_romaji_scheme.h | 2 +- engine/schemes/quanpin_scheme.h | 2 +- engine/schemes/shuangpin_scheme.h | 2 +- engine/schemes/wubi_scheme.h | 2 +- engine/tests/src/test_input_session.cpp | 210 ++++++++++++ engine/tests/src/test_runtime_isolation.cpp | 24 +- server/tests/CMakeLists.txt | 1 + .../src/test_caret_prefix_input_session.cpp | 299 ++++++++++++++++++ 14 files changed, 712 insertions(+), 22 deletions(-) create mode 100644 server/tests/src/test_caret_prefix_input_session.cpp diff --git a/engine/core/ime_session.cpp b/engine/core/ime_session.cpp index 9424fcb4..51d29d46 100644 --- a/engine/core/ime_session.cpp +++ b/engine/core/ime_session.cpp @@ -187,6 +187,23 @@ int ImeSession::cache_dynamic_candidate(const std::string &pinyin, const std::st return provider_registry_.cache_dynamic_candidate(current_scheme_type(), pinyin, word, source); } +std::vector ImeSession::query_raw_candidates(const std::string &raw_input, + const std::string &raw_input_with_cases) +{ + // A throwaway scheme instance: set_raw_input on the live scheme would clobber its key + // strokes, and a prefix query must leave the composition exactly as it found it. + const std::unique_ptr query_scheme = create_scheme(scheme_->type()); + query_scheme->set_raw_input(raw_input, raw_input_with_cases); + QueryRequest request = query_scheme->build_request(); + apply_request_options(request); + ApplyShuangpinHelpcodeSegmentation(request, shuangpin_profile_); + if (!request.valid) + { + return {}; + } + return provider_registry_.resolve(request.scheme).query(request); +} + void ImeSession::replace_japanese_raw_input(const std::string &raw_input, const std::string &raw_input_with_cases) { if (scheme_->type() != SchemeType::JapaneseRomaji) @@ -262,17 +279,21 @@ bool ImeSession::expand_initial_candidates() return provider_registry_.expand_initial_candidates(state_.request, state_.candidates); } +void ImeSession::apply_request_options(QueryRequest &request) const +{ + request.enable_shuangpin_helpcode = enable_shuangpin_helpcode_; + request.enable_quanpin_helpcode = enable_quanpin_helpcode_; + request.enable_quanpin_autocorrect_transposition = + (quanpin_autocorrect_types_ & quanpin::kAutocorrectTransposition) != 0; + request.enable_quanpin_autocorrect_neighbor = (quanpin_autocorrect_types_ & quanpin::kAutocorrectNeighbor) != 0; + request.fuzzy_pinyin = fuzzy_pinyin_; +} + void ImeSession::refresh_candidates() { state_.preedit = scheme_->get_preedit(); state_.request = scheme_->build_request(); - state_.request.enable_shuangpin_helpcode = enable_shuangpin_helpcode_; - state_.request.enable_quanpin_helpcode = enable_quanpin_helpcode_; - state_.request.enable_quanpin_autocorrect_transposition = - (quanpin_autocorrect_types_ & quanpin::kAutocorrectTransposition) != 0; - state_.request.enable_quanpin_autocorrect_neighbor = - (quanpin_autocorrect_types_ & quanpin::kAutocorrectNeighbor) != 0; - state_.request.fuzzy_pinyin = fuzzy_pinyin_; + apply_request_options(state_.request); ApplyShuangpinHelpcodeSegmentation(state_.request, shuangpin_profile_); state_.answered_by_pinyin_fallback = false; @@ -306,12 +327,7 @@ void ImeSession::refresh_candidates() QuanpinScheme pinyin; pinyin.set_raw_input(state_.request.raw_input, state_.request.raw_input_with_cases); QueryRequest fallback = pinyin.build_request(); - fallback.enable_quanpin_helpcode = enable_quanpin_helpcode_; - fallback.enable_quanpin_autocorrect_transposition = - (quanpin_autocorrect_types_ & quanpin::kAutocorrectTransposition) != 0; - fallback.enable_quanpin_autocorrect_neighbor = - (quanpin_autocorrect_types_ & quanpin::kAutocorrectNeighbor) != 0; - fallback.fuzzy_pinyin = fuzzy_pinyin_; + apply_request_options(fallback); // The same physical keys produced these letters, so the strokes carry over rather than // reaching the provider empty. fallback.key_strokes = state_.request.key_strokes; diff --git a/engine/core/ime_session.h b/engine/core/ime_session.h index 9b7919a0..cebaa470 100644 --- a/engine/core/ime_session.h +++ b/engine/core/ime_session.h @@ -34,6 +34,12 @@ class ImeSession // has to shorten the live composition, and under the wubi fallback the pinyin-shaped // caller would otherwise address a scheme that is not the active one and be ignored. void replace_active_raw_input(const std::string &raw_input, const std::string &raw_input_with_cases); + // Runs one standalone candidate query for the given raw input without touching the live + // composition: the active scheme's raw/key strokes and state_'s request/candidates stay put. + // Query options (helpcode, autocorrect, fuzzy) match refresh_candidates exactly. The caret + // driven prefix decoding in InputSession uses this to decode a prefix while the composition + // still owns the full raw string. + std::vector query_raw_candidates(const std::string &raw_input, const std::string &raw_input_with_cases); void reset(); void reset_cache(); int create_word(std::string pinyin, std::string word); @@ -61,6 +67,9 @@ class ImeSession } private: + // Shared option injection for refresh_candidates() and query_raw_candidates(); the two must + // not drift or a prefix query would answer with different candidates than the live pipeline. + void apply_request_options(QueryRequest &request) const; void refresh_candidates(); void bind_wubi_scheme(); SchemeType candidate_scheme() const; diff --git a/engine/core/input_session.cpp b/engine/core/input_session.cpp index 79fcfa1b..4edc82f7 100644 --- a/engine/core/input_session.cpp +++ b/engine/core/input_session.cpp @@ -641,8 +641,13 @@ const std::vector &InputSession::candidates() const mixed_expressive_options_.kaomoji_candidates) && (scheme() == SchemeType::Quanpin || scheme() == SchemeType::Shuangpin))) { + // update_mixed_candidates() assembles this from the decoded prefix while it is active. return mixed_candidates_; } + if (prefix_candidates_active_) + { + return prefix_candidates_; + } return engine_.get_candidates(); } @@ -810,12 +815,96 @@ std::optional InputSession::update_local_candidates() void InputSession::update_mixed_candidates() { - mixed_candidates_ = candidate_queries_.mixed(engine_.get_candidates(), engine_.get_request().raw_input, scheme(), - english_input_options_, mixed_expressive_options_, - dedicated_english_mode_, local_input_mode_); + refresh_prefix_candidates(); + const auto &decoded = prefix_candidates_active_ ? prefix_candidates_ : engine_.get_candidates(); + // Association (English/emoji suggestions) follows the string being converted: the caret + // prefix while it is active, the full raw input otherwise. + const std::string association_prefix = + prefix_candidates_active_ ? prefix_query_input_ : engine_.get_request().raw_input; + mixed_candidates_ = candidate_queries_.mixed(decoded, association_prefix, scheme(), english_input_options_, + mixed_expressive_options_, dedicated_english_mode_, local_input_mode_); apply_candidate_positions(mixed_candidates_); } +void InputSession::set_caret(std::optional caret) +{ + if (caret.has_value()) + { + *caret = std::min(*caret, editing_text().size()); + } + caret_ = caret; +} + +std::size_t InputSession::quantized_prefix_end() const +{ + // Unset caret means the end of the string: full-string decoding, exactly the legacy path. + const std::string &raw = get_pinyin_sequence_with_cases(); + if (!caret_.has_value()) + { + return raw.size(); + } + // Segmentation contract (engine spec #187): the caret prefix may only be quantized against + // segment_raw_boundaries(), never against a re-derived segmentation. + const std::vector boundaries = segment_raw_boundaries(); + // No unit model (wubi, japanese, local modes, dedicated English): do not quantize. + if (boundaries.empty()) + { + return raw.size(); + } + // Floor to the last complete unit at or before the caret: an incomplete trailing unit + // belongs to the pending suffix. boundaries always contains 0, so the boundary before + // upper_bound always exists. + return *std::prev(std::upper_bound(boundaries.begin(), boundaries.end(), caret_position())); +} + +std::size_t InputSession::prefix_end() const +{ + return quantized_prefix_end(); +} + +std::string InputSession::pending_suffix() const +{ + const std::string &raw = get_pinyin_sequence_with_cases(); + const std::size_t end = quantized_prefix_end(); + return end < raw.size() ? raw.substr(end) : std::string{}; +} + +void InputSession::refresh_prefix_candidates() +{ + prefix_candidates_active_ = false; + if (!caret_.has_value() || dedicated_english_mode_ || local_input_mode_ != LocalInputMode::None) + { + prefix_candidates_.clear(); + prefix_query_input_.clear(); + return; + } + const std::string &raw_with_cases = get_pinyin_sequence_with_cases(); + const std::size_t end = quantized_prefix_end(); + if (end >= raw_with_cases.size()) + { + // The caret sits on the final boundary or the scheme has no unit model: the full-string + // engine decode already on hand is the answer. + prefix_candidates_.clear(); + prefix_query_input_.clear(); + return; + } + std::string prefix; + prefix.reserve(end); + std::transform(raw_with_cases.begin(), raw_with_cases.begin() + static_cast(end), + std::back_inserter(prefix), + [](unsigned char character) { return static_cast(std::tolower(character)); }); + if (prefix_query_input_ != prefix) + { + // The with-cases prefix keeps the display casing the scheme expects; the lowercased + // copy doubles as the cache key (the query key itself is always lowercase). + prefix_candidates_ = engine_.query_raw_candidates(prefix, raw_with_cases.substr(0, end)); + prefix_query_input_ = prefix; + } + // An empty prefix (caret at/before the first boundary) decodes to no candidates on purpose: + // the host hides its candidate window while the preedit keeps showing the full pinyin. + prefix_candidates_active_ = true; +} + void InputSession::update_dedicated_english_candidates() { dedicated_english_candidates_.clear(); @@ -838,6 +927,9 @@ void InputSession::update_dedicated_english_candidates() void InputSession::reset_composition() { caret_.reset(); + prefix_candidates_.clear(); + prefix_query_input_.clear(); + prefix_candidates_active_ = false; immediate_phrase_progress_ = {}; clear_pending_sequence(); online_requests_.invalidate(); diff --git a/engine/core/input_session.h b/engine/core/input_session.h index 6094af39..320a88f9 100644 --- a/engine/core/input_session.h +++ b/engine/core/input_session.h @@ -133,6 +133,19 @@ class InputSession void handle_engine_key(ImeKeyCode vk, ImeModifierMask modifiers_down, ImeCharacter wch); void recompute_candidates(); + // Moves the caret without editing the raw string. nullopt = end of string (full-string + // decoding, the default). The value is clamped to [0, editing_text().size()]. Setting it + // only updates state; candidates re-decode by the new boundary on the next + // recompute_candidates() or key handling. + void set_caret(std::optional caret); + // Raw length consumed by the current decode: the caret moved onto the last complete + // syllable-unit boundary at or before it (floor). The caret being unset, or a scheme + // without the unit model (segment_raw_boundaries() empty), decodes the whole string and + // this equals the raw length. + std::size_t prefix_end() const; + // raw[prefix_end, size) with its original casing: the pending input this decode did not + // consume. Empty unless the caret prefix is strictly shorter than the raw string. + std::string pending_suffix() const; SchemeType current_scheme_type() const; void reset_state(); @@ -219,6 +232,18 @@ class InputSession KeyResult edit_at_caret(Command command); KeyResult replace_editing_text(std::string text, std::size_t caret); std::optional caret_; + // Last complete unit boundary at or before the caret; only consumes segment_raw_boundaries() + // (segmentation contract #187). caret unset or no unit model yields the full raw length. + std::size_t quantized_prefix_end() const; + // Decodes the quantized caret prefix into prefix_candidates_ when it is strictly shorter + // than the raw string, caching by prefix so unchanged keystrokes skip the extra query. + void refresh_prefix_candidates(); + std::vector prefix_candidates_; + // Lowercased prefix the cache was built from; also feeds mixed-candidate association so + // English/emoji suggestions follow the string being converted. + std::string prefix_query_input_; + // True while candidates()/mixed assembly must read prefix_candidates_ instead of engine_. + bool prefix_candidates_active_ = false; std::optional update_local_candidates(); void update_mixed_candidates(); void apply_candidate_positions(std::vector &items); diff --git a/engine/core/input_session_editing.cpp b/engine/core/input_session_editing.cpp index 68b66d5c..616c6a52 100644 --- a/engine/core/input_session_editing.cpp +++ b/engine/core/input_session_editing.cpp @@ -155,6 +155,13 @@ KeyResult InputSession::edit_at_caret(Command command) return {}; } caret_ = caret; + // Pure moves mutate the caret too, so they must re-evaluate the prefix boundary exactly + // like an insert/delete does; otherwise candidates() would keep serving the previous + // boundary's list until the next edit key (PRD R2: the boundary follows the caret). + if (!dedicated_english_mode_ && local_input_mode_ == LocalInputMode::None) + { + update_mixed_candidates(); + } return {true, std::nullopt, std::nullopt}; } @@ -240,6 +247,13 @@ KeyResult InputSession::replace_editing_text(std::string text, std::size_t caret caret_ = caret; online_requests_.invalidate(); discard_abandoned_phrase_progress(); + // apply_pending_sequence() already refreshed candidates while the caret was cleared; now + // that it is back in place, re-evaluate the caret prefix boundary (a caret at the end of + // the text deactivates the prefix decode, mid-text re-decodes it). + if (!dedicated_english_mode_ && local_input_mode_ == LocalInputMode::None) + { + update_mixed_candidates(); + } return {true, std::nullopt, std::move(diagnostic)}; } } // namespace metasequoia diff --git a/engine/schemes/input_scheme.h b/engine/schemes/input_scheme.h index 937e5d06..e16bc1d2 100644 --- a/engine/schemes/input_scheme.h +++ b/engine/schemes/input_scheme.h @@ -11,6 +11,10 @@ class IInputScheme virtual void reset() = 0; virtual void handle_key(ImeKeyCode vk, ImeModifierMask modifiers_down, ImeCharacter wch) = 0; + // Host-driven replacement of the composed raw string (middle editing, caret prefix + // decoding). Every scheme implements it; the interface only carries it so callers holding + // the base pointer can run standalone queries without downcasting. + virtual void set_raw_input(const std::string &raw_input, const std::string &raw_input_with_cases) = 0; virtual QueryRequest build_request() const = 0; virtual std::string get_preedit() const = 0; virtual SchemeType type() const = 0; diff --git a/engine/schemes/japanese_romaji_scheme.h b/engine/schemes/japanese_romaji_scheme.h index 358163c0..fd49976a 100644 --- a/engine/schemes/japanese_romaji_scheme.h +++ b/engine/schemes/japanese_romaji_scheme.h @@ -12,7 +12,7 @@ class JapaneseRomajiScheme : public IInputScheme QueryRequest build_request() const override; std::string get_preedit() const override; SchemeType type() const override; - void set_raw_input(const std::string &raw_input, const std::string &raw_input_with_cases); + void set_raw_input(const std::string &raw_input, const std::string &raw_input_with_cases) override; private: std::string raw_input_; diff --git a/engine/schemes/quanpin_scheme.h b/engine/schemes/quanpin_scheme.h index ca994ef6..1700b469 100644 --- a/engine/schemes/quanpin_scheme.h +++ b/engine/schemes/quanpin_scheme.h @@ -12,7 +12,7 @@ class QuanpinScheme : public IInputScheme QueryRequest build_request() const override; std::string get_preedit() const override; SchemeType type() const override; - void set_raw_input(const std::string &raw_input, const std::string &raw_input_with_cases); + void set_raw_input(const std::string &raw_input, const std::string &raw_input_with_cases) override; private: std::string raw_input_; diff --git a/engine/schemes/shuangpin_scheme.h b/engine/schemes/shuangpin_scheme.h index d0c8fbf8..957f7ccc 100644 --- a/engine/schemes/shuangpin_scheme.h +++ b/engine/schemes/shuangpin_scheme.h @@ -14,7 +14,7 @@ class ShuangpinScheme : public IInputScheme QueryRequest build_request() const override; std::string get_preedit() const override; SchemeType type() const override; - void set_raw_input(const std::string &raw_input, const std::string &raw_input_with_cases); + void set_raw_input(const std::string &raw_input, const std::string &raw_input_with_cases) override; private: const ShuangpinProfile profile_; diff --git a/engine/schemes/wubi_scheme.h b/engine/schemes/wubi_scheme.h index 780b1f73..d76814a2 100644 --- a/engine/schemes/wubi_scheme.h +++ b/engine/schemes/wubi_scheme.h @@ -12,7 +12,7 @@ class WubiScheme : public IInputScheme QueryRequest build_request() const override; std::string get_preedit() const override; SchemeType type() const override; - void set_raw_input(const std::string &raw_input, const std::string &raw_input_with_cases); + void set_raw_input(const std::string &raw_input, const std::string &raw_input_with_cases) override; // A wubi code is four letters, and a fifth is normally refused. Mixed input lifts that only // once the table has failed the code in hand, so a spelling long enough to need it -- nihao, // women, zhongguo -- can be finished. A code the table answers keeps the four-letter limit and diff --git a/engine/tests/src/test_input_session.cpp b/engine/tests/src/test_input_session.cpp index c6acd98b..43357f35 100644 --- a/engine/tests/src/test_input_session.cpp +++ b/engine/tests/src/test_input_session.cpp @@ -5,6 +5,7 @@ #include "../../contracts/dictionary/format.h" #include "../../quanpin/quanpin_query.h" #include "../../quanpin/quanpin_utils.h" +#include "../../shuangpin/shuangpin_profile.h" #include @@ -331,6 +332,214 @@ void run_umlaut_alias_session_tests(const std::filesystem::path &data_directory) std::filesystem::remove_all(directory); } + +// 光标驱动的前缀解码(PRD R2–R7,Stage 1):候选与量化边界按「光标之前的完整音节 +// 单元前缀」重算。自建隔离词库,不与主 fixture 互相污染;Server(Stage 2)将以 +// set_caret + recompute_candidates 的同一方式消费这些入口。 +void run_caret_prefix_session_tests(const std::filesystem::path &data_directory) +{ + const std::filesystem::path directory = data_directory / "caret-prefix"; + std::filesystem::create_directories(directory); + { + Database database(directory / "msime.db"); + database.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);"); + database.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);"); + database.execute("CREATE TABLE tbl_1_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);" + "INSERT INTO tbl_1_s VALUES('shi','sh','是',100);"); + database.execute("CREATE TABLE tbl_1_j(key TEXT,jp TEXT,value TEXT,weight INTEGER);" + "INSERT INTO tbl_1_j VALUES('jie','j','接',100);"); + database.execute("CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);" + "INSERT INTO wubi86 VALUES('aaaa','工',100);" + "INSERT INTO wubi86 VALUES('aaaa','或',50);"); + } + + metasequoia::RuntimePaths paths; + paths.resources = directory; + paths.user_data = directory; + paths.cache = directory; + paths.dictionaries = directory; + + const auto words_of = [](const metasequoia::InputSession &session) { + std::vector words; + words.reserve(session.candidates().size()); + for (const WordItem &item : session.candidates()) + { + words.push_back(item.word); + } + return words; + }; + const auto same_word_list = [](const metasequoia::InputSession &session, const std::vector &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; }); + }; + + // R2/R3/R7:音节内 caret 向下取整到最后一个完整单元边界,候选等于前缀的候选; + // caret 未设置或落在末尾时与现状整串解码零差异。 + const std::string sentence = "ni'hao'shi'jie"; + const auto quanpin_words = [&](const std::string &typed) { + metasequoia::InputSession other(SchemeType::Quanpin, 0, true, true, false, paths); + type(other, typed); + return words_of(other); + }; + { + metasequoia::InputSession session(SchemeType::Quanpin, 0, true, true, false, paths); + type(session, sentence); + require(session.has_composition() && session.prefix_end() == sentence.size() && + session.pending_suffix().empty(), + "An unset caret must decode the whole string"); + const auto full_words = words_of(session); + require(same_word_list(session, full_words), + "The unset-caret baseline diverged from a plain typed composition"); + + for (const std::size_t caret : {std::size_t(5), std::size_t(6)}) + { + session.set_caret(caret); + session.recompute_candidates(); + require(session.prefix_end() == 3, "An intra-syllable caret must floor to the last complete unit boundary"); + require(session.pending_suffix() == "hao'shi'jie", + "The pending suffix must keep the raw spelling the decode did not consume"); + require(candidate_index(session, "你") < session.candidates().size(), + "The 'ni' prefix lost its dictionary candidates"); + require(same_word_list(session, quanpin_words("ni")), + "The floored prefix must decode exactly like the typed prefix spelling"); + } + + session.set_caret(7); + session.recompute_candidates(); + require(session.prefix_end() == 7 && session.pending_suffix() == "shi'jie", + "A caret on the hao boundary must consume ni'hao"); + require(candidate_index(session, "你好") < session.candidates().size(), + "The ni'hao prefix lost its phrase candidates"); + require(same_word_list(session, quanpin_words("ni'hao")), + "The ni'hao prefix must decode exactly like a typed ni'hao"); + const auto at_hao = words_of(session); + session.set_caret(9); + session.recompute_candidates(); + require(session.prefix_end() == 7 && same_word_list(session, at_hao), + "A caret inside 'shi' must floor back to the hao boundary"); + + session.set_caret(13); + session.recompute_candidates(); + require(session.prefix_end() == 11 && session.pending_suffix() == "jie", + "A caret inside 'jie' must floor to the shi boundary"); + require(!session.candidates().empty() && same_word_list(session, quanpin_words("ni'hao'shi")), + "The ni'hao'shi prefix must decode exactly like the typed spelling"); + + // R4:量化后前缀为空 → 无候选,raw/preedit/caret 原样。 + session.set_caret(0); + session.recompute_candidates(); + require(session.candidates().empty(), "A caret before the first unit must offer no candidate"); + require(session.prefix_end() == 0 && session.pending_suffix() == sentence, + "An empty prefix must leave the whole string pending"); + require(session.editing_text() == sentence && session.caret_position() == 0 && session.preedit() == sentence, + "An empty prefix must not disturb the composition or the preedit"); + + // 越界 caret 被夹到串尾 → 退化为整串解码(R7)。 + session.set_caret(sentence.size() + 10); + session.recompute_candidates(); + require(session.caret_position() == sentence.size() && session.prefix_end() == sentence.size(), + "An out-of-range caret must clamp to the end"); + require(same_word_list(session, full_words), "A caret clamped to the end must restore the full-string decode"); + session.set_caret(std::nullopt); + session.recompute_candidates(); + require(same_word_list(session, full_words), "Unsetting the caret must restore the full-string decode"); + } + + // pending_suffix 保留原始大小写:大写字母从光标处插入后原样留在后缀里。 + { + metasequoia::InputSession session(SchemeType::Quanpin, 0, true, true, false, paths); + type(session, "ni'hao"); + session.handle_command(metasequoia::Command::MoveHome); + require(session.handle_character('H').handled, "The uppercase insert at the caret was rejected"); + require(session.editing_text() == "Hni'hao" && session.caret_position() == 1, + "The uppercase insert lost its case or position"); + session.set_caret(0); + session.recompute_candidates(); + require(session.prefix_end() == 0 && session.pending_suffix() == "Hni'hao", + "The pending suffix must preserve the typed casing"); + session.set_caret(std::nullopt); + session.recompute_candidates(); + require(session.editing_text() == "Hni'hao", "Restoring the end caret altered the raw text"); + } + + // 无单元模型(五笔):segment_raw_boundaries 为空 → caret 移动不量化,候选零变化。 + { + metasequoia::InputSession session(SchemeType::Wubi, 0, true, true, false, paths); + type(session, "aaaa"); + require(session.has_composition() && candidate_index(session, "工") < session.candidates().size(), + "The wubi fixture lost its candidates"); + const auto native = words_of(session); + for (const std::size_t caret : {std::size_t(0), std::size_t(2)}) + { + session.set_caret(caret); + session.recompute_candidates(); + require(same_word_list(session, native), "A scheme without the unit model must not re-decode by caret"); + require(session.prefix_end() == 4 && session.pending_suffix().empty(), + "Without a unit model the caret never shortens the decode"); + } + } + + // 双拼贪心配对(engine spec #187):nihkb; → {0,2,4,6}、nihcb; → {0,2,3,5,6}, + // caret 落在段中间时同样 floor 到完整段边界。 + const auto shuangpin_words = [&](const std::string &typed) { + metasequoia::InputSession other(SchemeType::Shuangpin, GetMicrosoftShuangpinProfile(), paths); + type(other, typed); + return words_of(other); + }; + { + metasequoia::InputSession session(SchemeType::Shuangpin, GetMicrosoftShuangpinProfile(), paths); + type(session, "nihkb;"); + require(candidate_index(session, "你好") < session.candidates().size(), + "The shuangpin baseline lost its phrase candidates"); + require(session.prefix_end() == 6 && session.pending_suffix().empty(), + "The unset shuangpin caret must decode the whole string"); + + session.set_caret(1); + session.recompute_candidates(); + require(session.candidates().empty() && session.prefix_end() == 0 && session.pending_suffix() == "nihkb;", + "A caret inside the first shuangpin unit must quantize to an empty prefix"); + + session.set_caret(3); + session.recompute_candidates(); + require(session.prefix_end() == 2 && session.pending_suffix() == "hkb;", + "A caret inside the hk unit must floor to the ni boundary"); + require(same_word_list(session, shuangpin_words("ni")), + "The shuangpin 'ni' prefix must decode exactly like the typed spelling"); + + session.set_caret(5); + session.recompute_candidates(); + require(session.prefix_end() == 4 && session.pending_suffix() == "b;", + "A caret inside the b; unit must floor to the hk boundary"); + require(candidate_index(session, "你好") < session.candidates().size(), + "The shuangpin nihk prefix lost its phrase candidates"); + require(same_word_list(session, shuangpin_words("nihk")), + "The shuangpin nihk prefix must decode exactly like the typed spelling"); + + session.set_caret(6); + session.recompute_candidates(); + require(session.prefix_end() == 6 && !session.candidates().empty(), + "A caret on the final shuangpin boundary must restore the full decode"); + + session.handle_command(metasequoia::Command::Cancel); + type(session, "nihcb;"); + session.set_caret(4); + session.recompute_candidates(); + require(session.prefix_end() == 3 && session.pending_suffix() == "cb;", + "The greedy pairing boundary must floor the caret to where cb starts"); + require(same_word_list(session, shuangpin_words("nih")), + "The shuangpin nih prefix must decode exactly like the typed spelling"); + } + + std::filesystem::remove_all(directory); +} } // namespace int run_test() @@ -870,6 +1079,7 @@ int run_test() } run_umlaut_alias_session_tests(data_directory); + run_caret_prefix_session_tests(data_directory); #endif #ifndef METASEQUOIA_SKIP_FREQUENCY_TESTS diff --git a/engine/tests/src/test_runtime_isolation.cpp b/engine/tests/src/test_runtime_isolation.cpp index da38aed4..6afa080a 100644 --- a/engine/tests/src/test_runtime_isolation.cpp +++ b/engine/tests/src/test_runtime_isolation.cpp @@ -262,11 +262,17 @@ void test_runtime_isolation() session.command(Command::DeleteForward); require(session.snapshot().editing_text == "i", "Forward delete removed the wrong character"); session.character('n'); + // The caret sits inside the single unit "ni": the quantized prefix is empty and no + // candidate may be offered (PRD R3/R4). The empty list itself proves the refresh ran. require(session.snapshot().editing_text == "ni" && session.snapshot().caret_position == 1 && - session.snapshot().candidates.front().word == "你", + session.snapshot().candidates.empty(), "Middle insertion did not refresh candidates"); + session.command(Command::MoveEnd); + require(session.snapshot().candidates.front().word == "你", + "Moving back to the end did not restore the full-string decode"); require(!session.apply_online_candidate(*query, "旧响应", CandidateSource::CloudSuggestion), "Editing back to the same text accepted an old online response"); + session.command(Command::MoveLeft); session.character('\''); session.character('\''); require(session.snapshot().editing_text == "n'i", "Duplicate separator was inserted at caret"); @@ -276,6 +282,11 @@ void test_runtime_isolation() require(session.snapshot().editing_text == "ni" && session.snapshot().caret_position == 2, "End forward delete changed input"); session.command(Command::MoveHome); + // A caret before the first unit offers no candidate, and the stale full-string list + // must not stay selectable there (PRD R4). + require(session.snapshot().candidates.empty() && !session.select(0).handled, + "A caret at the start still offered or accepted candidates"); + session.command(Command::MoveEnd); require(session.select(0).commit == "你" && session.snapshot().caret_position == 0, "Selection did not clear caret with composition"); type(session, "ni"); @@ -343,14 +354,23 @@ void test_runtime_isolation() const auto prefix = std::find_if(view.candidates.begin(), view.candidates.end(), [](const auto &word) { return word.word == "你"; }); require(prefix != view.candidates.end(), "Missing partial-selection fixture"); + const auto prefix_index = static_cast(prefix - view.candidates.begin()); session.command(Command::MoveHome); - const auto selected = session.select(static_cast(prefix - view.candidates.begin())); + // A caret before the first unit offers no candidate, and the stale full-string list + // must not stay selectable there (PRD R4). + require(session.snapshot().candidates.empty() && !session.select(prefix_index).handled, + "A caret at the start still offered or accepted candidates"); + session.command(Command::MoveEnd); + const auto selected = session.select(prefix_index); require(selected.commit == "你" && session.snapshot().editing_text == "hao" && session.snapshot().caret_position == 3, "Partial selection retained the old caret"); session.command(Command::MoveHome); session.command(Command::DeleteForward); session.character('h'); + // The remainder edits run at a caret whose quantized prefix is empty, so finish is + // pinned to the end boundary where the full-string decode lives (PRD R7). + session.command(Command::MoveEnd); require(session.finish().commit == "好", "Editing the remainder lost the selected prefix boundary"); } require(paths_a.resources != paths_a.dictionaries && paths_a.cache != paths_a.dictionaries, diff --git a/server/tests/CMakeLists.txt b/server/tests/CMakeLists.txt index f1d19564..676356b9 100644 --- a/server/tests/CMakeLists.txt +++ b/server/tests/CMakeLists.txt @@ -20,6 +20,7 @@ set(TEST_SOURCE_FILES "${CMAKE_CURRENT_SOURCE_DIR}/src/test_japanese_romaji.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_shuangpin_query.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_engine_shuangpin_session.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/test_caret_prefix_input_session.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_english_dictionary.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_translation_gloss.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_default_config_credentials.cpp" diff --git a/server/tests/src/test_caret_prefix_input_session.cpp b/server/tests/src/test_caret_prefix_input_session.cpp new file mode 100644 index 00000000..b81bc01a --- /dev/null +++ b/server/tests/src/test_caret_prefix_input_session.cpp @@ -0,0 +1,299 @@ +// 光标驱动的前缀解码(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 + +#include +#include +#include +#include +#include + +namespace +{ +// 隔离词库夹具:caret 前缀断言需要确定的候选集与权重次序,绝不依赖开发机或 CI +// 机器上的全局 msime.db。表内容与 engine 侧同名夹具逐字一致,权重决定候选顺序。 +std::filesystem::path CreateCaretPrefixFixture() +{ + const auto directory = std::filesystem::temp_directory_path() / "msime-caret-prefix-input-session-test"; + 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; +} + +void TypeText(metasequoia::InputSession &session, const std::string &text) +{ + for (const char character : text) + { + REQUIRE(session.handle_character(character).handled); + } +} + +std::vector CandidateWords(const metasequoia::InputSession &session) +{ + std::vector 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 &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(found - candidates.begin()); +} + +// 前缀等价断言的基准:同一拼写独立键入得到的候选列表。 +std::vector 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 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); +} + +// 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(session.candidates().empty()); + REQUIRE_EQ(session.prefix_end(), std::size_t{0}); + REQUIRE_EQ(session.pending_suffix(), std::string("nihkb;")); + + 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); +}