Skip to main content

msime_engine/
nine_key.rs

1//! T9 digit composition over quanpin syllables plus English T9 (core-session.md §8). Active while it holds digits; `Session` routes to it then.
2//!
3//! The digits stay the composition: a chosen spelling only rewrites its span of digits and is remembered in `locked`, and every candidate's `pinyin` is the run of digits it consumes, so selection advances the same way whichever reading produced the row.
4
5use std::borrow::Cow;
6use std::cmp::{Ordering, Reverse};
7use std::collections::{HashMap, HashSet};
8use std::hash::{BuildHasherDefault, Hasher};
9use std::path::PathBuf;
10use std::sync::OnceLock;
11
12use crate::assets;
13use crate::diagnostics;
14use crate::dictionary::english::{append_keypad_ranked, EnglishDictionary, KeypadRow};
15use crate::error::EngineError;
16use crate::ime::queries::{
17    insert_expressive_rows, keep_expressive_rows_off_the_lead, MIXED_EXPRESSIVE_MINIMUM_INPUT,
18};
19use crate::language_dictionary::{self, LanguageDictionary};
20use crate::lattice::decode::PHRASE_LENGTH_BONUS;
21use crate::lattice::neural::{
22    shared_sentence_model, NeuralReranker, CONTEXT_CHARACTERS, MAX_RERANK_PATHS,
23};
24use crate::lattice::personal::PersonalTransition;
25use crate::lattice::SentencePath;
26use crate::local::date_time::{inline_date_time_keyword, insert_inline_date_time};
27use crate::local::emoji::{query_emoji_readings, query_kaomoji_readings, ExpressiveRow};
28use crate::local::symbol_candidates::{query_symbol_readings, SymbolRow, MIXED_SYMBOL_LIMIT};
29use crate::nine_key_shuangpin::NineKeyShuangpin;
30use crate::paths::RuntimePaths;
31use crate::pinyin::segment::{is_complete_pinyin_input, split_segments};
32use crate::pinyin::syllables::intact_pinyin_list;
33use crate::quanpin::QuanpinDictionary;
34use crate::session::{LocalClock, SessionSnapshot};
35use crate::stroke;
36use crate::text::{count_utf8_chars, is_all_han, is_han_phrase, last_characters};
37use crate::types::{
38    CandidateSource, Command, EnglishInputOptions, FirstPageRows, FrequencyAdjustmentMode,
39    FrequencyAdjustmentOptions, FuzzyPinyinOptions, KeyResult, LocalInputMode,
40    MixedExpressiveOptions, PersonalDictionaryKind, SchemeType, SentenceAssociationOptions,
41    SharedString, WordItem,
42};
43use crate::user_dictionary::positions;
44use crate::user_dictionary::ranking::{self, RankingRequest};
45use crate::user_dictionary::removal;
46
47pub const PATH_LIMIT: usize = 48;
48pub const DIGIT_LIMIT: usize = 48;
49pub const CANDIDATE_LIMIT: usize = 128;
50/// 每次刷新从排在前面、覆盖全部数字的行里取几条切分当种子,见 `seeded_paths`。
51const SENTENCE_SEED_LIMIT: usize = 4;
52/// 上一次的种子仍对得上数字时一起留着,合计最多这么多条。只留这一次的种子时,末尾几个数字的噪声会把前面已经拼对的切分挤掉。
53const SEED_RETAIN_LIMIT: usize = 8;
54/// 每条种子接几条余下数字的切分。
55const SEED_TAIL_LIMIT: usize = 3;
56/// 有种子时,数字至少这么长才把按音节频度留的切分截到 `SEEDED_PATH_LIMIT` 条:长串的好切分主要靠种子补,频度切分截短省下的查询抵掉种子的开销;短串(词级输入)照旧留满 `PATH_LIMIT` 条。
57const SEEDED_TRUNCATE_DIGITS: usize = 12;
58const SEEDED_PATH_LIMIT: usize = 24;
59/// 每次刷新真正拿去查词库的切分条数。切分搜索本身每个位置仍留 `PATH_LIMIT` 条,这里只截最后物化的路径:每条路径都要查一遍候选、合并整句,一次刷新的耗时大致随条数增长。从 48 降到 24,`ninekey_latency` 整轮执行的指令数少约 31%;`convert_eval` 九键常用词首选 57.5% → 57.1%、前五 85.2% → 84.7%,九键长句首选不变、前五 65.4% → 64.2%,14 键常用词首选 66.0% → 65.8%、长句不变;先锁定首音节再打余下数字时,1500 个多音节词里正确词进前九的从 1448 个变为 1446 个。搜索宽度一起降到 24 时这几组数字完全相同,指令数也一样,所以只截查询这一步。有种子的长串原本截到的 `SEEDED_PATH_LIMIT` 与它相等,那一步现在不再多截。
60const QUERY_PATH_LIMIT: usize = 24;
61/// 音节频度表:每个音节在词库里出现的总权重,由 `msime-dict-build syllable-frequency` 从 msime-pinyin.db 生成,见 `SyllablePrior`。
62const SYLLABLE_FREQUENCY: &str = include_str!("nine_key/syllable_frequency.tsv");
63// 少量查询键直接扫描已有切分路径,避免刷新时为临时哈希表分配堆内存。
64const SMALL_QUERY_KEY_BATCH: usize = 64;
65// 少量九宫格候选直接扫描已保留词,避免排序后为一次去重分配哈希表。
66const SMALL_CANDIDATE_DEDUP: usize = 64;
67// 短列表保留直接排序,避免额外创建缓存键数组。
68const SMALL_CANDIDATE_SORT: usize = 32;
69
70/// The digit printed beside each of `a..=z` (NK:37).
71const KEYPAD: &[u8; 26] = b"22233344455566677778889999";
72/// The letters printed on each digit key (NK:74).
73const DIGIT_LETTERS: [&str; 10] = [
74    "", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz",
75];
76/// 全拼 14 键 `a..=z` 各自的组码:QWERTY 上相邻两个字母一个键,组码取这一组首字母的小写。
77const FOURTEEN_KEYPAD: &[u8; 26] = b"abcdedggujjlmbooqeatucqztz";
78/// 全拼 14 键的十四个键,按键面从左到右、从上到下。组码是每组的首字母,与左列的大写字母项和 `'` 都不冲突。
79const FOURTEEN_KEY_GROUPS: [&str; 14] = [
80    "qw", "er", "ty", "ui", "op", "as", "df", "gh", "jk", "l", "zx", "cv", "bn", "m",
81];
82/// 全拼十八键的字母归组,组码取键面首字母。
83const EIGHTEEN_KEYPAD: &[u8; 26] = b"abxswffhijjlmbipqrsruvwxyz";
84const EIGHTEEN_KEY_GROUPS: [&str; 18] = [
85    "q", "we", "rt", "y", "u", "io", "p", "a", "sd", "fg", "h", "jk", "l", "z", "xc", "v", "bn",
86    "m",
87];
88
89/// 十八键双拼沿用全拼字母分组,并把分号归入 L。
90pub(crate) fn eighteen_key_code(letter: u8) -> Option<u8> {
91    if letter == b';' {
92        Some(b'l')
93    } else {
94        KeyGrid::EighteenKey.code_of(letter)
95    }
96}
97
98/// T9 expansion multiplies by three or four per digit, so only the leading digits become letter prefixes and the dictionary's own prefix search carries the rest (NK:189).
99const ENGLISH_PREFIX_BUDGET: usize = 64;
100const ENGLISH_LIMIT: usize = 5;
101const ENGLISH_CANDIDATE_CAPACITY: usize = ENGLISH_PREFIX_BUDGET * ENGLISH_LIMIT;
102/// 一个字母前缀最多缓存这么多行;词库里最大的三字母前缀 `con` 有 1034 个词,超过上限的前缀组照旧逐次查询。
103const ENGLISH_CACHED_PREFIX_ROWS: usize = 2048;
104/// 九宫格简拼一次最多展开的码数:每个数字取它键上能作音节首字母的字母,五个数字最多 4^5 = 1024 个,五字以内的词都查得到;更长的输入展开太多,不查简拼。
105const INITIALS_CODE_LIMIT: usize = 1024;
106/// 简拼一次最多取的行数,按权重从高到低。
107const INITIALS_ROW_LIMIT: usize = 64;
108/// 九键双拼按声母那几位找词(见 `refresh`)从几个音节起查:两个音节的切分不超过 `PATH_LIMIT` 条,不用查。
109const SHUANGPIN_WORD_MIN_SYLLABLES: usize = 3;
110/// 九键双拼按声母那几位找词时每张首字母表取的行数,按权重;取出来还要按编码筛,所以比简拼行多取。
111const SHUANGPIN_WORD_SCAN_LIMIT: usize = 1024;
112/// 用户用过的简拼词可能排在按权重的前 `INITIALS_ROW_LIMIT` 行之外(出货词库里 9'7 的 隐私 前面有 284 行更重的;9'9'2'9 的 仔细查找 权重 100,前面有 246 行更重的、三百多行同样是 100)。个人上下文模型有记录时,每张首字母表各取权重最高的这么多行(`query_jianpin_codes_per_table`,九宫格的码最多分在四张表里),用过的词先留下,再截到 `INITIALS_ROW_LIMIT`(#6185)。
113const INITIALS_SCAN_LIMIT: usize = 512;
114/// 九键选中一个词时记进个人上下文模型的次数,与 26 键显式选词的 `session::learning::DICTIONARY_PICK_TIMES` 相同(`session/tests.rs` 核对两者一致)。简拼行按每 `PERSONAL_PICK_TIMES * trigger_count` 个计数算调频的一次触发,见 `boost_used_initials`。
115pub(crate) const PERSONAL_PICK_TIMES: u32 = 2;
116/// `NineKeySession::rerank_sentences` 缓存的重排结果条数。退格回到前一串数字、选词或筛选后刷新时,同一组整句和上下文会再出现;26 键按 series 槽缓存整张重排过的列表,九键只缓存模型给出的顺序,免得每次刷新都再跑一次模型。
117const RERANK_CACHE_ENTRIES: usize = 16;
118/// 没打切分时,同样覆盖的词典行里最前面留给音节行(最常用的单字)的位置数;其后的音节行和简拼行按权重归并,见 `interleave_initials`。
119const SYLLABLE_ROWS_BEFORE_INITIALS: usize = 3;
120/// 不打切分、首页里有拼满的多音节词时,完整读法的权重乘这个倍数再和补全比,见 `discount_completions_on_first_page`。
121const COMPLETION_WEIGHT_RATIO: i64 = 6;
122/// 混入 emoji、颜文字时最多按几种读法查。一串数字能拼出几十种读法,每种读法要各查一次 emoji 和颜文字;按候选列表排好先后之后只查前面这几种,最可能的读法总在里面。
123const EXPRESSIVE_READING_LIMIT: usize = 8;
124/// 选中整句时最多存多少个音节,与全拼键盘的 `session::learning::MAX_LEARNED_SENTENCE_SYLLABLES` 相同(`session/tests.rs` 核对两者一致):更长的整句只用于这一次上屏。
125pub(crate) const MAX_LEARNED_SENTENCE_SYLLABLES: usize = 7;
126
127/// 九宫格会话把字母归成组码用的键盘。一个组码键上有几个字母,解码只认组码:路径搜索、锁定、切分、整句、简拼和英文混入都与「字母怎么变成码」无关,只经由这里的表。
128///
129/// 九键双拼不按字母归组,而是一个音节两位数字(声母一位、韵母一位),表由用户选的方案给出,所以它带着那张表(进程里按内容只留一份,见 `nine_key_shuangpin`);相等按表的引用比较。
130#[derive(Clone, Copy, Debug)]
131pub enum KeyGrid {
132    /// 电话键盘:组码是 `2`–`9`。
133    NineKey,
134    /// 全拼 14 键:组码是每组首字母的小写 `q e t u o a d g j l z c b m`。
135    FourteenKey,
136    /// 全拼十八键:八组双字母键与十组单字母键。
137    EighteenKey,
138    /// 九键双拼:组码是方案用到的数字,可以有 `1` 和 `0`。
139    NineKeyShuangpin(&'static NineKeyShuangpin),
140    /// 14 键双拼:当前 26 键方案投到相邻字母组,每个音节仍占两键,分号与 L 共键。
141    FourteenKeyShuangpin(&'static NineKeyShuangpin),
142    /// 双拼 18 键:按 Q WE RT Y U IO P/A SD FG H JK L/Z XC V BN M 分组。
143    EighteenKeyShuangpin(&'static NineKeyShuangpin),
144}
145
146impl PartialEq for KeyGrid {
147    fn eq(&self, other: &Self) -> bool {
148        match (self, other) {
149            (Self::NineKey, Self::NineKey)
150            | (Self::FourteenKey, Self::FourteenKey)
151            | (Self::EighteenKey, Self::EighteenKey) => true,
152            (Self::NineKeyShuangpin(left), Self::NineKeyShuangpin(right))
153            | (Self::FourteenKeyShuangpin(left), Self::FourteenKeyShuangpin(right))
154            | (Self::EighteenKeyShuangpin(left), Self::EighteenKeyShuangpin(right)) => {
155                std::ptr::eq(*left, *right)
156            }
157            _ => false,
158        }
159    }
160}
161
162impl Eq for KeyGrid {}
163
164impl std::hash::Hash for KeyGrid {
165    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
166        std::mem::discriminant(self).hash(state);
167        if let Some(table) = self.shuangpin_table() {
168            std::ptr::hash(table, state);
169        }
170    }
171}
172
173impl KeyGrid {
174    fn shuangpin_table(self) -> Option<&'static NineKeyShuangpin> {
175        match self {
176            Self::NineKeyShuangpin(table)
177            | Self::FourteenKeyShuangpin(table)
178            | Self::EighteenKeyShuangpin(table) => Some(table),
179            _ => None,
180        }
181    }
182
183    /// `a..=z` 各自的组码;双拼网格使用音节编码表,不使用这张字母归组表。
184    fn keypad(self) -> Option<&'static [u8; 26]> {
185        match self {
186            Self::NineKey => Some(KEYPAD),
187            Self::FourteenKey => Some(FOURTEEN_KEYPAD),
188            Self::EighteenKey => Some(EIGHTEEN_KEYPAD),
189            Self::NineKeyShuangpin(_)
190            | Self::FourteenKeyShuangpin(_)
191            | Self::EighteenKeyShuangpin(_) => None,
192        }
193    }
194
195    /// 字母所在键的组码;十八键双拼的大写字母标记精确输入。字母双拼网格的分号归入 L,九键双拼不按字母归组。
196    pub fn code_of(self, letter: u8) -> Option<u8> {
197        if matches!(self, Self::EighteenKeyShuangpin(_)) {
198            if letter.is_ascii_uppercase() {
199                return Some(letter);
200            }
201            return eighteen_key_code(letter);
202        }
203        if matches!(self, Self::FourteenKeyShuangpin(_)) {
204            return if letter == b';' {
205                Some(b'l')
206            } else {
207                Self::FourteenKey.code_of(letter)
208            };
209        }
210        let keypad = self.keypad()?;
211        letter
212            .is_ascii_lowercase()
213            .then(|| keypad[usize::from(letter - b'a')])
214    }
215
216    /// 组码键上的字母;不是这种键盘的组码时为空。九键双拼是这个键上能起头一个音节的字母(声母的首字母和从这个键开始的零声母音节的首字母)。
217    pub fn letters(self, code: u8) -> &'static str {
218        match self {
219            Self::NineKey if (b'2'..=b'9').contains(&code) => {
220                DIGIT_LETTERS[usize::from(code - b'0')]
221            }
222            Self::NineKey => "",
223            Self::FourteenKey => FOURTEEN_KEY_GROUPS
224                .iter()
225                .find(|group| group.as_bytes().first() == Some(&code))
226                .copied()
227                .unwrap_or(""),
228            Self::EighteenKey => EIGHTEEN_KEY_GROUPS
229                .iter()
230                .find(|group| group.as_bytes().first() == Some(&code))
231                .copied()
232                .unwrap_or(""),
233            Self::NineKeyShuangpin(table)
234            | Self::FourteenKeyShuangpin(table)
235            | Self::EighteenKeyShuangpin(table) => table.letters(code),
236        }
237    }
238
239    /// `byte` 是这种键盘的一个组码键。九键双拼是方案用到的数字。
240    pub fn is_code(self, byte: u8) -> bool {
241        match self {
242            Self::NineKeyShuangpin(table)
243            | Self::FourteenKeyShuangpin(table)
244            | Self::EighteenKeyShuangpin(table) => table.uses_key(byte),
245            _ => !self.letters(byte).is_empty(),
246        }
247    }
248
249    /// 拼音(或任何字母串)的组码;`a..=z` 以外的字节(`'`)跳过。九键双拼按 `'` 分开的音节编码,完整音节两位、单独的声母一位,有一段两样都不是时为空。
250    pub fn encode(self, pinyin: &str) -> String {
251        let Some(keypad) = self.keypad() else {
252            return match self {
253                Self::NineKeyShuangpin(table)
254                | Self::FourteenKeyShuangpin(table)
255                | Self::EighteenKeyShuangpin(table) => table.encode(pinyin),
256                _ => String::new(),
257            };
258        };
259        pinyin
260            .bytes()
261            .filter(u8::is_ascii_lowercase)
262            .map(|letter| char::from(keypad[usize::from(letter - b'a')]))
263            .collect()
264    }
265
266    /// 返回相容组码覆盖的输入长度;按字母分组时只比较共同前缀,不拥有中间编码。
267    fn matching_code_prefix_len(self, pinyin: &str, digits: &str) -> Option<usize> {
268        let Some(keypad) = self.keypad() else {
269            return self.shuangpin_table()?.matching_prefix_len(pinyin, digits);
270        };
271        let digits = digits.as_bytes();
272        let mut covered = None;
273        for (index, letter) in pinyin.bytes().filter(u8::is_ascii_lowercase).enumerate() {
274            let Some(&digit) = digits.get(index) else {
275                return Some(index);
276            };
277            if keypad[usize::from(letter - b'a')] != digit {
278                return None;
279            }
280            covered = Some(index + 1);
281        }
282        covered
283    }
284
285    /// 一个完整音节(锁定的拼写、左列的音节、整句种子里的音节)占几个组码:全拼按字母计数,双拼总是两个。
286    pub(crate) fn syllable_code_len(self, syllable: &str) -> usize {
287        match self {
288            Self::NineKeyShuangpin(_)
289            | Self::FourteenKeyShuangpin(_)
290            | Self::EighteenKeyShuangpin(_) => 2,
291            _ => syllable.len(),
292        }
293    }
294
295    /// 一行候选的全拼(`'` 连接的完整音节)一共占几个组码,不分配。
296    fn reading_code_len(self, canonical_pinyin: &str) -> usize {
297        match self {
298            Self::NineKeyShuangpin(_)
299            | Self::FourteenKeyShuangpin(_)
300            | Self::EighteenKeyShuangpin(_) => 2 * reading_syllable_count(canonical_pinyin),
301            _ => canonical_pinyin
302                .bytes()
303                .filter(u8::is_ascii_lowercase)
304                .count(),
305        }
306    }
307
308    /// 组码本身是不是可上屏的文字。九键的数字是:左列在没有锁定时给出「原样上屏这一键」,`CommitRaw` 上屏数字。十四键或十八键的组码只是这一组的首字母,原样上屏没有意义:左列没有这一项,`CommitRaw` 改为结束组字。九键双拼的数字和九键一样是数字本身。
309    pub fn offers_raw_key(self) -> bool {
310        match self {
311            Self::NineKey | Self::NineKeyShuangpin(_) => true,
312            Self::FourteenKey
313            | Self::EighteenKey
314            | Self::FourteenKeyShuangpin(_)
315            | Self::EighteenKeyShuangpin(_) => false,
316        }
317    }
318
319    /// 组码是数字,从 `character` 进来:九键和九键双拼。数字在这两种网格下是组字的键,不是选词键。
320    pub fn uses_digits(self) -> bool {
321        matches!(self, Self::NineKey | Self::NineKeyShuangpin(_))
322    }
323
324    /// 能拼英文词(英文九键、英文混入)。双拼网格按声母和韵母编码,不按字母分组拼英文。
325    fn spells_english(self) -> bool {
326        self.shuangpin_table().is_none()
327    }
328
329    /// 这种键盘的拼写表,每种键盘(九键双拼是每张表)在进程里建一次。
330    fn spelling_table(self) -> &'static SpellingTable {
331        match self {
332            Self::NineKey => {
333                static TABLE: OnceLock<SpellingTable> = OnceLock::new();
334                TABLE.get_or_init(|| SpellingTable::build(self, intact_pinyin_list()))
335            }
336            Self::FourteenKey => {
337                static TABLE: OnceLock<SpellingTable> = OnceLock::new();
338                TABLE.get_or_init(|| SpellingTable::build(self, intact_pinyin_list()))
339            }
340            Self::EighteenKey => {
341                static TABLE: OnceLock<SpellingTable> = OnceLock::new();
342                TABLE.get_or_init(|| SpellingTable::build(self, intact_pinyin_list()))
343            }
344            Self::NineKeyShuangpin(table)
345            | Self::FourteenKeyShuangpin(table)
346            | Self::EighteenKeyShuangpin(table) => table
347                .spelling
348                .get_or_init(|| SpellingTable::build_shuangpin(self, table)),
349        }
350    }
351}
352
353type Path = Vec<String>;
354
355pub struct NineKeySession {
356    paths: RuntimePaths,
357    learning: bool,
358    frequency: FrequencyAdjustmentOptions,
359    fuzzy: FuzzyPinyinOptions,
360    english_options: EnglishInputOptions,
361    /// 候选里混入 emoji、颜文字和符号(共享偏好 `mixed_input.emoji` / `mixed_input.kaomoji` / `mixed_input.symbols`)。引擎层默认都关,宿主按偏好打开。
362    expressive: MixedExpressiveOptions,
363    /// 候选混输总开关(`SessionOptions::mixed_candidates`):关着时英文、emoji、颜文字、符号和行内日期时间都不混进候选。
364    mixed_candidates_enabled: bool,
365    /// 组字里的日期时间行(#5952)读的墙钟;`None` 时不出这些行。跟日期时间模式同一个开关。
366    inline_date_time: Option<LocalClock>,
367    /// 句子联想设置(`SessionOptions::sentence_association`),与 26 键相同:`word_lattice` 关掉时不出词网格整句行,`neural_keyboard` 打开时用键盘模型给整句重排(#6059)。
368    sentence_association: SentenceAssociationOptions,
369    /// `SessionOptions::sentence_alternatives`:每条切分交回全部整句读法,而不是只交回最好的一条。
370    sentence_alternatives: bool,
371    /// `SessionOptions::first_page_rows`:首页放得下几行,决定全长组的补全词是按权重并进来还是接在整组末尾。
372    first_page_rows: FirstPageRows,
373    /// 键盘模型读的上屏上下文,只留最后 `CONTEXT_CHARACTERS` 个字;`Session::set_rescoring_context` 随时更新。
374    rescoring_context: String,
375    /// 键盘模型,`neural_keyboard` 打开后第一次重排时加载;内层 `None` 是模型读不到。九键不让词库在每条切分里各重排一次,见 `rerank_sentences`。
376    keyboard: Option<Option<NeuralReranker>>,
377    /// 键盘模型最近几次给出的整句顺序,最新的在后,见 `rerank_sentences`。换掉模型(关掉 `neural_keyboard`)时清空。
378    rerank_cache: Vec<RerankEntry>,
379    /// 把选中的词记进 26 键共用的个人上下文模型(`SessionOptions::personal_context`,还要 `learning`),简拼行据此把用户用过的词排到前面(#6185)。
380    personal_context: bool,
381    /// `finish` 逐段代选时不把默认选择写成最近候选位置。
382    remember_recent: bool,
383    /// 最近一次刷新是否按 `discount_completions_on_first_page` 折算过首页里的补全;置顶要知道调频写进去的权重会不会被折算挡住。
384    completions_discounted: bool,
385    digits: String,
386    locked: Vec<String>,
387    /// 用户用 `'` 切开音节的数字位置,升序,都在已锁定的部分之后。和锁定的拼写不同,切分只定下一个音节在哪里结束,两边数字的各种读法都还保留:`94'26` 可以是 xi'an,也可以是 yi'an,但不会是 xian。
388    splits: Vec<usize>,
389    spellings: Vec<String>,
390    /// 拼写匹配的排序索引,刷新时复用,避免每次为借用的音节引用重新申请临时向量。
391    spelling_match_indices: Vec<usize>,
392    /// `SessionSnapshot::nine_key_reading`,随候选一起重建。
393    reading: String,
394    candidates: Vec<WordItem>,
395    /// 前几次刷新排在前面、覆盖全部数字的几行的全拼(`'` 连接)。下一次刷新把它们去掉最后一个音节,接上余下数字的切分一起查,见 `seeded_paths`。
396    sentence_seeds: Vec<String>,
397    /// 算下一批种子用的空缓冲,和 `sentence_seeds` 轮换,刷新时不再分配。
398    seed_buffer: Vec<String>,
399    /// 九键切分路径的输出缓冲;相邻刷新复用路径及其音节字符串。
400    path_buffer: Vec<Path>,
401    /// 活动拼音组字复用搜索页;组字结束时释放页容器。
402    path_scratch: PathScratch<'static>,
403    english_only: bool,
404    /// 部分选择正在拼的词:已经选掉的各段的全拼(`'` 连接)和文字。整串数字选完时把它们连同最后一段存成用户词,下次打简拼就能出来(#5640);有一段读不出一字一音节的全拼(英文词、模糊音行)时 `phrase_storable` 为假,这个词不存。
405    phrase_pinyin: String,
406    phrase_word: String,
407    phrase_storable: bool,
408    /// 组字光标在 `digits` 里的位置;`None` 是在末尾。用户把光标移进数字中间后(触屏点读音行、硬件键盘的方向键),数字、切分和退格都作用在光标处,用来改掉中间打错的一个数字而不必删掉后面的(#5613)。
409    caret: Option<usize>,
410    /// 会话允许全拼时为真。为假时九宫格只拼英文,拼音词库永远不打开。
411    pinyin: bool,
412    /// 只出单字:拼音读法里只收单个汉字,见 `SessionOptions::single_character_only`。
413    single_character_only: bool,
414    /// Opened on first use.
415    dictionary: Option<QuanpinDictionary>,
416    /// 每个音节及其前缀的频度,`SyllablePrior::shipped` 在第一次查词前按内嵌的音节频度表建好,给切分路径排序用。
417    prior: Option<SyllablePrior<'static>>,
418    english: Option<EnglishDictionary>,
419    /// 完整分组码的 SQLite GLOB 模式;热刷新复用容量。
420    english_pattern: String,
421    /// 上一次英文查询的数字和字母前缀下的全部匹配行,见 `EnglishKeypadCache`。
422    english_rows: Option<EnglishKeypadCache>,
423    /// 和 `locked` 一一对应:撤销那次锁定要用的东西。部分上屏之后锁定的数字位置变了,记录随之作废成 `None`。
424    lock_undo: Vec<Option<LockUndo>>,
425    /// 从左列末尾选的字母:第一个未锁定的音节必须以它开头。
426    initial: Option<Initial>,
427    /// 候选只留单字。
428    single_character: bool,
429    /// 候选首字的笔顺必须以这几笔开头(`hspnz`);空表示不按笔画筛选。
430    strokes: String,
431    /// `msime-stroke.db` 的位置;宿主没有时为空,笔画筛选不可用。
432    stroke_dictionary: PathBuf,
433    /// 第一次按笔画筛选时打开。
434    stroke: Option<LanguageDictionary>,
435    /// `strokes` 对应的字,随 `set_filter` 重建。
436    stroke_texts: HashSet<char>,
437    /// 把字母归成组码的键盘,`digits` 里存的是它的组码。
438    grid: KeyGrid,
439}
440
441/// 一次锁定之前的样子。
442struct LockUndo {
443    /// 这段数字锁定前的样子:锁定把它换成了拼写的编码,拼写比键入的长时还补齐了数字。
444    replaced: String,
445    /// 锁定时丢掉的切分,都在这段数字里。
446    splits: [usize; DIGIT_LIMIT],
447    split_count: usize,
448    /// 锁定前选的首字母,锁定时并进了拼写。
449    initial: Option<Initial>,
450    /// 锁定时左列给出的选项。数字全部锁定后左列还给出这一组,选其中一项就换掉这次锁定。
451    choices: Vec<String>,
452}
453
454#[derive(Clone, Copy)]
455struct Initial {
456    letter: u8,
457    /// 选字母时的数字个数和切分个数。两者都没变时退格撤销这个字母;之后删到更少的数字就再也不撤销它。
458    typed: usize,
459    splits: usize,
460}
461
462/// 左列的一项:完整音节、按键上的字母(大写),或者按键本身的数字。
463enum Choice {
464    Syllable,
465    Letter(u8),
466    Digit(u8),
467}
468
469fn choice_kind(choice: &str) -> Choice {
470    match choice.as_bytes() {
471        [letter] if letter.is_ascii_uppercase() => Choice::Letter(letter.to_ascii_lowercase()),
472        [digit] if digit.is_ascii_digit() => Choice::Digit(*digit),
473        _ => Choice::Syllable,
474    }
475}
476
477impl NineKeySession {
478    /// English options are fixed here; the reference never updated them afterwards. `pinyin` 是会话的 `enabled_schemes` 是否含全拼:九宫格拼的是全拼音节,不含全拼时只给英文行,也不打开拼音词库。
479    pub fn new(
480        paths: &RuntimePaths,
481        learning: bool,
482        frequency: FrequencyAdjustmentOptions,
483        fuzzy: FuzzyPinyinOptions,
484        english: EnglishInputOptions,
485        pinyin: bool,
486        single_character_only: bool,
487    ) -> Self {
488        Self {
489            paths: paths.clone(),
490            learning,
491            frequency,
492            fuzzy,
493            english_options: english,
494            expressive: MixedExpressiveOptions::default(),
495            mixed_candidates_enabled: true,
496            inline_date_time: None,
497            sentence_association: SentenceAssociationOptions::default(),
498            sentence_alternatives: false,
499            first_page_rows: FirstPageRows::default(),
500            rescoring_context: String::new(),
501            keyboard: None,
502            rerank_cache: Vec::new(),
503            personal_context: true,
504            remember_recent: true,
505            completions_discounted: false,
506            digits: String::new(),
507            locked: Vec::new(),
508            splits: Vec::new(),
509            spellings: Vec::new(),
510            spelling_match_indices: Vec::new(),
511            reading: String::new(),
512            candidates: Vec::new(),
513            // 两个缓冲一开始就留够容量,按键时轮换着用,不再分配。
514            sentence_seeds: Vec::with_capacity(SEED_RETAIN_LIMIT),
515            seed_buffer: Vec::with_capacity(SEED_RETAIN_LIMIT),
516            path_buffer: Vec::new(),
517            path_scratch: PathScratch::default(),
518            english_only: false,
519            phrase_pinyin: String::new(),
520            phrase_word: String::new(),
521            phrase_storable: true,
522            caret: None,
523            pinyin,
524            single_character_only,
525            dictionary: None,
526            prior: None,
527            english: None,
528            english_pattern: String::new(),
529            english_rows: None,
530            lock_undo: Vec::new(),
531            initial: None,
532            single_character: false,
533            strokes: String::new(),
534            stroke_dictionary: PathBuf::new(),
535            stroke: None,
536            stroke_texts: HashSet::new(),
537            grid: KeyGrid::NineKey,
538        }
539    }
540
541    /// 笔画筛选读的 `msime-stroke.db`;空路径表示没有,`set_filter` 带笔画时报 `LANGUAGE_DICTIONARY_UNAVAILABLE`。
542    pub fn set_stroke_dictionary(&mut self, path: PathBuf) {
543        if self.stroke_dictionary != path {
544            self.stroke = None;
545        }
546        self.stroke_dictionary = path;
547    }
548
549    /// 和 26 键共用的 emoji、颜文字混排开关;与英文选项一样只在建会话时设置。
550    pub fn set_mixed_expressive(&mut self, options: MixedExpressiveOptions) {
551        self.expressive = options;
552    }
553
554    /// 候选混输总开关(`SessionOptions::mixed_candidates`);与英文选项一样只在建会话时设置。
555    pub fn set_mixed_candidates_enabled(&mut self, enabled: bool) {
556        self.mixed_candidates_enabled = enabled;
557    }
558
559    /// 数字正好拼出 `riqi`、`sj` 这类关键词时,把当前日期、时间、星期或农历接在对应的词后面,见 `local::date_time::insert_inline_date_time`;`None` 关掉。与 emoji 选项一样只在建会话时设置,测试换时钟时再设一次。
560    pub fn set_inline_date_time(&mut self, clock: Option<LocalClock>) {
561        self.inline_date_time = clock;
562    }
563
564    /// 和 26 键共用的句子联想设置(#6059)。与英文选项一样只在建会话时设置;词库已经打开时同步给它,组字中则按新设置重排候选。
565    pub fn set_sentence_options(
566        &mut self,
567        association: SentenceAssociationOptions,
568        alternatives: bool,
569    ) {
570        if self.sentence_association == association && self.sentence_alternatives == alternatives {
571            return;
572        }
573        self.sentence_association = association.clone();
574        self.sentence_alternatives = alternatives;
575        if !association.neural_keyboard {
576            self.keyboard = None;
577            self.rerank_cache.clear();
578        }
579        let dictionary_association = self.dictionary_association();
580        if let Some(dictionary) = self.dictionary.as_mut() {
581            dictionary.set_sentence_association(dictionary_association);
582            dictionary.set_sentence_alternatives(alternatives);
583        }
584        if self.active() {
585            self.refresh();
586        }
587    }
588
589    /// 首页行数只影响词库要不要并入补全词;词库已经打开时同步给它。`rows` 用 `FirstPageRows`,0 在这里没有入口,全拼和九键的归一只有 [`FirstPageRows::new`] 一处。
590    pub fn set_first_page_rows(&mut self, rows: FirstPageRows) {
591        if self.first_page_rows == rows {
592            return;
593        }
594        self.first_page_rows = rows;
595        if let Some(dictionary) = self.dictionary.as_mut() {
596            dictionary.set_first_page_rows(rows);
597        }
598        if self.active() {
599            self.refresh();
600        }
601    }
602
603    /// 与 26 键的 `InputSession::set_personal_context_enabled` 一起设置:关掉后九键选中的词不再记进个人上下文模型。
604    pub fn set_personal_context_enabled(&mut self, enabled: bool) {
605        self.personal_context = enabled;
606    }
607
608    /// 键盘模型读的上屏上下文,与 26 键的 `QuanpinDictionary::set_rescoring_context` 一样只记下最后 `CONTEXT_CHARACTERS` 个字,不重排已有的候选。
609    pub fn set_rescoring_context(&mut self, context: &str) {
610        let trimmed = last_characters(context, CONTEXT_CHARACTERS);
611        if self.rescoring_context != trimmed {
612            self.rescoring_context.clear();
613            self.rescoring_context.push_str(trimmed);
614        }
615    }
616
617    /// 交给词库的句子联想设置。键盘模型不在词库里跑(每条切分各跑一次太贵),而由 `rerank_sentences` 跨切分统一跑一次;它要重排的是词网格整句,所以模型打开时即使用户关了词网格,词库也照样解出整句,挑完再去掉。
618    fn dictionary_association(&self) -> SentenceAssociationOptions {
619        let association = self.sentence_association.clone();
620        SentenceAssociationOptions {
621            word_lattice: association.word_lattice || association.neural_keyboard,
622            neural_keyboard: false,
623            show_next_on_duplicate: association.show_next_on_duplicate,
624            ..association
625        }
626    }
627
628    /// 打开词库要用的选项。在这里先算出来,是因为 `dictionary_association` 要读整个 `self`,
629    /// 不能在 `get_or_insert_with` 的闭包里调用;三个取用点共用这一处。
630    fn dictionary_options(&self) -> DictionaryOptions {
631        DictionaryOptions {
632            association: self.dictionary_association(),
633            alternatives: self.sentence_alternatives,
634            first_page_rows: self.first_page_rows,
635        }
636    }
637
638    /// Holds digits.
639    pub fn active(&self) -> bool {
640        !self.digits.is_empty()
641    }
642
643    /// English-only T9 is a mode the host enters deliberately, so it is not gated on the mixed-candidate setting or on the minimum prefix a mixed list needs (nine_key_session.h:22-35).
644    pub fn set_english_only(&mut self, english_only: bool) {
645        if self.english_only == english_only {
646            return;
647        }
648        self.english_only = english_only;
649        self.refresh();
650    }
651
652    /// 换一种网格。已打的组码和整句种子都是按原来的网格编的,所以先结束组字,再丢掉种子。
653    pub fn set_grid(&mut self, grid: KeyGrid) {
654        self.command(Command::Cancel);
655        self.sentence_seeds.clear();
656        self.grid = grid;
657    }
658
659    /// `2`..=`9`;到 `DIGIT_LIMIT` 个数字时按 `NINE_KEY_DIGIT_LIMIT` 处理(运行时会先用 `limit_pick_ready` 判断能不能替用户选掉首选再接着打)。组字中按 `'` 在光标处切开音节;在同一处再切一次,或者紧跟在锁定的拼写之后切,都没有作用。光标在数字中间时数字插在光标处,光标落在锁定的拼写里时从那个音节起解除锁定。
660    pub fn character(&mut self, digit: u8) -> KeyResult {
661        if digit == b'\'' {
662            // 英文九键的数字拼的是字母不是音节,没有可切的地方,记下的切分上屏时也只会被丢掉。
663            if !self.active() || self.english_only || !self.pinyin {
664                return KeyResult::unhandled();
665            }
666            let caret = self.caret_position();
667            // 开头和锁定拼写之间的边界本来就是音节的分界,切分没有作用,锁定的拼写也不解除;只有切在一个锁定拼写中间时,才从那个音节起解除锁定,再记下切分。
668            if self.on_lock_boundary(caret) || self.splits.contains(&caret) {
669                return KeyResult::handled();
670            }
671            self.unlock_from(caret);
672            let at = self.splits.partition_point(|&split| split < caret);
673            self.splits.insert(at, caret);
674            self.refresh();
675            return KeyResult::handled();
676        }
677        if !self.grid.is_code(digit) {
678            return KeyResult::unhandled();
679        }
680        if self.digits.len() >= DIGIT_LIMIT {
681            return KeyResult::handled()
682                .with_diagnostic(Some(diagnostics::NINE_KEY_DIGIT_LIMIT.to_string()));
683        }
684        let caret = self.caret_position();
685        self.unlock_from(caret);
686        if caret == self.locked_length() {
687            // 插在选了首字母的那一位前面:新数字成了下一个音节的开头,首字母是给原来那一位选的。
688            self.initial = None;
689        }
690        self.digits.insert(caret, char::from(digit));
691        // 光标前紧挨着的切分留在新数字前面(`94'|26` 打 5 是 `94'5|26`),光标后的切分随数字后移。
692        for split in &mut self.splits {
693            if *split > caret {
694                *split += 1;
695            }
696        }
697        self.set_caret(caret + 1);
698        self.refresh();
699        KeyResult::handled()
700    }
701
702    /// 组码已经到 `DIGIT_LIMIT`、光标在末尾,再按一个组码 `digit` 时,先选掉首选能不能腾出位置:首选要占用至少一位组码。光标在中间(用户在改前面的码)、首选不占组码或者没有候选时不行,那一键照旧按上限拦下。
703    pub fn limit_pick_ready(&self, digit: u8) -> bool {
704        self.active()
705            && self.grid.is_code(digit)
706            && self.digits.len() >= DIGIT_LIMIT
707            && self.caret_position() == self.digits.len()
708            && self
709                .candidates
710                .first()
711                .is_some_and(|candidate| !candidate.pinyin.is_empty())
712    }
713
714    /// 组字光标的位置,末尾是 `digits.len()`。
715    fn caret_position(&self) -> usize {
716        let length = self.digits.len();
717        self.caret.map_or(length, |caret| caret.min(length))
718    }
719
720    fn set_caret(&mut self, caret: usize) {
721        self.caret = (caret < self.digits.len()).then_some(caret);
722    }
723
724    /// `position` 是开头,或者正好是某个锁定拼写的结尾。
725    fn on_lock_boundary(&self, position: usize) -> bool {
726        let mut end = 0;
727        position == 0
728            || self.locked.iter().any(|spelling| {
729                end += self.grid.syllable_code_len(spelling);
730                end == position
731            })
732    }
733
734    /// 在 `position` 处改数字前,解除盖住它的锁定拼写和其后的全部锁定:改动之后那些拼写不一定还拼得出来。锁定少了,原来落在锁定范围里的切分(不会有,切分总在锁定之后)不受影响。解除的锁定连同撤销记录一起丢掉,数字保持锁定时的样子(拼写补齐的数字留着),不像退格撤销锁定那样换回键入的数字:光标位置是按现在的数字算的。首字母限定的是第一个未锁定的音节,锁定少了它的位置就变了,一并丢掉。
735    fn unlock_from(&mut self, position: usize) {
736        while self.locked_length() > position {
737            self.locked.pop();
738            self.lock_undo.pop();
739            self.initial = None;
740        }
741    }
742
743    /// 选左列的一项。音节锁进数字;字母限定下一个音节的首字母;数字直接上屏这一位。数字全部锁定时左列是最后一次锁定时的选项,选哪一项都先撤销那次锁定,相当于换选。
744    pub fn choose_spelling(&mut self, index: usize) -> KeyResult {
745        let Some(choice) = self.spellings.get(index).cloned() else {
746            return KeyResult::unhandled();
747        };
748        let reselecting = self.reselecting();
749        let choices = if reselecting {
750            match self.undo_last_lock() {
751                Some(choices) => choices,
752                None => return KeyResult::unhandled(),
753            }
754        } else {
755            Vec::new()
756        };
757        match choice_kind(&choice) {
758            Choice::Digit(digit) => {
759                // 只在没有锁定时提供:前面锁定的音节还没上屏,先上屏这一位会颠倒文字的顺序。
760                self.consume(1);
761                // 这一位原样上屏,和读不出全拼的一段(英文词)一样记进正在拼的词,这个词就不存了;数字上屏完时组字结束,正在拼的词随之作废。
762                if self.active() {
763                    self.phrase_storable = false;
764                    self.phrase_word.push(char::from(digit));
765                } else {
766                    self.reset_phrase();
767                }
768                self.refresh();
769                return KeyResult::committed(char::from(digit).to_string());
770            }
771            Choice::Letter(letter) => {
772                self.initial = Some(Initial {
773                    letter,
774                    typed: self.digits.len(),
775                    splits: self.splits.len(),
776                });
777            }
778            Choice::Syllable => {
779                let choices = if reselecting {
780                    choices
781                } else {
782                    std::mem::take(&mut self.spellings)
783                };
784                let offset = self.locked_length();
785                // A spelling longer than what is typed extends the digits to its whole code; the spelling list only offers ones that stay within the digit limit.
786                let end = offset
787                    + self
788                        .grid
789                        .syllable_code_len(&choice)
790                        .min(self.digits.len() - offset);
791                let replaced = self.digits[offset..end].to_string();
792                let before = self.digits.len();
793                self.digits
794                    .replace_range(offset..end, &self.grid.encode(&choice));
795                // 拼写比已打的数字长时数字串变长;光标原来在被替换的那段之后的,跟着后移。
796                if let Some(caret) = self.caret {
797                    if caret >= end {
798                        self.set_caret(caret + self.digits.len() - before);
799                    }
800                }
801                self.locked.push(choice);
802                let locked_length = self.locked_length();
803                let mut dropped = [0; DIGIT_LIMIT];
804                let mut split_count = 0;
805                self.splits.retain(|&split| {
806                    if split <= locked_length {
807                        dropped[split_count] = split;
808                        split_count += 1;
809                        false
810                    } else {
811                        true
812                    }
813                });
814                self.lock_undo.push(Some(LockUndo {
815                    replaced,
816                    splits: dropped,
817                    split_count,
818                    initial: self.initial.take(),
819                    choices,
820                }));
821            }
822        }
823        self.refresh();
824        KeyResult::handled()
825    }
826
827    /// 数字全部锁定,而且最后一次锁定还能撤销。
828    fn reselecting(&self) -> bool {
829        self.active()
830            && self.locked_length() >= self.digits.len()
831            && self.lock_undo.last().is_some_and(Option::is_some)
832    }
833
834    /// 撤销最后一次锁定,不刷新;返回那次锁定时左列的选项。
835    fn undo_last_lock(&mut self) -> Option<Vec<String>> {
836        let undo = self.lock_undo.last_mut()?.take()?;
837        self.lock_undo.pop();
838        let spelling = self.locked.pop()?;
839        let offset = self.locked_length();
840        let end = (offset + self.grid.syllable_code_len(&spelling)).min(self.digits.len());
841        self.digits.replace_range(offset..end, &undo.replaced);
842        self.splits
843            .extend(undo.splits[..undo.split_count].iter().copied());
844        self.splits.sort_unstable();
845        self.splits.dedup();
846        self.initial = undo.initial.map(|initial| Initial {
847            typed: self.digits.len(),
848            splits: self.splits.len(),
849            ..initial
850        });
851        // 拼写补齐的数字换回键入的数字后数字串可能变短。这一段就是数字的末尾,光标在它前面或里面键入的那几位上时位置不变(拼写的编码以键入的数字开头),落在补齐的部分里时回到末尾。
852        if let Some(caret) = self.caret {
853            self.set_caret(caret);
854        }
855        Some(undo.choices)
856    }
857
858    pub fn select(&mut self, index: usize) -> KeyResult {
859        let Some(selected) = self.candidates.get(index) else {
860            return KeyResult::unhandled();
861        };
862        let selected_source = selected.source;
863        let selected_fixed_position = selected.fixed_position;
864        let selected_pinyin_length = selected.pinyin.len();
865        let selected_word = selected.word.clone();
866        let selected_is_initials = is_initials_row(selected);
867        let selected_canonical_pinyin = if self.learning
868            && (selected_source.is_dictionary() || selected_source.is_sentence_learning())
869        {
870            selected.canonical_pinyin.to_string()
871        } else {
872            String::new()
873        };
874        // 用过的简拼行在九键里的位置只由个人上下文模型里的计数决定(`boost_used_initials`)。这一行会记进模型时不再调全局词频:两边都挪的话一次选词挪两次,全局权重还会连带改掉 26 键里这个词的位置(#6185)。
875        let recorded_initials =
876            self.records_personal_use(selected_source, &selected_word) && selected_is_initials;
877        let mut diagnostic = if self.learning
878            && self.frequency.mode != FrequencyAdjustmentMode::Disabled
879            && index != 0
880            && selected_fixed_position == 0
881            && !recorded_initials
882            && self.editable(index)
883        {
884            self.adjust_frequency(index, false)
885        } else {
886            None
887        };
888        // 最近选中的候选要在下一次相同数字输入中直接显示在首位。选定读法后,首候选也是显式选择,另外记到未锁定的上下文;不把单字写进个人模型,也不改主词库权重。
889        if self.learning
890            && selected_source.is_dictionary()
891            && !selected_canonical_pinyin.is_empty()
892            && !selected_is_initials
893            && selected_pinyin_length == self.digits.len()
894            && self.remember_recent
895        {
896            if index != 0 && selected_fixed_position == 0 {
897                diagnostic = diagnostic.or(self.remember_recent_candidate(
898                    &self.ranking_context(),
899                    &selected_canonical_pinyin,
900                    &selected_word,
901                ));
902            }
903            if !self.locked.is_empty() {
904                let unlocked_context = format!("nine-key:{}:", self.digits);
905                // 两个上下文的固定位置互相独立,不能因为读法内未固定就覆盖未锁定上下文里同一候选的手动槽位。
906                if !positions::is_fixed(
907                    &self.paths.user(assets::USER_JOURNAL),
908                    &unlocked_context,
909                    &selected_canonical_pinyin,
910                    &selected_word,
911                ) {
912                    diagnostic = diagnostic.or(self.remember_recent_candidate(
913                        &unlocked_context,
914                        &selected_canonical_pinyin,
915                        &selected_word,
916                    ));
917                }
918            }
919        }
920        self.consume(selected_pinyin_length);
921        if self.learning {
922            let (learned, stored) =
923                self.learn_selection(selected_source, &selected_canonical_pinyin, &selected_word);
924            diagnostic = diagnostic.or(learned);
925            let recorded = self.record_personal_use(selected_source, &selected_word, stored);
926            diagnostic = diagnostic.or(recorded);
927        } else {
928            self.reset_phrase();
929        }
930        self.refresh();
931        KeyResult::committed(selected_word).with_diagnostic(diagnostic)
932    }
933
934    /// 选中一行之后的造词,与全拼键盘的规则相同:选掉一部分数字时记下这一段;选完全部数字时,前面有选过的段就把各段连成一个词存起来(「我滴」+「个天呐」),没有就只在选中的是整句行(词库里没有的句子)时把整句存起来,最多 `MAX_LEARNED_SENTENCE_SYLLABLES` 个音节。词库里本来就有的词不再写。返回诊断,以及这次存进(或词库里本来就有)的词。
935    fn learn_selection(
936        &mut self,
937        selected_source: CandidateSource,
938        selected_canonical_pinyin: &str,
939        selected_word: &str,
940    ) -> (Option<String>, Option<String>) {
941        let reading = if selected_source.is_dictionary() || selected_source.is_sentence_learning() {
942            selected_canonical_pinyin
943        } else {
944            ""
945        };
946        if self.active() {
947            if reading.is_empty() {
948                self.phrase_storable = false;
949            } else if self.phrase_storable {
950                if !self.phrase_pinyin.is_empty() {
951                    self.phrase_pinyin.push('\'');
952                }
953                self.phrase_pinyin.push_str(reading);
954            }
955            self.phrase_word.push_str(selected_word);
956            return (None, None);
957        }
958        let phrase = !self.phrase_word.is_empty();
959        let stored = if phrase {
960            (self.phrase_storable && !reading.is_empty()).then(|| {
961                (
962                    format!("{}'{reading}", self.phrase_pinyin),
963                    format!("{}{}", self.phrase_word, selected_word),
964                )
965            })
966        } else {
967            (selected_source.is_sentence_learning()
968                && !reading.is_empty()
969                && split_segments(reading).len() <= MAX_LEARNED_SENTENCE_SYLLABLES)
970                .then(|| (reading.to_owned(), selected_word.to_owned()))
971        };
972        self.reset_phrase();
973        let Some((pinyin, word)) = stored else {
974            return (None, None);
975        };
976        // 写入前由 `create_word_from_canonical_pinyin` 核对一字一个完整音节(与全拼键盘存词前的检查相同);读不出的词(夹着英文或符号)被它拒绝,这不是写入失败,不报诊断。
977        let options = self.dictionary_options();
978        match self
979            .dictionary
980            .get_or_insert_with(|| open_dictionary(&self.paths, options))
981            .create_word_from_canonical_pinyin(&pinyin, &word)
982        {
983            Ok(()) => (None, Some(word)),
984            Err(EngineError::InvalidArgument(_)) => (None, None),
985            Err(_) if phrase => (Some(diagnostics::PHRASE_NOT_PERSISTED.to_string()), None),
986            Err(_) => (Some(diagnostics::SENTENCE_NOT_PERSISTED.to_string()), None),
987        }
988    }
989
990    /// 把这次选中的词记进个人上下文模型(#6185):选中的多字汉字词库词,以及这次存进词库的词(分段连成的词组、选中的整句)。不论词库里原来有没有、选的是不是首位都记,简拼行据此把用户用过的词排到前面,26 键选的词也记在同一个模型里。九键没有 26 键那样的上屏词链,前一个词一律当作句首。
991    fn record_personal_use(
992        &self,
993        selected_source: CandidateSource,
994        selected_word: &str,
995        stored: Option<String>,
996    ) -> Option<String> {
997        if !self.personal_context {
998            return None;
999        }
1000        let mut transitions: Vec<PersonalTransition> = Vec::with_capacity(2);
1001        let words = selected_source
1002            .is_dictionary()
1003            .then_some(selected_word)
1004            .into_iter()
1005            .chain(stored.as_deref());
1006        for word in words {
1007            if is_personal_word(word)
1008                && !transitions.iter().any(|transition| transition.word == word)
1009            {
1010                transitions.push(PersonalTransition {
1011                    earlier: String::new(),
1012                    previous: String::new(),
1013                    word: word.to_owned(),
1014                    times: PERSONAL_PICK_TIMES,
1015                });
1016            }
1017        }
1018        if transitions.is_empty() {
1019            return None;
1020        }
1021        // 候选都来自词库,选得到词时词库一定已经打开。
1022        let dictionary = self.dictionary.as_ref()?;
1023        dictionary
1024            .record_personal_use(&transitions)
1025            .err()
1026            .map(|_| diagnostics::PERSONAL_CONTEXT_NOT_PERSISTED.to_string())
1027    }
1028
1029    /// 选中的词库词会不会被 `record_personal_use` 记进个人上下文模型:学习和个人上下文都开着,选的是两个字以上的纯汉字词。
1030    fn records_personal_use(&self, selected_source: CandidateSource, selected_word: &str) -> bool {
1031        self.learning
1032            && self.personal_context
1033            && selected_source.is_dictionary()
1034            && is_personal_word(selected_word)
1035    }
1036
1037    fn reset_phrase(&mut self) {
1038        self.phrase_pinyin.clear();
1039        self.phrase_word.clear();
1040        self.phrase_storable = true;
1041    }
1042
1043    /// `first_index` 越界时与 `CommitRaw` 相同:九键上屏数字,14 键因此取首选。与全拼键盘的 `finish_composition` 相同,余下各段逐个按 `select` 选首选,造词也一样:用户先选掉的段和替他选的余下各段连成一个词存起来(选了「我滴」再打标点,存的是「我滴个天呐」),首选是整句行时存整句。
1044    pub fn finish(&mut self, first_index: usize) -> KeyResult {
1045        if !self.active() {
1046            return KeyResult::unhandled();
1047        }
1048        if first_index >= self.candidates.len() {
1049            return self.command(Command::CommitRaw);
1050        }
1051        let mut commit = String::new();
1052        let mut diagnostic = None;
1053        let mut index = first_index;
1054        while self.active() {
1055            if self.candidates.is_empty() {
1056                if matches!(self.grid, KeyGrid::EighteenKeyShuangpin(_)) {
1057                    self.digits.make_ascii_lowercase();
1058                }
1059                commit.push_str(&self.digits);
1060                break;
1061            }
1062            let previous_remember_recent = self.remember_recent;
1063            self.remember_recent = false;
1064            let result = self.select(index);
1065            self.remember_recent = previous_remember_recent;
1066            if diagnostic.is_none() {
1067                diagnostic = result.diagnostic;
1068            }
1069            if let Some(text) = result.commit {
1070                if commit.is_empty() {
1071                    commit = text;
1072                } else {
1073                    commit.push_str(&text);
1074                }
1075            }
1076            index = 0;
1077        }
1078        self.command(Command::Cancel);
1079        KeyResult::committed(commit).with_diagnostic(diagnostic)
1080    }
1081
1082    pub fn command(&mut self, command: Command) -> KeyResult {
1083        if !self.active() {
1084            return KeyResult::unhandled();
1085        }
1086        match command {
1087            Command::CommitCandidate => return self.select(0),
1088            Command::CommitRaw => {
1089                // 14 键的组码只是每组的首字母(按「你好」的键是 `bugao`),不是用户想写的字母:原样上屏改为结束组字、逐段取首选,与 `finish(0)` 相同。没有候选时只剩组码可上屏。
1090                if !self.grid.offers_raw_key() && !self.candidates.is_empty() {
1091                    return self.finish(0);
1092                }
1093                let mut raw = std::mem::take(&mut self.digits);
1094                // 精确字母的大写只是十八键双拼内部标记,兜底上屏仍是小写字母。
1095                if matches!(self.grid, KeyGrid::EighteenKeyShuangpin(_)) {
1096                    raw.make_ascii_lowercase();
1097                }
1098                self.clear_composition();
1099                return KeyResult::committed(raw);
1100            }
1101            Command::Cancel => self.clear_composition(),
1102            // 光标在末尾时退格撤销最后一步:刚选的首字母、数字全部锁定时的最后一次锁定、末尾的切分,都没有时才删数字。锁定之后还有没锁定的数字时,删的是数字而不是锁定。
1103            // 光标移进数字中间时退格是在那里改字:先删光标前的切分,再删光标前的数字,光标在开头时什么也不删;不撤销首字母和锁定,那是「撤销最后一步」,而用户把光标移过去是要改那里的数字。
1104            Command::Backspace => {
1105                let caret = self.caret_position();
1106                let at_end = caret == self.digits.len();
1107                let initial_untouched = at_end
1108                    && self.initial.is_some_and(|initial| {
1109                        initial.typed == self.digits.len() && initial.splits == self.splits.len()
1110                    });
1111                if initial_untouched {
1112                    self.initial = None;
1113                } else if at_end && self.reselecting() {
1114                    self.undo_last_lock();
1115                } else if let Some(at) = self.splits.iter().position(|&split| split == caret) {
1116                    self.splits.remove(at);
1117                } else if caret > 0 {
1118                    self.remove_digit(caret - 1);
1119                    self.set_caret(caret - 1);
1120                }
1121            }
1122            Command::DeleteForward => {
1123                let caret = self.caret_position();
1124                if caret < self.digits.len() {
1125                    self.remove_digit(caret);
1126                    self.set_caret(caret);
1127                }
1128            }
1129            // 光标移动不改数字,候选不变,不必重查。
1130            Command::MoveLeft | Command::MoveRight | Command::MoveHome | Command::MoveEnd => {
1131                let caret = self.caret_position();
1132                let target = match command {
1133                    Command::MoveLeft => caret.saturating_sub(1),
1134                    Command::MoveRight => caret + 1,
1135                    Command::MoveHome => 0,
1136                    _ => self.digits.len(),
1137                };
1138                self.set_caret(target);
1139                return KeyResult::handled();
1140            }
1141            _ => return KeyResult::unhandled(),
1142        }
1143        if !self.active() {
1144            // 数字删光了,组字结束:锁定、切分、首字母、光标和正在拼的词都不再有意义。
1145            self.clear_composition();
1146        }
1147        self.refresh();
1148        KeyResult::handled()
1149    }
1150
1151    /// 结束这次组字:数字连同锁定、撤销记录、切分、首字母、光标和正在拼的词一起清掉。
1152    fn clear_composition(&mut self) {
1153        self.path_scratch.pages.clear();
1154        // 选词学习可能刚改了英文权重。
1155        self.english_rows = None;
1156        self.digits.clear();
1157        self.locked.clear();
1158        self.lock_undo.clear();
1159        self.splits.clear();
1160        self.initial = None;
1161        self.caret = None;
1162        self.reset_phrase();
1163    }
1164
1165    /// 删掉 `index` 处的数字:盖住它的锁定拼写和其后的锁定一起解除,其后的切分前移一位;前移后重合的、落到锁定范围或开头的切分丢掉。删的是选了首字母的那一位、或者删完没有未锁定的数字时,首字母一起丢掉;删的是它后面的数字时首字母留着,删到比选字母时少的数字后退格不再撤销它(与在末尾退格相同)。
1166    fn remove_digit(&mut self, index: usize) {
1167        let anchor = self.locked_length();
1168        self.unlock_from(index);
1169        self.digits.remove(index);
1170        for split in &mut self.splits {
1171            if *split > index {
1172                *split -= 1;
1173            }
1174        }
1175        let locked_length = self.locked_length();
1176        self.splits.retain(|&split| split > locked_length);
1177        self.splits.dedup();
1178        let length = self.digits.len();
1179        if index <= anchor || length <= locked_length {
1180            self.initial = None;
1181        } else if let Some(initial) = self.initial.as_mut() {
1182            if initial.typed > length {
1183                initial.typed = usize::MAX;
1184            }
1185        }
1186    }
1187
1188    pub fn pin(&mut self, index: usize) -> KeyResult {
1189        if !self.editable(index) {
1190            return KeyResult::unhandled();
1191        }
1192        let selected = self.candidates[index].clone();
1193        let replaces_recent = selected.fixed_position == 0
1194            && self
1195                .candidates
1196                .iter()
1197                .any(|candidate| candidate.fixed_position == 1);
1198        let diagnostic = self.adjust_frequency(index, true);
1199        let position_diagnostic = replaces_recent.then(|| {
1200            positions::set_fixed_position(
1201                &self.paths.user(assets::USER_JOURNAL),
1202                &self.ranking_context(),
1203                &selected.canonical_pinyin,
1204                &selected.word,
1205                1,
1206            )
1207            .err()
1208            .map(|_| diagnostics::NINE_KEY_POSITION_NOT_PERSISTED.to_string())
1209        });
1210        self.refresh();
1211        let mut diagnostic = diagnostic.or(position_diagnostic.flatten());
1212        // 首页里的补全按权重的几分之一和完整读法比,置顶写进去的权重(最重的权重再加一点)越不过完整读法;这时同 `replaces_recent` 一样固定到第 1 位。
1213        if diagnostic.is_none()
1214            && self.completions_discounted
1215            && self
1216                .candidates
1217                .first()
1218                .is_some_and(|first| first.word != selected.word)
1219            && self
1220                .candidates
1221                .iter()
1222                .any(|candidate| candidate.word == selected.word)
1223        {
1224            diagnostic = positions::set_fixed_position(
1225                &self.paths.user(assets::USER_JOURNAL),
1226                &self.ranking_context(),
1227                &selected.canonical_pinyin,
1228                &selected.word,
1229                1,
1230            )
1231            .err()
1232            .map(|_| diagnostics::NINE_KEY_POSITION_NOT_PERSISTED.to_string());
1233            self.refresh();
1234        }
1235        KeyResult::handled().with_diagnostic(diagnostic)
1236    }
1237
1238    pub fn remove(&mut self, index: usize) -> KeyResult {
1239        if !self.editable(index) || count_utf8_chars(&self.candidates[index].word) <= 1 {
1240            return KeyResult::unhandled();
1241        }
1242        let item = &self.candidates[index];
1243        let deleted = removal::delete_dictionary_candidate(
1244            &self.paths.dictionary(assets::MAIN_DICTIONARY),
1245            &self.paths.user(assets::USER_JOURNAL),
1246            PersonalDictionaryKind::Pinyin,
1247            &item.canonical_pinyin,
1248            &item.word,
1249        );
1250        // The reference reports every failed removal with this one message and keeps the composition (NK:434-437).
1251        if deleted.is_err() {
1252            return KeyResult::handled().with_diagnostic(Some(
1253                diagnostics::NINE_KEY_REMOVAL_NOT_PERSISTED.to_string(),
1254            ));
1255        }
1256        if let Some(dictionary) = self.dictionary.as_mut() {
1257            dictionary.reset_cache();
1258        }
1259        self.refresh();
1260        KeyResult::handled()
1261    }
1262
1263    /// 0 clears.
1264    pub fn set_position(&mut self, index: usize, position: i32) -> KeyResult {
1265        if !self.editable(index) || !(0..=5).contains(&position) {
1266            return KeyResult::unhandled();
1267        }
1268        let item = &self.candidates[index];
1269        let journal = self.paths.user(assets::USER_JOURNAL);
1270        let context = self.ranking_context();
1271        let saved = if position == 0 {
1272            positions::clear_fixed_position(&journal, &context, &item.canonical_pinyin, &item.word)
1273        } else {
1274            positions::set_fixed_position(
1275                &journal,
1276                &context,
1277                &item.canonical_pinyin,
1278                &item.word,
1279                position,
1280            )
1281        };
1282        // Same rule as removal: one message for every failure, composition kept (NK:453-454).
1283        if saved.is_err() {
1284            return KeyResult::handled().with_diagnostic(Some(
1285                diagnostics::NINE_KEY_POSITION_NOT_PERSISTED.to_string(),
1286            ));
1287        }
1288        self.refresh();
1289        KeyResult::handled()
1290    }
1291
1292    pub fn snapshot(&self) -> SessionSnapshot {
1293        let locked_length = self.locked_length();
1294        let locked_preedit_length = self
1295            .locked
1296            .iter()
1297            .map(String::len)
1298            .sum::<usize>()
1299            .saturating_add(self.locked.len().saturating_sub(1));
1300        let mut preedit = String::with_capacity(
1301            locked_preedit_length
1302                .saturating_add(self.digits.len().saturating_sub(locked_length))
1303                .saturating_add(self.splits.len())
1304                .saturating_add(usize::from(
1305                    self.active() && locked_length < self.digits.len() && !self.locked.is_empty(),
1306                )),
1307        );
1308        for (index, spelling) in self.locked.iter().enumerate() {
1309            if index != 0 {
1310                preedit.push('\'');
1311            }
1312            preedit.push_str(spelling);
1313        }
1314        if self.active() && locked_length < self.digits.len() {
1315            if !preedit.is_empty() {
1316                preedit.push('\'');
1317            }
1318            let mut start = locked_length;
1319            for &split in &self.splits {
1320                preedit.push_str(&self.digits[start..split]);
1321                preedit.push('\'');
1322                start = split;
1323            }
1324            preedit.push_str(&self.digits[start..]);
1325            if let Some(initial) = self.initial {
1326                // 选了首字母的那一位显示成字母。
1327                let at = locked_preedit_length + usize::from(!self.locked.is_empty());
1328                let mut initial_text = [0; 4];
1329                let initial_text = char::from(initial.letter).encode_utf8(&mut initial_text);
1330                preedit.replace_range(at..=at, initial_text);
1331            }
1332        }
1333        SessionSnapshot {
1334            prediction: false,
1335            scheme: if matches!(
1336                self.grid,
1337                KeyGrid::FourteenKeyShuangpin(_) | KeyGrid::EighteenKeyShuangpin(_)
1338            ) {
1339                SchemeType::Shuangpin
1340            } else {
1341                SchemeType::Quanpin
1342            },
1343            local_mode: LocalInputMode::None,
1344            // The grid's snapshot stands in for the whole session's while it is composing, so it carries the mode too: a host that draws its English keys from this flag would otherwise put Chinese ones back on the first digit.
1345            dedicated_english: self.english_only,
1346            preedit,
1347            candidates: self.candidates.clone(),
1348            editing_text: self.digits.clone(),
1349            caret_position: self.caret_position(),
1350            nine_key_spellings: self.spellings.clone(),
1351            nine_key_reading: self.reading.clone(),
1352            nine_key_single_character: self.single_character,
1353            nine_key_strokes: self.strokes.clone(),
1354            candidate_sources: self.candidates.iter().map(|item| item.source).collect(),
1355            candidate_annotations: self
1356                .candidates
1357                .iter()
1358                .map(|item| item.corrected_from.to_string())
1359                .collect(),
1360            // The same test `select` performs: consuming the candidate's digits empties the buffer exactly when its code covers everything typed.
1361            candidate_answers_key: self
1362                .candidates
1363                .iter()
1364                .map(|item| item.pinyin.len() >= self.digits.len())
1365                .collect(),
1366            ..SessionSnapshot::default()
1367        }
1368    }
1369
1370    fn refresh(&mut self) {
1371        self.candidates.clear();
1372        self.reading.clear();
1373        self.completions_discounted = false;
1374        if !self.active() {
1375            self.path_scratch.pages.clear();
1376            self.spellings.clear();
1377            // 筛选和撤销记录只属于这一次组字。
1378            self.sentence_seeds.clear();
1379            self.lock_undo.clear();
1380            self.initial = None;
1381            self.single_character = false;
1382            self.strokes.clear();
1383            return;
1384        }
1385        if self.english_only || !self.pinyin {
1386            self.path_scratch.pages.clear();
1387            self.spellings.clear();
1388            // No syllables to offer and no pinyin to look up: the digits stand for letters only.
1389            self.candidates = self.english_candidates(false);
1390            return;
1391        }
1392        // 每个锁定都有一条撤销记录,哪怕是作废的。
1393        self.lock_undo.resize_with(self.locked.len(), || None);
1394        let grid = self.grid;
1395        let table = grid.spelling_table();
1396        let locked_length = self.locked_length();
1397        let remaining = remaining_digits(&self.digits, locked_length);
1398        let unlocked_digit_count = remaining.len();
1399        let mut split_offsets = [0usize; DIGIT_LIMIT];
1400        for (index, split) in self.splits.iter().enumerate() {
1401            split_offsets[index] = split - locked_length;
1402        }
1403        let splits = &split_offsets[..self.splits.len()];
1404        let initial = self.initial.map(|initial| initial.letter);
1405        let starts_right =
1406            |piece: &str| initial.is_none_or(|letter| piece.as_bytes().first() == Some(&letter));
1407        let dictionary_options = self.dictionary_options();
1408        let mut alternatives = std::mem::take(&mut self.path_buffer);
1409        let alternatives = if remaining.is_empty() {
1410            self.spellings.clear();
1411            alternatives.clear();
1412            alternatives.push(Vec::new());
1413            alternatives
1414        } else {
1415            let prior = self
1416                .prior
1417                .get_or_insert_with(|| SyllablePrior::shipped(table));
1418            table.spellings_for_into(
1419                remaining,
1420                locked_length,
1421                splits.first().copied(),
1422                prior,
1423                &mut self.spellings,
1424                &mut self.spelling_match_indices,
1425            );
1426            self.spellings.retain(|syllable| starts_right(syllable));
1427            table.split_paths_with_scratch(
1428                remaining,
1429                splits,
1430                prior,
1431                QUERY_PATH_LIMIT,
1432                &mut self.path_scratch,
1433                &mut alternatives,
1434            );
1435            alternatives.retain(|path| path.first().is_none_or(|piece| starts_right(piece)));
1436            // 种子只在用户没锁定拼写、没打切分、没选首字母时用:那些情况下切分已经被用户限定,种子未必守得住这些限定。
1437            if self.locked.is_empty() && splits.is_empty() && initial.is_none() {
1438                if !self.sentence_seeds.is_empty() && remaining.len() >= SEEDED_TRUNCATE_DIGITS {
1439                    alternatives.truncate(SEEDED_PATH_LIMIT);
1440                }
1441                seeded_paths(
1442                    &mut alternatives,
1443                    &self.sentence_seeds,
1444                    remaining,
1445                    table,
1446                    prior,
1447                );
1448            }
1449            // Even an unfinished or invalid tail must still offer the leading syllable for partial selection.
1450            alternatives.extend(
1451                self.spellings
1452                    .iter()
1453                    .filter(|spelling| grid.syllable_code_len(spelling) <= remaining.len())
1454                    .map(|spelling| vec![spelling.clone()]),
1455            );
1456            alternatives
1457        };
1458
1459        let mut locked_key = String::new();
1460        append_path_key(&mut locked_key, "", &self.locked);
1461        let mut dictionary = self
1462            .dictionary
1463            .get_or_insert_with(|| open_dictionary(&self.paths, dictionary_options.clone()))
1464            .row_cache_batch();
1465        let mut queried = (alternatives.len() > SMALL_QUERY_KEY_BATCH)
1466            .then(|| HashSet::with_capacity(alternatives.len()));
1467        // 接管已清空的会话候选向量,刷新时复用行容器容量。
1468        let mut candidates = std::mem::take(&mut self.candidates);
1469        candidates.clear();
1470        let mut key = String::with_capacity(locked_key.len() + self.digits.len() * 4 + 1);
1471        // 各条切分的前缀组彼此大量重复,一次刷新会推入上万行,见 `push_ranked`。
1472        let mut leading = LeadingMap::default();
1473        // Only a split the user typed says where a syllable ends; without one, `3` must keep 的 (a completion of d) ahead of the rarer 额 (e).
1474        let prefer_exact = !self.splits.is_empty();
1475        // 每个数字都能当一个音节的首字母时也按简拼查(`68` 是 m't:明天、每天)。用户在每个数字之间都打了切分(`6'8`),说的就是简拼,简拼行排在前面;没打切分时数字也可能是完整音节(`68` 是 mu),简拼行排在同样覆盖的音节行之后。
1476        // 在展开面板里选定了首字母时用户是在逐个拼音节,简拼行不经过 `starts_right` 的首字母过滤,这时不查简拼。
1477        let initials =
1478            self.locked.is_empty() && initial.is_none() && initials_apply(remaining.len(), splits);
1479        let initials_lead = initials && !splits.is_empty();
1480        // 模糊页已拥有结果;九键双拼的覆盖核对会另建编码,保留原单次核对路径。
1481        let filter_dominated = self.fuzzy.rules == 0
1482            && matches!(
1483                grid,
1484                KeyGrid::NineKey | KeyGrid::FourteenKey | KeyGrid::EighteenKey
1485            );
1486        for (index, path) in alternatives.iter().enumerate() {
1487            append_path_key(&mut key, &locked_key, path);
1488            if key.is_empty() || !query_key_is_new(&alternatives, index, &key, queried.as_ref()) {
1489                continue;
1490            }
1491            let full_len = self.locked.len() + path.len();
1492            let accepts_reading = |candidate: &WordItem, cached: bool| {
1493                let word = candidate.word.as_str();
1494                let pinyin = candidate.pinyin.as_str();
1495                let canonical = candidate.canonical_pinyin.as_str();
1496                let fuzzy = candidate.fuzzy;
1497                if self.single_character_only && is_han_phrase(word) {
1498                    return false;
1499                }
1500                let canonical = if canonical.is_empty() {
1501                    pinyin
1502                } else {
1503                    canonical
1504                };
1505                // 多出音节的行是越过本条路径的补全,不参与这条读法。
1506                if reading_syllable_count(canonical) > full_len {
1507                    return false;
1508                }
1509                let matched = if fuzzy { pinyin } else { canonical };
1510                if !agrees_with_locked(matched, &locked_key) {
1511                    return false;
1512                }
1513                if let Some(letter) = initial {
1514                    // 模糊音按实际匹配的拼写核对已选声母。
1515                    let next = matched
1516                        .split('\'')
1517                        .nth(self.locked.len())
1518                        .and_then(|syllable| syllable.as_bytes().first());
1519                    if next != Some(&letter) {
1520                        return false;
1521                    }
1522                }
1523                if cached && filter_dominated {
1524                    if let Some(best) = leading_rank(&leading, &candidates, word) {
1525                        let Some(covered) = grid.matching_code_prefix_len(matched, &self.digits)
1526                        else {
1527                            return false;
1528                        };
1529                        // 只查已接纳的前页结果;本页内的同词仍由 `push_ranked` 处理。
1530                        if best
1531                            <= rank_key_for_reading(
1532                                candidate,
1533                                covered,
1534                                canonical,
1535                                prefer_exact,
1536                                initials_lead,
1537                                grid,
1538                            )
1539                        {
1540                            return false;
1541                        }
1542                    }
1543                }
1544                true
1545            };
1546            for mut candidate in
1547                dictionary.query_filtered_rows(&key, &key, self.fuzzy, accepts_reading)
1548            {
1549                let canonical = take_candidate_canonical_pinyin(&mut candidate);
1550                let matched = if candidate.fuzzy {
1551                    candidate.pinyin.as_str()
1552                } else {
1553                    canonical.as_str()
1554                };
1555                // 组码覆盖只算一次;九键双拼的既有实现会申请临时编码。
1556                let Some(covered) = grid.matching_code_prefix_len(matched, &self.digits) else {
1557                    continue;
1558                };
1559                // 词库行与缓存共享,整段换新比在共享的那份上清空再写少一次复制。
1560                candidate.pinyin = SharedString::from(&self.digits[..covered]);
1561                candidate.canonical_pinyin = canonical;
1562                push_ranked(
1563                    &mut candidates,
1564                    &mut leading,
1565                    candidate,
1566                    prefer_exact,
1567                    initials_lead,
1568                    grid,
1569                );
1570            }
1571            if let Some(seen) = queried.as_mut() {
1572                seen.insert(key.clone());
1573            }
1574        }
1575        // 九键双拼一个两位编码对应五六个音节,三个音节以上时,按音节频度留下的切分(每个位置 `PATH_LIMIT` 条)常常没有词的那一条:`22942288` 有一千多种读法,百依百顺 的 bai'yi'bai'shun 排不进去。词可以不靠切分找:按每个音节的声母那一位(第 1、3、5… 位)查简拼,只留编码正好以这串数字开头、音节数也对得上的词。全拼九宫格一个音节的码长不定,没有这一步。
1576        if let Some(table) = grid.shuangpin_table() {
1577            let codes = (self.locked.is_empty()
1578                && initial.is_none()
1579                && splits.is_empty()
1580                && !self.single_character_only)
1581                .then(|| shuangpin_word_codes(grid, remaining))
1582                .flatten();
1583            if let Some(codes) = codes {
1584                for mut candidate in
1585                    dictionary.query_jianpin_codes_per_table(&codes, SHUANGPIN_WORD_SCAN_LIMIT)
1586                {
1587                    if !shuangpin_word_spells(table, &candidate.canonical_pinyin, remaining) {
1588                        continue;
1589                    }
1590                    candidate.pinyin.clear();
1591                    candidate.pinyin.push_str(&self.digits);
1592                    push_ranked(
1593                        &mut candidates,
1594                        &mut leading,
1595                        candidate,
1596                        prefer_exact,
1597                        initials_lead,
1598                        grid,
1599                    );
1600                }
1601            }
1602        }
1603        let mut boost = None;
1604        if let Some(codes) = initials
1605            .then(|| initials_codes(grid, remaining, INITIALS_CODE_LIMIT))
1606            .flatten()
1607        {
1608            // 用户用过的词(个人上下文模型里有计数,26 键选的也算)不能被按权重的截断截掉(#6185):模型有记录时多扫一些行,用过的先留下。学习或个人上下文关掉时不读个人数据,与 26 键的 `personal_context_applies` 相同。
1609            let personal =
1610                self.learning && self.personal_context && !dictionary.personal_model().is_empty();
1611            // 九键双拼的简拼按首字母查出来的行还要逐个核对声母所在的键:`z` 同时查出 z 和 zh 开头的词,而它们常在不同的键上。核对会筛掉一部分,所以先多取一些行,核对之后再截。
1612            let shuangpin = match grid {
1613                KeyGrid::NineKeyShuangpin(table)
1614                | KeyGrid::FourteenKeyShuangpin(table)
1615                | KeyGrid::EighteenKeyShuangpin(table) => Some(table),
1616                _ => None,
1617            };
1618            let mut rows = if personal {
1619                dictionary.query_jianpin_codes_per_table(&codes, INITIALS_SCAN_LIMIT)
1620            } else if shuangpin.is_some() {
1621                dictionary.query_jianpin_codes(&codes, INITIALS_SCAN_LIMIT)
1622            } else {
1623                dictionary.query_jianpin_codes(&codes, INITIALS_ROW_LIMIT)
1624            };
1625            if let Some(table) = shuangpin {
1626                rows.retain(|row| table.spells_initials(&row.canonical_pinyin, remaining));
1627                if !personal {
1628                    rows.truncate(INITIALS_ROW_LIMIT);
1629                }
1630            }
1631            let mut used = HashMap::new();
1632            if personal {
1633                // 一次查询只取一次读锁,不按行各取一次。
1634                let model = dictionary.personal_model();
1635                for row in &rows {
1636                    let count = model.word_count(&row.word);
1637                    if count > 0 {
1638                        used.insert(row.word.clone(), count);
1639                    }
1640                }
1641                drop(model);
1642                if rows.len() > INITIALS_ROW_LIMIT {
1643                    // 稳定排序:用过的词在前,两边各自仍按权重。
1644                    rows.sort_by_key(|row| !used.contains_key(&row.word));
1645                    rows.truncate(INITIALS_ROW_LIMIT);
1646                }
1647            }
1648            // 排位沿用调频设置:调频关掉时用过的词只保证查得到,不往前挪。
1649            if self.frequency.mode != FrequencyAdjustmentMode::Disabled && !used.is_empty() {
1650                boost = Some(InitialsBoost {
1651                    used,
1652                    frequency: self.frequency,
1653                });
1654            }
1655            for mut candidate in rows {
1656                // 没有锁定的拼音时 `remaining` 就是全部数字,简拼行一个数字一个音节,吃掉全部数字。
1657                candidate.pinyin.clear();
1658                candidate.pinyin.push_str(&self.digits);
1659                push_ranked(
1660                    &mut candidates,
1661                    &mut leading,
1662                    candidate,
1663                    prefer_exact,
1664                    initials_lead,
1665                    grid,
1666                );
1667            }
1668        }
1669        drop(dictionary);
1670        let filtering = self.single_character || !self.strokes.is_empty();
1671        if filtering {
1672            let strokes = (!self.strokes.is_empty()).then_some(&self.stroke_texts);
1673            candidates.retain(|item| passes_filter(&item.word, self.single_character, strokes));
1674        }
1675        rank_candidates(
1676            &mut candidates,
1677            prefer_exact,
1678            initials_lead,
1679            boost.as_ref(),
1680            grid,
1681        );
1682        self.rerank_sentences(&mut candidates);
1683        let mut seeds = std::mem::take(&mut self.seed_buffer);
1684        next_seeds(
1685            grid,
1686            &candidates,
1687            &self.digits,
1688            &mut self.sentence_seeds,
1689            &mut seeds,
1690        );
1691        self.seed_buffer = std::mem::replace(&mut self.sentence_seeds, seeds);
1692        // emoji、颜文字和符号按拼音查,读法的先后要参照排好的拼音候选,所以在插入英文行之前查;插入在英文行之后,它们也可以接在英文词后面。单字、笔画筛选针对的是汉字,筛选时不混入。
1693        let (emoji, kaomoji, symbols) = if filtering {
1694            (Vec::new(), Vec::new(), Vec::new())
1695        } else {
1696            self.expressive_candidates(&alternatives, &candidates)
1697        };
1698
1699        // 没有任何拼音读法时(77 拼不出音节),列表本来是空的,混输开关和最短前缀保护的「拼音列表的可读性」无从谈起;这时照样给英文九键词,否则 QQ 这类词只能切到全键盘去打。
1700        let unanswered = candidates.is_empty();
1701        // 首字母和筛选都只针对拼音读法,英文词一概不列。
1702        let mut english = if initial.is_some() || filtering {
1703            Vec::new()
1704        } else {
1705            self.english_candidates(unanswered)
1706        };
1707        if !english.is_empty() {
1708            // 有权重的英文保留第二位,零权重词仍接在拼音末尾;额外英文只占首页剩余空间。
1709            let first = english.remove(0);
1710            let slot = if first.weight > 0 {
1711                candidates.len().min(1)
1712            } else {
1713                candidates.len()
1714            };
1715            candidates.insert(slot, first);
1716            let extra = self
1717                .english_options
1718                .first_page_count
1719                .saturating_sub(1)
1720                .min(self.first_page_rows.get().saturating_sub(slot + 1))
1721                .min(english.len());
1722            if extra > 0 {
1723                candidates.splice(slot + 1..slot + 1, english.drain(..extra));
1724            }
1725            candidates.extend(english);
1726        }
1727        let mut candidates = insert_expressive_rows(candidates, emoji, kaomoji, symbols);
1728        // 选了首字母或在筛选时用户是在逐字拼,不加日期时间行。
1729        if let Some(clock) = self
1730            .inline_date_time
1731            .as_ref()
1732            .filter(|_| self.mixed_candidates_enabled && initial.is_none() && !filtering)
1733        {
1734            let digits = self.digits.as_str();
1735            if let Some((_keyword, anchor, kind)) =
1736                inline_date_time_keyword(|keyword| keyword_spells_digits(grid, keyword, digits))
1737            {
1738                // 九键不给「📅日期」入口:换页交互只在 26 键会话实现。
1739                insert_inline_date_time(&mut candidates, anchor, kind, digits, || clock(), None);
1740            }
1741        }
1742        positions::apply_fixed_positions(
1743            &self.paths.user(assets::USER_JOURNAL),
1744            &format!("nine-key:{}:{locked_key}", self.digits),
1745            &mut candidates,
1746            false,
1747            None,
1748            false,
1749        );
1750        keep_expressive_rows_off_the_lead(&mut candidates);
1751        // 混入行、固定位置都排好之后才折算,只在首页的拼音词典行之间调序:英文、固定位置这些行坐着原来的位置,首页由哪些行组成与不折算时相同。句子种子和 emoji 查读法用的也是折算前的顺序。
1752        self.completions_discounted = !prefer_exact
1753            && discount_completions_on_first_page(
1754                &mut candidates,
1755                self.first_page_rows.get(),
1756                locked_syllables(&locked_key),
1757                grid,
1758            );
1759        // One digit is predictive; after that, keep a likely reading visible
1760        // only once its digits are complete. An unfinished longer syllable
1761        // must not hide readings already spelled in full.
1762        if let Some(front) = candidates.first() {
1763            let offset = if locked_key.is_empty() {
1764                0
1765            } else {
1766                locked_key.len() + 1
1767            };
1768            if let Some(rest) = front.canonical_pinyin.get(offset..) {
1769                let preferred = rest.split('\'').next().unwrap_or_default();
1770                if unlocked_digit_count < 2 || preferred.len() <= unlocked_digit_count {
1771                    if let Some(found) = self.spellings.iter().position(|s| s == preferred) {
1772                        self.spellings[..=found].rotate_right(1);
1773                    }
1774                }
1775            }
1776        }
1777        let mut reading = std::mem::take(&mut self.reading);
1778        self.write_reading(candidates.first(), &mut reading);
1779        self.reading = reading;
1780        self.candidates = candidates;
1781        self.path_buffer = alternatives;
1782        let remaining = remaining_digits(&self.digits, locked_length);
1783        if remaining.is_empty() {
1784            // 数字全部锁定时,左列还是最后一次锁定时的选项,可以换选。
1785            if let Some(Some(undo)) = self.lock_undo.last() {
1786                self.spellings = undo.choices.clone();
1787            }
1788        } else {
1789            self.append_key_choices(locked_length);
1790        }
1791    }
1792
1793    /// `neural_keyboard` 打开时用键盘模型给整句重排,与 26 键一样多出一行模型挑的整句(`NeuralKeyboard`),排在词网格最好的整句之后(`lattice::merge::reranked_block`)。26 键在词库里对一串音节的几个最好整句重排;九键每次按键有几十条切分,各重排一次太贵,所以这里取各条切分解出的整句里排在最前的 `MAX_RERANK_PATHS` 条(已经按 `comparable_weight` 跨切分比较过,它也是交给模型的词网格分),统一重排一次。
1794    fn rerank_sentences(&mut self, candidates: &mut Vec<WordItem>) {
1795        if !self.sentence_association.neural_keyboard {
1796            return;
1797        }
1798        let paths = &self.paths;
1799        let keyboard = self.keyboard.get_or_insert_with(|| {
1800            shared_sentence_model(&paths.resource(assets::NEURAL_MODEL_KEYBOARD))
1801                .map(|model| NeuralReranker::new(CandidateSource::NeuralKeyboard, model))
1802        });
1803        let ranked: &[String] = if let Some(keyboard) = keyboard.as_mut() {
1804            let (scored, scored_len) = collect_lattice_sentences(candidates);
1805            let scored = &scored[..scored_len];
1806            let context = &self.rescoring_context;
1807            let ranked = if let Some(index) = self
1808                .rerank_cache
1809                .iter()
1810                .position(|entry| rerank_cache_matches(entry, context, scored))
1811            {
1812                // 缓存命中直接借用已拥有的顺序;不再复制签名或排名字符串。
1813                self.rerank_cache[index].ranked.as_slice()
1814            } else {
1815                // 缓存未命中时才拥有签名;命中路径只借用当前候选行比较。
1816                let signature: Vec<(String, i64)> = scored
1817                    .iter()
1818                    .flatten()
1819                    .map(|item| (item.word.to_string(), comparable_weight(item)))
1820                    .collect();
1821                let mut sentences: Vec<SentencePath> = scored
1822                    .iter()
1823                    .flatten()
1824                    .map(|item| SentencePath {
1825                        sentence: item.word.to_string(),
1826                        // 重排器只读取词面和静态分;规范读法和词段不会参与模型评分,
1827                        // 也不会在重排后回到候选行,因此不必为临时路径复制它们。
1828                        key: String::new(),
1829                        log_prob: comparable_weight(item) as f64 / 1000.0,
1830                        words: Vec::new(),
1831                        typo_edges: 0,
1832                    })
1833                    .collect();
1834                let ranked = if keyboard.rerank(&mut sentences, context) {
1835                    sentences.into_iter().map(|path| path.sentence).collect()
1836                } else {
1837                    Vec::new()
1838                };
1839                if self.rerank_cache.len() >= RERANK_CACHE_ENTRIES {
1840                    self.rerank_cache.remove(0);
1841                }
1842                self.rerank_cache.push(RerankEntry {
1843                    context: context.clone(),
1844                    sentences: signature,
1845                    ranked,
1846                });
1847                self.rerank_cache.last().unwrap().ranked.as_slice()
1848            };
1849            ranked
1850        } else {
1851            &[]
1852        };
1853        place_keyboard_pick(
1854            candidates,
1855            ranked,
1856            self.sentence_association.show_next_on_duplicate,
1857            self.sentence_association.word_lattice,
1858        );
1859    }
1860
1861    /// 按数字串可能的读法查要混入的 emoji、颜文字和符号,开关都关、数字不足 `MIXED_EXPRESSIVE_MINIMUM_INPUT` 个时不查。符号要读法与名称的全拼完全相等(`query_symbol_readings`),不认首字母。
1862    ///
1863    /// 读法是锁定的拼写加上一条拼满其余数字的切分路径(`alternatives` 已经按切分和左列选的首字母筛过),只保留各个字母;只拼了开头一个音节、供部分选择用的路径不算,它对应的不是整串数字。读法按支持它的拼音候选在列表里的位置排先后(候选的全拼以这个读法开头),没有候选支持的按路径原来的先后排在后面,只查前 `EXPRESSIVE_READING_LIMIT` 种。命中的编码还要能在已确定的音节边界(锁定拼写的结尾、用户打的切分)处切成完整音节,见 `splits_into_syllables_at`。
1864    fn expressive_candidates(
1865        &self,
1866        alternatives: &[Path],
1867        candidates: &[WordItem],
1868    ) -> (Vec<ExpressiveRow>, Vec<ExpressiveRow>, Vec<SymbolRow>) {
1869        let enabled = self.mixed_candidates_enabled
1870            && (self.expressive.emoji_candidates
1871                || self.expressive.kaomoji_candidates
1872                || self.expressive.symbol_candidates);
1873        if !enabled || self.digits.len() < MIXED_EXPRESSIVE_MINIMUM_INPUT {
1874            return (Vec::new(), Vec::new(), Vec::new());
1875        }
1876        let mut boundaries: Vec<usize> = self
1877            .locked
1878            .iter()
1879            .scan(0, |end, spelling| {
1880                *end += self.grid.syllable_code_len(spelling);
1881                Some(*end)
1882            })
1883            .chain(self.splits.iter().copied())
1884            .collect();
1885        boundaries.sort_unstable();
1886        boundaries.dedup();
1887        // 边界是数字位置,读法是字母:按字母归组的键盘一个数字一个字母,所有读法共用一组边界;九键双拼的边界落在每条读法的不同字母位置上,按读法换算,边界相同的读法一起查。
1888        let mut groups: Vec<(Vec<usize>, Vec<String>)> = Vec::new();
1889        for (reading, letters) in self.expressive_readings(alternatives, &boundaries, candidates) {
1890            match groups
1891                .iter_mut()
1892                .find(|(known, _)| known.as_slice() == letters.as_ref())
1893            {
1894                Some((_, readings)) => readings.push(reading),
1895                None => groups.push((letters.into_owned(), vec![reading])),
1896            }
1897        }
1898        let others = (self.expressive.emoji_candidates || self.expressive.kaomoji_candidates)
1899            .then(|| self.paths.resource(assets::OTHER_DICTIONARY));
1900        let mut emoji = Vec::new();
1901        let mut kaomoji = Vec::new();
1902        let mut symbols: Vec<SymbolRow> = Vec::new();
1903        for (letters, readings) in &groups {
1904            let accept = |key: &str| splits_into_syllables_at(key, letters);
1905            // 与 26 键相同,资源打不开或查询失败时只是没有这些行,不报诊断。
1906            if self.expressive.emoji_candidates {
1907                if let Some(others) = others.as_deref() {
1908                    append_unique_rows(&mut emoji, query_emoji_readings(readings, others, &accept));
1909                }
1910            }
1911            if self.expressive.kaomoji_candidates {
1912                if let Some(others) = others.as_deref() {
1913                    append_unique_rows(
1914                        &mut kaomoji,
1915                        query_kaomoji_readings(readings, others, &accept),
1916                    );
1917                }
1918            }
1919            if self.expressive.symbol_candidates {
1920                for row in query_symbol_readings(readings, &accept) {
1921                    if symbols.len() < MIXED_SYMBOL_LIMIT
1922                        && !symbols.iter().any(|known| known.item.word == row.item.word)
1923                    {
1924                        symbols.push(row);
1925                    }
1926                }
1927            }
1928        }
1929        // 选中后吃掉全部数字,与覆盖整串数字的拼音候选一样结束组字。
1930        for item in emoji
1931            .iter_mut()
1932            .chain(kaomoji.iter_mut())
1933            .map(|row| &mut row.item)
1934            .chain(symbols.iter_mut().map(|row| &mut row.item))
1935        {
1936            item.pinyin = SharedString::from(&self.digits);
1937        }
1938        (emoji, kaomoji, symbols)
1939    }
1940
1941    /// 见 `expressive_candidates`。每种读法带着 `boundaries`(数字位置)在它里面的字母位置。
1942    fn expressive_readings<'a>(
1943        &self,
1944        alternatives: &[Path],
1945        boundaries: &'a [usize],
1946        candidates: &[WordItem],
1947    ) -> Vec<(String, Cow<'a, [usize]>)> {
1948        let mut readings: Vec<(String, usize)> = Vec::new();
1949        for (path_index, path) in alternatives.iter().enumerate() {
1950            if !self.reading_fits(path) {
1951                continue;
1952            }
1953            let mut reading = String::with_capacity(self.digits.len());
1954            for spelling in &self.locked {
1955                reading.push_str(spelling);
1956            }
1957            path.iter().for_each(|piece| reading.push_str(piece));
1958            if !readings.iter().any(|(known, _)| *known == reading) {
1959                readings.push((reading, path_index));
1960            }
1961        }
1962        let support = |reading: &String| {
1963            candidates
1964                .iter()
1965                .position(|item| {
1966                    !item.fuzzy
1967                        && item.pinyin.len() == self.digits.len()
1968                        && letters_start_with(&item.canonical_pinyin, reading)
1969                })
1970                .unwrap_or(usize::MAX)
1971        };
1972        // 稳定排序:同样的支持位置保留路径原来的先后。
1973        readings.sort_by_cached_key(|(reading, _)| support(reading));
1974        readings.truncate(EXPRESSIVE_READING_LIMIT);
1975        readings
1976            .into_iter()
1977            .filter_map(|(reading, path_index)| {
1978                self.reading_boundaries(&alternatives[path_index], boundaries)
1979                    .map(|letters| (reading, letters))
1980            })
1981            .collect()
1982    }
1983
1984    /// 只验证切分路径是否拼满输入,延后双拼边界向量的物化,避免为排序后丢弃的读法分配存储。
1985    fn reading_fits(&self, path: &Path) -> bool {
1986        let remaining = remaining_digits(&self.digits, self.locked_length());
1987        let Some(table) = self.grid.shuangpin_table() else {
1988            return path.iter().map(String::len).sum::<usize>() == remaining.len();
1989        };
1990        let mut offset = 0;
1991        for piece in path {
1992            let Some(length) = table.piece_code_len(piece, &remaining[offset..]) else {
1993                return false;
1994            };
1995            offset += length;
1996        }
1997        offset == remaining.len()
1998    }
1999
2000    /// 切分路径正好拼满未锁定的数字时,`boundaries`(数字位置,含锁定拼写的结尾)在「锁定拼写加这条路径」的字母里落在哪些位置;没拼满(只拼了开头一个音节、供部分选择用的路径)时为 `None`。按字母归组的键盘一个数字一个字母,位置不变。
2001    fn reading_boundaries<'a>(
2002        &self,
2003        path: &Path,
2004        boundaries: &'a [usize],
2005    ) -> Option<Cow<'a, [usize]>> {
2006        let remaining = remaining_digits(&self.digits, self.locked_length());
2007        let Some(table) = self.grid.shuangpin_table() else {
2008            return (path.iter().map(String::len).sum::<usize>() == remaining.len())
2009                .then_some(Cow::Borrowed(boundaries));
2010        };
2011        // 边界按数字位置升序;走到每个音节末尾时直接记录对应的字母位置。
2012        let mut letters_at_boundaries = Vec::with_capacity(boundaries.len());
2013        let mut next_boundary = 0;
2014        let (mut digit, mut letter) = (0, 0);
2015        for spelling in &self.locked {
2016            digit += self.grid.syllable_code_len(spelling);
2017            letter += spelling.len();
2018            while next_boundary < boundaries.len() && boundaries[next_boundary] <= digit {
2019                if boundaries[next_boundary] == digit {
2020                    letters_at_boundaries.push(letter);
2021                }
2022                next_boundary += 1;
2023            }
2024        }
2025        let mut offset = 0;
2026        for piece in path {
2027            let length = table.piece_code_len(piece, &remaining[offset..])?;
2028            offset += length;
2029            digit += length;
2030            letter += piece.len();
2031            while next_boundary < boundaries.len() && boundaries[next_boundary] <= digit {
2032                if boundaries[next_boundary] == digit {
2033                    letters_at_boundaries.push(letter);
2034                }
2035                next_boundary += 1;
2036            }
2037        }
2038        (offset == remaining.len()).then_some(Cow::Owned(letters_at_boundaries))
2039    }
2040
2041    /// 左列末尾的按键选项:下一个数字键上能起头一个音节的字母(大写,和同形的音节 `o`、`a`、`e` 区分开),再是这个数字本身。数字只在没有锁定时给:前面锁定的音节还没上屏。
2042    fn append_key_choices(&mut self, locked_length: usize) {
2043        let remaining = remaining_digits(&self.digits, locked_length);
2044        let Some(&digit) = remaining.as_bytes().first() else {
2045            return;
2046        };
2047        let table = self.grid.spelling_table();
2048        let letters = self.grid.letters(digit);
2049        let raw_key = self.locked.is_empty() && self.grid.offers_raw_key();
2050        self.spellings.reserve(letters.len() + usize::from(raw_key));
2051        for letter in letters
2052            .bytes()
2053            .filter(|&letter| table.starts_syllable(letter))
2054        {
2055            self.spellings
2056                .push(char::from(letter.to_ascii_uppercase()).to_string());
2057        }
2058        if raw_key {
2059            self.spellings.push(char::from(digit).to_string());
2060        }
2061    }
2062
2063    #[cfg(test)]
2064    fn key_choices(&self, remaining: &str) -> Vec<String> {
2065        let Some(&digit) = remaining.as_bytes().first() else {
2066            return Vec::new();
2067        };
2068        let table = self.grid.spelling_table();
2069        let mut choices: Vec<String> = self
2070            .grid
2071            .letters(digit)
2072            .bytes()
2073            .filter(|&letter| table.starts_syllable(letter))
2074            .map(|letter| char::from(letter.to_ascii_uppercase()).to_string())
2075            .collect();
2076        if self.locked.is_empty() && self.grid.offers_raw_key() {
2077            choices.push(char::from(digit).to_string());
2078        }
2079        choices
2080    }
2081
2082    /// 按单字和笔画筛选候选,作用到组字结束。笔画是 `hspnz` 组成的笔顺前缀,空表示不按笔画筛选;比较的是候选的第一个字。笔画字典打不开时报 `LANGUAGE_DICTIONARY_UNAVAILABLE`,筛选保持原样。英文九键和没有组字时不处理。
2083    pub fn set_filter(&mut self, single_character: bool, strokes: &str) -> KeyResult {
2084        if !self.active() || self.english_only || !self.pinyin {
2085            return KeyResult::unhandled();
2086        }
2087        if strokes.len() > stroke::MAX_STROKES || !strokes.bytes().all(stroke::is_stroke) {
2088            return KeyResult::unhandled();
2089        }
2090        if !strokes.is_empty() && strokes != self.strokes {
2091            let Some(texts) = self.stroke_texts_for(strokes) else {
2092                return KeyResult::handled().with_diagnostic(Some(
2093                    diagnostics::LANGUAGE_DICTIONARY_UNAVAILABLE.to_string(),
2094                ));
2095            };
2096            self.stroke_texts = texts;
2097        }
2098        if strokes.is_empty() {
2099            self.stroke_texts.clear();
2100        }
2101        self.single_character = single_character;
2102        self.strokes = strokes.to_string();
2103        self.refresh();
2104        KeyResult::handled()
2105    }
2106
2107    fn stroke_texts_for(&mut self, strokes: &str) -> Option<HashSet<char>> {
2108        if self.stroke.is_none() {
2109            self.stroke = language_dictionary::open_read_only(&self.stroke_dictionary).ok();
2110        }
2111        let texts = self.stroke.as_ref()?.texts_with_key_prefix(strokes).ok()?;
2112        Some(
2113            texts
2114                .iter()
2115                .filter_map(|text| {
2116                    let mut characters = text.chars();
2117                    let first = characters.next()?;
2118                    characters.next().is_none().then_some(first)
2119                })
2120                .collect(),
2121        )
2122    }
2123
2124    /// 读音行:首行的拼音截到它覆盖的那几个键,后面接上没覆盖的键码和切分,例如 `94'26` 上的 西安 是 `xi'an`,`942` 上的 遗产 是 `yi'c`。首行不是从拼音读出来的(英文词、emoji、日期时间行)或者没有首行时为空;14 键的首行是英文词时是这个词覆盖已打键数的那段小写字母,一键一个字母,没有歧义。
2125    #[cfg(test)]
2126    fn reading_for(&self, front: Option<&WordItem>) -> String {
2127        let mut reading = String::new();
2128        self.write_reading(front, &mut reading);
2129        reading
2130    }
2131
2132    fn write_reading(&self, front: Option<&WordItem>, reading: &mut String) {
2133        reading.clear();
2134        let Some(front) = front else {
2135            return;
2136        };
2137        // A fuzzy row was matched through another spelling (知 under `94` as zi), so its canonical letters do not line up one per typed digit.
2138        if front.fuzzy {
2139            return;
2140        }
2141        if front.source == CandidateSource::EnglishDictionary
2142            && matches!(self.grid, KeyGrid::FourteenKey | KeyGrid::EighteenKey)
2143        {
2144            // 英文行的 `pinyin` 是小写的查询键(完整组码查询保证全是字母);九键不给英文读音,与按数字判断时相同。
2145            let typed = front.pinyin.len().min(self.digits.len());
2146            for byte in front.pinyin[..typed].bytes() {
2147                reading.push(char::from(byte.to_ascii_lowercase()));
2148            }
2149            return;
2150        }
2151        // 只有从拼音读出来的行(词库行、整句)才有可显示的读音:按来源正向判定,不看 `pinyin` 的字节形状,因为 14 键的组码和英文词一样都是小写字母。
2152        if front.canonical_pinyin.is_empty() || !reads_pinyin(front.source) {
2153            return;
2154        }
2155        // 简拼行一个数字一个音节,读音行显示每个音节的首字母(`m't`),而不是全拼的前几个字母(`me`)。
2156        if is_initials_row(front) {
2157            let mut first = true;
2158            for syllable in front.canonical_pinyin.split('\'') {
2159                let Some(letter) = syllable.get(..1) else {
2160                    continue;
2161                };
2162                if !first {
2163                    reading.push('\'');
2164                }
2165                reading.push_str(letter);
2166                first = false;
2167            }
2168            return;
2169        }
2170        let covered = front.pinyin.len();
2171        if let Some(table) = self.grid.shuangpin_table() {
2172            // 一个音节两位;只覆盖了一位的那个音节显示它的声母(零声母音节显示首字母):`8146` 上没打完的 中国 是 `zhong'g`。
2173            let mut digits = covered;
2174            for syllable in front.canonical_pinyin.split('\'') {
2175                if digits == 0 {
2176                    break;
2177                }
2178                if !reading.is_empty() {
2179                    reading.push('\'');
2180                }
2181                if digits >= 2 {
2182                    reading.push_str(syllable);
2183                    digits -= 2;
2184                } else {
2185                    reading.push_str(table.initial_part(syllable));
2186                    digits = 0;
2187                }
2188            }
2189        } else {
2190            let mut letters = 0;
2191            for character in front.canonical_pinyin.chars() {
2192                if letters == covered {
2193                    break;
2194                }
2195                if character.is_ascii_lowercase() {
2196                    letters += 1;
2197                }
2198                reading.push(character);
2199            }
2200        }
2201        let trimmed_len = reading.trim_end_matches('\'').len();
2202        reading.truncate(trimmed_len);
2203        if reading.is_empty() {
2204            return;
2205        }
2206        let mut start = covered.min(self.digits.len());
2207        if start < self.digits.len() {
2208            reading.push('\'');
2209            for &split in &self.splits {
2210                if split > start && split < self.digits.len() {
2211                    reading.push_str(&self.digits[start..split]);
2212                    reading.push('\'');
2213                    start = split;
2214                }
2215            }
2216            reading.push_str(&self.digits[start..]);
2217        }
2218    }
2219
2220    /// `unanswered`:拼音一行候选都没有,英文词就是整个答案,和英文模式一样不受混输开关和最短前缀限制;锁定了拼音时仍不给。
2221    fn english_candidates(&mut self, unanswered: bool) -> Vec<WordItem> {
2222        // 九键双拼的数字不是字母的分组,拼不出英文词。
2223        if !self.grid.spells_english() {
2224            return Vec::new();
2225        }
2226        // In English-only mode the words are the whole answer, so neither the mixed-candidate setting nor the prefix length that keeps a mixed list readable applies: one digit already narrows the alphabet enough (NK:169-178).
2227        if self.english_only {
2228            if self.digits.is_empty() {
2229                return Vec::new();
2230            }
2231        } else if unanswered {
2232            if self.digits.is_empty() || !self.locked.is_empty() {
2233                return Vec::new();
2234            }
2235        } else if !self.mixed_candidates_enabled
2236            || !self.english_options.mixed_candidates
2237            || !self.locked.is_empty()
2238            || self.digits.len() < self.english_options.minimum_prefix
2239        {
2240            return Vec::new();
2241        }
2242        let grid = self.grid;
2243        if self.open_english().is_none() {
2244            return Vec::new();
2245        }
2246        let Some(english) = self.english.as_ref() else {
2247            return Vec::new();
2248        };
2249        let digits = self.digits.as_str();
2250        self.english_pattern.clear();
2251        for digit in digits.bytes() {
2252            let letters = grid.letters(digit);
2253            if letters.is_empty() {
2254                return Vec::new();
2255            }
2256            self.english_pattern.push('[');
2257            self.english_pattern.push_str(letters);
2258            for letter in letters.bytes() {
2259                self.english_pattern
2260                    .push(char::from(letter.to_ascii_uppercase()));
2261            }
2262            self.english_pattern.push(']');
2263        }
2264        self.english_pattern.push('*');
2265        let depth = letter_prefix_depth(grid, digits, ENGLISH_PREFIX_BUDGET);
2266        let reusable = self.english_rows.as_mut().filter(|cache| {
2267            cache.grid == grid && cache.depth == depth && digits.starts_with(&cache.digits)
2268        });
2269        match reusable {
2270            Some(cache) => {
2271                if let Some(rows) = &mut cache.rows {
2272                    let from = cache.digits.len();
2273                    for prefix_rows in rows.iter_mut() {
2274                        prefix_rows.retain(|row| word_spells_from(grid, &row.word, digits, from));
2275                    }
2276                } else {
2277                    // 前缀组第二次出现时才取全部行:九键前三位每打一位前缀组都变,取了也用不上。
2278                    cache.rows = letter_prefixes(grid, digits, ENGLISH_PREFIX_BUDGET)
2279                        .iter()
2280                        .map(|prefix| {
2281                            english.keypad_prefix_rows(
2282                                prefix,
2283                                &self.english_pattern,
2284                                ENGLISH_CACHED_PREFIX_ROWS,
2285                            )
2286                        })
2287                        .collect();
2288                }
2289                cache.digits.clear();
2290                cache.digits.push_str(digits);
2291            }
2292            None => {
2293                let cache = self.english_rows.get_or_insert_with(|| EnglishKeypadCache {
2294                    grid,
2295                    depth,
2296                    digits: String::new(),
2297                    rows: None,
2298                });
2299                cache.grid = grid;
2300                cache.depth = depth;
2301                cache.rows = None;
2302                cache.digits.clear();
2303                cache.digits.push_str(digits);
2304            }
2305        }
2306        let mut words = Vec::new();
2307        if let Some(rows) = self
2308            .english_rows
2309            .as_ref()
2310            .and_then(|cache| cache.rows.as_ref())
2311        {
2312            let capacity = rows.len().saturating_mul(ENGLISH_LIMIT);
2313            for prefix_rows in rows {
2314                append_keypad_ranked(
2315                    prefix_rows,
2316                    digits.len(),
2317                    ENGLISH_LIMIT,
2318                    &mut words,
2319                    capacity,
2320                );
2321            }
2322        } else {
2323            let prefixes = letter_prefixes(grid, digits, ENGLISH_PREFIX_BUDGET);
2324            let capacity = prefixes.len().saturating_mul(ENGLISH_LIMIT);
2325            for prefix in prefixes {
2326                english.append_keypad_prefix(
2327                    &prefix,
2328                    &self.english_pattern,
2329                    digits.len(),
2330                    ENGLISH_LIMIT,
2331                    &mut words,
2332                    capacity,
2333                );
2334            }
2335        }
2336        deduplicate_english_words(&mut words);
2337        rank_english(&mut words, digits.len());
2338        words
2339    }
2340
2341    /// `msime-english.db` is optional; a missing file means no English rows and is looked for again on the next refresh (NK:179-186).
2342    fn open_english(&mut self) -> Option<&EnglishDictionary> {
2343        if self.english.is_none() {
2344            let path = self.paths.dictionary(assets::ENGLISH_DICTIONARY);
2345            if !path.exists() {
2346                return None;
2347            }
2348            self.english = Some(EnglishDictionary::open(&path, None, None));
2349        }
2350        self.english.as_ref()
2351    }
2352
2353    fn locked_length(&self) -> usize {
2354        self.locked
2355            .iter()
2356            .map(|spelling| self.grid.syllable_code_len(spelling))
2357            .sum()
2358    }
2359
2360    /// Drop the digits a selection consumed and every locked syllable they fully cover (NK:355-361). An English row's `pinyin` is the word, which can be longer than what was typed.
2361    fn consume(&mut self, count: usize) {
2362        let count = count.min(self.digits.len());
2363        self.digits.drain(..count);
2364        // 选词学习可能刚改了英文权重。
2365        self.english_rows = None;
2366        // 与全拼键盘相同(`session/commit.rs` 的 `commit`):选中一行后光标回到末尾。改完中间的数字选掉前一个词,接着打的是下一个词,应接在剩下的数字后面,而不是插在它们中间。
2367        self.caret = None;
2368        self.splits.retain_mut(|split| {
2369            if *split > count {
2370                *split -= count;
2371                true
2372            } else {
2373                false
2374            }
2375        });
2376        let mut consumed = count;
2377        while let Some(front) = self.locked.first() {
2378            let length = self.grid.syllable_code_len(front);
2379            if consumed < length {
2380                break;
2381            }
2382            consumed -= length;
2383            self.locked.remove(0);
2384            if !self.lock_undo.is_empty() {
2385                self.lock_undo.remove(0);
2386            }
2387        }
2388        // 剩下的锁定记着的是上屏前的数字位置。
2389        self.lock_undo.iter_mut().for_each(|undo| *undo = None);
2390        self.initial = None;
2391    }
2392
2393    fn ranking_context(&self) -> String {
2394        format!("nine-key:{}:{}", self.digits, self.locked.join("'"))
2395    }
2396
2397    fn editable(&self, index: usize) -> bool {
2398        self.candidates
2399            .get(index)
2400            .is_some_and(|item| !item.canonical_pinyin.is_empty() && item.source.is_dictionary())
2401    }
2402
2403    /// Learning and explicit management use canonical dictionary keys, never digit strings.
2404    fn adjust_frequency(&mut self, index: usize, force_top: bool) -> Option<String> {
2405        let item = &self.candidates[index];
2406        let main_db = self.paths.dictionary(assets::MAIN_DICTIONARY);
2407        let user_db = self.paths.user(assets::USER_JOURNAL);
2408        let context = self.ranking_context();
2409        let request = RankingRequest {
2410            main_db: &main_db,
2411            user_db: &user_db,
2412            context_key: &context,
2413            ordered: &self.candidates,
2414            entry_key: &item.canonical_pinyin,
2415            value: &item.word,
2416            mode: if force_top {
2417                FrequencyAdjustmentMode::Pin
2418            } else {
2419                self.frequency.mode
2420            },
2421            linear_step: self.frequency.linear_step,
2422            trigger_count: self.frequency.trigger_count,
2423            force_top,
2424            kind: PersonalDictionaryKind::Pinyin,
2425        };
2426        match ranking::adjust_candidate_ranking(&request) {
2427            Ok(changed) => {
2428                if changed {
2429                    if let Some(dictionary) = self.dictionary.as_mut() {
2430                        dictionary.reset_cache();
2431                    }
2432                }
2433                None
2434            }
2435            // A failed write keeps the commit and reports this one message (NK:409-414).
2436            Err(_) => Some(diagnostics::NINE_KEY_FREQUENCY_NOT_PERSISTED.to_string()),
2437        }
2438    }
2439
2440    /// 把最近一次九键选择固定到指定上下文的首位;后一次选择会替换第 1 槽。
2441    fn remember_recent_candidate(
2442        &self,
2443        context: &str,
2444        canonical_pinyin: &str,
2445        word: &str,
2446    ) -> Option<String> {
2447        positions::set_fixed_position(
2448            &self.paths.user(assets::USER_JOURNAL),
2449            context,
2450            canonical_pinyin,
2451            word,
2452            1,
2453        )
2454        .err()
2455        .map(|_| diagnostics::NINE_KEY_POSITION_NOT_PERSISTED.to_string())
2456    }
2457}
2458
2459/// 键盘模型的一次重排:上屏上下文、交给模型的整句和词网格分(`comparable_weight`),以及模型给出的顺序(没有重排时为空)。
2460struct RerankEntry {
2461    context: String,
2462    sentences: Vec<(String, i64)>,
2463    ranked: Vec<String>,
2464}
2465
2466/// 缓存命中只比较当前候选行,不为签名复制词面;签名仍在未命中时拥有并存入缓存。
2467fn rerank_cache_matches(entry: &RerankEntry, context: &str, scored: &[Option<&WordItem>]) -> bool {
2468    entry.context == context
2469        && entry.sentences.len() == scored.len()
2470        && entry
2471            .sentences
2472            .iter()
2473            .zip(scored)
2474            .all(|((word, weight), item)| {
2475                item.is_some_and(|item| word == &item.word && *weight == comparable_weight(item))
2476            })
2477}
2478
2479/// 打开词库时要交给它的选项。取用词库的地方有三处,选项只在这一处攒齐(`dictionary_options`),
2480/// 所以以后加选项不会再漏掉某一条路径,让它悄悄用上默认值。
2481#[derive(Clone)]
2482struct DictionaryOptions {
2483    /// `NineKeySession::dictionary_association` 给的句子联想设置。
2484    association: SentenceAssociationOptions,
2485    alternatives: bool,
2486    first_page_rows: FirstPageRows,
2487}
2488
2489/// 打开九键用的词库,带上 `NineKeySession::dictionary_association` 给的句子联想设置。
2490fn open_dictionary(paths: &RuntimePaths, options: DictionaryOptions) -> QuanpinDictionary {
2491    let mut dictionary = QuanpinDictionary::new(paths);
2492    dictionary.set_sentence_association(options.association);
2493    dictionary.set_sentence_alternatives(options.alternatives);
2494    dictionary.set_first_page_rows(options.first_page_rows);
2495    dictionary
2496}
2497
2498/// 词网格解出的整句行(`lattice::merge::sentence_row` 的 `Generated` 行)。
2499fn is_lattice_sentence(item: &WordItem) -> bool {
2500    matches!(
2501        item.source,
2502        CandidateSource::Generated | CandidateSource::Collocation
2503    ) && item.sentence_association
2504}
2505
2506/// 取候选列表中最多 `MAX_RERANK_PATHS` 条词网格整句,借用行本身而不建立临时索引向量。
2507fn collect_lattice_sentences(
2508    candidates: &[WordItem],
2509) -> ([Option<&WordItem>; MAX_RERANK_PATHS], usize) {
2510    let mut slots = [None; MAX_RERANK_PATHS];
2511    let mut len = 0;
2512    for item in candidates {
2513        if !is_lattice_sentence(item) {
2514            continue;
2515        }
2516        slots[len] = Some(item);
2517        len += 1;
2518        if len == MAX_RERANK_PATHS {
2519            break;
2520        }
2521    }
2522    (slots, len)
2523}
2524
2525/// 把键盘模型重排后的整句顺序 `ranked` 落到候选上,规则同 `lattice::merge::reranked_block`。词网格开关开着时(`include_lattice_best`),模型排第一的整句若不是词网格最好的那一行,就把它改成 `NeuralKeyboard` 行,挪到词网格最好的整句后面;它就是那一行时,`show_next_on_duplicate` 打开才接着看模型的下一句,否则不出模型行。词网格开关关着时词网格最好的整句不显示,模型排第一的整句总是改成 `NeuralKeyboard` 行、占它的位置(与它相同也一样),随后去掉其余的词网格整句行。`ranked` 为空(模型没有重排)时只做去掉整句这一步。
2526fn place_keyboard_pick(
2527    candidates: &mut Vec<WordItem>,
2528    ranked: &[String],
2529    show_next_on_duplicate: bool,
2530    word_lattice: bool,
2531) {
2532    let take_pick = |candidates: &mut Vec<WordItem>, sentence: &str, at_position: usize| {
2533        // 候选按词去过重,同一句只有一行。
2534        if let Some(at) = candidates
2535            .iter()
2536            .position(|item| is_lattice_sentence(item) && item.word == sentence)
2537        {
2538            let mut pick = candidates.remove(at);
2539            pick.source = CandidateSource::NeuralKeyboard;
2540            candidates.insert(at_position, pick);
2541        }
2542    };
2543    if let Some(best) = candidates.iter().position(is_lattice_sentence) {
2544        if !word_lattice {
2545            if let Some(sentence) = ranked.first() {
2546                take_pick(candidates, sentence, best);
2547            }
2548        } else {
2549            for sentence in ranked {
2550                if candidates[best].word != *sentence {
2551                    // 这句只可能是排在 `best` 之后的另一条词网格整句。
2552                    take_pick(candidates, sentence, best + 1);
2553                    break;
2554                }
2555                if !show_next_on_duplicate {
2556                    break;
2557                }
2558            }
2559        }
2560    }
2561    if !word_lattice {
2562        candidates.retain(|item| !is_lattice_sentence(item));
2563    }
2564}
2565
2566/// 种子真正用到的部分:去掉最后一个音节,它可能被后面的数字续长,或者本来只拼了一半。
2567fn seed_prefix(seed: &str) -> &str {
2568    seed.rfind('\'').map_or("", |end| &seed[..end])
2569}
2570
2571/// `prefix` 开头有几个音节按键盘依次拼得出 `digits` 的开头:(音节数, 用掉的数字数, 是否全部拼得出)。不分配。
2572fn fitting_syllables(grid: KeyGrid, prefix: &str, digits: &str) -> (usize, usize, bool) {
2573    let mut syllables = 0;
2574    let mut covered = 0;
2575    if prefix.is_empty() {
2576        return (0, 0, true);
2577    }
2578    for syllable in prefix.split('\'') {
2579        let end = covered + grid.syllable_code_len(syllable);
2580        if !digits
2581            .get(covered..end)
2582            .is_some_and(|code| letters_spell_code(grid, syllable, code))
2583        {
2584            return (syllables, covered, false);
2585        }
2586        syllables += 1;
2587        covered = end;
2588    }
2589    (syllables, covered, true)
2590}
2591
2592/// 小写字母串按键盘上印的字母正好拼出同样长的 `code`。九键双拼按完整音节的两位码比。
2593fn letters_spell_code(grid: KeyGrid, letters: &str, code: &str) -> bool {
2594    let Some(keypad) = grid.keypad() else {
2595        return match grid {
2596            KeyGrid::NineKeyShuangpin(table)
2597            | KeyGrid::FourteenKeyShuangpin(table)
2598            | KeyGrid::EighteenKeyShuangpin(table) => table
2599                .syllable_codes(letters)
2600                .any(|pair| pair.as_slice() == code.as_bytes()),
2601            _ => false,
2602        };
2603    };
2604    letters.len() == code.len()
2605        && letters.bytes().zip(code.bytes()).all(|(letter, digit)| {
2606            letter.is_ascii_lowercase() && keypad[usize::from(letter - b'a')] == digit
2607        })
2608}
2609
2610/// 把这次刷新之后的种子写进 `seeds`(先清空),`previous` 用完清空:先取排好的候选里覆盖全部数字、不是模糊音的前 `SENTENCE_SEED_LIMIT` 条全拼(`seed_prefix` 相同的只留一条,免得几条种子只差在最后一个音节上),再接上 `previous` 里 `seed_prefix` 仍拼得出数字开头的,合计最多 `SEED_RETAIN_LIMIT` 条。末尾只按了下一个音节的头一两个数字时没有覆盖全部数字的行,这时全靠留下来的种子,否则每隔一键就丢一次。`previous` 里已有的同一条直接挪过来,不再复制字符串。
2611fn next_seeds(
2612    grid: KeyGrid,
2613    candidates: &[WordItem],
2614    digits: &str,
2615    previous: &mut Vec<String>,
2616    seeds: &mut Vec<String>,
2617) {
2618    seeds.clear();
2619    let is_new = |seeds: &[String], seed: &str| {
2620        seeds
2621            .iter()
2622            .all(|existing| seed_prefix(existing) != seed_prefix(seed))
2623    };
2624    for item in candidates {
2625        if seeds.len() == SENTENCE_SEED_LIMIT {
2626            break;
2627        }
2628        if item.pinyin.len() != digits.len()
2629            || item.canonical_pinyin.is_empty()
2630            || item.fuzzy
2631            || !is_new(seeds, &item.canonical_pinyin)
2632        {
2633            continue;
2634        }
2635        let seed = match previous
2636            .iter()
2637            .position(|old| *old == item.canonical_pinyin)
2638        {
2639            Some(index) => previous.remove(index),
2640            None => item.canonical_pinyin.to_string(),
2641        };
2642        seeds.push(seed);
2643    }
2644    for seed in previous.drain(..) {
2645        if seeds.len() < SEED_RETAIN_LIMIT
2646            && fitting_syllables(grid, seed_prefix(&seed), digits).2
2647            && is_new(seeds, &seed)
2648        {
2649            seeds.push(seed);
2650        }
2651    }
2652}
2653
2654/// 按数字切分路径只看音节频度,长串里正确的切分常常排不进每个位置的 `PATH_LIMIT` 条(#6059);而词网格看得懂词,前几次刷新排在前面的整句的切分多半就是这一次的前半截。所以取每条种子的 `seed_prefix` 里仍拼得出 `remaining` 开头的那几个音节,接上余下数字的前 `SEED_TAIL_LIMIT` 条切分,补进 `alternatives`。等于在按键之间做一次宽度为 `SEED_RETAIN_LIMIT` 的束搜索,每次按键只把束里的切分往后接一两个音节。种子的末音节整个拼得出时,再多接一条保住这个末音节的尾段。
2655fn seeded_paths(
2656    alternatives: &mut Vec<Path>,
2657    seeds: &[String],
2658    remaining: &str,
2659    table: &SpellingTable,
2660    prior: &SyllablePrior,
2661) {
2662    for seed in seeds {
2663        let (kept, covered, _) = fitting_syllables(table.grid, seed_prefix(seed), remaining);
2664        if kept == 0 {
2665            continue;
2666        }
2667        // 实际会话最多 32 位;常见前缀只切分一次,超长纯函数调用仍走完整迭代。
2668        let mut pieces = [""; DIGIT_LIMIT];
2669        if kept <= pieces.len() {
2670            for (slot, piece) in pieces.iter_mut().zip(seed.split('\'').take(kept)) {
2671                *slot = piece;
2672            }
2673        }
2674        let mut append = |tail_path| {
2675            if kept <= pieces.len() {
2676                append_seeded_path(
2677                    alternatives,
2678                    pieces[..kept].iter().copied(),
2679                    kept,
2680                    tail_path,
2681                );
2682            } else {
2683                append_seeded_path(alternatives, seed.split('\'').take(kept), kept, tail_path);
2684            }
2685        };
2686        let tail = &remaining[covered..];
2687        if tail.is_empty() {
2688            append(Vec::new());
2689        } else {
2690            for tail_path in table
2691                .split_paths_limited(tail, &[], prior, SEED_TAIL_LIMIT)
2692                .into_iter()
2693                .take(SEED_TAIL_LIMIT)
2694            {
2695                append(tail_path);
2696            }
2697            // 种子的末音节整个拼得出、后面又按了数字时,再接一条以这个末音节开头、余下数字取频度最高那一条切分的尾段。末音节是词网格已经认过的,不该再交给音节频度重排:`32622` 的尾段按频度是 dao'ba、dan'ba、dao'ca,「吃饭」之后的 fan'ba 排第四被丢掉。
2698            let (_, full_covered, full_fits) = fitting_syllables(table.grid, seed, remaining);
2699            if full_fits {
2700                let last = &seed[seed_prefix(seed).len() + 1..];
2701                let rest = &remaining[full_covered..];
2702                let rest_path = if rest.is_empty() {
2703                    Some(Vec::new())
2704                } else {
2705                    table
2706                        .split_paths_limited(rest, &[], prior, 1)
2707                        .into_iter()
2708                        .next()
2709                };
2710                if let Some(rest_path) = rest_path {
2711                    let mut anchored = Vec::with_capacity(1 + rest_path.len());
2712                    anchored.push(last.to_owned());
2713                    anchored.extend(rest_path);
2714                    append(anchored);
2715                }
2716            }
2717        }
2718    }
2719}
2720
2721/// 先用借用音节检查重复,只为新增组合建立路径和复制种子前缀。
2722fn append_seeded_path<'a>(
2723    alternatives: &mut Vec<Path>,
2724    prefix: impl Iterator<Item = &'a str> + Clone,
2725    kept: usize,
2726    tail_path: Path,
2727) {
2728    if alternatives.iter().any(|path| {
2729        path.len() == kept + tail_path.len()
2730            && path[..kept].iter().map(String::as_str).eq(prefix.clone())
2731            && path[kept..] == tail_path
2732    }) {
2733        return;
2734    }
2735    let mut path = Vec::with_capacity(kept + tail_path.len());
2736    path.extend(prefix.map(str::to_owned));
2737    path.extend(tail_path);
2738    alternatives.push(path);
2739}
2740
2741fn remaining_digits(digits: &str, locked_length: usize) -> &str {
2742    &digits[locked_length..]
2743}
2744
2745fn append_path_key(output: &mut String, prefix: &str, path: &[String]) {
2746    output.clear();
2747    output.push_str(prefix);
2748    for (index, part) in path.iter().enumerate() {
2749        if !prefix.is_empty() || index != 0 {
2750            output.push('\'');
2751        }
2752        output.push_str(part);
2753    }
2754}
2755
2756/// A row read under the locked syllables must spell them, or be a whole-syllable prefix of them.
2757fn agrees_with_locked(matched: &str, locked_key: &str) -> bool {
2758    if locked_key.is_empty() || matched == locked_key {
2759        return true;
2760    }
2761    let under = matched
2762        .strip_prefix(locked_key)
2763        .is_some_and(|rest| rest.starts_with('\''));
2764    let over = locked_key
2765        .strip_prefix(matched)
2766        .is_some_and(|rest| rest.starts_with('\''));
2767    under || over
2768}
2769
2770/// 把另一组读法查到的 emoji、颜文字接在后面,按文字去重。
2771fn append_unique_rows(rows: &mut Vec<ExpressiveRow>, more: Vec<ExpressiveRow>) {
2772    for row in more {
2773        if !rows.iter().any(|known| known.item.word == row.item.word) {
2774            rows.push(row);
2775        }
2776    }
2777}
2778
2779/// 去掉 `'` 之后,`pinyin` 的字母以 `reading` 开头。
2780fn letters_start_with(pinyin: &str, reading: &str) -> bool {
2781    let mut letters = pinyin.bytes().filter(|&byte| byte != b'\'');
2782    reading.bytes().all(|byte| letters.next() == Some(byte))
2783}
2784
2785/// emoji、颜文字的编码(全拼连写,没有 `'`)能不能让 `boundaries`(字母位置,升序)都落在音节之间:各边界之间、以及最后一个边界之后的部分都能切成完整音节。最后一个边界正好在编码末尾时,后面什么都没有也算。没有边界时都算,与 26 键按前缀匹配相同。
2786///
2787/// 编码本身不记音节在哪里分开,`xiangjiao` 也能切成 xi'ang'jiao,所以锁定 xi 之后它仍然算;这里排除的是怎么切都对不上的编码,比如锁定 xian 之后的 `xiangjiao`(剩下的 gjiao 切不成音节)。
2788fn splits_into_syllables_at(key: &str, boundaries: &[usize]) -> bool {
2789    let mut start = 0;
2790    for &end in boundaries.iter().chain(std::iter::once(&key.len())) {
2791        let Some(piece) = key.get(start..end) else {
2792            return false;
2793        };
2794        // 单片编码不接受撇号;完整性接口接受撇号分段,因此在这里保留旧限制。
2795        if !piece.is_empty() && (piece.contains('\'') || !is_complete_pinyin_input(piece)) {
2796            return false;
2797        }
2798        start = end;
2799    }
2800    true
2801}
2802
2803/// 候选能否通过筛选:`single_character` 只留单字,`strokes` 是笔顺以所选几笔开头的字,比较候选的第一个字。
2804fn passes_filter(word: &str, single_character: bool, strokes: Option<&HashSet<char>>) -> bool {
2805    let mut characters = word.chars();
2806    let Some(first) = characters.next() else {
2807        return false;
2808    };
2809    if single_character && characters.next().is_some() {
2810        return false;
2811    }
2812    strokes.is_none_or(|texts| texts.contains(&first))
2813}
2814
2815#[cfg(test)]
2816fn has_candidate_word(candidates: &[WordItem], word: &str) -> bool {
2817    candidates.iter().any(|candidate| candidate.word == word)
2818}
2819
2820fn query_key_is_new(
2821    alternatives: &[Path],
2822    index: usize,
2823    key: &str,
2824    queried: Option<&HashSet<String>>,
2825) -> bool {
2826    match queried {
2827        Some(queried) => !queried.contains(key),
2828        None => !alternatives[..index]
2829            .iter()
2830            .any(|alternative| alternative == &alternatives[index]),
2831    }
2832}
2833
2834/// `rank_candidates` 的排序键,小的在前。
2835type RankKey = (Reverse<usize>, bool, bool, bool, bool, Reverse<i64>);
2836
2837const LEADING_FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
2838const LEADING_FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
2839
2840#[derive(Default)]
2841struct LeadingHasher(u64);
2842
2843impl Hasher for LeadingHasher {
2844    fn finish(&self) -> u64 {
2845        self.0
2846    }
2847
2848    fn write(&mut self, bytes: &[u8]) {
2849        self.0 = bytes.iter().fold(LEADING_FNV_OFFSET, |state, &byte| {
2850            (state ^ u64::from(byte)).wrapping_mul(LEADING_FNV_PRIME)
2851        });
2852    }
2853
2854    fn write_u64(&mut self, value: u64) {
2855        self.0 = value;
2856    }
2857}
2858
2859#[derive(Clone, Copy)]
2860struct LeadingEntry {
2861    candidate: usize,
2862    key: RankKey,
2863}
2864
2865enum LeadingSlot {
2866    One(LeadingEntry),
2867    Many(Vec<LeadingEntry>),
2868}
2869
2870impl LeadingSlot {
2871    fn find(&self, candidates: &[WordItem], word: &str) -> Option<(usize, RankKey)> {
2872        let entries: &[LeadingEntry] = match self {
2873            Self::One(entry) => std::slice::from_ref(entry),
2874            Self::Many(entries) => entries,
2875        };
2876        entries.iter().enumerate().find_map(|(slot, entry)| {
2877            (candidates[entry.candidate].word == word).then_some((slot, entry.key))
2878        })
2879    }
2880
2881    fn update(&mut self, slot: usize, candidate: usize, key: RankKey) {
2882        let entries: &mut [LeadingEntry] = match self {
2883            Self::One(entry) => std::slice::from_mut(entry),
2884            Self::Many(entries) => entries,
2885        };
2886        entries[slot] = LeadingEntry { candidate, key };
2887    }
2888
2889    fn insert(&mut self, entry: LeadingEntry) {
2890        match self {
2891            Self::One(previous) => {
2892                *self = Self::Many(vec![*previous, entry]);
2893            }
2894            Self::Many(entries) => entries.push(entry),
2895        }
2896    }
2897}
2898
2899type LeadingMap = HashMap<u64, LeadingSlot, BuildHasherDefault<LeadingHasher>>;
2900
2901fn leading_hash(word: &str) -> u64 {
2902    word.bytes().fold(LEADING_FNV_OFFSET, |state, byte| {
2903        (state ^ u64::from(byte)).wrapping_mul(LEADING_FNV_PRIME)
2904    })
2905}
2906
2907fn leading_rank(leading: &LeadingMap, candidates: &[WordItem], word: &str) -> Option<RankKey> {
2908    leading
2909        .get(&leading_hash(word))
2910        .and_then(|slot| slot.find(candidates, word).map(|(_, key)| key))
2911}
2912
2913/// More digits covered first. Synthesised rows (whole-sentence Generated, Fallback) score on a different scale from dictionary weights, so within one coverage bucket dictionary rows lead; then exact before fuzzy, then weight.
2914/// With `prefer_exact` (the user typed a split), a row the typed digits spell to its end then leads one that has to be completed past them: over `94'26` 西安 (xi'an) comes before 自从 (zi'cong), however common the longer word. Without a split the digits do not say where a syllable ends, so `3` keeps 的 (de) ahead of the rarer 额 (e) by weight.
2915/// 简拼行(`is_initials_row`)在同样覆盖的词典行里的位置由 `initials_lead` 决定:用户在每个数字之间都打了切分时排在前面,否则先排在音节行之后、整句行之前,去重后再由 `interleave_initials` 按权重插进音节行里。
2916/// `grid` 只用来算全拼占几个组码:九键双拼一个音节两位,不是一个字母一位。
2917fn rank_key(item: &WordItem, prefer_exact: bool, initials_lead: bool, grid: KeyGrid) -> RankKey {
2918    rank_key_for_reading(
2919        item,
2920        item.pinyin.len(),
2921        &item.canonical_pinyin,
2922        prefer_exact,
2923        initials_lead,
2924        grid,
2925    )
2926}
2927
2928/// 复制前用将来的数字覆盖长度和规范读法求同一个排序键。
2929fn rank_key_for_reading(
2930    item: &WordItem,
2931    covered: usize,
2932    canonical: &str,
2933    prefer_exact: bool,
2934    initials_lead: bool,
2935    grid: KeyGrid,
2936) -> RankKey {
2937    #[cfg(all(test, debug_assertions))]
2938    cached_rank_tests::KEY_EVALUATIONS.with(|count| count.set(count.get() + 1));
2939    let completion = prefer_exact && grid.reading_code_len(canonical) > covered;
2940    let initials = is_initials_reading(item, covered, canonical);
2941    (
2942        Reverse(covered),
2943        item.source.is_generated_or_fallback(),
2944        initials != initials_lead,
2945        completion,
2946        item.fuzzy,
2947        Reverse(comparable_weight_for_reading(item, canonical)),
2948    )
2949}
2950
2951/// 不打切分时,数字拼满一个多音节词典词(`2443` 的 挨个 ai'ge),而同一段里还有要补全的词典行(吃饭 chi'fan,要把 f 补成 fan),`rank_key` 只按词库权重比,常用的补全总排在完整读法前面。打全读音的人要的是完整读法,停在声母上的人要的是补全,这里只在首页(`rows`,即 `first_page_rows`)之内调序:完整读法的权重乘 `COMPLETION_WEIGHT_RATIO` 倍再和补全比,比完整读法常用得多的补全仍在前面。首页由哪些行组成、候选表截到哪里都不变,所以停在声母上的用户要的词仍在首页;放到整张表里比时,三四个数字的输入有几百个单字,补全会被挤出 `CANDIDATE_LIMIT` 行,见 `.agents/notes/implemented/bug-fix/2026-10-27-nine-key-completion-discount-on-first-page.md`。返回是否折算过。
2952/// 首页里没有拼满的多音节词时不折算:`3` 的 的 de 照旧凭权重排在 额 e 前面,`536` 的 可能 ke'neng 照旧排在 肯 ken 前面。锁定的音节(`locked_syllables` 个)不算,锁定 wo 之后再打 `3` 与单独打 `3` 一样。所有补全按原权重彼此比较,单音节的 冷(len 补成 leng)不会越过多音节的 可能。
2953/// 在拼音列表排好、混入行和固定位置都放好之后调用,只动首页里覆盖全部数字的拼音词典行,其余行坐在原位:英文、快捷短语、日文、固定位置的行跳过;简拼行由 `interleave_initials` 和 `boost_used_initials` 排好,留在原位,音节行也不越过它;遇到覆盖不同的词典行、整句行、日期时间行和 emoji、颜文字、符号行就停下,emoji 这些行接在它们的词后面,不能把词换走。权重相同时完整读法在前,模糊行照旧排在精确行之后;负权重的补全再乘一次倍数,折算后不会反而变大。
2954fn discount_completions_on_first_page(
2955    candidates: &mut [WordItem],
2956    rows: usize,
2957    locked_syllables: usize,
2958    grid: KeyGrid,
2959) -> bool {
2960    let rows = rows.min(candidates.len());
2961    let completion =
2962        |item: &WordItem| grid.reading_code_len(&item.canonical_pinyin) > item.pinyin.len();
2963    let mut coverage = None;
2964    let mut end = rows;
2965    let mut spelled_phrase = false;
2966    for (seat, item) in candidates[..rows].iter().enumerate() {
2967        match discount_seat(item, &mut coverage) {
2968            DiscountSeat::Stop => {
2969                end = seat;
2970                break;
2971            }
2972            DiscountSeat::Row => {
2973                spelled_phrase |= !item.fuzzy
2974                    && !completion(item)
2975                    && item
2976                        .canonical_pinyin
2977                        .split('\'')
2978                        .nth(locked_syllables + 1)
2979                        .is_some();
2980            }
2981            DiscountSeat::Skip | DiscountSeat::Boundary => {}
2982        }
2983    }
2984    if !spelled_phrase {
2985        return false;
2986    }
2987    let ratio = COMPLETION_WEIGHT_RATIO;
2988    let key = |item: &WordItem| {
2989        let discounted = !item.fuzzy && completion(item);
2990        let scaled = match (discounted, item.weight >= 0) {
2991            (false, _) => item.weight.saturating_mul(ratio),
2992            (true, true) => item.weight,
2993            (true, false) => item.weight.saturating_mul(ratio).saturating_mul(ratio),
2994        };
2995        (item.fuzzy, Reverse(scaled), discounted)
2996    };
2997    // 插入排序,只在 `Row` 的位置之间交换:其余行不动,简拼行把各段隔开。首页只有几行,不为这几行申请内存。
2998    for seat in 0..end {
2999        if discount_seat(&candidates[seat], &mut coverage) != DiscountSeat::Row {
3000            continue;
3001        }
3002        let mut current = seat;
3003        loop {
3004            let mut previous = None;
3005            for earlier in (0..current).rev() {
3006                match discount_seat(&candidates[earlier], &mut coverage) {
3007                    DiscountSeat::Row => {
3008                        previous = Some(earlier);
3009                        break;
3010                    }
3011                    DiscountSeat::Skip => {}
3012                    DiscountSeat::Boundary | DiscountSeat::Stop => break,
3013                }
3014            }
3015            match previous {
3016                Some(earlier) if key(&candidates[earlier]) > key(&candidates[current]) => {
3017                    candidates.swap(earlier, current);
3018                    current = earlier;
3019                }
3020                _ => break,
3021            }
3022        }
3023    }
3024    true
3025}
3026
3027/// `discount_completions_on_first_page` 怎么对待首页里的一行。
3028#[derive(Clone, Copy, PartialEq, Eq)]
3029enum DiscountSeat {
3030    /// 覆盖全部数字的拼音词典行,参与调序。
3031    Row,
3032    /// 简拼行:坐在原位,两边的行不越过它。
3033    Boundary,
3034    /// 英文、快捷短语、固定位置这些插进来的行:坐在原位,两边的行可以越过它。
3035    Skip,
3036    /// 覆盖不同的词典行、整句行、日期时间行,以及接在词后面的 emoji、颜文字、符号行:从这里起不再调序。
3037    Stop,
3038}
3039
3040fn discount_seat(item: &WordItem, coverage: &mut Option<usize>) -> DiscountSeat {
3041    if item.fixed_position != 0 {
3042        return DiscountSeat::Skip;
3043    }
3044    if !item.source.is_dictionary() {
3045        return match item.source {
3046            CandidateSource::Generated
3047            | CandidateSource::Fallback
3048            | CandidateSource::Collocation
3049            | CandidateSource::NeuralKeyboard
3050            | CandidateSource::Emoji
3051            | CandidateSource::Kaomoji => DiscountSeat::Stop,
3052            _ => DiscountSeat::Skip,
3053        };
3054    }
3055    if *coverage.get_or_insert(item.pinyin.len()) != item.pinyin.len() {
3056        DiscountSeat::Stop
3057    } else if is_initials_row(item) {
3058        DiscountSeat::Boundary
3059    } else {
3060        DiscountSeat::Row
3061    }
3062}
3063
3064/// 锁定的拼写有几个音节;`locked_key` 是 `append_path_key` 拼出来的、以 `'` 分隔的锁定拼写。
3065fn locked_syllables(locked_key: &str) -> usize {
3066    if locked_key.is_empty() {
3067        0
3068    } else {
3069        locked_key.split('\'').count()
3070    }
3071}
3072
3073/// 整句行的分是 `log_prob * 1000`,词组边每覆盖一个音节就带一份 `PHRASE_LENGTH_BONUS`。同一串音节里比较时这无关紧要,九宫格却要比较不同音节串各自解出的整句:音节越多、越能拼成词组的读法分越高,`9436364782662` 的 xi'e'meng'suan'ma(洗噩梦算吗)就会压过 zhe'neng'suan'ma(这能算吗)。这里按路径的音节数每个扣一份奖励:同一条路径内的先后不变,多切出来的音节要靠词本身的分量才能赢。只扣词组实际拿到的那部分反而更差(整句集 top-1 降了一截),见 `.agents/notes/implemented/bug-fix/2026-10-08-nine-key-path-ranking.md`。
3074fn comparable_weight(item: &WordItem) -> i64 {
3075    comparable_weight_for_reading(item, &item.canonical_pinyin)
3076}
3077
3078/// 保留 `split` 的空段语义;ASCII 撇号不会出现在 UTF-8 多字节编码内部。
3079fn reading_syllable_count(canonical: &str) -> usize {
3080    canonical.bytes().filter(|&byte| byte == b'\'').count() + 1
3081}
3082
3083fn comparable_weight_for_reading(item: &WordItem, canonical: &str) -> i64 {
3084    if item.source != CandidateSource::Generated || canonical.is_empty() {
3085        return item.weight;
3086    }
3087    let syllables = reading_syllable_count(canonical);
3088    // 每词插入代价已在共享词网格解码中扣除;这里只消去跨切分比较的音节奖励。
3089    item.weight - (PHRASE_LENGTH_BONUS * 1000.0 * syllables as f64) as i64
3090}
3091
3092/// 九键记进个人上下文模型的词:两个字以上的纯汉字词,单字和夹着英文、符号的不记(#6185)。
3093fn is_personal_word(word: &str) -> bool {
3094    count_utf8_chars(word) >= 2 && is_all_han(word)
3095}
3096
3097/// 这一来源的行是查拼音词库得来的(词库行、词网格和键盘模型的整句、回退解码),`canonical_pinyin` 是它的全拼。
3098fn reads_pinyin(source: CandidateSource) -> bool {
3099    source.is_dictionary()
3100        || source.is_generated_or_fallback()
3101        || source == CandidateSource::NeuralKeyboard
3102}
3103
3104/// 一个数字一个音节、全拼比数字长的词典行:按简拼查出来的行(`68` 的 明天 mei'tian)。只有两个及以上数字时才算,一个数字本来就按首字母补全。
3105fn is_initials_row(item: &WordItem) -> bool {
3106    is_initials_reading(item, item.pinyin.len(), &item.canonical_pinyin)
3107}
3108
3109fn is_initials_reading(item: &WordItem, digits: usize, canonical: &str) -> bool {
3110    digits >= 2
3111        && !item.source.is_generated_or_fallback()
3112        && reading_syllable_count(canonical) == digits
3113        && canonical.bytes().filter(u8::is_ascii_lowercase).count() > digits
3114}
3115
3116/// 简拼只在每个数字都能是一个音节时才查:至少两个数字,用户打的切分(`splits`,相对未锁定的数字)把数字切成的每一段都只有一个数字。末尾的切分也是一个音节的结尾:`68'` 说 68 是一个音节,不是两个首字母。
3117fn initials_apply(digits: usize, splits: &[usize]) -> bool {
3118    if digits < 2 {
3119        return false;
3120    }
3121    let mut start = 0;
3122    for &split in splits {
3123        if split - start > 1 {
3124            return false;
3125        }
3126        start = split;
3127    }
3128    splits.is_empty() || digits - start <= 1
3129}
3130
3131/// 九键双拼按声母那几位找词时查的简拼(见 `refresh`):每两位数字一个音节,取第 1、3、5… 位当声母展开。不到 `SHUANGPIN_WORD_MIN_SYLLABLES` 个音节,或展开超过 `INITIALS_CODE_LIMIT` 个码时为 `None`。
3132fn shuangpin_word_codes(grid: KeyGrid, digits: &str) -> Option<Vec<String>> {
3133    if digits.len().div_ceil(2) < SHUANGPIN_WORD_MIN_SYLLABLES {
3134        return None;
3135    }
3136    let initials: String = digits.bytes().step_by(2).map(char::from).collect();
3137    initials_codes(grid, &initials, INITIALS_CODE_LIMIT)
3138}
3139
3140/// 词的全拼在九键双拼上的编码以 `digits` 开头,而且音节数正好是 `digits` 拼得出的那么多(末尾单独一位是下一个音节的声母)。
3141fn shuangpin_word_spells(table: &NineKeyShuangpin, canonical_pinyin: &str, digits: &str) -> bool {
3142    reading_syllable_count(canonical_pinyin) == digits.len().div_ceil(2)
3143        && table.matching_prefix_len(canonical_pinyin, digits) == Some(digits.len())
3144}
3145
3146/// 数字键上能作音节首字母的字母:i、u、v 不起头任何音节,词库也没有以它们命名的表,所以 4 只有 g、h,8 只有 t。
3147fn initials_for_digit(grid: KeyGrid, digit: u8) -> impl Iterator<Item = u8> {
3148    grid.letters(digit)
3149        .bytes()
3150        .filter(|letter| !matches!(letter, b'i' | b'u' | b'v'))
3151}
3152
3153/// 每个数字当一个音节的首字母时能拼出的全部简拼(`68` → mt、nt、ot);多于 `limit` 个,或有数字不是 2-9 时为 `None`。
3154fn initials_codes(grid: KeyGrid, digits: &str, limit: usize) -> Option<Vec<String>> {
3155    // 先检查完整展开的预算,拒绝时不创建任何中间前缀。
3156    let mut total = 1usize;
3157    for digit in digits.bytes() {
3158        let count = initials_for_digit(grid, digit).count();
3159        total = total.saturating_mul(count);
3160        if count == 0 || total > limit {
3161            return None;
3162        }
3163    }
3164    let mut codes = vec![String::with_capacity(digits.len())];
3165    for digit in digits.bytes() {
3166        codes = codes
3167            .iter()
3168            .flat_map(|code| {
3169                initials_for_digit(grid, digit).map(move |letter| {
3170                    let mut next = String::with_capacity(digits.len());
3171                    next.push_str(code);
3172                    next.push(char::from(letter));
3173                    next
3174                })
3175            })
3176            .collect();
3177    }
3178    Some(codes)
3179}
3180
3181fn take_candidate_canonical_pinyin(candidate: &mut WordItem) -> SharedString {
3182    if candidate.canonical_pinyin.is_empty() {
3183        candidate.pinyin.clone()
3184    } else {
3185        std::mem::take(&mut candidate.canonical_pinyin)
3186    }
3187}
3188
3189/// 英文九键前缀展开最多产生 320 行;用栈上借用表和索引表去重,释放借用后再原地压缩。
3190fn deduplicate_english_words(words: &mut Vec<WordItem>) {
3191    assert!(words.len() <= ENGLISH_CANDIDATE_CAPACITY);
3192    let mut duplicates = [0usize; ENGLISH_CANDIDATE_CAPACITY];
3193    let mut duplicate_len = 0;
3194    {
3195        let mut seen: [Option<&str>; ENGLISH_CANDIDATE_CAPACITY] =
3196            [None; ENGLISH_CANDIDATE_CAPACITY];
3197        let mut seen_len = 0;
3198        for (index, word) in words.iter().enumerate() {
3199            if seen[..seen_len]
3200                .iter()
3201                .flatten()
3202                .any(|existing| *existing == word.word.as_str())
3203            {
3204                duplicates[duplicate_len] = index;
3205                duplicate_len += 1;
3206            } else {
3207                seen[seen_len] = Some(word.word.as_str());
3208                seen_len += 1;
3209            }
3210        }
3211    }
3212    let mut next_duplicate = 0;
3213    let mut write = 0;
3214    for read in 0..words.len() {
3215        if next_duplicate < duplicate_len && duplicates[next_duplicate] == read {
3216            next_duplicate += 1;
3217            continue;
3218        }
3219        if write != read {
3220            words.swap(write, read);
3221        }
3222        write += 1;
3223    }
3224    words.truncate(write);
3225}
3226
3227/// 推入一行,除非同一个词已有一行排得不比它靠后。`leading` 记着每个词目前排得最靠前的那一行的排序键。被跳过的行在稳定排序后必然落在那一行之后(键更大,或键相同而推入更晚),会被 `retain_unique_words` 删掉;它也不会让别的行多删或少删,因为它能挡住的行那一行同样挡得住。所以跳过与全部推入再 `rank_candidates`,结果完全相同。前提是两边用同一个 `prefer_exact`。
3228fn push_ranked(
3229    candidates: &mut Vec<WordItem>,
3230    leading: &mut LeadingMap,
3231    candidate: WordItem,
3232    prefer_exact: bool,
3233    initials_lead: bool,
3234    grid: KeyGrid,
3235) {
3236    let key = rank_key(&candidate, prefer_exact, initials_lead, grid);
3237    let hash = leading_hash(&candidate.word);
3238    let existing = leading
3239        .get(&hash)
3240        .and_then(|slot| slot.find(candidates, &candidate.word));
3241    if let Some((slot, best)) = existing {
3242        if best <= key {
3243            return;
3244        }
3245        let candidate_index = candidates.len();
3246        candidates.push(candidate);
3247        leading
3248            .get_mut(&hash)
3249            .expect("leading hash entry remains present")
3250            .update(slot, candidate_index, key);
3251        return;
3252    }
3253    if leading.is_empty() {
3254        leading.reserve(CANDIDATE_LIMIT);
3255    }
3256    let candidate_index = candidates.len();
3257    candidates.push(candidate);
3258    let entry = LeadingEntry {
3259        candidate: candidate_index,
3260        key,
3261    };
3262    if let Some(slot) = leading.get_mut(&hash) {
3263        slot.insert(entry);
3264    } else {
3265        leading.insert(hash, LeadingSlot::One(entry));
3266    }
3267}
3268
3269/// 按 `rank_key` 稳定排序、按词去重,简拼交错和个人调频后再截断(NK:283-307)。
3270fn rank_candidates(
3271    candidates: &mut Vec<WordItem>,
3272    prefer_exact: bool,
3273    initials_lead: bool,
3274    boost: Option<&InitialsBoost>,
3275    grid: KeyGrid,
3276) {
3277    if candidates.len() <= SMALL_CANDIDATE_SORT {
3278        candidates.sort_by_key(|item| rank_key(item, prefer_exact, initials_lead, grid));
3279    } else {
3280        candidates.sort_by_cached_key(|item| rank_key(item, prefer_exact, initials_lead, grid));
3281    }
3282    retain_unique_words(candidates);
3283    if !initials_lead {
3284        interleave_initials(candidates);
3285    }
3286    if let Some(boost) = boost {
3287        boost_used_initials(candidates, initials_lead, boost);
3288    }
3289    candidates.truncate(CANDIDATE_LIMIT);
3290}
3291
3292/// 简拼行按用户用过的次数挪位(#6185),沿用调频的模式、触发次数和步长。
3293struct InitialsBoost {
3294    /// 用过的简拼词和它在个人上下文模型里的计数。
3295    used: HashMap<SharedString, u32>,
3296    frequency: FrequencyAdjustmentOptions,
3297}
3298
3299/// 把用户用过的简拼行往前挪(#6185)。简拼行都在覆盖全部数字的那一段词典行里(`rank_key` 先按覆盖、再按是否合成行排,所以这一段从列表开头算起),只在这一段里挪。每个用过的词从它现在的位置起,计数每够一次触发(`PERSONAL_PICK_TIMES * trigger_count`)就按调频模式算一次新位置(`ranking::ranking_target`:置顶到头、减半、按步长、提到第五位以内再逐位上移),与 26 键调频一次次挪位的结果相同。挪不过这一段开头留给音节行的位置:没打切分时前 `SYLLABLE_ROWS_BEFORE_INITIALS` 个音节行(最常用的单字)照旧在最前;打了切分时简拼行本来就领先,可以挪到最前。几个用过的词抢同一个位置时,原来靠前(权重高)的先占。只重排去重之后的列表,保留哪一行不受影响,`push_ranked` 的跳过规则照样成立。
3300fn boost_used_initials(candidates: &mut Vec<WordItem>, initials_lead: bool, boost: &InitialsBoost) {
3301    let Some(first) = candidates.first() else {
3302        return;
3303    };
3304    let coverage = first.pinyin.len();
3305    let end = candidates
3306        .iter()
3307        .take_while(|item| item.pinyin.len() == coverage && !item.source.is_generated_or_fallback())
3308        .count();
3309    let floor = if initials_lead {
3310        0
3311    } else {
3312        candidates[..end]
3313            .iter()
3314            .take(SYLLABLE_ROWS_BEFORE_INITIALS)
3315            .take_while(|item| !is_initials_row(item))
3316            .count()
3317    };
3318    let trigger_count = u32::try_from(boost.frequency.trigger_count.clamp(1, 10)).unwrap_or(1);
3319    let per_trigger = PERSONAL_PICK_TIMES * trigger_count;
3320    // (目标位置, 现在的位置)
3321    let mut moves = Vec::new();
3322    for (index, item) in candidates[..end].iter().enumerate() {
3323        if !is_initials_row(item) {
3324            continue;
3325        }
3326        let Some(&count) = boost.used.get(&item.word) else {
3327            continue;
3328        };
3329        let mut rank = index;
3330        // 每次触发至少挪一位(步长为 0 的线性模式除外,它一位也不挪),所以最多算 `index` 次。
3331        let triggers = usize::try_from(count / per_trigger).unwrap_or(usize::MAX);
3332        for _ in 0..triggers.min(index) {
3333            if rank == 0 {
3334                break;
3335            }
3336            rank = ranking::ranking_target(
3337                rank,
3338                boost.frequency.mode,
3339                boost.frequency.linear_step,
3340                false,
3341            );
3342        }
3343        let target = rank.max(floor);
3344        if target < index {
3345            moves.push((target, index));
3346        }
3347    }
3348    if moves.is_empty() {
3349        return;
3350    }
3351    // 先从后往前取出要挪的行(前面的下标不受影响),再按目标位置从前往后放回;同一个位置先放原来靠前的,后来的顺延一位。
3352    let mut rows: Vec<(usize, usize, WordItem)> = moves
3353        .iter()
3354        .rev()
3355        .map(|&(target, index)| (target, index, candidates.remove(index)))
3356        .collect();
3357    rows.sort_by_key(|&(target, index, _)| (target, index));
3358    let mut next = 0;
3359    for (target, _, row) in rows {
3360        let at = target.max(next);
3361        candidates.insert(at, row);
3362        next = at + 1;
3363    }
3364}
3365
3366/// 没打切分时,`rank_key` 把简拼行排在同样覆盖的全部音节行之后。两位数字的音节行常有几百行(`68` 的 mu、nu、nv、ou 在出货词库里有两百多个单字),截到 `CANDIDATE_LIMIT` 时简拼行会整个被截掉,明天、今天就再也出不来(#5640)。所以在截断前,每段同样覆盖的词典行里先留下最前面 `SYLLABLE_ROWS_BEFORE_INITIALS` 个音节行,其后的音节行和简拼行按权重归并:常用词排在生僻单字前面,常用单字仍排在少见的词前面。两边各自的先后不变,权重相同时音节行在前。只重排去重之后的列表,保留哪一行不受影响,`push_ranked` 的跳过规则照样成立。
3367fn interleave_initials(candidates: &mut Vec<WordItem>) {
3368    let mut start = 0;
3369    while start < candidates.len() {
3370        let coverage = candidates[start].pinyin.len();
3371        let generated = candidates[start].source.is_generated_or_fallback();
3372        let end = start
3373            + candidates[start..]
3374                .iter()
3375                .take_while(|item| {
3376                    item.pinyin.len() == coverage
3377                        && item.source.is_generated_or_fallback() == generated
3378                })
3379                .count();
3380        if !generated {
3381            // 这一段里音节行在前、简拼行在后(`rank_key` 的顺序)。
3382            let middle = start
3383                + candidates[start..end]
3384                    .iter()
3385                    .take_while(|item| !is_initials_row(item))
3386                    .count();
3387            let kept = start + SYLLABLE_ROWS_BEFORE_INITIALS;
3388            if kept < middle && middle < end {
3389                let mut syllables: Vec<WordItem> = candidates.drain(kept..end).collect();
3390                let initials = syllables.split_off(middle - kept);
3391                let mut syllables = syllables.into_iter().peekable();
3392                let mut initials = initials.into_iter().peekable();
3393                let mut merged = Vec::with_capacity(end - kept);
3394                loop {
3395                    let take_initial = match (syllables.peek(), initials.peek()) {
3396                        (Some(syllable), Some(initial)) => initial.weight > syllable.weight,
3397                        (None, Some(_)) => true,
3398                        (_, None) => false,
3399                    };
3400                    let next = if take_initial {
3401                        initials.next()
3402                    } else {
3403                        syllables.next()
3404                    };
3405                    match next {
3406                        Some(item) => merged.push(item),
3407                        None => break,
3408                    }
3409                }
3410                candidates.splice(kept..kept, merged);
3411            }
3412        }
3413        start = end;
3414    }
3415}
3416
3417fn retain_unique_words(candidates: &mut Vec<WordItem>) {
3418    if candidates.len() <= SMALL_CANDIDATE_DEDUP {
3419        let mut write = 0;
3420        for read in 0..candidates.len() {
3421            if candidates[..write]
3422                .iter()
3423                .any(|existing| existing.word == candidates[read].word)
3424            {
3425                continue;
3426            }
3427            if write != read {
3428                candidates.swap(write, read);
3429            }
3430            write += 1;
3431        }
3432        candidates.truncate(write);
3433        return;
3434    }
3435    let mut seen = HashSet::with_capacity(candidates.len());
3436    let duplicates = candidates
3437        .iter()
3438        .enumerate()
3439        .filter_map(|(index, item)| (!seen.insert(item.word.as_str())).then_some(index))
3440        .collect::<Vec<_>>();
3441    drop(seen);
3442    let mut duplicates = duplicates.into_iter().peekable();
3443    let mut write = 0;
3444    for read in 0..candidates.len() {
3445        if duplicates.peek() == Some(&read) {
3446            duplicates.next();
3447            continue;
3448        }
3449        if write != read {
3450            candidates.swap(write, read);
3451        }
3452        write += 1;
3453    }
3454    candidates.truncate(write);
3455}
3456
3457/// A word spelling the typed code exactly leads, but only when people type it: 64426 is 你好 and `ogham` is the only five-letter word those keys spell, so a zero-weight exact word gets no privilege. Then weight, then shorter (NK:199-220).
3458fn rank_english(words: &mut Vec<WordItem>, typed: usize) {
3459    let exact = |item: &WordItem| item.word.len() == typed && item.weight > 0;
3460    words.sort_by(|a, b| {
3461        exact(b)
3462            .cmp(&exact(a))
3463            .then_with(|| b.weight.cmp(&a.weight))
3464            .then_with(|| a.word.len().cmp(&b.word.len()))
3465    });
3466    words.truncate(ENGLISH_LIMIT);
3467}
3468
3469/// The digit code of an English word, ignoring case; a word with any non-letter has none.
3470#[cfg(test)]
3471fn digits_for_word(word: &str) -> String {
3472    let lowered = word.to_ascii_lowercase();
3473    if !lowered.bytes().all(|byte| byte.is_ascii_lowercase()) {
3474        return String::new();
3475    }
3476    KeyGrid::NineKey.encode(&lowered)
3477}
3478
3479#[cfg(test)]
3480fn word_matches_digits(grid: KeyGrid, word: &str, digits: &str) -> bool {
3481    let Some(keypad) = grid.keypad() else {
3482        return false;
3483    };
3484    let mut matched = 0;
3485    for byte in word.bytes() {
3486        let letter = byte.to_ascii_lowercase();
3487        if !letter.is_ascii_lowercase() {
3488            return false;
3489        }
3490        if let Some(&digit) = digits.as_bytes().get(matched) {
3491            if keypad[usize::from(letter - b'a')] != digit {
3492                return false;
3493            }
3494            matched += 1;
3495        }
3496    }
3497    matched == digits.len()
3498}
3499
3500/// `keyword`(小写字母)按键盘上的字母正好拼出 `digits`,不分配。
3501fn keyword_spells_digits(grid: KeyGrid, keyword: &str, digits: &str) -> bool {
3502    let Some(keypad) = grid.keypad() else {
3503        return match grid {
3504            KeyGrid::NineKeyShuangpin(table)
3505            | KeyGrid::FourteenKeyShuangpin(table)
3506            | KeyGrid::EighteenKeyShuangpin(table) => table.spells_keyword(keyword, digits),
3507            _ => false,
3508        };
3509    };
3510    keyword.len() == digits.len()
3511        && keyword.bytes().zip(digits.bytes()).all(|(letter, digit)| {
3512            letter.is_ascii_lowercase() && keypad[usize::from(letter - b'a')] == digit
3513        })
3514}
3515
3516/// `letter_prefixes` 展开到第几位数字、共多少个前缀。
3517fn letter_prefix_span(grid: KeyGrid, digits: &str, budget: usize) -> (usize, usize) {
3518    let mut count = 1;
3519    let mut depth = 0;
3520    for digit in digits.bytes() {
3521        let letters = grid.letters(digit);
3522        let width = letters.chars().count();
3523        if width == 0 || count > budget / width {
3524            break;
3525        }
3526        count *= width;
3527        depth += 1;
3528    }
3529    (depth, count)
3530}
3531
3532fn letter_prefix_depth(grid: KeyGrid, digits: &str, budget: usize) -> usize {
3533    letter_prefix_span(grid, digits, budget).0
3534}
3535
3536/// `word` 从第 `from` 位起逐位落在 `digits` 对应键的字母里,即 `english_pattern` 在已知前 `from` 位匹配时的剩余条件;大小写都算。
3537fn word_spells_from(grid: KeyGrid, word: &str, digits: &str, from: usize) -> bool {
3538    word.len() >= digits.len()
3539        && word.as_bytes()[from..digits.len()]
3540            .iter()
3541            .zip(&digits.as_bytes()[from..])
3542            .all(|(&letter, &digit)| {
3543                grid.letters(digit)
3544                    .as_bytes()
3545                    .contains(&letter.to_ascii_lowercase())
3546            })
3547}
3548
3549/// Every letter prefix of the leading digits, expanded while the count stays within `budget`.
3550fn letter_prefixes(grid: KeyGrid, digits: &str, budget: usize) -> Vec<String> {
3551    let (depth, count) = letter_prefix_span(grid, digits, budget);
3552    if depth == 0 {
3553        Vec::new()
3554    } else {
3555        fn visit(grid: KeyGrid, digits: &[u8], prefix: &mut String, result: &mut Vec<String>) {
3556            let Some((&digit, rest)) = digits.split_first() else {
3557                result.push(prefix.clone());
3558                return;
3559            };
3560            for letter in grid.letters(digit).chars() {
3561                prefix.push(letter);
3562                visit(grid, rest, prefix, result);
3563                prefix.pop();
3564            }
3565        }
3566
3567        let mut prefixes = Vec::with_capacity(count);
3568        let mut prefix = String::with_capacity(depth);
3569        visit(
3570            grid,
3571            &digits.as_bytes()[..depth],
3572            &mut prefix,
3573            &mut prefixes,
3574        );
3575        prefixes
3576    }
3577}
3578
3579/// The syllables and spelling pieces digits can stand for, built once from the intact syllable list.
3580pub(crate) struct SpellingTable {
3581    /// 码按哪种键盘算。
3582    grid: KeyGrid,
3583    /// Every complete syllable with its code, in list order.
3584    syllables: Vec<(String, String)>,
3585    /// Every prefix of every syllable, grouped by its code, with whether that piece is a complete syllable under this code. Partial syllables are included, but never letter combinations pinyin does not have (NK:52-68). 按字母归组的键盘上一个片段完不完整只看它是不是完整音节;九键双拼的 `a` 既是两位的完整音节,又是只打了一位的零声母片段,所以按码记。
3586    by_code: HashMap<String, Vec<(String, bool)>>,
3587    longest_code: usize,
3588}
3589
3590/// 九键英文的逐前缀查询在第四位数字以后占一次刷新的约两成(每位最多 64 条带排序的 SQL)。前缀组只由前几位数字决定,之后数字变长只是让匹配的词变少,所以把每个前缀下匹配的全部行留在内存里:数字只在末尾变长时按新增的位筛掉不再匹配的行,再按 `append_keypad_ranked` 取前五,不查 SQLite。行按数字内容复用,只有词库变了才会过时,而组字中改英文权重的只有选词学习,`consume` 和 `clear_composition` 因此清掉它。
3591struct EnglishKeypadCache {
3592    grid: KeyGrid,
3593    /// `letter_prefix_depth`:前缀组由前这么多位数字决定。
3594    depth: usize,
3595    /// `rows` 匹配的数字串。
3596    digits: String,
3597    /// 按 `letter_prefixes` 的顺序每个前缀一组;前缀组只出现过一次或某个前缀超过 `ENGLISH_CACHED_PREFIX_ROWS` 行时为 `None`。
3598    rows: Option<Vec<Vec<KeypadRow>>>,
3599}
3600
3601/// 搜索页只借用拼写表音节;每次切分从后向前完整覆写旧节点。
3602#[derive(Default)]
3603struct PathScratch<'a> {
3604    pages: Vec<Vec<SpellingPath<'a>>>,
3605}
3606
3607/// 中间路径只借用音节;后继索引指向已经定序、不会再改变的后页。
3608#[derive(Clone, Copy)]
3609struct SpellingPath<'a> {
3610    score: f64,
3611    complete: bool,
3612    piece: &'a str,
3613    next: (usize, usize),
3614    length: usize,
3615}
3616
3617impl<'a> SpellingPath<'a> {
3618    fn pieces<'s>(self, suffix: &'s [Vec<Self>]) -> impl Iterator<Item = &'a str> + 's
3619    where
3620        'a: 's,
3621    {
3622        let mut first = (self.length != 0).then_some(self.piece);
3623        let mut next = (self.length > 1).then_some(self.next);
3624        std::iter::from_fn(move || {
3625            if let Some(piece) = first.take() {
3626                return Some(piece);
3627            }
3628            let (column, row) = next?;
3629            let node = &suffix[column][row];
3630            next = (node.length > 1).then_some(node.next);
3631            Some(node.piece)
3632        })
3633    }
3634}
3635
3636impl SpellingTable {
3637    fn build(grid: KeyGrid, syllables: &[&str]) -> Self {
3638        let prefix_capacity = syllables.iter().map(|syllable| syllable.len()).sum();
3639        let intact: HashSet<&str> = syllables.iter().copied().collect();
3640        let mut seen = HashSet::with_capacity(prefix_capacity);
3641        let mut by_code: HashMap<String, Vec<(String, bool)>> =
3642            HashMap::with_capacity(prefix_capacity);
3643        let mut longest_code = 0;
3644        for syllable in syllables {
3645            for end in 1..=syllable.len() {
3646                let Some(prefix) = syllable.get(..end) else {
3647                    continue;
3648                };
3649                if seen.insert(prefix.to_string()) {
3650                    let code = grid.encode(prefix);
3651                    longest_code = longest_code.max(code.len());
3652                    by_code
3653                        .entry(code)
3654                        .or_default()
3655                        .push((prefix.to_string(), intact.contains(prefix)));
3656                }
3657            }
3658        }
3659        Self {
3660            grid,
3661            syllables: syllables
3662                .iter()
3663                .map(|syllable| (syllable.to_string(), grid.encode(syllable)))
3664                .collect(),
3665            by_code,
3666            longest_code,
3667        }
3668    }
3669
3670    /// 九键双拼的拼写表:完整音节是方案给的两位码,只打了一位的片段是声母(`zh`、`b`)和零声母音节的首字母(`a`、`e`、`o`),见 `NineKeyShuangpin::one_key_pieces`。
3671    fn build_shuangpin(grid: KeyGrid, table: &NineKeyShuangpin) -> Self {
3672        let mut syllables = Vec::new();
3673        let mut by_code: HashMap<String, Vec<(String, bool)>> = HashMap::new();
3674        for (syllable, code) in table.coded_syllables() {
3675            let code: String = code.iter().map(|&digit| char::from(digit)).collect();
3676            by_code
3677                .entry(code.clone())
3678                .or_default()
3679                .push((syllable.to_owned(), true));
3680            syllables.push((syllable.to_owned(), code));
3681        }
3682        for (piece, key) in table.one_key_pieces() {
3683            by_code
3684                .entry(char::from(key).to_string())
3685                .or_default()
3686                .push((piece.to_owned(), false));
3687        }
3688        Self {
3689            grid,
3690            syllables,
3691            by_code,
3692            longest_code: 2,
3693        }
3694    }
3695
3696    /// 有音节以这个字母开头。`i`、`u`、`v` 没有。
3697    fn starts_syllable(&self, letter: u8) -> bool {
3698        self.syllables
3699            .iter()
3700            .any(|(syllable, _)| syllable.as_bytes().first() == Some(&letter))
3701    }
3702
3703    /// Complete syllables the unlocked digits can start with, or that complete them, longest covered first (NK:238-250). Coverage is counted in digits; after the first digit, a reading already spelled in full comes before a longer completion within one coverage bucket.
3704    /// 有切分时,只有在切分处或之前结束的音节才算。
3705    ///
3706    /// 覆盖数字一样多时,已打两位以上的数字先按编码长度排(正好拼完的音节在前,要补键少的在前),编码一样长的按 `prior` 给的单字频度排,频度相同才按字典序;只打了一位数字时不比编码长度,直接按频度排。原先同组只按字典序,`74` 的左列是 pi、qi、ri、si,最常用的 qi 排不到前面,要补键的 sha、shai 也排在 shi、shu 前面(#6654)。
3707    #[cfg(test)]
3708    fn spellings_for(
3709        &self,
3710        remaining: &str,
3711        locked_length: usize,
3712        split: Option<usize>,
3713        prior: &SyllablePrior,
3714    ) -> Vec<String> {
3715        let mut spellings = Vec::new();
3716        let mut matches = Vec::new();
3717        self.spellings_for_into(
3718            remaining,
3719            locked_length,
3720            split,
3721            prior,
3722            &mut spellings,
3723            &mut matches,
3724        );
3725        spellings
3726    }
3727
3728    fn spellings_for_into(
3729        &self,
3730        remaining: &str,
3731        locked_length: usize,
3732        split: Option<usize>,
3733        prior: &SyllablePrior,
3734        destination: &mut Vec<String>,
3735        matches: &mut Vec<usize>,
3736    ) {
3737        matches.clear();
3738        if remaining.is_empty() {
3739            destination.clear();
3740            return;
3741        }
3742        matches.extend(
3743            self.syllables
3744                .iter()
3745                .enumerate()
3746                .filter_map(|(index, (_, code))| {
3747                    let matches = match split {
3748                        Some(split) => code.len() <= split && remaining.starts_with(code.as_str()),
3749                        None => {
3750                            locked_length + remaining.len().max(code.len()) <= DIGIT_LIMIT
3751                                && (remaining.starts_with(code.as_str())
3752                                    || code.starts_with(remaining))
3753                        }
3754                    };
3755                    matches.then_some(index)
3756                }),
3757        );
3758        let covered = |code: &str| code.len().min(remaining.len());
3759        matches.sort_by(|&a, &b| {
3760            let (a, a_code) = &self.syllables[a];
3761            let (b, b_code) = &self.syllables[b];
3762            covered(b_code)
3763                .cmp(&covered(a_code))
3764                .then_with(|| {
3765                    if remaining.len() > 1 {
3766                        a_code.len().cmp(&b_code.len())
3767                    } else {
3768                        Ordering::Equal
3769                    }
3770                })
3771                .then_with(|| prior.syllable_score(b).total_cmp(&prior.syllable_score(a)))
3772                .then_with(|| a.cmp(b))
3773        });
3774        // 双拼的同一读法可以有整组、精确与别名编码,左侧拼写只显示一次。
3775        matches.dedup_by(|a, b| self.syllables[*a].0 == self.syllables[*b].0);
3776        destination.truncate(matches.len());
3777        for (position, &index) in matches.iter().enumerate() {
3778            if let Some(spelling) = destination.get_mut(position) {
3779                spelling.clear();
3780                spelling.push_str(&self.syllables[index].0);
3781            } else {
3782                destination.push(self.syllables[index].0.clone());
3783            }
3784        }
3785    }
3786
3787    /// Syllable paths spelling `digits`, built from the end. A piece may end a path early only if it is a complete syllable; each offset keeps the 48 best by fewer syllables, complete last syllable, then lexicographic (NK:122-156). 不带频度,测试用。
3788    #[cfg(test)]
3789    fn paths(&self, digits: &str) -> Vec<Path> {
3790        self.split_paths(digits, &[], &SyllablePrior::default())
3791    }
3792
3793    /// 和 `paths` 一样,但每个 `splits` 位置(`digits` 里的下标)都必须有一个音节在那里结束,而且这个音节必须完整;同样音节数、同样收尾的路径按 `prior` 给的频度之和排,频度相同才按字典序。
3794    ///
3795    /// 每个位置只留 48 条,留哪些就决定了哪些读法还能查到。原先在字典序上截断,而 9 键是 wxyz、7 键是 pqrs,于是长串里 xi 开头的路径把 yi、zhe 开头的全部挤掉,`943426943426` 只剩「洗点一点」查不到「一点一点」,`7264685487` 只剩「盘后」查不到「然后」。
3796    #[cfg(test)]
3797    fn split_paths(&self, digits: &str, splits: &[usize], prior: &SyllablePrior) -> Vec<Path> {
3798        self.split_paths_limited(digits, splits, prior, PATH_LIMIT)
3799    }
3800
3801    /// 所有中间页仍保留完整搜索宽度,只按消费者上限物化最终路径。
3802    fn split_paths_limited(
3803        &self,
3804        digits: &str,
3805        splits: &[usize],
3806        prior: &SyllablePrior,
3807        output_limit: usize,
3808    ) -> Vec<Path> {
3809        let mut paths = Vec::new();
3810        self.split_paths_into(digits, splits, prior, output_limit, &mut paths);
3811        paths
3812    }
3813
3814    /// 将最终路径写入已有缓冲,保留路径和音节字符串的容量。
3815    fn split_paths_into(
3816        &self,
3817        digits: &str,
3818        splits: &[usize],
3819        prior: &SyllablePrior,
3820        output_limit: usize,
3821        destination: &mut Vec<Path>,
3822    ) {
3823        self.split_paths_with_scratch(
3824            digits,
3825            splits,
3826            prior,
3827            output_limit,
3828            &mut PathScratch::default(),
3829            destination,
3830        );
3831    }
3832
3833    fn split_paths_with_scratch<'a>(
3834        &'a self,
3835        digits: &str,
3836        splits: &[usize],
3837        prior: &SyllablePrior,
3838        output_limit: usize,
3839        scratch: &mut PathScratch<'a>,
3840        destination: &mut Vec<Path>,
3841    ) {
3842        let length = digits.len();
3843        if scratch.pages.is_empty() {
3844            scratch.pages = vec![Vec::new(); length + 1];
3845        } else {
3846            scratch.pages.resize_with(length + 1, Vec::new);
3847        }
3848        let suffix = &mut scratch.pages;
3849        if destination.is_empty() {
3850            // 冷输出的根页从空容器建立,保持消费式 collect 的原分配与容量。
3851            suffix[0] = Vec::new();
3852        }
3853        suffix[length].clear();
3854        suffix[length].push(SpellingPath {
3855            score: 0.0,
3856            complete: false,
3857            piece: "",
3858            next: (length, 0),
3859            length: 0,
3860        });
3861        for offset in (0..length).rev() {
3862            let mut result = std::mem::take(&mut suffix[offset]);
3863            result.clear();
3864            for end in offset + 1..=length.min(offset + self.longest_code) {
3865                if splits.iter().any(|&split| offset < split && split < end) {
3866                    break;
3867                }
3868                let Some(pieces) = self.by_code.get(&digits[offset..end]) else {
3869                    continue;
3870                };
3871                let must_complete = end != length || splits.contains(&end);
3872                for (piece, complete) in pieces {
3873                    if must_complete && !complete {
3874                        continue;
3875                    }
3876                    let score = prior.score(piece);
3877                    for (row, tail) in suffix[end].iter().enumerate() {
3878                        if result.is_empty() {
3879                            result.reserve_exact(PATH_LIMIT);
3880                        }
3881                        result.push(SpellingPath {
3882                            score: score + tail.score,
3883                            complete: if tail.length == 0 {
3884                                *complete
3885                            } else {
3886                                tail.complete
3887                            },
3888                            piece,
3889                            next: (end, row),
3890                            length: tail.length + 1,
3891                        });
3892                    }
3893                }
3894            }
3895            // 按码分组访问音节;比较项与原拥有路径相同,只在需要字典序时回溯音节。
3896            let compare = |a: &SpellingPath<'_>, b: &SpellingPath<'_>| {
3897                a.length
3898                    .cmp(&b.length)
3899                    .then_with(|| b.complete.cmp(&a.complete))
3900                    .then_with(|| b.score.total_cmp(&a.score))
3901                    .then_with(|| a.pieces(suffix).cmp(b.pieces(suffix)))
3902            };
3903            let keep = if offset == 0 {
3904                output_limit.min(PATH_LIMIT)
3905            } else {
3906                PATH_LIMIT
3907            };
3908            // 根页没有前驱消费者;同分同读法的路径输出相同,只选择最终采用的前几条。
3909            if keep < PATH_LIMIT && result.len() > keep {
3910                result.select_nth_unstable_by(keep, compare);
3911                result.truncate(keep);
3912            }
3913            result.sort_by(compare);
3914            result.truncate(keep);
3915            suffix[offset] = result;
3916        }
3917        // 冷输出仍消费根页,让 collect 复用原分配;已有输出时才保留根页容量。
3918        if destination.is_empty() {
3919            let roots = std::mem::take(&mut suffix[0]);
3920            *destination = roots
3921                .into_iter()
3922                .take(output_limit)
3923                .map(|root| {
3924                    let mut path = Vec::with_capacity(root.length);
3925                    path.extend(root.pieces(suffix).map(str::to_owned));
3926                    path
3927                })
3928                .collect();
3929            return;
3930        }
3931        let roots = &suffix[0];
3932        let output_len = roots.len().min(output_limit);
3933        destination.reserve(output_len.saturating_sub(destination.len()));
3934        destination.truncate(output_len);
3935        for (index, root) in roots.iter().copied().take(output_limit).enumerate() {
3936            let path = if let Some(path) = destination.get_mut(index) {
3937                path
3938            } else {
3939                destination.push(Vec::with_capacity(root.length));
3940                destination.last_mut().expect("new path")
3941            };
3942            path.truncate(root.length);
3943            path.reserve(root.length.saturating_sub(path.len()));
3944            for (piece_index, piece) in root.pieces(suffix).enumerate() {
3945                if piece_index == path.len() {
3946                    path.push(String::new());
3947                }
3948                path[piece_index].clear();
3949                path[piece_index].push_str(piece);
3950            }
3951            path.truncate(root.length);
3952        }
3953    }
3954}
3955
3956/// 音节和音节前缀的频度:音节取内嵌的音节频度表(`SYLLABLE_FREQUENCY`,单字行的权重加上音节在多字词里每出现一次计一次的词的权重)取对数,前缀取以它开头的音节里最高的那个。九宫格用它决定每个位置留哪 48 条切分路径、种子接哪几条尾段和左列音节的先后,候选本身的先后仍由词库和整句解码决定。
3957///
3958/// 原先只从词库的单字行估:先是最常用的那一个字,dict-v2.0.30 调单字权重后 na 和 ma 的先后翻了过来,改成前三个字之和。dict-v2.0.39 起单字行记的是这个字单用的次数,「饭、午、物」这类多半出现在词里的字权重掉到原来的一成上下(fan 前三个字合计 3234183 → 374521,wu 6572932 → 1131635),而 he、hao 涨了七倍;种子接尾段时 `94298` 的 xia'wu 排在 xia'yu、xia'zu、zha'yu 之后被丢掉,「我们几个人下午一起去吃饭」成了「下雨」。单字行到底记的是什么,随词库怎么分单字和词的权重而变;音节在全部词条里出现的总权重不随它变,两版词库按它排的音节先后几乎一致(421 个音节的秩相关 0.977,只看前三个单字时 0.780)。表是内嵌的,不从运行时的词库现算:现算要扫一遍全部多字词,第一次按键等不起。
3959#[derive(Default)]
3960struct SyllablePrior<'a> {
3961    scores: HashMap<&'a str, f64>,
3962    /// 完整音节自己的频度,不取以它开头的更长音节的:左列里 pin 不该沾 ping 的光。
3963    syllables: HashMap<&'a str, f64>,
3964}
3965
3966impl<'a> SyllablePrior<'a> {
3967    /// 按内嵌的音节频度表给 `table` 的音节估频度。
3968    fn shipped(table: &'a SpellingTable) -> SyllablePrior<'a> {
3969        Self::from_weights(&table.syllables, shipped_syllable_frequency())
3970    }
3971
3972    fn from_weights(syllables: &'a [(String, String)], weights: &HashMap<String, i64>) -> Self {
3973        let total: f64 = syllables
3974            .iter()
3975            .map(|(syllable, _)| weights.get(syllable).copied().unwrap_or(0).max(1) as f64)
3976            .sum();
3977        let prefix_capacity = syllables.iter().map(|(syllable, _)| syllable.len()).sum();
3978        let mut scores: HashMap<&str, f64> = HashMap::with_capacity(prefix_capacity);
3979        let mut own: HashMap<&str, f64> = HashMap::with_capacity(syllables.len());
3980        for (syllable, _) in syllables {
3981            // 没有单字行的音节和权重为 0 的一样按 1 算。
3982            let weight = weights.get(syllable).copied().unwrap_or(0).max(1) as f64;
3983            let score = (weight / total).ln();
3984            own.insert(syllable.as_str(), score);
3985            for end in 1..=syllable.len() {
3986                let Some(prefix) = syllable.get(..end) else {
3987                    continue;
3988                };
3989                let slot = scores.entry(prefix).or_insert(score);
3990                if *slot < score {
3991                    *slot = score;
3992                }
3993            }
3994        }
3995        Self {
3996            scores,
3997            syllables: own,
3998        }
3999    }
4000
4001    fn score(&self, piece: &str) -> f64 {
4002        self.scores.get(piece).copied().unwrap_or(0.0)
4003    }
4004
4005    fn syllable_score(&self, syllable: &str) -> f64 {
4006        self.syllables.get(syllable).copied().unwrap_or(0.0)
4007    }
4008}
4009
4010/// 解析内嵌的音节频度表,进程里只解析一次。`#` 开头的是来源说明;其余每行都必须是 `音节<TAB>权重`,表是编进二进制的,生成坏了要在测试里就炸,不能悄悄退化成全部按 1 算的均匀频度。
4011fn shipped_syllable_frequency() -> &'static HashMap<String, i64> {
4012    static TABLE: OnceLock<HashMap<String, i64>> = OnceLock::new();
4013    TABLE.get_or_init(|| {
4014        SYLLABLE_FREQUENCY
4015            .lines()
4016            .filter(|line| !line.starts_with('#'))
4017            .map(|line| {
4018                let (syllable, weight) = line
4019                    .split_once('\t')
4020                    .expect("syllable_frequency.tsv: syllable<TAB>weight");
4021                let weight = weight
4022                    .parse()
4023                    .expect("syllable_frequency.tsv: integer weight");
4024                (syllable.to_owned(), weight)
4025            })
4026            .collect()
4027    })
4028}
4029
4030#[cfg(test)]
4031mod dominated_projection_tests;
4032#[cfg(test)]
4033mod filter_projection_tests;
4034#[cfg(test)]
4035mod isolated_test;
4036#[cfg(test)]
4037mod leading_entry_refresh_tests;
4038#[cfg(test)]
4039mod leading_entry_tests;
4040
4041#[cfg(test)]
4042mod cached_rank_tests;
4043#[cfg(test)]
4044mod english_cache_tests;
4045#[cfg(test)]
4046mod syllable_count_tests;
4047
4048#[cfg(test)]
4049mod initials_code_tests;
4050
4051#[cfg(test)]
4052mod path_chain_tests;
4053
4054#[cfg(test)]
4055mod path_buffer_tests;
4056
4057#[cfg(test)]
4058mod path_scratch_tests;
4059
4060#[cfg(test)]
4061mod seed_tail_tests;
4062
4063#[cfg(test)]
4064mod seed_dedup_tests;
4065
4066#[cfg(test)]
4067mod code_prefix_tests;
4068
4069#[cfg(test)]
4070mod spelling_buffer_tests;
4071
4072#[cfg(test)]
4073mod tests {
4074    use super::*;
4075    use crate::types::{CandidateSource, NineKeyShuangpinKind, DEFAULT_FIRST_PAGE_ROWS};
4076    use rusqlite::Connection;
4077
4078    // 九键的单测按电话键盘写,这几个函数固定取九键。
4079    fn encode(pinyin: &str) -> String {
4080        KeyGrid::NineKey.encode(pinyin)
4081    }
4082
4083    fn word_matches_digits(word: &str, digits: &str) -> bool {
4084        super::word_matches_digits(KeyGrid::NineKey, word, digits)
4085    }
4086
4087    fn initials_codes(digits: &str, limit: usize) -> Option<Vec<String>> {
4088        super::initials_codes(KeyGrid::NineKey, digits, limit)
4089    }
4090
4091    fn letter_prefixes(digits: &str, budget: usize) -> Vec<String> {
4092        super::letter_prefixes(KeyGrid::NineKey, digits, budget)
4093    }
4094
4095    fn spelling_table() -> &'static SpellingTable {
4096        KeyGrid::NineKey.spelling_table()
4097    }
4098
4099    #[test]
4100    fn spelling_match_indices_reuse_storage() {
4101        let table = spelling_table();
4102        let prior = SyllablePrior::default();
4103        let mut destination = Vec::with_capacity(128);
4104        let mut matches = Vec::new();
4105        table.spellings_for_into("64", 0, None, &prior, &mut destination, &mut matches);
4106        let (_, allocations) = crate::ime::personal_rerank::allocations::count(|| {
4107            table.spellings_for_into("64", 0, None, &prior, &mut destination, &mut matches);
4108        });
4109        assert!(
4110            allocations <= destination.len() + 1,
4111            "拼写匹配刷新不应重新申请索引工作区:{allocations} 次分配,{} 个读音",
4112            destination.len()
4113        );
4114    }
4115
4116    #[test]
4117    fn syllable_prior_does_not_clone_static_spelling_keys() {
4118        let table = spelling_table();
4119        let weights = HashMap::new();
4120        let (_, allocations) = crate::ime::personal_rerank::allocations::count(|| {
4121            SyllablePrior::from_weights(&table.syllables, &weights)
4122        });
4123        assert!(
4124            allocations <= 4,
4125            "静态九键音节表的频度索引不应复制音节和前缀键:{allocations} 次分配"
4126        );
4127    }
4128
4129    fn fitting_syllables(prefix: &str, digits: &str) -> (usize, usize, bool) {
4130        super::fitting_syllables(KeyGrid::NineKey, prefix, digits)
4131    }
4132
4133    fn next_seeds(
4134        candidates: &[WordItem],
4135        digits: &str,
4136        previous: &mut Vec<String>,
4137        seeds: &mut Vec<String>,
4138    ) {
4139        super::next_seeds(KeyGrid::NineKey, candidates, digits, previous, seeds);
4140    }
4141
4142    impl SpellingTable {
4143        fn new(syllables: &[&str]) -> Self {
4144            Self::build(KeyGrid::NineKey, syllables)
4145        }
4146    }
4147
4148    // ---- Pure helpers ----
4149
4150    #[test]
4151    fn codes_follow_the_keypad() {
4152        assert_eq!(encode("ni'hao"), "64426");
4153        assert_eq!(encode("zhuang"), "948264");
4154        assert_eq!(digits_for_word("OK"), "65");
4155        assert_eq!(digits_for_word("ogham"), "64426");
4156        assert_eq!(digits_for_word("don't"), "");
4157        assert_eq!(digits_for_word("café"), "");
4158        assert!(word_matches_digits("OK", "65"));
4159        assert!(word_matches_digits("ogham", "64426"));
4160        assert!(!word_matches_digits("ogham", "64427"));
4161        assert!(!word_matches_digits("don't", "3668"));
4162        assert!(!word_matches_digits("ogham", "644260"));
4163    }
4164
4165    #[test]
4166    fn initials_codes_take_one_initial_per_digit() {
4167        assert_eq!(initials_codes("68", 1024).unwrap(), ["mt", "nt", "ot"]);
4168        // 4 上的 i、8 上的 u 和 v 不起头任何音节。
4169        assert_eq!(initials_codes("48", 1024).unwrap(), ["gt", "ht"]);
4170        assert_eq!(initials_codes("2356", 1024).unwrap().len(), 3 * 3 * 3 * 3);
4171        assert!(initials_codes("2356", 1024)
4172            .unwrap()
4173            .contains(&"cflm".to_string()));
4174        assert_eq!(initials_codes("99999", 1024).unwrap().len(), 1024);
4175        assert!(initials_codes("999999", 1024).is_none(), "past the limit");
4176        assert!(initials_codes("61", 1024).is_none(), "1 spells no letter");
4177    }
4178
4179    #[test]
4180    fn initials_apply_only_when_every_digit_can_be_a_syllable() {
4181        assert!(
4182            !initials_apply(1, &[]),
4183            "one digit already completes by its initial"
4184        );
4185        assert!(initials_apply(2, &[]));
4186        assert!(initials_apply(2, &[1]));
4187        assert!(initials_apply(5, &[1, 2, 3, 4]));
4188        assert!(
4189            initials_apply(3, &[1, 2, 3]),
4190            "a trailing split after one digit"
4191        );
4192        assert!(!initials_apply(3, &[2]), "94'2: 94 is one syllable");
4193        assert!(!initials_apply(2, &[2]), "68': 68 is one syllable");
4194        assert!(!initials_apply(3, &[1]), "6'84: 84 is one syllable");
4195    }
4196
4197    #[test]
4198    fn letter_prefixes_stop_at_the_budget() {
4199        assert_eq!(letter_prefixes("6", 64), ["m", "n", "o"]);
4200        let prefixes = letter_prefixes("64426", 64);
4201        assert_eq!(prefixes.len(), 27);
4202        assert_eq!(prefixes.first().map(String::as_str), Some("mgg"));
4203        assert_eq!(prefixes.last().map(String::as_str), Some("oii"));
4204        assert_eq!(letter_prefixes("79", 64).len(), 16);
4205        assert!(letter_prefixes("1", 64).is_empty());
4206        assert!(letter_prefixes("", 64).is_empty());
4207    }
4208
4209    #[test]
4210    fn letter_prefixes_do_not_allocate_discarded_intermediate_levels() {
4211        let (_, allocations) =
4212            crate::ime::personal_rerank::allocations::count(|| letter_prefixes("64426", 64));
4213        assert!(
4214            allocations <= 30,
4215            "letter prefix expansion allocated {allocations} buffers"
4216        );
4217    }
4218
4219    #[test]
4220    fn paths_prefer_fewer_syllables_then_a_complete_tail() {
4221        let table = SpellingTable::new(&["ni", "mi", "o", "ma", "hao", "gao"]);
4222        // Fewer syllables first; an incomplete piece (`g`, `h`) may only end the path.
4223        assert_eq!(
4224            table.paths("64"),
4225            [vec!["mi"], vec!["ni"], vec!["o", "g"], vec!["o", "h"]]
4226        );
4227        // `o` is a syllable, `m` and `n` are only pieces of one: a complete tail comes first at equal length.
4228        assert_eq!(table.paths("6"), [vec!["o"], vec!["m"], vec!["n"]]);
4229        let paths = table.paths("64426");
4230        assert_eq!(
4231            paths[..4],
4232            [
4233                vec!["mi", "gao"],
4234                vec!["mi", "hao"],
4235                vec!["ni", "gao"],
4236                vec!["ni", "hao"]
4237            ]
4238        );
4239        // A piece may only end the path: `ni'ha'o` would need `ha`, which is not a syllable here.
4240        assert_eq!(paths.len(), 4);
4241        assert!(table.paths("11").is_empty());
4242    }
4243
4244    #[test]
4245    fn unreadable_paths_do_not_reserve_each_offset_page() {
4246        let table = SpellingTable::new(&["ni", "mi", "o"]);
4247        let digits = "1".repeat(32);
4248        let (paths, allocations) =
4249            crate::ime::personal_rerank::allocations::count(|| table.paths(&digits));
4250        assert!(paths.is_empty());
4251        assert!(
4252            allocations <= 2,
4253            "死路偏移不应逐偏移申请路径页:{allocations}"
4254        );
4255    }
4256
4257    #[test]
4258    fn paths_keep_at_most_the_limit_per_offset() {
4259        let table = SpellingTable::new(&["m", "n", "o"]);
4260        let paths = table.paths("66666");
4261        assert_eq!(paths.len(), PATH_LIMIT);
4262        assert_eq!(paths[0], ["m", "m", "m", "m", "m"]);
4263        assert!(paths.iter().all(|path| path.len() == 5));
4264    }
4265
4266    #[test]
4267    fn the_limit_keeps_frequent_syllables_rather_than_early_letters() {
4268        let table = SpellingTable::new(&["xi", "yi", "dian"]);
4269        let weights = HashMap::from([
4270            ("xi".to_string(), 1_000),
4271            ("yi".to_string(), 1_000_000),
4272            ("dian".to_string(), 10_000),
4273        ]);
4274        let prior = SyllablePrior::from_weights(&table.syllables, &weights);
4275        // 94 3426 重复七次,每个位置都要截到 48 条:按字典序截断时第一个音节全是 xi,一点一点……不在里面。
4276        let digits = "943426".repeat(7);
4277        let paths = table.split_paths(&digits, &[], &prior);
4278        assert_eq!(paths.len(), PATH_LIMIT);
4279        assert_eq!(paths[0], ["yi", "dian"].repeat(7));
4280        assert!(table
4281            .split_paths(&digits, &[], &SyllablePrior::default())
4282            .iter()
4283            .all(|path| path[0] == "xi"));
4284    }
4285
4286    #[test]
4287    fn a_prefix_scores_as_its_best_syllable() {
4288        let table = SpellingTable::new(&["shi", "si", "ran"]);
4289        let weights = HashMap::from([("shi".to_string(), 5_000), ("si".to_string(), 50)]);
4290        let prior = SyllablePrior::from_weights(&table.syllables, &weights);
4291        // 分母是各音节权重之和,没有单字行的 ran 按 1 算:5000 + 50 + 1。
4292        let total = 5_051f64;
4293        assert_eq!(prior.score("s"), (5_000f64 / total).ln());
4294        assert_eq!(prior.score("sh"), (5_000f64 / total).ln());
4295        assert_eq!(prior.score("si"), (50f64 / total).ln());
4296        assert_eq!(prior.score("ran"), (1f64 / total).ln());
4297    }
4298
4299    #[test]
4300    fn sentences_from_different_paths_compare_without_the_phrase_bonus() {
4301        let bonus = (PHRASE_LENGTH_BONUS * 1000.0) as i64;
4302        // 五个音节的读法每个音节都在词组里,多拿一份奖励;扣掉之后四个音节的读法分更高就该排在前面。
4303        let mut longer = item(
4304            "洗噩梦算吗",
4305            "9436364782662",
4306            5 * bonus - 30_000,
4307            CandidateSource::Generated,
4308        );
4309        longer.canonical_pinyin = "xi'e'meng'suan'ma".into();
4310        let mut shorter = item(
4311            "这能算吗",
4312            "9436364782662",
4313            4 * bonus - 20_000,
4314            CandidateSource::Generated,
4315        );
4316        shorter.canonical_pinyin = "zhe'neng'suan'ma".into();
4317        assert!(longer.weight > shorter.weight);
4318        assert_eq!(
4319            ranked(vec![longer, shorter], false),
4320            ["这能算吗", "洗噩梦算吗"]
4321        );
4322        // 词库行不带这份奖励,按原权重比。
4323        let mut word = item("西安", "9426", 500, CandidateSource::Database);
4324        word.canonical_pinyin = "xi'an".into();
4325        assert_eq!(comparable_weight(&word), 500);
4326    }
4327
4328    /// 每词插入代价由词网格计入,跨切分比较不能再扣一次。
4329    #[test]
4330    fn phrase_insertion_cost_is_not_deducted_again_across_paths() {
4331        let bonus = (PHRASE_LENGTH_BONUS * 1000.0) as i64;
4332        let sentence = |word: &str, pinyin: &str, words: &[&str], weight: i64| {
4333            let mut row = item(
4334                word,
4335                "64646484268966566262",
4336                weight,
4337                CandidateSource::Generated,
4338            );
4339            row.canonical_pinyin = pinyin.into();
4340            row.sentence_words = words.iter().map(|word| (*word).to_owned()).collect();
4341            row
4342        };
4343        // 两句都是六个音节;输入分已含三份和两份词组插入代价,九键只扣相同的六份音节奖励。
4344        let phrases = sentence(
4345            "匿名天佑龙马",
4346            "ni'ming'tian'you'long'ma",
4347            &["匿名", "天佑", "龙马"],
4348            40_000 - 3 * bonus,
4349        );
4350        let plain = sentence(
4351            "你明天有空吗",
4352            "ni'ming'tian'you'kong'ma",
4353            &["你", "明天", "有空", "吗"],
4354            30_000 - 2 * bonus,
4355        );
4356        assert_eq!(comparable_weight(&phrases), 40_000 - 9 * bonus);
4357        assert_eq!(comparable_weight(&plain), 30_000 - 8 * bonus);
4358        assert_eq!(
4359            ranked(vec![phrases, plain], false),
4360            ["你明天有空吗", "匿名天佑龙马"]
4361        );
4362    }
4363
4364    fn path(syllables: &[&str]) -> Path {
4365        syllables
4366            .iter()
4367            .map(|syllable| (*syllable).to_owned())
4368            .collect()
4369    }
4370
4371    #[test]
4372    fn seeds_take_distinct_prefixes_then_keep_old_ones_that_still_fit() {
4373        let digits = encode("wo'men'yi'qi");
4374        let full = |word: &str, pinyin: &str| {
4375            let mut row = item(word, &digits, 100, CandidateSource::Generated);
4376            row.canonical_pinyin = pinyin.into();
4377            row
4378        };
4379        let mut fuzzy = full("我们一起", "wo'men'yi'qi");
4380        fuzzy.fuzzy = true;
4381        let candidates = vec![
4382            fuzzy,
4383            full("我们一起", "wo'men'yi'qi"),
4384            // 和上一行只差最后一个音节,前半截相同,不再当一条种子。
4385            full("我们一批", "wo'men'yi'pi"),
4386            // 只覆盖一部分数字的行不算。
4387            item("我们", "96636", 100, CandidateSource::Database),
4388            full("我们洗漆", "wo'men'xi'qi"),
4389        ];
4390        let previous = vec![
4391            // 和这次的第一条完全相同:直接挪过来,不复制。
4392            "wo'men'yi'qi".to_owned(),
4393            // 前半截对不上数字。
4394            "ni'men".to_owned(),
4395            "wo'men'yi'pi'a".to_owned(),
4396        ];
4397        let mut previous = previous;
4398        let reused = previous[0].as_ptr();
4399        let mut seeds = vec!["stale".to_owned()];
4400        next_seeds(&candidates, &digits, &mut previous, &mut seeds);
4401        assert_eq!(seeds, ["wo'men'yi'qi", "wo'men'xi'qi", "wo'men'yi'pi'a"]);
4402        assert_eq!(seeds[0].as_ptr(), reused);
4403        assert!(previous.is_empty());
4404        // 没有覆盖全部数字的行时全靠留下来的种子。
4405        next_seeds(&[], &digits, &mut vec!["wo'men'yi".to_owned()], &mut seeds);
4406        assert_eq!(seeds, ["wo'men'yi"]);
4407    }
4408
4409    #[test]
4410    fn seed_prefixes_fit_digits_syllable_by_syllable() {
4411        assert_eq!(seed_prefix("wo'men'yi"), "wo'men");
4412        assert_eq!(seed_prefix("wo"), "");
4413        let digits = encode("wo'men'yi'qi");
4414        assert_eq!(fitting_syllables("wo'men", &digits), (2, 5, true));
4415        // ne 的 63 也是 men 的开头,接下来的 yi 就对不上了。
4416        assert_eq!(fitting_syllables("wo'ne'yi", &digits), (2, 4, false));
4417        assert_eq!(fitting_syllables("wo'mo", &digits), (1, 2, false));
4418        assert_eq!(fitting_syllables("", &digits), (0, 0, true));
4419        assert_eq!(fitting_syllables("wo'men'yi'qi'a", &digits), (4, 9, false));
4420    }
4421
4422    #[test]
4423    fn seeded_paths_extend_the_fitting_prefix_with_tail_splits() {
4424        let table = SpellingTable::new(&["wo", "men", "yi", "qi", "xi", "pi"]);
4425        let prior = SyllablePrior::default();
4426        let remaining = encode("wo'men'yi'qi");
4427        let mut alternatives = vec![path(&["wo", "men", "xi", "pi"])];
4428        seeded_paths(
4429            &mut alternatives,
4430            // 第二条的前半截对不上数字,不加。
4431            &["wo'men'yi".to_owned(), "xi'men'yi".to_owned()],
4432            &remaining,
4433            &table,
4434            &prior,
4435        );
4436        // 种子去掉最后一个音节剩 wo'men,余下的 9474 按切分先后取前三条,已经在的不重复。
4437        assert_eq!(
4438            alternatives,
4439            [
4440                path(&["wo", "men", "xi", "pi"]),
4441                path(&["wo", "men", "xi", "qi"]),
4442                path(&["wo", "men", "yi", "pi"]),
4443            ]
4444        );
4445        // 前半截正好拼完全部数字时加它自己。
4446        let mut whole = Vec::new();
4447        seeded_paths(
4448            &mut whole,
4449            &["wo'men'yi'qi'a".to_owned()],
4450            &remaining,
4451            &table,
4452            &prior,
4453        );
4454        assert_eq!(whole, [path(&["wo", "men", "yi", "qi"])]);
4455    }
4456
4457    /// 「吃饭」之后再按 `22`,种子 chi'fan 去掉末音节接 `32622` 的三条尾段按频度是 dao'ba、dan'ba、dao'ca,fan'ba 排第四;种子的末音节 fan 整个拼得出,再接一条以它开头的尾段,「吃饭吧」的切分就不丢。
4458    #[test]
4459    fn seed_tails_keep_the_seed_last_syllable_when_it_is_spelled_in_full() {
4460        let table = KeyGrid::NineKey.spelling_table();
4461        let prior = SyllablePrior::shipped(table);
4462        let remaining = encode("chi'fan'ba");
4463        let seeds = ["chi'fan".to_owned()];
4464        let tail = &remaining[encode("chi").len()..];
4465        assert!(!table
4466            .split_paths_limited(tail, &[], &prior, SEED_TAIL_LIMIT)
4467            .contains(&path(&["fan", "ba"])));
4468        let mut alternatives = Vec::new();
4469        seeded_paths(&mut alternatives, &seeds, &remaining, table, &prior);
4470        assert!(alternatives.contains(&path(&["chi", "fan", "ba"])));
4471        assert_eq!(alternatives.len(), SEED_TAIL_LIMIT + 1);
4472    }
4473
4474    /// 种子接尾段时只取频度最高的 `SEED_TAIL_LIMIT` 条,两个音节各有一对读法(ma/na、ji/li)时四种组合留三种。只按最常用的一个字估频度时(dict-v2.0.32 的单字权重),吗 比 那 高 1.5%,尾段取 ma'ji、na'ji、ma'li,na'li 排第四;内嵌的音节频度表里 na 远比 ma 常见,na'li 留在尾段里。
4475    #[test]
4476    fn seed_tails_keep_a_reading_whose_best_character_barely_loses() {
4477        let table = KeyGrid::NineKey.spelling_table();
4478        let remaining = encode("wo'men'jin'tian'qu'na'li");
4479        let seeds = ["wo'men'jin'tian'qu'na".to_owned()];
4480        let wanted = path(&["wo", "men", "jin", "tian", "qu", "na", "li"]);
4481        let tails = |prior: &SyllablePrior| {
4482            let mut alternatives = Vec::new();
4483            seeded_paths(&mut alternatives, &seeds, &remaining, table, prior);
4484            alternatives
4485        };
4486        // dict-v2.0.32 各音节最常用的那个字的权重;末尾的 ji 按前缀取以它开头的 jiu 的频度,jiu 的权重是编的。
4487        let best: HashMap<String, i64> = [
4488            ("na", 5_580_163),
4489            ("ma", 5_664_547),
4490            ("li", 7_995_093),
4491            ("ji", 2_726_986),
4492            ("jiu", 8_500_000),
4493        ]
4494        .into_iter()
4495        .map(|(syllable, weight)| (syllable.to_owned(), weight))
4496        .collect();
4497        assert!(!tails(&SyllablePrior::from_weights(&table.syllables, &best)).contains(&wanted));
4498        assert!(tails(&SyllablePrior::shipped(table)).contains(&wanted));
4499    }
4500
4501    #[test]
4502    fn shipped_syllable_frequency_covers_every_spelling_and_names_its_source() {
4503        // 第一行记着取自哪份 msime-pinyin.db,和那一版词库发布锁里的 sha256 对得上;换词库重新生成时一起换。
4504        let source = SYLLABLE_FREQUENCY
4505            .lines()
4506            .next()
4507            .and_then(|line| line.strip_prefix("# msime-pinyin.db sha256="))
4508            .expect("source line");
4509        assert!(source.len() == 64 && source.bytes().all(|byte| byte.is_ascii_hexdigit()));
4510        // 只缺 jv、lue 这类别写:词库行记在 ju、lve 这些正写下,原先现查单字时别写同样查不到、按 1 算。
4511        let weights = shipped_syllable_frequency();
4512        let mut missing: Vec<&str> = KeyGrid::NineKey
4513            .spelling_table()
4514            .syllables
4515            .iter()
4516            .map(|(syllable, _)| syllable.as_str())
4517            .filter(|syllable| weights.get(*syllable).is_none_or(|weight| *weight <= 0))
4518            .collect();
4519        missing.sort_unstable();
4520        assert_eq!(
4521            missing,
4522            ["jv", "jve", "lue", "nue", "qv", "qve", "xv", "xve", "yv", "yve"]
4523        );
4524    }
4525
4526    /// dict-v2.0.39 的单字行记的是字单用的次数:wu 前三个字(无 五 伍)合计 1131635,yu(与 于 雨)18021628,「午」根本不在前三。种子 wo'men'ji'ge'ren 接 `94298` 的三条尾段只按单字估时是 xia'yu、xia'zu、zha'yu,「我们几个人下午一起去吃饭」的 xia'wu 排第五被丢掉,之后每一键都接不回来,首选成了「下雨」。音节频度表把 下午、午饭 里的 wu 也算上,xia'wu 留在前三。
4527    #[test]
4528    fn seed_tails_keep_a_syllable_that_mostly_occurs_inside_words() {
4529        // 完整音节也按前缀取以它开头的音节里最高的频度,所以 xu 沾 xue、zu 沾 zuo、yu 沾 yue 的光。
4530        let table =
4531            SpellingTable::new(&["xia", "zha", "wu", "yu", "yue", "xu", "xue", "zu", "zuo"]);
4532        let tail = encode("xia'wu");
4533        let wanted = path(&["xia", "wu"]);
4534        // dict-v2.0.39 各音节权重最高的三个单字之和,即原先的音节频度口径。
4535        let characters: HashMap<String, i64> = [
4536            ("xia", 4_490_788),
4537            ("zha", 175_682),
4538            ("wu", 1_131_635),
4539            ("yu", 18_021_628),
4540            ("yue", 6_441_425),
4541            ("xu", 271_070),
4542            ("xue", 1_673_519),
4543            ("zu", 685_682),
4544            ("zuo", 7_592_605),
4545        ]
4546        .into_iter()
4547        .map(|(syllable, weight)| (syllable.to_owned(), weight))
4548        .collect();
4549        let tails = |weights: &HashMap<String, i64>| {
4550            let prior = SyllablePrior::from_weights(&table.syllables, weights);
4551            table.split_paths_limited(&tail, &[], &prior, SEED_TAIL_LIMIT)
4552        };
4553        // 只按单字:xia'yu、xia'zu、xia'xu,xia'wu 排第四。
4554        assert!(!tails(&characters).contains(&wanted));
4555        assert!(tails(shipped_syllable_frequency()).contains(&wanted));
4556    }
4557
4558    /// 末尾只按了一个音节开头的数字、没有覆盖全部数字的行时,种子留着;组字结束时清空。
4559    #[test]
4560    fn seeds_survive_a_partial_syllable_and_end_with_the_composition() {
4561        let fixture = fixture();
4562        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
4563        type_digits(&mut session, "64426");
4564        assert!(session.sentence_seeds.contains(&"ni'hao".to_owned()));
4565        type_digits(&mut session, "3");
4566        assert!(session.sentence_seeds.contains(&"ni'hao".to_owned()));
4567        session.command(Command::Cancel);
4568        assert!(session.sentence_seeds.is_empty());
4569    }
4570
4571    #[test]
4572    fn query_key_scan_uses_no_temporary_heap_state() {
4573        let alternatives = vec![vec!["ni".to_owned(), "hao".to_owned()]];
4574        let (found, allocations) = crate::ime::personal_rerank::allocations::count(|| {
4575            query_key_is_new(&alternatives, 0, "ni'hao", None)
4576        });
4577
4578        assert!(found);
4579        assert_eq!(allocations, 0);
4580    }
4581
4582    #[test]
4583    fn spellings_sort_by_digits_covered_within_the_limit() {
4584        let table = SpellingTable::new(&[
4585            "ga", "gan", "gang", "gao", "ha", "han", "hang", "hao", "ni", "a", "ai",
4586        ]);
4587        let prior = SyllablePrior::default();
4588        // 覆盖三位数字的音节里,正好三键拼完的 gan、gao、han、hao 在要补第四键的 gang、hang 前面。
4589        assert_eq!(
4590            table.spellings_for("426", 2, None, &prior),
4591            ["gan", "gao", "han", "hao", "gang", "hang", "ga", "ha"]
4592        );
4593        // 已经锁定了 `DIGIT_LIMIT - 1` 位时,只有一位就拼完的音节还放得下。
4594        assert_eq!(
4595            table.spellings_for("2", DIGIT_LIMIT - 1, None, &prior),
4596            ["a"]
4597        );
4598        assert!(table.spellings_for("", 0, None, &prior).is_empty());
4599        // 在两个数字之后切开,就排除了所有跨过这个位置的音节。
4600        assert_eq!(table.spellings_for("426", 0, Some(2), &prior), ["ga", "ha"]);
4601    }
4602
4603    #[test]
4604    fn spellings_put_exact_syllables_first_then_frequent_ones() {
4605        // #6654:`74` 下正好两键的 qi、si、ri、pi 先列,按单字频度排;要补键的排在后面,补一键的 shi、pie、pin、qia 在补两键的 pian、qian、ping、piao 前面,同样长的按频度排。
4606        let table = SpellingTable::new(&[
4607            "pi", "pian", "piao", "pie", "pin", "ping", "qi", "qia", "qian", "ri", "shi", "si",
4608        ]);
4609        let weights: HashMap<String, i64> = [
4610            ("shi", 9_000),
4611            ("qi", 5_000),
4612            ("si", 3_000),
4613            ("ri", 2_000),
4614            ("pi", 1_000),
4615            ("pian", 800),
4616            ("qian", 700),
4617            ("ping", 600),
4618        ]
4619        .into_iter()
4620        .map(|(syllable, weight)| (syllable.to_owned(), weight))
4621        .collect();
4622        let prior = SyllablePrior::from_weights(&table.syllables, &weights);
4623        assert_eq!(
4624            table.spellings_for("74", 0, None, &prior),
4625            ["qi", "si", "ri", "pi", "shi", "pie", "pin", "qia", "pian", "qian", "ping", "piao"]
4626        );
4627    }
4628
4629    #[test]
4630    fn complete_two_digit_readings_precede_longer_completions() {
4631        let table = SpellingTable::new(&["pi", "pian", "piao", "pie", "qi", "ri", "shi", "si"]);
4632        let choices = table.spellings_for("74", 0, None, &SyllablePrior::default());
4633        assert_eq!(&choices[..4], ["pi", "qi", "ri", "si"]);
4634    }
4635
4636    #[test]
4637    fn one_digit_keeps_predictive_readings_ahead_of_single_letter_syllables() {
4638        let table = SpellingTable::new(&["ma", "mi", "o"]);
4639        assert_eq!(
4640            table.spellings_for("6", 0, None, &SyllablePrior::default()),
4641            ["ma", "mi", "o"]
4642        );
4643    }
4644
4645    fn item(word: &str, digits: &str, weight: i64, source: CandidateSource) -> WordItem {
4646        WordItem::new(digits, word, weight, source, "")
4647    }
4648
4649    #[test]
4650    fn refresh_tail_is_borrowed_from_digits() {
4651        assert_eq!(remaining_digits("64426", 2), "426");
4652    }
4653
4654    #[test]
4655    fn path_key_builder_reuses_existing_string_storage() {
4656        let path = vec!["hao".to_owned(), "ma".to_owned()];
4657        let mut key = String::with_capacity(32);
4658        append_path_key(&mut key, "ni", &path);
4659        assert_eq!(key, "ni'hao'ma");
4660
4661        let ((), allocations) = crate::ime::personal_rerank::allocations::count(|| {
4662            append_path_key(&mut key, "ni", &path);
4663        });
4664
4665        assert_eq!(key, "ni'hao'ma");
4666        assert_eq!(allocations, 0);
4667    }
4668
4669    #[test]
4670    fn candidate_word_lookup_scans_existing_rows() {
4671        let candidates = vec![item("old", "653", 1, CandidateSource::EnglishDictionary)];
4672        assert!(has_candidate_word(&candidates, "old"));
4673        assert!(!has_candidate_word(&candidates, "older"));
4674    }
4675
4676    #[test]
4677    fn push_ranked_defers_leading_capacity_until_a_candidate_exists() {
4678        let mut candidates = Vec::new();
4679        let mut leading = LeadingMap::default();
4680        assert_eq!(leading.capacity(), 0);
4681
4682        push_ranked(
4683            &mut candidates,
4684            &mut leading,
4685            item("你", "64", 1, CandidateSource::Database),
4686            false,
4687            false,
4688            KeyGrid::NineKey,
4689        );
4690
4691        assert_eq!(candidates.len(), 1);
4692        assert!(leading.capacity() >= CANDIDATE_LIMIT);
4693    }
4694
4695    #[test]
4696    fn push_ranked_leading_map_allocation_profile() {
4697        let rows: Vec<_> = (0..256)
4698            .map(|index| {
4699                item(
4700                    &format!("合成词{index}"),
4701                    "64426",
4702                    index,
4703                    CandidateSource::Database,
4704                )
4705            })
4706            .collect();
4707        let ((), allocations) = crate::ime::personal_rerank::allocations::count(|| {
4708            let mut candidates = Vec::new();
4709            let mut leading = LeadingMap::default();
4710            for row in rows.iter().cloned() {
4711                push_ranked(
4712                    &mut candidates,
4713                    &mut leading,
4714                    row,
4715                    false,
4716                    false,
4717                    KeyGrid::NineKey,
4718                );
4719            }
4720        });
4721        eprintln!("push-ranked-leading allocations: {allocations}");
4722        assert!(allocations < 700, "leading map allocations: {allocations}");
4723    }
4724
4725    #[test]
4726    fn leading_slot_keeps_distinct_words_when_hashes_collide() {
4727        let candidates = vec![
4728            item("甲", "64", 1, CandidateSource::Database),
4729            item("乙", "64", 2, CandidateSource::Database),
4730        ];
4731        let first = LeadingEntry {
4732            candidate: 0,
4733            key: rank_key(&candidates[0], false, false, KeyGrid::NineKey),
4734        };
4735        let second = LeadingEntry {
4736            candidate: 1,
4737            key: rank_key(&candidates[1], false, false, KeyGrid::NineKey),
4738        };
4739        let mut slot = LeadingSlot::One(first);
4740        slot.insert(second);
4741        assert!(slot.find(&candidates, "甲").is_some());
4742        assert!(slot.find(&candidates, "乙").is_some());
4743        assert!(slot.find(&candidates, "丙").is_none());
4744    }
4745
4746    #[test]
4747    fn unique_word_retain_keeps_the_first_sorted_row() {
4748        let mut candidates = vec![
4749            item("你", "644", 10, CandidateSource::Database),
4750            item("你", "64", 1, CandidateSource::Generated),
4751            item("泥", "64", 2, CandidateSource::Database),
4752        ];
4753        retain_unique_words(&mut candidates);
4754        assert_eq!(
4755            candidates
4756                .iter()
4757                .map(|candidate| candidate.word.as_str())
4758                .collect::<Vec<_>>(),
4759            ["你", "泥"]
4760        );
4761    }
4762
4763    #[test]
4764    fn short_unique_word_retain_avoids_temporary_heap_state() {
4765        let mut candidates = vec![
4766            item("你", "644", 10, CandidateSource::Database),
4767            item("你", "64", 1, CandidateSource::Generated),
4768            item("泥", "64", 2, CandidateSource::Database),
4769        ];
4770        let ((), allocations) = crate::ime::personal_rerank::allocations::count(|| {
4771            retain_unique_words(&mut candidates);
4772        });
4773
4774        assert_eq!(allocations, 0);
4775        assert_eq!(
4776            candidates
4777                .iter()
4778                .map(|candidate| candidate.word.as_str())
4779                .collect::<Vec<_>>(),
4780            ["你", "泥"]
4781        );
4782    }
4783
4784    #[test]
4785    fn query_key_lookup_deduplicates_paths_and_hashed_keys() {
4786        let alternatives = vec![
4787            vec!["ni".to_owned(), "hao".to_owned()],
4788            vec!["ni".to_owned(), "hao".to_owned()],
4789        ];
4790        assert!(query_key_is_new(&alternatives, 0, "ni'hao", None));
4791        assert!(!query_key_is_new(&alternatives, 1, "ni'hao", None));
4792
4793        let mut queried = HashSet::new();
4794        assert!(query_key_is_new(&alternatives, 0, "ni'hao", Some(&queried)));
4795        queried.insert("ni'hao".to_owned());
4796        assert!(!query_key_is_new(
4797            &alternatives,
4798            1,
4799            "ni'hao",
4800            Some(&queried)
4801        ));
4802    }
4803
4804    #[test]
4805    fn query_key_dedup_switches_to_hashing_after_small_batch_boundary() {
4806        let alternatives = (0..=SMALL_QUERY_KEY_BATCH)
4807            .map(|index| vec![format!("syllable-{}", index % 32)])
4808            .collect::<Vec<_>>();
4809        let mut queried = (alternatives.len() > SMALL_QUERY_KEY_BATCH)
4810            .then(|| HashSet::with_capacity(alternatives.len()));
4811        let mut unique = 0;
4812        for (index, path) in alternatives.iter().enumerate() {
4813            let key = path[0].as_str();
4814            if query_key_is_new(&alternatives, index, key, queried.as_ref()) {
4815                unique += 1;
4816                if let Some(seen) = queried.as_mut() {
4817                    seen.insert(key.to_owned());
4818                }
4819            }
4820        }
4821        assert_eq!(unique, 32);
4822        assert!(queried.is_some());
4823    }
4824
4825    #[test]
4826    fn candidates_rank_by_coverage_then_dictionary_rows() {
4827        let mut fuzzy = item("泥", "64", 900, CandidateSource::Database);
4828        fuzzy.fuzzy = true;
4829        let mut candidates = vec![
4830            item("你", "64", 100, CandidateSource::Database),
4831            item("米好", "64426", -19113, CandidateSource::Generated),
4832            fuzzy,
4833            item("米", "64", 50, CandidateSource::UserDatabase),
4834            item("你好", "64426", 1000, CandidateSource::Database),
4835            item("你", "64", 10, CandidateSource::Database),
4836        ];
4837        rank_candidates(&mut candidates, false, false, None, KeyGrid::NineKey);
4838        let words: Vec<_> = candidates.iter().map(|item| item.word.as_str()).collect();
4839        assert_eq!(words, ["你好", "米好", "你", "米", "泥"]);
4840        assert_eq!(candidates[2].weight, 100);
4841    }
4842
4843    fn spelled(
4844        word: &str,
4845        digits: &str,
4846        canonical: &str,
4847        weight: i64,
4848        source: CandidateSource,
4849    ) -> WordItem {
4850        let mut row = item(word, digits, weight, source);
4851        row.canonical_pinyin = canonical.into();
4852        row
4853    }
4854
4855    fn ranked(mut candidates: Vec<WordItem>, prefer_exact: bool) -> Vec<String> {
4856        rank_candidates(&mut candidates, prefer_exact, false, None, KeyGrid::NineKey);
4857        candidates
4858            .into_iter()
4859            .map(|item| item.word.into_string())
4860            .collect()
4861    }
4862
4863    #[test]
4864    fn without_a_split_a_completion_keeps_its_weight_over_an_exact_syllable() {
4865        // `3` alone: 的 completes d to de, 额 spells e exactly; the more common word still leads.
4866        let rows = vec![
4867            spelled("额", "3", "e", 10, CandidateSource::Database),
4868            spelled("的", "3", "de", 1000, CandidateSource::Database),
4869        ];
4870        assert_eq!(ranked(rows, false), ["的", "额"]);
4871    }
4872
4873    #[test]
4874    fn a_synthesised_exact_row_never_leads_a_dictionary_completion() {
4875        for prefer_exact in [false, true] {
4876            let rows = vec![
4877                spelled("额", "3", "e", 99_999, CandidateSource::Generated),
4878                spelled("的", "3", "de", 1000, CandidateSource::Database),
4879            ];
4880            assert_eq!(
4881                ranked(rows, prefer_exact),
4882                ["的", "额"],
4883                "prefer_exact {prefer_exact}"
4884            );
4885        }
4886    }
4887
4888    #[test]
4889    fn after_a_split_an_exact_reading_leads_a_completion() {
4890        // `94'26`: 西安 spells xi'an to its end, 自从 has to be completed to zi'cong.
4891        let rows = vec![
4892            spelled("自从", "9426", "zi'cong", 5000, CandidateSource::Database),
4893            spelled("西安", "9426", "xi'an", 100, CandidateSource::Database),
4894        ];
4895        assert_eq!(ranked(rows.clone(), true), ["西安", "自从"]);
4896        assert_eq!(ranked(rows, false), ["自从", "西安"]);
4897    }
4898
4899    /// `rank_candidates` 之后再按首页调序,与 `refresh` 的顺序相同(打了切分时不折算),首页按默认的行数。
4900    fn ranked_on_page(mut candidates: Vec<WordItem>, prefer_exact: bool) -> Vec<String> {
4901        rank_candidates(&mut candidates, prefer_exact, false, None, KeyGrid::NineKey);
4902        if !prefer_exact {
4903            discount_completions_on_first_page(
4904                &mut candidates,
4905                DEFAULT_FIRST_PAGE_ROWS,
4906                0,
4907                KeyGrid::NineKey,
4908            );
4909        }
4910        candidates
4911            .into_iter()
4912            .map(|item| item.word.into_string())
4913            .collect()
4914    }
4915
4916    #[test]
4917    fn without_a_split_a_completion_competes_with_a_spelled_phrase_at_a_fraction_of_its_weight() {
4918        // `2443`:挨个 拼满 ai'ge,吃饭 要把 f 补成 fan。没打切分时,首页里 吃饭 按权重的 `COMPLETION_WEIGHT_RATIO` 分之一和 挨个 比;打了切分时 `rank_key` 已经把它整个排在后面。
4919        let ratio = COMPLETION_WEIGHT_RATIO;
4920        let rows = |chifan: i64| {
4921            vec![
4922                spelled("吃饭", "2443", "chi'fan", chifan, CandidateSource::Database),
4923                spelled("挨个", "2443", "ai'ge", 1000, CandidateSource::Database),
4924            ]
4925        };
4926        assert_eq!(
4927            ranked_on_page(rows(1000 * ratio - 1), false),
4928            ["挨个", "吃饭"]
4929        );
4930        assert_eq!(ranked_on_page(rows(1000 * ratio), false), ["挨个", "吃饭"]);
4931        assert_eq!(
4932            ranked_on_page(rows(1000 * ratio + 1), false),
4933            ["吃饭", "挨个"]
4934        );
4935        assert_eq!(
4936            ranked_on_page(rows(1000 * ratio * 1000), true),
4937            ["挨个", "吃饭"]
4938        );
4939    }
4940
4941    #[test]
4942    fn without_a_spelled_phrase_on_the_first_page_nothing_is_discounted() {
4943        // `3`:的 de 是补全,额 e 拼满了,但数字拼不出多音节词,照旧按权重。`536`:可能 ke'neng 和 冷(len 补成 leng)都是补全,拼满的只有单字 肯,也照旧按权重。
4944        let rows = vec![
4945            spelled("额", "3", "e", 99, CandidateSource::Database),
4946            spelled("的", "3", "de", 100, CandidateSource::Database),
4947        ];
4948        assert_eq!(ranked_on_page(rows, false), ["的", "额"]);
4949        let rows = vec![
4950            spelled("肯", "536", "ken", 40_000, CandidateSource::Database),
4951            spelled("冷", "536", "leng", 113_000, CandidateSource::Database),
4952            spelled(
4953                "可能",
4954                "536",
4955                "ke'neng",
4956                5_500_000,
4957                CandidateSource::Database,
4958            ),
4959        ];
4960        assert_eq!(ranked_on_page(rows, false), ["可能", "冷", "肯"]);
4961    }
4962
4963    #[test]
4964    fn completions_compare_with_each_other_at_their_own_weights() {
4965        // `9482`:猪啊 zhu'a 拼满了,折算生效。主编 zhu'bian 和 装(zhua 补成 zhuang)都是补全,彼此仍按原权重比,单音节的 装 不会越过更常用的 主编;拼满的单字 抓 按倍数排到前面。
4966        let rows = vec![
4967            spelled(
4968                "主编",
4969                "9482",
4970                "zhu'bian",
4971                10_000_000,
4972                CandidateSource::Database,
4973            ),
4974            spelled("装", "9482", "zhuang", 3_000_000, CandidateSource::Database),
4975            spelled(
4976                "抓",
4977                "9482",
4978                "zhua",
4979                10_000_000 / COMPLETION_WEIGHT_RATIO + 1,
4980                CandidateSource::Database,
4981            ),
4982            spelled("猪啊", "9482", "zhu'a", 500, CandidateSource::Database),
4983        ];
4984        assert_eq!(ranked_on_page(rows, false), ["抓", "主编", "装", "猪啊"]);
4985    }
4986
4987    #[test]
4988    fn the_discount_keeps_the_first_page_and_the_rest_of_the_list() {
4989        // `266`:覆盖三个数字的单字有几百个,大半权重为 0。从、聪(con 补成 cong)和 按钮、澳门 都是补全,彼此按原权重比;阿莫 a'mo 拼满了,乘倍数之后排到 从 之后、按钮 之前。折算只在首页里调序,首页以外一行不动。
4990        let mut rows = vec![
4991            spelled("从", "266", "cong", 30_000_000, CandidateSource::Database),
4992            spelled("聪", "266", "cong", 130_000, CandidateSource::Database),
4993            spelled(
4994                "阿莫",
4995                "266",
4996                "a'mo",
4997                30_000_000 / COMPLETION_WEIGHT_RATIO - 1,
4998                CandidateSource::Database,
4999            ),
5000            spelled("匆", "266", "cong", 60_000, CandidateSource::Database),
5001            spelled("丛", "266", "cong", 40_000, CandidateSource::Database),
5002            spelled("按钮", "266", "an'niu", 500_000, CandidateSource::Database),
5003            spelled("澳门", "266", "ao'men", 300_000, CandidateSource::Database),
5004            spelled(
5005                "不那么",
5006                "266",
5007                "bu'na'me",
5008                20_000,
5009                CandidateSource::Database,
5010            ),
5011        ];
5012        for offset in 0..200 {
5013            let word = char::from_u32(0x3400 + offset).unwrap().to_string();
5014            rows.push(spelled(&word, "266", "cong", 0, CandidateSource::Database));
5015        }
5016        let before = ranked(rows.clone(), false);
5017        let shown = ranked_on_page(rows, false);
5018        let page = DEFAULT_FIRST_PAGE_ROWS;
5019        assert_eq!(shown[..page], ["从", "阿莫", "按钮", "澳门", "聪", "匆"]);
5020        let mut sorted_shown = shown[..page].to_vec();
5021        let mut sorted_before = before[..page].to_vec();
5022        sorted_shown.sort();
5023        sorted_before.sort();
5024        assert_eq!(sorted_shown, sorted_before);
5025        assert_eq!(shown[page..], before[page..]);
5026    }
5027
5028    #[test]
5029    fn initials_rows_keep_their_seats_and_no_row_passes_them() {
5030        // 吃饭 按原权重领先;首页调序把它挪到完整读法之后,但只在简拼行之前的那几行里挪,简拼行 不会过的 留在原位,也就不会越到折算前比它重的 吃饭 前面。
5031        let rows = vec![
5032            spelled(
5033                "吃饭",
5034                "2443",
5035                "chi'fan",
5036                1_100_000,
5037                CandidateSource::Database,
5038            ),
5039            spelled(
5040                "挨个",
5041                "2443",
5042                "ai'ge",
5043                1_000_000,
5044                CandidateSource::Database,
5045            ),
5046            spelled("爱", "2443", "ai", 800_000, CandidateSource::Database),
5047            spelled("碧荷", "2443", "bi'he", 700_000, CandidateSource::Database),
5048            spelled(
5049                "不会过的",
5050                "2443",
5051                "bu'hui'guo'de",
5052                500_000,
5053                CandidateSource::Database,
5054            ),
5055            spelled(
5056                "吃过",
5057                "2443",
5058                "chi'guo",
5059                400_000,
5060                CandidateSource::Database,
5061            ),
5062        ];
5063        assert_eq!(
5064            ranked(rows.clone(), false),
5065            ["吃饭", "挨个", "爱", "碧荷", "不会过的", "吃过"]
5066        );
5067        assert_eq!(
5068            ranked_on_page(rows, false),
5069            ["挨个", "爱", "碧荷", "吃饭", "不会过的", "吃过"]
5070        );
5071    }
5072
5073    #[test]
5074    fn rows_mixed_in_after_ranking_keep_their_seats() {
5075        // 英文、固定位置的行坐在原位,折算只在它们周围的拼音行之间调序;emoji 接在它的词后面,折算到它就停。
5076        let mut rows = vec![
5077            spelled("吃饭", "2443", "chi'fan", 1100, CandidateSource::Database),
5078            spelled(
5079                "chief",
5080                "2443",
5081                "",
5082                1000,
5083                CandidateSource::EnglishDictionary,
5084            ),
5085            spelled("挨个", "2443", "ai'ge", 1000, CandidateSource::Database),
5086            spelled("爱河", "2443", "ai'he", 900, CandidateSource::Database),
5087            spelled("碧荷", "2443", "bi'he", 800, CandidateSource::Database),
5088            spelled("次格", "2443", "ci'ge", 700, CandidateSource::Database),
5089            spelled("笔格", "2443", "bi'ge", 600, CandidateSource::Database),
5090        ];
5091        let mut shown = rows.clone();
5092        assert!(discount_completions_on_first_page(
5093            &mut shown,
5094            6,
5095            0,
5096            KeyGrid::NineKey
5097        ));
5098        let words = |rows: &[WordItem]| {
5099            rows.iter()
5100                .map(|row| row.word.to_string())
5101                .collect::<Vec<_>>()
5102        };
5103        assert_eq!(
5104            words(&shown),
5105            ["挨个", "chief", "爱河", "碧荷", "次格", "吃饭", "笔格"]
5106        );
5107        rows[3].fixed_position = 4;
5108        rows.insert(1, spelled("🍚", "2443", "", 0, CandidateSource::Emoji));
5109        let mut shown = rows.clone();
5110        discount_completions_on_first_page(&mut shown, 6, 0, KeyGrid::NineKey);
5111        assert_eq!(shown, rows);
5112        rows.remove(1);
5113        let mut shown = rows.clone();
5114        discount_completions_on_first_page(&mut shown, 6, 0, KeyGrid::NineKey);
5115        assert_eq!(
5116            words(&shown),
5117            ["挨个", "chief", "碧荷", "爱河", "次格", "吃饭", "笔格"]
5118        );
5119    }
5120
5121    #[test]
5122    fn locked_syllables_do_not_count_toward_a_spelled_phrase() {
5123        // 锁定 wo 之后再打 `3`:我饿 wo'e 剩下的只有一个音节,与单独打 `3` 一样不折算,我的 照旧凭权重领先。
5124        let rows = || {
5125            vec![
5126                spelled("我的", "963", "wo'de", 150, CandidateSource::Database),
5127                spelled("我饿", "963", "wo'e", 100, CandidateSource::Database),
5128            ]
5129        };
5130        let mut shown = rows();
5131        assert!(!discount_completions_on_first_page(
5132            &mut shown,
5133            6,
5134            1,
5135            KeyGrid::NineKey
5136        ));
5137        assert_eq!(shown, rows());
5138        let mut shown = rows();
5139        assert!(discount_completions_on_first_page(
5140            &mut shown,
5141            6,
5142            0,
5143            KeyGrid::NineKey
5144        ));
5145        assert_eq!(shown[0].word, "我饿");
5146    }
5147
5148    #[test]
5149    fn the_discount_only_permutes_pinyin_rows_inside_the_first_page() {
5150        let readings = [
5151            "ai'ge",
5152            "bi'he",
5153            "chi'fan",
5154            "ai'hao",
5155            "bu'hui'guo'de",
5156            "chang",
5157            "ci",
5158        ];
5159        let mut state = 0x9E37_79B9_7F4A_7C15_u64;
5160        let mut next = |bound: u64| {
5161            state ^= state << 13;
5162            state ^= state >> 7;
5163            state ^= state << 17;
5164            state % bound
5165        };
5166        for round in 0..300 {
5167            let mut rows = Vec::new();
5168            for index in 0..(1 + next(40)) {
5169                let reading = readings[next(readings.len() as u64) as usize];
5170                let weight = [0, 1, 7, 100, 999, 1000, 64_000, 1_000_000][next(8) as usize];
5171                let mut row = spelled(
5172                    &format!("w{round}-{index}"),
5173                    "2443",
5174                    reading,
5175                    weight,
5176                    CandidateSource::Database,
5177                );
5178                row.fuzzy = next(6) == 0;
5179                rows.push(row);
5180            }
5181            let page = 1 + next(9) as usize;
5182            let mut before = rows;
5183            rank_candidates(&mut before, false, false, None, KeyGrid::NineKey);
5184            // 混入行在排好之后插进来:英文、固定位置的行留在原位,整句行之后不再调序。
5185            for extra in 0..next(3) {
5186                let seat = next(before.len() as u64 + 1) as usize;
5187                let source = [
5188                    CandidateSource::EnglishDictionary,
5189                    CandidateSource::Generated,
5190                    CandidateSource::Database,
5191                ][next(3) as usize];
5192                let mut row = spelled(&format!("x{round}-{extra}"), "2443", "ai'ge", 5, source);
5193                row.fixed_position = i32::from(source == CandidateSource::Database);
5194                before.insert(seat, row);
5195            }
5196            let mut after = before.clone();
5197            discount_completions_on_first_page(&mut after, page, 0, KeyGrid::NineKey);
5198            let cut = page.min(before.len());
5199            assert_eq!(after[cut..], before[cut..], "round {round}");
5200            let stop = before[..cut]
5201                .iter()
5202                .position(|row| row.source == CandidateSource::Generated)
5203                .unwrap_or(cut);
5204            assert_eq!(after[stop..], before[stop..], "round {round}");
5205            for index in 0..stop {
5206                let fixed = !before[index].source.is_dictionary()
5207                    || before[index].fixed_position != 0
5208                    || is_initials_row(&before[index]);
5209                if fixed {
5210                    assert_eq!(after[index], before[index], "round {round}");
5211                } else {
5212                    assert!(after[index].source.is_dictionary(), "round {round}");
5213                    assert_eq!(after[index].fixed_position, 0, "round {round}");
5214                    assert!(!is_initials_row(&after[index]), "round {round}");
5215                }
5216            }
5217            let mut words_before: Vec<_> = before[..cut].iter().map(|row| &row.word).collect();
5218            let mut words_after: Vec<_> = after[..cut].iter().map(|row| &row.word).collect();
5219            words_before.sort();
5220            words_after.sort();
5221            assert_eq!(words_after, words_before, "round {round}");
5222        }
5223    }
5224
5225    #[test]
5226    fn skipping_outranked_rows_ranks_like_pushing_everything() {
5227        let sources = [
5228            CandidateSource::Database,
5229            CandidateSource::UserDatabase,
5230            CandidateSource::Generated,
5231            CandidateSource::Fallback,
5232        ];
5233        let mut state = 0x2545_F491_4F6C_DD1D_u64;
5234        let mut next = |bound: u64| {
5235            state ^= state << 13;
5236            state ^= state >> 7;
5237            state ^= state << 17;
5238            state % bound
5239        };
5240        for round in 0..200 {
5241            let mut rows = Vec::new();
5242            for _ in 0..(1 + next(300)) {
5243                let digits = &"64426646"[..1 + next(8) as usize];
5244                let mut row = item(
5245                    &format!("w{}", next(40)),
5246                    digits,
5247                    next(5) as i64 * 10,
5248                    sources[next(4) as usize],
5249                );
5250                row.fuzzy = next(3) == 0;
5251                row.canonical_pinyin =
5252                    ["e", "de", "xi'an", "zi'cong", "ni'hao'ma"][next(5) as usize].into();
5253                rows.push(row);
5254            }
5255            let prefer_exact = round % 2 == 0;
5256            let mut everything = rows.clone();
5257            rank_candidates(&mut everything, prefer_exact, false, None, KeyGrid::NineKey);
5258            let mut skipped = Vec::new();
5259            let mut leading = LeadingMap::default();
5260            for row in rows {
5261                push_ranked(
5262                    &mut skipped,
5263                    &mut leading,
5264                    row,
5265                    prefer_exact,
5266                    false,
5267                    KeyGrid::NineKey,
5268                );
5269            }
5270            rank_candidates(&mut skipped, prefer_exact, false, None, KeyGrid::NineKey);
5271            assert_eq!(skipped, everything, "round {round}");
5272        }
5273    }
5274
5275    #[test]
5276    fn english_exact_code_leads_only_with_a_weight() {
5277        let english =
5278            |word: &str, weight| item(word, word, weight, CandidateSource::EnglishDictionary);
5279        let mut words = vec![
5280            english("old", 1000),
5281            english("ok", 900),
5282            english("older", 800),
5283        ];
5284        rank_english(&mut words, 2);
5285        let order: Vec<_> = words.iter().map(|item| item.word.as_str()).collect();
5286        assert_eq!(order, ["ok", "old", "older"]);
5287        let mut words = vec![english("ogham", 0), english("oh", 5), english("ogre", 5)];
5288        rank_english(&mut words, 5);
5289        let order: Vec<_> = words.iter().map(|item| item.word.as_str()).collect();
5290        assert_eq!(order, ["oh", "ogre", "ogham"]);
5291        let mut many: Vec<_> = (0..8).map(|n| english(&format!("w{n}"), n)).collect();
5292        rank_english(&mut many, 2);
5293        assert_eq!(many.len(), ENGLISH_LIMIT);
5294    }
5295
5296    #[test]
5297    fn english_dedup_keeps_the_first_row_for_each_display_word() {
5298        let mut words = vec![
5299            item("one", "one", 100, CandidateSource::EnglishDictionary),
5300            item("two", "two", 90, CandidateSource::EnglishDictionary),
5301            item("one", "one", 80, CandidateSource::EnglishDictionary),
5302        ];
5303
5304        deduplicate_english_words(&mut words);
5305
5306        assert_eq!(
5307            words
5308                .iter()
5309                .map(|word| (word.word.as_str(), word.weight))
5310                .collect::<Vec<_>>(),
5311            [("one", 100), ("two", 90)]
5312        );
5313    }
5314
5315    #[test]
5316    fn locked_syllables_constrain_readings() {
5317        assert!(agrees_with_locked("mi", ""));
5318        assert!(agrees_with_locked("ni", "ni"));
5319        assert!(agrees_with_locked("ni'hao", "ni"));
5320        assert!(agrees_with_locked("ni", "ni'hao"));
5321        assert!(!agrees_with_locked("mi", "ni"));
5322        assert!(!agrees_with_locked("nian", "ni"));
5323        assert!(!agrees_with_locked("ni", "nian"));
5324    }
5325
5326    fn detached() -> NineKeySession {
5327        NineKeySession::new(
5328            &RuntimePaths::default(),
5329            false,
5330            FrequencyAdjustmentOptions::default(),
5331            FuzzyPinyinOptions::default(),
5332            EnglishInputOptions::default(),
5333            true,
5334            false,
5335        )
5336    }
5337
5338    #[test]
5339    fn consuming_digits_drops_covered_locked_syllables() {
5340        let mut session = detached();
5341        session.digits = "64426".into();
5342        session.locked = vec!["ni".into(), "hao".into()];
5343        session.consume(2);
5344        assert_eq!(session.digits, "426");
5345        assert_eq!(session.locked, ["hao"]);
5346        // An English row's code is the word, which may run past the digits.
5347        session.consume(10);
5348        assert!(session.digits.is_empty());
5349        assert!(session.locked.is_empty());
5350    }
5351
5352    #[test]
5353    fn consuming_digits_reuses_split_storage() {
5354        let mut session = detached();
5355        session.digits = "64426".into();
5356        session.splits = vec![2, 4];
5357        let (_, allocations) =
5358            crate::ime::personal_rerank::allocations::count(|| session.consume(2));
5359        assert_eq!(session.splits, [2]);
5360        assert_eq!(allocations, 0);
5361    }
5362
5363    #[test]
5364    fn snapshot_shows_locked_syllables_before_the_open_digits() {
5365        let mut session = detached();
5366        session.digits = "64426".into();
5367        assert_eq!(session.snapshot().preedit, "64426");
5368        session.locked = vec!["ni".into()];
5369        assert_eq!(session.snapshot().preedit, "ni'426");
5370        session.locked.push("hao".into());
5371        session.candidates = vec![
5372            item("你好", "64426", 1000, CandidateSource::Database),
5373            item("你", "64", 100, CandidateSource::Database),
5374        ];
5375        let view = session.snapshot();
5376        assert_eq!(view.preedit, "ni'hao");
5377        assert_eq!(view.editing_text, "64426");
5378        assert_eq!(view.caret_position, 5);
5379        assert_eq!(view.scheme, SchemeType::Quanpin);
5380        assert_eq!(view.candidate_answers_key, [true, false]);
5381        assert_eq!(view.candidate_sources, [CandidateSource::Database; 2]);
5382        assert_eq!(view.candidate_annotations, ["", ""]);
5383        assert_eq!(session.ranking_context(), "nine-key:64426:ni'hao");
5384    }
5385
5386    #[test]
5387    fn snapshot_reuses_locked_prefix_length_for_an_initial() {
5388        let mut session = detached();
5389        session.digits = "644269".into();
5390        session.locked = vec!["ni".into(), "hao".into()];
5391        session.initial = Some(Initial {
5392            letter: b'n',
5393            typed: 6,
5394            splits: 0,
5395        });
5396        let (_, allocations) =
5397            crate::ime::personal_rerank::allocations::count(|| session.snapshot());
5398        assert!(
5399            allocations <= 2,
5400            "九键快照预编辑缓冲扩容产生了 {allocations} 次分配"
5401        );
5402    }
5403
5404    // ---- Ported from test_nine_key_session.cpp against the session itself ----
5405
5406    const MAIN_FIXTURE: &str = "CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_n VALUES('ni','n','你',100);CREATE TABLE tbl_1_m(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_m VALUES('mi','m','米',50);CREATE TABLE tbl_1_h(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_h VALUES('hao','h','好',100);CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_2_n VALUES('ni''hao','nh','你好',1000);";
5407    const ENGLISH_FIXTURE: &str = "CREATE TABLE english_words(word TEXT,display TEXT,weight INTEGER);INSERT INTO english_words VALUES('ok','ok',900);INSERT INTO english_words VALUES('old','old',1000);INSERT INTO english_words VALUES('older','older',800);INSERT INTO english_words VALUES('ogham','ogham',0);INSERT INTO english_words VALUES('qq','QQ',500);";
5408
5409    struct Fixture {
5410        directory: tempfile::TempDir,
5411        paths: RuntimePaths,
5412    }
5413
5414    fn fixture() -> Fixture {
5415        fixture_with(MAIN_FIXTURE)
5416    }
5417
5418    fn fixture_with(main: &str) -> Fixture {
5419        let directory = tempfile::tempdir().expect("temporary directory");
5420        let root = directory.path().to_path_buf();
5421        Connection::open(root.join(assets::MAIN_DICTIONARY))
5422            .and_then(|db| db.execute_batch(main))
5423            .expect("main fixture");
5424        Connection::open(root.join(assets::ENGLISH_DICTIONARY))
5425            .and_then(|db| db.execute_batch(ENGLISH_FIXTURE))
5426            .expect("english fixture");
5427        let paths = RuntimePaths {
5428            resources: root.clone(),
5429            user_data: root.clone(),
5430            cache: root.clone(),
5431            dictionaries: root,
5432        };
5433        Fixture { directory, paths }
5434    }
5435
5436    fn open(paths: &RuntimePaths, learning: bool, english: EnglishInputOptions) -> NineKeySession {
5437        let frequency = FrequencyAdjustmentOptions {
5438            mode: if learning {
5439                FrequencyAdjustmentMode::Promote
5440            } else {
5441                FrequencyAdjustmentMode::Disabled
5442            },
5443            ..FrequencyAdjustmentOptions::default()
5444        };
5445        NineKeySession::new(
5446            paths,
5447            learning,
5448            frequency,
5449            FuzzyPinyinOptions::default(),
5450            english,
5451            true,
5452            false,
5453        )
5454    }
5455
5456    fn mixed() -> EnglishInputOptions {
5457        EnglishInputOptions {
5458            mixed_candidates: true,
5459            ..EnglishInputOptions::default()
5460        }
5461    }
5462
5463    fn type_digits(session: &mut NineKeySession, digits: &str) {
5464        for digit in digits.bytes() {
5465            assert!(session.character(digit).handled, "digit {digit} unhandled");
5466        }
5467    }
5468
5469    fn index_of(session: &NineKeySession, word: &str) -> usize {
5470        session
5471            .snapshot()
5472            .candidates
5473            .iter()
5474            .position(|item| item.word == word)
5475            .unwrap_or_else(|| panic!("missing candidate {word}"))
5476    }
5477
5478    fn words(session: &NineKeySession) -> Vec<String> {
5479        session
5480            .snapshot()
5481            .candidates
5482            .into_iter()
5483            .map(|item| item.word.into_string())
5484            .collect()
5485    }
5486
5487    #[test]
5488    fn digits_offer_readings_and_ranked_english() {
5489        let fixture = fixture();
5490        let mut session = open(&fixture.paths, false, mixed());
5491        assert!(!session.character(b'0').handled && !session.character(b'1').handled);
5492        // old is more frequent, but 65 spells ok exactly.
5493        type_digits(&mut session, "65");
5494        assert_eq!(words(&session), ["ok", "old", "older"]);
5495        assert_eq!(
5496            session.snapshot().nine_key_spellings,
5497            ["o", "M", "N", "O", "6"]
5498        );
5499        session.command(Command::Cancel);
5500
5501        // 64426 is ni'hao and also the only code for ogham; spelling it exactly does not earn a zero-weight word the second slot. The two-syllable lattice adds a Generated 米好 that covers every digit, so it ranks above the two-digit dictionary rows (tests-inventory.md §2.4).
5502        type_digits(&mut session, "64426");
5503        let view = session.snapshot();
5504        assert_eq!(words(&session), ["你好", "米好", "你", "米", "ogham"]);
5505        assert_eq!(view.candidate_sources[1], CandidateSource::Generated);
5506        assert_eq!(
5507            view.nine_key_spellings,
5508            ["ni", "mi", "o", "M", "N", "O", "6"]
5509        );
5510        assert_eq!(view.candidate_answers_key[..4], [true, true, false, false]);
5511        // Within one coverage bucket no dictionary row follows a synthesised one.
5512        for pair in view.candidates.windows(2) {
5513            if pair[0].pinyin.len() == pair[1].pinyin.len()
5514                && pair[0].source.is_generated_or_fallback()
5515            {
5516                assert!(
5517                    !pair[1].source.is_dictionary(),
5518                    "{} after {}",
5519                    pair[1].word,
5520                    pair[0].word
5521                );
5522            }
5523        }
5524        session.command(Command::Cancel);
5525
5526        type_digits(&mut session, "6");
5527        assert_eq!(
5528            words(&session),
5529            ["你", "米"],
5530            "mixed English waits for the minimum prefix"
5531        );
5532        assert_eq!(session.snapshot().candidates[0].pinyin, "6");
5533    }
5534
5535    #[test]
5536    fn unfinished_top_candidate_does_not_displace_complete_readings() {
5537        let fixture = fixture_with("CREATE TABLE tbl_1_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_s VALUES('shi','s','是',10000);");
5538        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
5539        type_digits(&mut session, "74");
5540        let view = session.snapshot();
5541        assert_eq!(
5542            view.candidates
5543                .first()
5544                .map(|candidate| candidate.word.as_str()),
5545            Some("是")
5546        );
5547        // 同样两键的音节按内嵌的音节频度表排,不看夹具词库的单字。
5548        assert_eq!(&view.nine_key_spellings[..4], ["qi", "si", "ri", "pi"]);
5549    }
5550
5551    #[test]
5552    fn selecting_a_nine_key_candidate_does_not_clone_unused_row_fields() {
5553        let fixture = fixture();
5554        let mut session = open(&fixture.paths, false, mixed());
5555        type_digits(&mut session, "64426");
5556        let index = index_of(&session, "你好");
5557
5558        let (result, allocations) =
5559            crate::ime::personal_rerank::allocations::count(|| session.select(index));
5560
5561        assert_eq!(result.commit.as_deref(), Some("你好"));
5562        assert!(
5563            allocations <= 1,
5564            "九键选择候选复制无用行字段产生了 {allocations} 次分配"
5565        );
5566    }
5567
5568    #[test]
5569    fn choosing_a_nine_key_letter_does_not_clone_spelling_choices() {
5570        let fixture = fixture();
5571        let mut session = open(&fixture.paths, false, mixed());
5572        type_digits(&mut session, "64426");
5573        let index = session
5574            .snapshot()
5575            .nine_key_spellings
5576            .iter()
5577            .position(|spelling| spelling == "N")
5578            .unwrap();
5579
5580        let (result, allocations) =
5581            crate::ime::personal_rerank::allocations::count(|| session.choose_spelling(index));
5582
5583        assert!(result.handled);
5584        assert!(
5585            allocations <= 347,
5586            "九键选择字母复制音节列表产生了 {allocations} 次分配"
5587        );
5588    }
5589
5590    #[test]
5591    fn split_refresh_reuses_normalized_boundaries() {
5592        let fixture = fixture();
5593        let mut plain = open(&fixture.paths, false, EnglishInputOptions::default());
5594        plain.digits = "644".into();
5595        plain.refresh();
5596        let (_, plain_allocations) =
5597            crate::ime::personal_rerank::allocations::count(|| plain.refresh());
5598        let mut split = open(&fixture.paths, false, EnglishInputOptions::default());
5599        split.digits = "644".into();
5600        split.splits = vec![2];
5601        split.refresh();
5602        let (_, split_allocations) =
5603            crate::ime::personal_rerank::allocations::count(|| split.refresh());
5604
5605        assert!(
5606            split_allocations <= 151,
5607            "split refresh should keep normalized boundaries off the heap: {split_allocations}"
5608        );
5609        assert!(split_allocations < plain_allocations);
5610    }
5611
5612    #[test]
5613    fn refresh_reuses_candidate_pinyin_storage() {
5614        let fixture = fixture();
5615        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
5616        session.digits = "64".into();
5617        session.refresh();
5618        let (_, allocations) =
5619            crate::ime::personal_rerank::allocations::count(|| session.refresh());
5620        assert!(
5621            allocations <= 149,
5622            "candidate pinyin normalization allocated {allocations} buffers"
5623        );
5624    }
5625
5626    #[test]
5627    fn refresh_appends_key_choices_without_a_temporary_vector() {
5628        let fixture = fixture();
5629        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
5630        session.digits = "64".into();
5631        session.refresh();
5632        let (_, allocations) =
5633            crate::ime::personal_rerank::allocations::count(|| session.refresh());
5634        let spellings = session.snapshot().nine_key_spellings;
5635        assert!(spellings.ends_with(
5636            &["o", "M", "N", "O", "6"]
5637                .into_iter()
5638                .map(String::from)
5639                .collect::<Vec<_>>()
5640        ));
5641        assert!(
5642            allocations <= 110,
5643            "key choices refresh allocations: {allocations}"
5644        );
5645    }
5646
5647    #[test]
5648    fn refresh_reuses_reading_storage() {
5649        let fixture = fixture();
5650        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
5651        session.digits = "64426".into();
5652        session.refresh();
5653
5654        let (_, allocations) =
5655            crate::ime::personal_rerank::allocations::count(|| session.refresh());
5656        assert!(allocations < 601, "九键读音热刷新分配了 {allocations} 次");
5657    }
5658
5659    #[test]
5660    fn refresh_reuses_search_pages_and_releases_them_outside_pinyin_composition() {
5661        let fixture = fixture();
5662        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
5663        session.digits = "64426".into();
5664        session.refresh();
5665        session.refresh();
5666        let pages: Vec<_> = session
5667            .path_scratch
5668            .pages
5669            .iter()
5670            .map(|page| (page.as_ptr(), page.capacity()))
5671            .collect();
5672        assert!(pages.iter().any(|(_, capacity)| *capacity != 0));
5673        session.refresh();
5674        assert_eq!(
5675            pages,
5676            session
5677                .path_scratch
5678                .pages
5679                .iter()
5680                .map(|page| (page.as_ptr(), page.capacity()))
5681                .collect::<Vec<_>>()
5682        );
5683        session.english_only = true;
5684        session.refresh();
5685        assert!(session.path_scratch.pages.is_empty());
5686        session.english_only = false;
5687        session.refresh();
5688        assert!(!session.path_scratch.pages.is_empty());
5689        session.digits.clear();
5690        session.refresh();
5691        assert!(session.path_scratch.pages.is_empty());
5692    }
5693
5694    #[test]
5695    fn commit_raw_releases_search_pages_before_returning() {
5696        let fixture = fixture();
5697        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
5698        session.digits = "64426".into();
5699        session.refresh();
5700        assert!(!session.path_scratch.pages.is_empty());
5701        let result = session.command(Command::CommitRaw);
5702        assert_eq!(result.commit.as_deref(), Some("64426"));
5703        assert!(session.path_scratch.pages.is_empty());
5704    }
5705
5706    #[test]
5707    fn refresh_reuses_split_path_storage() {
5708        let fixture = fixture();
5709        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
5710        session.digits = "64426".into();
5711        session.refresh();
5712        let pointer = session.path_buffer[0][0].as_ptr();
5713        let capacity = session.path_buffer[0][0].capacity();
5714        session.refresh();
5715        assert_eq!(session.path_buffer[0][0].as_ptr(), pointer);
5716        assert_eq!(session.path_buffer[0][0].capacity(), capacity);
5717    }
5718
5719    #[test]
5720    fn refresh_reuses_candidate_vector_storage() {
5721        let fixture = fixture();
5722        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
5723        session.digits = "64".into();
5724        session.refresh();
5725        let pointer = session.candidates.as_ptr();
5726        let capacity = session.candidates.capacity();
5727        assert!(capacity > 0);
5728        session.refresh();
5729        assert_eq!(session.candidates.as_ptr(), pointer);
5730        assert_eq!(session.candidates.capacity(), capacity);
5731    }
5732
5733    #[test]
5734    fn refresh_reuses_spelling_vector_storage() {
5735        let fixture = fixture();
5736        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
5737        session.digits = "64426".into();
5738        session.refresh();
5739        let pointer = session.spellings.as_ptr();
5740        let capacity = session.spellings.capacity();
5741        assert!(capacity > 0);
5742        session.refresh();
5743        assert_eq!(session.spellings.as_ptr(), pointer);
5744        assert_eq!(session.spellings.capacity(), capacity);
5745    }
5746
5747    #[test]
5748    fn refresh_keeps_spelling_string_buffers() {
5749        let fixture = fixture();
5750        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
5751        session.digits = "64".into();
5752        session.refresh();
5753        let expected = session.snapshot();
5754        // 排名会移动行,给每个音节留下足够容量后,按指针集合检查复用。
5755        let syllables = session.spellings.len() - session.key_choices("64").len();
5756        assert!(syllables > 0);
5757        for value in &mut session.spellings[..syllables] {
5758            value.reserve(64);
5759        }
5760        let mut before: Vec<_> = session.spellings[..syllables]
5761            .iter()
5762            .map(|value| value.as_ptr())
5763            .collect();
5764        before.sort_unstable();
5765        session.refresh();
5766        assert_eq!(session.snapshot(), expected);
5767        let mut after: Vec<_> = session.spellings[..syllables]
5768            .iter()
5769            .map(|value| value.as_ptr())
5770            .collect();
5771        after.sort_unstable();
5772        assert_eq!(after, before, "刷新不应清空已有音节字符串");
5773        assert!(session.spellings[..syllables]
5774            .iter()
5775            .all(|value| value.capacity() >= 64));
5776    }
5777
5778    #[test]
5779    fn refresh_discards_spellings_when_there_is_no_pinyin_target() {
5780        let fixture = fixture();
5781        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
5782        session.digits = "64".into();
5783        session.refresh();
5784        assert!(!session.spellings.is_empty());
5785        session.set_english_only(true);
5786        assert!(session.spellings.is_empty());
5787        session.set_english_only(false);
5788        assert!(!session.spellings.is_empty());
5789        session.pinyin = false;
5790        session.refresh();
5791        assert!(session.spellings.is_empty());
5792        session.pinyin = true;
5793        session.refresh();
5794        assert!(!session.spellings.is_empty());
5795        // 没有撤销选项的全锁定状态也不能带出上一次的读音。
5796        session.locked = vec!["ni".to_owned()];
5797        session.refresh();
5798        assert!(session.spellings.is_empty());
5799        session.locked.clear();
5800        session.refresh();
5801        assert!(!session.spellings.is_empty());
5802        session.command(Command::Cancel);
5803        assert!(session.spellings.is_empty());
5804    }
5805
5806    #[test]
5807    fn locked_refresh_reuses_the_existing_ranking_context_prefix() {
5808        let fixture = fixture();
5809        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
5810        type_digits(&mut session, "64426");
5811        assert!(
5812            session
5813                .choose_spelling(spelling_index(&session, "ni"))
5814                .handled
5815        );
5816        let (_, allocations) =
5817            crate::ime::personal_rerank::allocations::count(|| session.refresh());
5818        assert!(
5819            allocations <= 106,
5820            "locked refresh rebuilt the ranking context prefix: {allocations}"
5821        );
5822    }
5823
5824    #[test]
5825    fn finish_reuses_the_first_commit_buffer() {
5826        let fixture = fixture_with(INITIALS_FIXTURE);
5827        let mut session = open(&fixture.paths, true, EnglishInputOptions::default());
5828        type_digits(&mut session, "963443842662");
5829        assert!(session.select(index_of(&session, "我滴")).handled);
5830
5831        let (_, allocations) =
5832            crate::ime::personal_rerank::allocations::count(|| session.finish(0));
5833        assert!(
5834            allocations <= 32,
5835            "九键结束组字复制了首段提交文本:{allocations} 次分配"
5836        );
5837    }
5838
5839    #[test]
5840    fn choosing_a_spelling_with_a_split_does_not_allocate_a_second_partition() {
5841        let fixture = fixture();
5842        let mut plain = open(&fixture.paths, false, mixed());
5843        type_digits(&mut plain, "64426");
5844        let index = plain
5845            .spellings
5846            .iter()
5847            .position(|spelling| spelling == "ni")
5848            .unwrap();
5849        let (_, plain_allocations) =
5850            crate::ime::personal_rerank::allocations::count(|| plain.choose_spelling(index));
5851
5852        let mut session = open(&fixture.paths, false, mixed());
5853        type_digits(&mut session, "64426");
5854        session.splits = vec![2];
5855        session.refresh();
5856        let index = session
5857            .spellings
5858            .iter()
5859            .position(|spelling| spelling == "ni")
5860            .unwrap();
5861        let (_, allocations) =
5862            crate::ime::personal_rerank::allocations::count(|| session.choose_spelling(index));
5863
5864        assert!(
5865            allocations <= plain_allocations,
5866            "九键选择带切分的音节分配多于无切分路径:{allocations} > {plain_allocations}"
5867        );
5868    }
5869
5870    #[test]
5871    fn english_keypad_filters_full_code_before_prefix_limit() {
5872        for grid in [KeyGrid::NineKey, KeyGrid::FourteenKey] {
5873            for english_only in [false, true] {
5874                let fixture = fixture();
5875                Connection::open(fixture.paths.dictionary(assets::ENGLISH_DICTIONARY))
5876                    .unwrap()
5877                    .execute_batch("INSERT INTO english_words VALUES('previewing','previewing',1),('previewsa','previewsa',100),('previewsb','previewsb',100),('previewsc','previewsc',100),('previewsd','previewsd',100),('previewse','previewse',100);")
5878                    .unwrap();
5879                let mut session = open(&fixture.paths, false, mixed());
5880                session.set_grid(grid);
5881                session.set_english_only(english_only);
5882                type_digits(&mut session, &grid.encode("previewing"));
5883                assert!(
5884                    words(&session).iter().any(|word| word == "previewing"),
5885                    "完整组码不能被不匹配的高权重前缀词挤掉:{grid:?},专用英文={english_only}"
5886                );
5887                let result = session.select(index_of(&session, "previewing"));
5888                assert_eq!(result.commit.as_deref(), Some("previewing"));
5889            }
5890        }
5891    }
5892
5893    #[test]
5894    fn english_keypad_prefix_limit_uses_final_ranking() {
5895        let fixture = fixture();
5896        Connection::open(fixture.paths.dictionary(assets::ENGLISH_DICTIONARY))
5897            .unwrap()
5898            .execute_batch("INSERT INTO english_words VALUES('commit','COMMIT',1),('commita','commita',100),('commitb','commitb',100),('commitc','commitc',100),('commitd','commitd',100),('commite','commite',100);")
5899            .unwrap();
5900        let mut session = open(&fixture.paths, false, mixed());
5901        session.set_english_only(true);
5902        type_digits(&mut session, "266648");
5903        assert_eq!(words(&session).first().map(String::as_str), Some("COMMIT"));
5904        assert_eq!(words(&session).len(), ENGLISH_LIMIT);
5905    }
5906
5907    #[test]
5908    fn english_keypad_invalid_rows_do_not_consume_prefix_limit() {
5909        let fixture = fixture();
5910        let db = Connection::open(fixture.paths.dictionary(assets::ENGLISH_DICTIONARY)).unwrap();
5911        for lookup in [
5912            "commit!",
5913            "commit ",
5914            "commit9",
5915            "commit-é",
5916            "commit\0suffix",
5917            "commit",
5918        ] {
5919            let display = (lookup != "commit").then_some("合成无效词");
5920            db.execute(
5921                "INSERT INTO english_words VALUES(?1,?2,1000)",
5922                rusqlite::params![lookup, display],
5923            )
5924            .unwrap();
5925        }
5926        db.execute_batch("INSERT INTO english_words VALUES('commiT','don''t',1);")
5927            .unwrap();
5928        let mut session = open(&fixture.paths, false, mixed());
5929        session.set_english_only(true);
5930        type_digits(&mut session, "266648");
5931        assert_eq!(words(&session), ["don't"]);
5932    }
5933
5934    #[test]
5935    #[ignore = "需要通过 MSIME_TEST_ENGLISH_DICTIONARY 指定已校验的发布英文词库"]
5936    fn english_keypad_shipped_dictionary_reported_words() {
5937        let database =
5938            std::env::var_os("MSIME_TEST_ENGLISH_DICTIONARY").expect("指定已校验的发布英文词库");
5939        let fixture = fixture();
5940        std::fs::copy(
5941            database,
5942            fixture.paths.dictionary(assets::ENGLISH_DICTIONARY),
5943        )
5944        .unwrap();
5945        for grid in [KeyGrid::NineKey, KeyGrid::FourteenKey] {
5946            for english_only in [false, true] {
5947                for (lookup, display) in [
5948                    ("github", "GitHub"),
5949                    ("preview", "preview"),
5950                    ("commit", "commit"),
5951                    ("clash", "clash"),
5952                ] {
5953                    let mut session = open(&fixture.paths, false, mixed());
5954                    session.set_grid(grid);
5955                    session.set_english_only(english_only);
5956                    type_digits(&mut session, &grid.encode(lookup));
5957                    assert!(
5958                        words(&session).iter().any(|word| word == display),
5959                        "发布词库缺少候选:{display},{grid:?},专用英文={english_only}"
5960                    );
5961                    assert_eq!(
5962                        session
5963                            .select(index_of(&session, display))
5964                            .commit
5965                            .as_deref(),
5966                        Some(display)
5967                    );
5968                }
5969            }
5970        }
5971    }
5972
5973    #[test]
5974    fn english_t9_empty_matches_do_not_reserve_prefix_capacity() {
5975        let fixture = fixture();
5976        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
5977        session.set_english_only(true);
5978        session.digits = "999999".into();
5979        let words = session.english_candidates(false);
5980        assert!(words.is_empty());
5981        assert_eq!(words.capacity(), 0);
5982    }
5983
5984    #[test]
5985    fn english_t9_filtered_matches_do_not_reserve_prefix_capacity() {
5986        let fixture = fixture();
5987        Connection::open(fixture.paths.dictionary(assets::ENGLISH_DICTIONARY))
5988            .unwrap()
5989            .execute(
5990                "INSERT INTO english_words(word, display, weight) VALUES ('wwwa', '合成展示', 100)",
5991                [],
5992            )
5993            .unwrap();
5994        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
5995        session.set_english_only(true);
5996        session.digits = "999999".into();
5997        assert!(!session
5998            .open_english()
5999            .unwrap()
6000            .query_prefix("www", ENGLISH_LIMIT)
6001            .is_empty());
6002        let words = session.english_candidates(false);
6003        assert!(words.is_empty());
6004        assert_eq!(words.capacity(), 0);
6005    }
6006
6007    #[test]
6008    fn english_t9_hits_keep_the_original_prefix_capacity() {
6009        let fixture = fixture();
6010        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
6011        session.set_english_only(true);
6012        session.digits = "3668".into();
6013        let capacity =
6014            letter_prefixes(&session.digits, ENGLISH_PREFIX_BUDGET).len() * ENGLISH_LIMIT;
6015        Connection::open(fixture.paths.dictionary(assets::ENGLISH_DICTIONARY))
6016            .unwrap()
6017            .execute(
6018                "INSERT INTO english_words(word, display, weight) VALUES ('dont', '合成展示', 100)",
6019                [],
6020            )
6021            .unwrap();
6022        let words = session.english_candidates(false);
6023        assert!(words
6024            .iter()
6025            .any(|word| word.word == "合成展示" && word.pinyin == "dont"));
6026        assert_eq!(words.capacity(), capacity);
6027    }
6028
6029    #[test]
6030    fn english_refresh_does_not_allocate_a_copy_of_typed_digits() {
6031        let fixture = fixture();
6032        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
6033        session.set_english_only(true);
6034        session.digits = "3668".into();
6035        session.english_candidates(false);
6036
6037        let (_, allocations) =
6038            crate::ime::personal_rerank::allocations::count(|| session.english_candidates(false));
6039        assert!(
6040            allocations < 157,
6041            "英文候选热刷新不应复制已输入的数字:{allocations} 次分配"
6042        );
6043    }
6044
6045    #[test]
6046    fn english_t9_uses_the_lookup_word_when_display_has_punctuation() {
6047        let fixture = fixture();
6048        Connection::open(fixture.paths.dictionary(assets::ENGLISH_DICTIONARY))
6049            .unwrap()
6050            .execute(
6051                "INSERT INTO english_words(word, display, weight) VALUES ('dont', 'don''t', 100)",
6052                [],
6053            )
6054            .unwrap();
6055        let mut session = open(
6056            &fixture.paths,
6057            false,
6058            EnglishInputOptions {
6059                mixed_candidates: false,
6060                ..EnglishInputOptions::default()
6061            },
6062        );
6063        session.set_english_only(true);
6064        type_digits(&mut session, "3668");
6065        assert!(
6066            words(&session).contains(&"don't".to_owned()),
6067            "T9 should match the lookup key even when the displayed word contains punctuation"
6068        );
6069    }
6070
6071    #[test]
6072    fn english_digits_take_no_split() {
6073        let fixture = fixture();
6074        let mut english_only = open(&fixture.paths, false, mixed());
6075        english_only.set_english_only(true);
6076        let mut without_pinyin = NineKeySession::new(
6077            &fixture.paths,
6078            false,
6079            FrequencyAdjustmentOptions::default(),
6080            FuzzyPinyinOptions::default(),
6081            mixed(),
6082            false,
6083            false,
6084        );
6085        for session in [&mut english_only, &mut without_pinyin] {
6086            type_digits(session, "65");
6087            assert!(!session.character(b'\'').handled);
6088            assert!(session.splits.is_empty());
6089            assert_eq!(session.snapshot().preedit, "65");
6090        }
6091    }
6092
6093    #[test]
6094    fn a_fuzzy_leading_row_has_no_reading() {
6095        let mut session = detached();
6096        session.digits = "94".into();
6097        let xi = spelled("西", "94", "xi", 100, CandidateSource::Database);
6098        assert_eq!(session.reading_for(Some(&xi)), "xi");
6099        // 知 reached through zi under `94`: cutting zhi to two letters would show zh.
6100        let mut zhi = spelled("知", "94", "zhi", 100, CandidateSource::Database);
6101        zhi.fuzzy = true;
6102        assert_eq!(session.reading_for(Some(&zhi)), "");
6103    }
6104
6105    #[test]
6106    fn split_keeps_both_sides_open_and_backspace_removes_it_first() {
6107        let table = SpellingTable::new(&["xi", "yi", "an", "xian", "yan"]);
6108        assert_eq!(
6109            table
6110                .split_paths("9426", &[], &SyllablePrior::default())
6111                .first(),
6112            Some(&vec!["xian".to_string()])
6113        );
6114        let split = table.split_paths("9426", &[2], &SyllablePrior::default());
6115        assert!(!split.is_empty());
6116        assert!(
6117            split.iter().all(|path| path.len() == 2 && path[1] == "an"),
6118            "{split:?}"
6119        );
6120
6121        let fixture = fixture();
6122        let mut session = open(&fixture.paths, false, mixed());
6123        assert!(!session.character(b'\'').handled, "nothing to split");
6124        type_digits(&mut session, "64");
6125        assert!(session.character(b'\'').handled);
6126        assert!(
6127            session.character(b'\'').handled,
6128            "a repeated split is absorbed"
6129        );
6130        assert_eq!(session.snapshot().preedit, "64'");
6131        assert!(
6132            session
6133                .snapshot()
6134                .nine_key_spellings
6135                .iter()
6136                .all(|s| s.len() <= 2),
6137            "no syllable runs past the split"
6138        );
6139        type_digits(&mut session, "426");
6140        assert_eq!(session.snapshot().preedit, "64'426");
6141        assert_eq!(words(&session).first().map(String::as_str), Some("你好"));
6142        assert_eq!(session.snapshot().nine_key_reading, "ni'hao");
6143        assert!(session.command(Command::Backspace).handled);
6144        assert_eq!(session.snapshot().preedit, "64'42");
6145        type_digits(&mut session, "6");
6146        let result = session.select(index_of(&session, "你"));
6147        assert_eq!(result.commit.as_deref(), Some("你"));
6148        assert_eq!(
6149            session.snapshot().preedit,
6150            "426",
6151            "the split went with the consumed digits"
6152        );
6153
6154        session.command(Command::Cancel);
6155        type_digits(&mut session, "64");
6156        session.character(b'\'');
6157        assert!(session.command(Command::Backspace).handled);
6158        assert_eq!(
6159            session.snapshot().preedit,
6160            "64",
6161            "Backspace takes the split before a digit"
6162        );
6163    }
6164
6165    #[test]
6166    fn spellings_lock_and_partial_selection_advances() {
6167        let fixture = fixture();
6168        let mut session = open(&fixture.paths, false, mixed());
6169        type_digits(&mut session, "64");
6170        let before = session.snapshot();
6171        assert!(!session.select(999).handled && !session.choose_spelling(999).handled);
6172        assert_eq!(session.snapshot(), before);
6173        assert_eq!(
6174            before.nine_key_spellings,
6175            [
6176                "ni", "mi", "min", "niu", "nin", "mie", "nie", "miu", "nian", "mian", "ming",
6177                "miao", "ning", "niao", "niang", "o", "M", "N", "O", "6"
6178            ],
6179            "the preferred spelling leads, the key's letters and digit follow"
6180        );
6181        assert!(session.choose_spelling(0).handled);
6182        assert_eq!(session.snapshot().preedit, "ni");
6183        assert_eq!(words(&session), ["你"], "the lock keeps 米 out");
6184        type_digits(&mut session, "426");
6185        let view = session.snapshot();
6186        assert_eq!(view.preedit, "ni'426");
6187        assert_eq!(
6188            view.nine_key_spellings,
6189            ["hao", "gao", "gan", "han", "hang", "gang", "ha", "ga", "G", "H"],
6190            "no i, which starts no syllable, and no digit behind a lock"
6191        );
6192        let result = session.select(index_of(&session, "你好"));
6193        assert_eq!(result.commit.as_deref(), Some("你好"));
6194        assert!(!session.active() && session.snapshot().preedit.is_empty());
6195
6196        type_digits(&mut session, "64426");
6197        let ni = session
6198            .snapshot()
6199            .nine_key_spellings
6200            .iter()
6201            .position(|s| s == "ni")
6202            .expect("ni offered");
6203        session.choose_spelling(ni);
6204        let hao_choices = session.snapshot().nine_key_spellings;
6205        session.choose_spelling(0);
6206        assert_eq!(session.snapshot().preedit, "ni'hao");
6207        assert_eq!(
6208            session.snapshot().nine_key_spellings,
6209            hao_choices,
6210            "every digit locked still offers the last lock's choices"
6211        );
6212        let result = session.select(index_of(&session, "你"));
6213        assert_eq!(result.commit.as_deref(), Some("你"));
6214        let view = session.snapshot();
6215        assert_eq!(view.editing_text, "426");
6216        assert_eq!(
6217            view.preedit, "hao",
6218            "partial selection lost the locked suffix"
6219        );
6220        let result = session.finish(0);
6221        assert_eq!(result.commit.as_deref(), Some("好"));
6222        assert!(!session.active());
6223
6224        // A longer spelling extends the digits to its whole code.
6225        type_digits(&mut session, "6");
6226        let ming = session
6227            .snapshot()
6228            .nine_key_spellings
6229            .iter()
6230            .position(|s| s == "ming")
6231            .expect("ming offered");
6232        session.choose_spelling(ming);
6233        assert_eq!(session.snapshot().editing_text, "6464");
6234        assert_eq!(session.snapshot().preedit, "ming");
6235    }
6236
6237    fn spelling_index(session: &NineKeySession, spelling: &str) -> usize {
6238        session
6239            .snapshot()
6240            .nine_key_spellings
6241            .iter()
6242            .position(|s| s == spelling)
6243            .unwrap_or_else(|| panic!("missing spelling {spelling}"))
6244    }
6245
6246    #[test]
6247    fn backspace_takes_back_the_last_lock_only_while_every_digit_is_locked() {
6248        let fixture = fixture();
6249        let mut session = open(&fixture.paths, false, mixed());
6250        type_digits(&mut session, "6464224");
6251        session.choose_spelling(spelling_index(&session, "ning"));
6252        assert_eq!(session.snapshot().preedit, "ning'224");
6253        let after_ning = session.snapshot().nine_key_spellings;
6254        assert!(after_ning.contains(&"bai".to_owned()) && after_ning.contains(&"cai".to_owned()));
6255        session.choose_spelling(spelling_index(&session, "bai"));
6256        assert_eq!(session.snapshot().preedit, "ning'bai");
6257        assert_eq!(session.snapshot().nine_key_spellings, after_ning);
6258
6259        session.choose_spelling(spelling_index(&session, "cai"));
6260        assert_eq!(
6261            session.snapshot().preedit,
6262            "ning'cai",
6263            "picking again swaps the last lock"
6264        );
6265        assert_eq!(session.locked, ["ning", "cai"]);
6266        assert_eq!(session.snapshot().nine_key_spellings, after_ning);
6267
6268        assert!(session.command(Command::Backspace).handled);
6269        assert_eq!(session.snapshot().preedit, "ning'224");
6270        assert_eq!(session.snapshot().nine_key_spellings, after_ning);
6271        assert!(session.command(Command::Backspace).handled);
6272        assert_eq!(
6273            session.locked,
6274            ["ning"],
6275            "with digits open a digit goes, not the lock"
6276        );
6277        assert_eq!(session.snapshot().preedit, "ning'22");
6278    }
6279
6280    #[test]
6281    fn taking_back_a_lock_restores_the_digits_it_extended() {
6282        let fixture = fixture();
6283        let mut session = open(&fixture.paths, false, mixed());
6284        type_digits(&mut session, "6");
6285        session.choose_spelling(spelling_index(&session, "ming"));
6286        assert_eq!(session.snapshot().editing_text, "6464");
6287        session.command(Command::Backspace);
6288        assert_eq!(session.snapshot().editing_text, "6");
6289        assert!(session.locked.is_empty());
6290        assert!(session
6291            .snapshot()
6292            .nine_key_spellings
6293            .contains(&"ming".to_owned()));
6294    }
6295
6296    #[test]
6297    fn a_split_dropped_by_a_lock_comes_back_with_it() {
6298        let fixture = fixture();
6299        let mut session = open(&fixture.paths, false, mixed());
6300        type_digits(&mut session, "64");
6301        session.character(b'\'');
6302        session.choose_spelling(spelling_index(&session, "ni"));
6303        assert!(session.splits.is_empty());
6304        assert_eq!(session.snapshot().preedit, "ni");
6305        session.command(Command::Backspace);
6306        assert_eq!(
6307            session.snapshot().preedit,
6308            "64'",
6309            "the split is back with the digits"
6310        );
6311        session.command(Command::Backspace);
6312        assert_eq!(session.snapshot().preedit, "64");
6313    }
6314
6315    #[test]
6316    fn a_key_letter_narrows_the_next_syllable_and_backspace_takes_it_back() {
6317        let fixture = fixture();
6318        let mut session = open(&fixture.paths, false, mixed());
6319        type_digits(&mut session, "64");
6320        // 你好 是 `64` 的简拼行(n'h);选了首字母后不查简拼,它和英文词一起退出。
6321        assert_eq!(words(&session), ["你", "米", "你好", "ogham"]);
6322        assert!(
6323            session
6324                .choose_spelling(spelling_index(&session, "M"))
6325                .handled
6326        );
6327        let view = session.snapshot();
6328        assert_eq!(view.preedit, "m4");
6329        assert_eq!(words(&session), ["米"]);
6330        assert!(view
6331            .nine_key_spellings
6332            .iter()
6333            .filter(|s| s.len() > 1)
6334            .all(|s| s.starts_with('m')));
6335        assert!(view.nine_key_spellings.ends_with(&[
6336            "M".into(),
6337            "N".into(),
6338            "O".into(),
6339            "6".into()
6340        ]));
6341
6342        session.choose_spelling(spelling_index(&session, "mi"));
6343        assert_eq!(session.snapshot().preedit, "mi");
6344        session.command(Command::Backspace);
6345        assert_eq!(
6346            session.snapshot().preedit,
6347            "m4",
6348            "taking back the lock restores the letter"
6349        );
6350        session.command(Command::Backspace);
6351        assert_eq!(session.snapshot().preedit, "64");
6352        assert_eq!(words(&session), ["你", "米", "你好", "ogham"]);
6353    }
6354
6355    #[test]
6356    fn a_letter_survives_typing_but_not_deleting_past_it() {
6357        let fixture = fixture();
6358        let mut session = open(&fixture.paths, false, mixed());
6359        type_digits(&mut session, "64");
6360        session.choose_spelling(spelling_index(&session, "N"));
6361        type_digits(&mut session, "4");
6362        session.command(Command::Backspace);
6363        assert_eq!(
6364            session.snapshot().preedit,
6365            "n4",
6366            "the typed digit goes first"
6367        );
6368        session.command(Command::Backspace);
6369        assert_eq!(session.snapshot().preedit, "64");
6370
6371        type_digits(&mut session, "426");
6372        session.choose_spelling(spelling_index(&session, "N"));
6373        session.command(Command::Cancel);
6374        type_digits(&mut session, "64");
6375        assert_eq!(
6376            session.snapshot().preedit,
6377            "64",
6378            "a new composition starts without it"
6379        );
6380    }
6381
6382    #[test]
6383    fn the_key_digit_commits_itself_and_the_rest_keeps_composing() {
6384        let fixture = fixture();
6385        let mut session = open(&fixture.paths, false, mixed());
6386        type_digits(&mut session, "64");
6387        let result = session.choose_spelling(spelling_index(&session, "6"));
6388        assert_eq!(result.commit.as_deref(), Some("6"));
6389        assert_eq!(session.snapshot().editing_text, "4");
6390    }
6391
6392    /// 左列的数字原样上屏一位:正在拼的词里夹了这一位就不再存(否则选完时会把它前后的两段连成一个错的词);数字上屏完时组字结束,正在拼的词随之作废。
6393    #[test]
6394    fn a_key_digit_inside_a_phrase_keeps_it_from_being_stored() {
6395        let fixture = fixture();
6396        let mut session = open(&fixture.paths, true, mixed());
6397        type_digits(&mut session, "64426");
6398        session.select(index_of(&session, "你"));
6399        let result = session.choose_spelling(spelling_index(&session, "4"));
6400        assert_eq!(result.commit.as_deref(), Some("4"));
6401        assert!(!session.phrase_storable);
6402        assert_eq!(session.phrase_word, "你4");
6403        session.command(Command::Cancel);
6404
6405        type_digits(&mut session, "644");
6406        session.select(index_of(&session, "你"));
6407        session.choose_spelling(spelling_index(&session, "4"));
6408        assert!(!session.active());
6409        assert!(session.phrase_word.is_empty() && session.phrase_storable);
6410    }
6411
6412    #[test]
6413    fn single_character_filter_keeps_single_characters_until_the_composition_ends() {
6414        let fixture = fixture();
6415        let mut session = open(&fixture.paths, false, mixed());
6416        assert!(!session.set_filter(true, "").handled, "nothing composing");
6417        type_digits(&mut session, "64426");
6418        assert!(session.set_filter(true, "").handled);
6419        assert!(session.snapshot().nine_key_single_character);
6420        assert_eq!(words(&session), ["你", "米"]);
6421        assert!(session.set_filter(false, "").handled);
6422        assert!(words(&session).contains(&"你好".to_owned()));
6423        session.set_filter(true, "");
6424        session.command(Command::Cancel);
6425        type_digits(&mut session, "64426");
6426        assert!(!session.snapshot().nine_key_single_character);
6427        assert!(words(&session).contains(&"ogham".to_owned()));
6428    }
6429
6430    #[test]
6431    fn stroke_filter_matches_the_first_character_stroke_prefix() {
6432        let fixture = fixture();
6433        let strokes = fixture.directory.path().join("msime-stroke.db");
6434        crate::stroke::fixture::build(&strokes);
6435        Connection::open(&strokes)
6436            .unwrap()
6437            .execute_batch(
6438                "INSERT INTO entries VALUES ('pspzspn', '你', 100); INSERT INTO entries VALUES ('nphspn', '米', 100);",
6439            )
6440            .unwrap();
6441        let mut session = open(&fixture.paths, false, mixed());
6442        type_digits(&mut session, "64426");
6443        assert_eq!(
6444            session.set_filter(false, "p").diagnostic.as_deref(),
6445            Some(diagnostics::LANGUAGE_DICTIONARY_UNAVAILABLE),
6446            "no stroke dictionary yet"
6447        );
6448        assert!(session.snapshot().nine_key_strokes.is_empty());
6449
6450        session.set_stroke_dictionary(strokes);
6451        assert!(!session.set_filter(false, "q").handled, "not a stroke");
6452        assert!(session.set_filter(false, "ps").handled);
6453        assert_eq!(session.snapshot().nine_key_strokes, "ps");
6454        assert_eq!(words(&session), ["你好", "你"]);
6455        session.set_filter(true, "n");
6456        assert_eq!(words(&session), ["米"]);
6457        session.set_filter(false, "z");
6458        assert!(words(&session).is_empty());
6459        session.set_filter(false, "");
6460        assert!(words(&session).contains(&"米好".to_owned()));
6461    }
6462
6463    /// 只出单字时拼音读法只剩单字,英文行照旧;选一个字后剩下的数字接着出单字。
6464    #[test]
6465    fn single_character_only_keeps_one_character_readings() {
6466        let fixture = fixture();
6467        let mut session = NineKeySession::new(
6468            &fixture.paths,
6469            false,
6470            FrequencyAdjustmentOptions::default(),
6471            FuzzyPinyinOptions::default(),
6472            mixed(),
6473            true,
6474            true,
6475        );
6476        type_digits(&mut session, "64426");
6477        assert_eq!(words(&session), ["你", "米", "ogham"]);
6478        let result = session.select(index_of(&session, "你"));
6479        assert_eq!(result.commit.as_deref(), Some("你"));
6480        assert_eq!(session.snapshot().editing_text, "426");
6481        assert_eq!(words(&session), ["好"]);
6482        let result = session.finish(0);
6483        assert_eq!(result.commit.as_deref(), Some("好"));
6484        assert!(!session.active());
6485    }
6486
6487    #[test]
6488    fn commands_edit_commit_and_cancel_the_digits() {
6489        let fixture = fixture();
6490        let mut session = open(&fixture.paths, false, mixed());
6491        assert!(!session.command(Command::Backspace).handled && !session.finish(0).handled);
6492        type_digits(&mut session, "64");
6493        session.command(Command::Backspace);
6494        assert_eq!(session.snapshot().editing_text, "6");
6495        session.command(Command::Backspace);
6496        assert!(
6497            !session.command(Command::Backspace).handled,
6498            "idle backspace"
6499        );
6500
6501        type_digits(&mut session, "64");
6502        session.choose_spelling(0);
6503        session.command(Command::Backspace);
6504        assert!(
6505            session.locked.is_empty(),
6506            "backspace first takes back a lock that covers every digit"
6507        );
6508        assert_eq!(session.snapshot().preedit, "64");
6509        session.command(Command::Backspace);
6510        assert_eq!(session.snapshot().preedit, "6");
6511        session.command(Command::Cancel);
6512
6513        type_digits(&mut session, "64");
6514        // 光标可以移进数字中间(#5613),上屏原样数字时仍是整串。
6515        assert!(session.command(Command::MoveLeft).handled);
6516        assert_eq!(session.snapshot().caret_position, 1);
6517        assert_eq!(
6518            session.command(Command::CommitRaw).commit.as_deref(),
6519            Some("64")
6520        );
6521        assert_eq!(
6522            session.snapshot().caret_position,
6523            0,
6524            "a new composition starts at its end"
6525        );
6526        assert!(!session.active());
6527        type_digits(&mut session, "64");
6528        assert_eq!(session.finish(999).commit.as_deref(), Some("64"));
6529        type_digits(&mut session, "64426");
6530        assert_eq!(
6531            session.command(Command::CommitCandidate).commit.as_deref(),
6532            Some("你好")
6533        );
6534        type_digits(&mut session, "64");
6535        session.command(Command::Cancel);
6536        let view = session.snapshot();
6537        assert!(
6538            view.preedit.is_empty()
6539                && view.nine_key_spellings.is_empty()
6540                && view.candidates.is_empty()
6541        );
6542
6543        type_digits(&mut session, &"7".repeat(DIGIT_LIMIT));
6544        // 到上限但没有候选可选:运行时不能替用户选首选,这一键照旧拦下。
6545        assert!(!session.limit_pick_ready(b'7'));
6546        let result = session.character(b'7');
6547        assert!(result.handled);
6548        assert_eq!(
6549            result.diagnostic.as_deref(),
6550            Some(diagnostics::NINE_KEY_DIGIT_LIMIT)
6551        );
6552        assert_eq!(
6553            result.diagnostic_code(),
6554            Some(diagnostics::DiagnosticCode::NineKeyDigitLimit)
6555        );
6556        assert_eq!(session.snapshot().editing_text.len(), DIGIT_LIMIT);
6557        assert!(session.snapshot().candidates.is_empty());
6558        // Finishing with nothing to choose commits the digits as typed.
6559        assert_eq!(
6560            session.finish(0).commit.as_deref(),
6561            Some("7".repeat(DIGIT_LIMIT).as_str())
6562        );
6563
6564        // 到上限、首选占用组码、光标在末尾时,运行时可以先选掉首选再接着打;光标移到中间时不行。不是组码的键不算。
6565        let long = "64426".repeat(DIGIT_LIMIT / 5 + 1);
6566        type_digits(&mut session, &long[..DIGIT_LIMIT]);
6567        assert!(!session.snapshot().candidates.is_empty());
6568        assert!(session.limit_pick_ready(b'6'));
6569        assert!(!session.limit_pick_ready(b'1'));
6570        session.command(Command::MoveLeft);
6571        assert!(!session.limit_pick_ready(b'6'));
6572        session.command(Command::MoveEnd);
6573        assert!(session.limit_pick_ready(b'6'));
6574        // 提示里写的上限和常量一致。
6575        assert!(diagnostics::NINE_KEY_DIGIT_LIMIT.contains(&DIGIT_LIMIT.to_string()));
6576        session.command(Command::Cancel);
6577    }
6578
6579    /// #5613:光标移进数字中间后,退格、向后删除、打数字和切分都作用在光标处,用来改掉中间打错的那个数字。
6580    #[test]
6581    fn the_caret_edits_digits_in_the_middle() {
6582        let fixture = fixture();
6583        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
6584        // 想打 64426(你好),中间的 4 错打成了 5。
6585        type_digits(&mut session, "64526");
6586        assert_eq!(session.snapshot().caret_position, 5);
6587        for _ in 0..2 {
6588            assert!(session.command(Command::MoveLeft).handled);
6589        }
6590        assert_eq!(session.snapshot().caret_position, 3);
6591        assert!(session.command(Command::Backspace).handled);
6592        assert_eq!(session.snapshot().editing_text, "6426");
6593        assert_eq!(session.snapshot().caret_position, 2);
6594        type_digits(&mut session, "4");
6595        let view = session.snapshot();
6596        assert_eq!(view.editing_text, "64426");
6597        assert_eq!(view.caret_position, 3);
6598        assert_eq!(
6599            view.candidates[0].word, "你好",
6600            "the whole digits are decoded again"
6601        );
6602
6603        // 到头和到尾都停住;移到末尾后光标回到「在末尾」。
6604        assert!(session.command(Command::MoveHome).handled);
6605        assert_eq!(session.snapshot().caret_position, 0);
6606        assert!(session.command(Command::MoveLeft).handled);
6607        assert_eq!(session.snapshot().caret_position, 0);
6608        assert!(
6609            session.command(Command::Backspace).handled,
6610            "backspace at the start does nothing"
6611        );
6612        assert_eq!(session.snapshot().editing_text, "64426");
6613        assert!(session.command(Command::DeleteForward).handled);
6614        assert_eq!(session.snapshot().editing_text, "4426");
6615        assert_eq!(session.snapshot().caret_position, 0);
6616        type_digits(&mut session, "6");
6617        assert!(session.command(Command::MoveEnd).handled);
6618        assert!(session.command(Command::MoveRight).handled);
6619        assert_eq!(session.snapshot().caret_position, 5);
6620        assert!(
6621            session.command(Command::DeleteForward).handled,
6622            "delete at the end does nothing"
6623        );
6624        assert_eq!(session.snapshot().editing_text, "64426");
6625        session.command(Command::Cancel);
6626
6627        // 切分打在光标处;光标紧跟在切分后面时,退格先删掉切分。
6628        type_digits(&mut session, "6426");
6629        session.command(Command::MoveLeft);
6630        session.command(Command::MoveLeft);
6631        assert!(session.character(b'\'').handled);
6632        assert_eq!(session.snapshot().preedit, "64'26");
6633        type_digits(&mut session, "4");
6634        assert_eq!(session.snapshot().preedit, "64'426");
6635        assert_eq!(session.snapshot().caret_position, 3);
6636        session.command(Command::MoveLeft);
6637        assert!(session.command(Command::Backspace).handled);
6638        assert_eq!(session.snapshot().preedit, "64426");
6639        assert_eq!(session.snapshot().caret_position, 2);
6640        session.command(Command::Cancel);
6641
6642        // 在锁定的拼写里改数字,从那个音节起解除锁定。
6643        type_digits(&mut session, "64426");
6644        let ni = session
6645            .snapshot()
6646            .nine_key_spellings
6647            .iter()
6648            .position(|spelling| spelling == "ni")
6649            .expect("ni offered");
6650        session.choose_spelling(ni);
6651        assert_eq!(session.snapshot().preedit, "ni'426");
6652        session.command(Command::MoveHome);
6653        session.command(Command::MoveRight);
6654        type_digits(&mut session, "4");
6655        assert!(
6656            session.locked.is_empty(),
6657            "an edit inside a locked spelling unlocks it"
6658        );
6659        assert_eq!(session.snapshot().editing_text, "644426");
6660
6661        // 选掉前面一段后光标回到末尾,接着打的数字接在剩下的数字后面。
6662        session.command(Command::Cancel);
6663        type_digits(&mut session, "64426");
6664        session.command(Command::MoveLeft);
6665        let ni = session.select(index_of(&session, "你"));
6666        assert_eq!(ni.commit.as_deref(), Some("你"));
6667        let view = session.snapshot();
6668        assert_eq!(view.editing_text, "426");
6669        assert_eq!(view.caret_position, 3);
6670        type_digits(&mut session, "6");
6671        assert_eq!(session.snapshot().editing_text, "4266");
6672        session.command(Command::Cancel);
6673
6674        // 切在开头或锁定拼写之间的边界上没有作用,锁定的拼写都留着;切在锁定拼写中间时从那个音节起解除锁定。
6675        type_digits(&mut session, "64426");
6676        for spelling in ["ni", "hao"] {
6677            let index = session
6678                .snapshot()
6679                .nine_key_spellings
6680                .iter()
6681                .position(|offered| offered == spelling)
6682                .unwrap_or_else(|| panic!("{spelling} offered"));
6683            session.choose_spelling(index);
6684        }
6685        assert_eq!(session.snapshot().preedit, "ni'hao");
6686        let before = session.snapshot();
6687        session.command(Command::MoveHome);
6688        assert!(session.character(b'\'').handled);
6689        session.command(Command::MoveRight);
6690        session.command(Command::MoveRight);
6691        assert!(session.character(b'\'').handled);
6692        assert_eq!(session.locked, ["ni", "hao"]);
6693        let after = session.snapshot();
6694        assert_eq!(after.preedit, before.preedit);
6695        assert_eq!(after.candidates, before.candidates);
6696        assert_eq!(after.nine_key_reading, before.nine_key_reading);
6697        session.command(Command::MoveRight);
6698        assert!(session.character(b'\'').handled);
6699        assert_eq!(session.locked, ["ni"]);
6700        assert_eq!(session.snapshot().preedit, "ni'4'26");
6701    }
6702
6703    /// #5613 的光标与左列的首字母、锁定的撤销记录和换选:退格只在光标在末尾时撤销最后一步,光标在中间时是在那里改字;改到锁定的拼写时撤销记录随锁定一起丢掉;取消把光标和这些状态一起复位。
6704    #[test]
6705    fn the_caret_meets_key_letters_and_taking_back_locks() {
6706        let fixture = fixture();
6707        let mut session = open(&fixture.paths, false, mixed());
6708
6709        // 选了首字母后在中间退格,删的是光标前的数字,首字母留着。
6710        type_digits(&mut session, "644");
6711        session.choose_spelling(spelling_index(&session, "N"));
6712        assert_eq!(session.snapshot().preedit, "n44");
6713        session.command(Command::MoveLeft);
6714        assert!(session.command(Command::Backspace).handled);
6715        let view = session.snapshot();
6716        assert_eq!(view.preedit, "n4");
6717        assert_eq!(view.caret_position, 1);
6718        // 在选了首字母的那一位前面插数字,或者删掉那一位,首字母都不再成立。
6719        session.command(Command::MoveHome);
6720        type_digits(&mut session, "9");
6721        assert!(session.initial.is_none());
6722        assert_eq!(session.snapshot().preedit, "964");
6723        session.command(Command::Cancel);
6724        type_digits(&mut session, "644");
6725        session.choose_spelling(spelling_index(&session, "N"));
6726        session.command(Command::MoveHome);
6727        assert!(session.command(Command::DeleteForward).handled);
6728        assert!(session.initial.is_none());
6729        assert_eq!(session.snapshot().preedit, "44");
6730        session.command(Command::Cancel);
6731
6732        // 数字全部锁定时在中间退格:删光标前的数字,盖住它的锁定连同撤销记录一起解除,前面的锁定留着。
6733        type_digits(&mut session, "6464224");
6734        session.choose_spelling(spelling_index(&session, "ning"));
6735        session.choose_spelling(spelling_index(&session, "bai"));
6736        assert_eq!(session.snapshot().preedit, "ning'bai");
6737        session.command(Command::MoveLeft);
6738        session.command(Command::MoveLeft);
6739        assert!(session.command(Command::Backspace).handled);
6740        assert_eq!(session.locked, ["ning"]);
6741        assert_eq!(session.lock_undo.len(), 1);
6742        let view = session.snapshot();
6743        assert_eq!(view.preedit, "ning'24");
6744        assert_eq!(view.caret_position, 4);
6745        session.command(Command::Cancel);
6746
6747        // 在锁定的拼写里插数字后没有锁定可撤销,回到末尾退格删的是数字。
6748        type_digits(&mut session, "64426");
6749        session.choose_spelling(spelling_index(&session, "ni"));
6750        session.choose_spelling(spelling_index(&session, "hao"));
6751        session.command(Command::MoveHome);
6752        session.command(Command::MoveRight);
6753        type_digits(&mut session, "4");
6754        assert!(session.locked.is_empty() && session.lock_undo.is_empty());
6755        session.command(Command::MoveEnd);
6756        session.command(Command::Backspace);
6757        assert_eq!(session.snapshot().editing_text, "64442");
6758        session.command(Command::Cancel);
6759
6760        // 换选先撤销最后一次锁定:光标在键入的数字上时位置不变,落在拼写补齐的数字里时回到末尾。
6761        type_digits(&mut session, "64");
6762        session.choose_spelling(spelling_index(&session, "ming"));
6763        assert_eq!(session.snapshot().editing_text, "6464");
6764        session.command(Command::MoveHome);
6765        session.command(Command::MoveRight);
6766        session.choose_spelling(spelling_index(&session, "ni"));
6767        let view = session.snapshot();
6768        assert_eq!(
6769            (view.preedit.as_str(), view.editing_text.as_str()),
6770            ("ni", "64")
6771        );
6772        assert_eq!(view.caret_position, 1);
6773        session.choose_spelling(spelling_index(&session, "ming"));
6774        assert_eq!(session.snapshot().caret_position, 1);
6775        session.command(Command::MoveEnd);
6776        session.command(Command::MoveLeft);
6777        assert_eq!(session.snapshot().caret_position, 3);
6778        session.choose_spelling(spelling_index(&session, "ni"));
6779        let view = session.snapshot();
6780        assert_eq!(view.editing_text, "64");
6781        assert_eq!(view.caret_position, 2);
6782        session.command(Command::Cancel);
6783
6784        // 取消把锁定、撤销记录、首字母和光标一起清掉。
6785        type_digits(&mut session, "64426");
6786        session.choose_spelling(spelling_index(&session, "ni"));
6787        session.choose_spelling(spelling_index(&session, "H"));
6788        session.command(Command::MoveHome);
6789        assert!(session.command(Command::Cancel).handled);
6790        assert!(session.locked.is_empty() && session.lock_undo.is_empty());
6791        assert!(session.initial.is_none() && session.caret.is_none());
6792        type_digits(&mut session, "64");
6793        let view = session.snapshot();
6794        assert_eq!(view.preedit, "64");
6795        assert_eq!(view.caret_position, 2);
6796        session.command(Command::Cancel);
6797
6798        // 左列的数字直接上屏第一位,与选中候选一样光标回到末尾。
6799        type_digits(&mut session, "64426");
6800        session.command(Command::MoveHome);
6801        session.command(Command::MoveRight);
6802        let result = session.choose_spelling(spelling_index(&session, "6"));
6803        assert_eq!(result.commit.as_deref(), Some("6"));
6804        let view = session.snapshot();
6805        assert_eq!(view.editing_text, "4426");
6806        assert_eq!(view.caret_position, 4);
6807    }
6808
6809    #[test]
6810    fn learning_and_management_use_canonical_keys() {
6811        let fixture = fixture();
6812        let mut session = open(&fixture.paths, false, mixed());
6813        type_digits(&mut session, "64");
6814        assert_eq!(
6815            session.select(index_of(&session, "米")).commit.as_deref(),
6816            Some("米")
6817        );
6818        let mut unchanged = open(&fixture.paths, false, mixed());
6819        type_digits(&mut unchanged, "64");
6820        assert_eq!(
6821            words(&unchanged)[0],
6822            "你",
6823            "disabled learning changed ranking"
6824        );
6825
6826        let mut learner = open(&fixture.paths, true, mixed());
6827        type_digits(&mut learner, "64");
6828        let learned = learner.select(index_of(&learner, "米"));
6829        assert_eq!(learned.commit.as_deref(), Some("米"));
6830        assert_eq!(learned.diagnostic, None);
6831
6832        let mut managed = open(&fixture.paths, false, mixed());
6833        type_digits(&mut managed, "64");
6834        assert_eq!(words(&managed)[0], "米", "learning did not persist");
6835        let pinned = managed.pin(index_of(&managed, "你"));
6836        assert_eq!(pinned, KeyResult::handled());
6837        assert_eq!(managed.snapshot().editing_text, "64");
6838        assert_eq!(words(&managed)[0], "你");
6839        assert!(
6840            !managed.remove(index_of(&managed, "你")).handled,
6841            "single character was removed"
6842        );
6843        let ogham = index_of(&managed, "ogham");
6844        assert!(
6845            !managed.pin(ogham).handled,
6846            "an English row is not a pinyin dictionary row"
6847        );
6848        assert!(!managed.pin(999).handled && !managed.remove(999).handled);
6849        assert!(!managed.set_position(0, 6).handled && !managed.set_position(0, -1).handled);
6850        assert!(!managed.set_position(999, 0).handled);
6851        assert_eq!(
6852            managed.set_position(index_of(&managed, "米"), 1),
6853            KeyResult::handled()
6854        );
6855        assert_eq!(words(&managed)[0], "米", "fixed position not applied");
6856
6857        let mut fixed = open(&fixture.paths, false, mixed());
6858        type_digits(&mut fixed, "64");
6859        assert_eq!(words(&fixed)[0], "米", "fixed position not persisted");
6860        let ni = fixed
6861            .snapshot()
6862            .nine_key_spellings
6863            .iter()
6864            .position(|s| s == "ni")
6865            .expect("ni offered");
6866        fixed.choose_spelling(ni);
6867        assert_eq!(
6868            words(&fixed)[0],
6869            "你",
6870            "fixed position escaped the spelling constraint"
6871        );
6872
6873        assert_eq!(
6874            managed.set_position(index_of(&managed, "米"), 0),
6875            KeyResult::handled()
6876        );
6877        assert_eq!(words(&managed)[0], "你", "clear fixed position failed");
6878        managed.command(Command::Cancel);
6879        type_digits(&mut managed, "64426");
6880        let removed = managed.remove(index_of(&managed, "你好"));
6881        assert_eq!(removed, KeyResult::handled());
6882        assert_eq!(managed.snapshot().editing_text, "64426");
6883
6884        let mut after_removal = open(&fixture.paths, false, mixed());
6885        type_digits(&mut after_removal, "64426");
6886        // Only the dictionary row is gone; the two-syllable lattice may still compose 你好 from 你 and 好.
6887        assert!(
6888            !after_removal
6889                .snapshot()
6890                .candidates
6891                .iter()
6892                .any(|item| item.word == "你好" && item.source.is_dictionary()),
6893            "phrase removal did not persist"
6894        );
6895    }
6896
6897    #[test]
6898    fn failed_learning_keeps_the_commit() {
6899        let fixture = fixture();
6900        let mut paths = fixture.paths.clone();
6901        paths.user_data = fixture.directory.path().join("blocked");
6902        std::fs::create_dir_all(paths.user(assets::USER_JOURNAL)).expect("block the journal");
6903        let mut failing = open(&paths, true, mixed());
6904        type_digits(&mut failing, "64");
6905        let result = failing.finish(index_of(&failing, "米"));
6906        assert_eq!(result.commit.as_deref(), Some("米"));
6907        assert_eq!(
6908            result.diagnostic.as_deref(),
6909            Some(diagnostics::NINE_KEY_FREQUENCY_NOT_PERSISTED)
6910        );
6911        assert!(!failing.active() && failing.snapshot().preedit.is_empty());
6912    }
6913
6914    #[test]
6915    fn mixed_english_first_page_count_respects_the_page_budget() {
6916        let fixture = fixture();
6917        Connection::open(fixture.paths.dictionary(assets::ENGLISH_DICTIONARY))
6918            .unwrap()
6919            .execute_batch("INSERT INTO english_words VALUES('night','night',900),('nights','nights',800),('nightly','nightly',700);")
6920            .unwrap();
6921        for (count, page_rows, expected) in [
6922            (
6923                1,
6924                5,
6925                vec!["你", "night", "米", "你好", "nights", "nightly", "ogham"],
6926            ),
6927            (
6928                2,
6929                5,
6930                vec!["你", "night", "nights", "米", "你好", "nightly", "ogham"],
6931            ),
6932            (
6933                3,
6934                5,
6935                vec!["你", "night", "nights", "nightly", "米", "你好", "ogham"],
6936            ),
6937            (
6938                3,
6939                2,
6940                vec!["你", "night", "米", "你好", "nights", "nightly", "ogham"],
6941            ),
6942        ] {
6943            let mut session = open(
6944                &fixture.paths,
6945                false,
6946                EnglishInputOptions {
6947                    mixed_candidates: true,
6948                    minimum_prefix: 2,
6949                    first_page_count: count,
6950                },
6951            );
6952            session.set_first_page_rows(FirstPageRows::new(page_rows));
6953            type_digits(&mut session, "64");
6954            assert_eq!(
6955                words(&session),
6956                expected,
6957                "首页英文数={count},首页行数={page_rows}"
6958            );
6959            assert_eq!(
6960                session
6961                    .select(index_of(&session, "nights"))
6962                    .commit
6963                    .as_deref(),
6964                Some("nights")
6965            );
6966        }
6967    }
6968
6969    #[test]
6970    fn english_only_spells_words_from_the_first_digit() {
6971        let fixture = fixture();
6972        let options = EnglishInputOptions {
6973            mixed_candidates: false,
6974            minimum_prefix: 2,
6975            first_page_count: 1,
6976        };
6977        let mut words_only = open(&fixture.paths, false, options);
6978        words_only.set_english_only(true);
6979        type_digits(&mut words_only, "65");
6980        let view = words_only.snapshot();
6981        assert_eq!(words(&words_only), ["ok", "old", "older"]);
6982        assert!(
6983            view.nine_key_spellings.is_empty(),
6984            "english nine-key offered pinyin spellings"
6985        );
6986        assert!(
6987            view.dedicated_english,
6988            "the grid's snapshot dropped the English mode"
6989        );
6990        words_only.command(Command::Cancel);
6991        type_digits(&mut words_only, "653");
6992        assert_eq!(words(&words_only), ["old", "older"]);
6993        words_only.command(Command::Cancel);
6994
6995        // One digit is enough: the prefix length that quiets a mixed list means nothing when words are the whole list.
6996        type_digits(&mut words_only, "6");
6997        let result = words_only.select(index_of(&words_only, "old"));
6998        assert_eq!(result.commit.as_deref(), Some("old"));
6999        assert!(!words_only.active());
7000
7001        // Leaving English brings syllables back, and English rows stay off without mixed candidates.
7002        words_only.set_english_only(false);
7003        type_digits(&mut words_only, "64");
7004        // 你好 是 64 的简拼 n'h,排在音节行之后。
7005        assert_eq!(words(&words_only), ["你", "米", "你好"]);
7006        assert!(!words_only.snapshot().nine_key_spellings.is_empty());
7007        assert!(!words_only.snapshot().dedicated_english);
7008        // Switching mid-composition requeries the same digits.
7009        words_only.set_english_only(true);
7010        assert_eq!(words(&words_only), ["ogham"]);
7011    }
7012
7013    fn sentence_words(session: &NineKeySession) -> Vec<String> {
7014        session
7015            .snapshot()
7016            .candidates
7017            .into_iter()
7018            .filter(is_lattice_sentence)
7019            .map(|item| item.word.into_string())
7020            .collect()
7021    }
7022
7023    /// #6059:九键遵守句子联想的词网格开关和整句备选开关,与 26 键一致。
7024    #[test]
7025    fn sentence_rows_follow_the_sentence_association_options() {
7026        let fixture = fixture_with(&format!(
7027            "{MAIN_FIXTURE}INSERT INTO tbl_1_h VALUES('hao','h','号',50);"
7028        ));
7029        let mut session = open(&fixture.paths, false, mixed());
7030        type_digits(&mut session, "64426");
7031        assert_eq!(sentence_words(&session), ["米好"]);
7032
7033        session.set_sentence_options(
7034            SentenceAssociationOptions {
7035                word_lattice: false,
7036                ..SentenceAssociationOptions::default()
7037            },
7038            false,
7039        );
7040        assert!(sentence_words(&session).is_empty(), "{:?}", words(&session));
7041        assert_eq!(words(&session), ["你好", "你", "米", "ogham"]);
7042
7043        // 每条切分交回全部整句读法:mi'hao 的 米号 和 ni'hao 的 你号 也出来了。
7044        session.set_sentence_options(SentenceAssociationOptions::default(), true);
7045        let sentences = sentence_words(&session);
7046        for sentence in ["米好", "米号", "你号"] {
7047            assert!(
7048                sentences.iter().any(|word| word == sentence),
7049                "{sentences:?}"
7050            );
7051        }
7052        session.command(Command::Cancel);
7053
7054        // 打开之前就设好的会话,第一次打开词库时就带上设置。
7055        let mut fresh = open(&fixture.paths, false, mixed());
7056        fresh.set_sentence_options(
7057            SentenceAssociationOptions {
7058                word_lattice: false,
7059                ..SentenceAssociationOptions::default()
7060            },
7061            false,
7062        );
7063        type_digits(&mut fresh, "64426");
7064        assert!(sentence_words(&fresh).is_empty(), "{:?}", words(&fresh));
7065    }
7066
7067    /// 键盘模型读不到时(测试目录里没有模型文件)没有模型行;词网格开关关着时,为重排解出的整句照样去掉,与 26 键里模型重排失败时一样。
7068    #[test]
7069    fn a_missing_keyboard_model_leaves_the_lattice_rows_to_their_switch() {
7070        let fixture = fixture();
7071        let mut session = open(&fixture.paths, false, mixed());
7072        let neural = |word_lattice| SentenceAssociationOptions {
7073            word_lattice,
7074            neural_keyboard: true,
7075            show_next_on_duplicate: false,
7076            ..Default::default()
7077        };
7078        session.set_sentence_options(neural(true), false);
7079        type_digits(&mut session, "64426");
7080        assert_eq!(sentence_words(&session), ["米好"]);
7081        assert!(!session
7082            .snapshot()
7083            .candidates
7084            .iter()
7085            .any(|item| item.source == CandidateSource::NeuralKeyboard));
7086        session.set_sentence_options(neural(false), false);
7087        assert!(sentence_words(&session).is_empty(), "{:?}", words(&session));
7088    }
7089
7090    #[test]
7091    fn the_rescoring_context_keeps_the_characters_the_model_reads() {
7092        let fixture = fixture();
7093        let mut session = open(&fixture.paths, false, mixed());
7094        let long: String = "甲乙".repeat(CONTEXT_CHARACTERS);
7095        session.set_rescoring_context(&long);
7096        assert_eq!(
7097            session.rescoring_context.chars().count(),
7098            CONTEXT_CHARACTERS
7099        );
7100        assert!(long.ends_with(&session.rescoring_context));
7101        session.set_rescoring_context("今天");
7102        assert_eq!(session.rescoring_context, "今天");
7103    }
7104
7105    fn sentence(word: &str, weight: i64) -> WordItem {
7106        let mut item = item(word, "64426", weight, CandidateSource::Generated);
7107        item.sentence_association = true;
7108        item
7109    }
7110
7111    #[test]
7112    fn lattice_sentence_slots_filter_in_order_without_allocations() {
7113        let mut candidates = Vec::new();
7114        candidates.push(item("跳过来源", "64426", 0, CandidateSource::Database));
7115        candidates.push(sentence("整句 0", 0));
7116        candidates.push(sentence("整句 1", 0));
7117        let mut non_sentence = sentence("跳过标记", 0);
7118        non_sentence.sentence_association = false;
7119        candidates.push(non_sentence);
7120        for index in 2..14 {
7121            candidates.push(sentence(&format!("整句 {index}"), 0));
7122        }
7123
7124        let ((slots, len), allocations) = crate::ime::personal_rerank::allocations::count(|| {
7125            collect_lattice_sentences(&candidates)
7126        });
7127
7128        assert_eq!(len, MAX_RERANK_PATHS);
7129        assert_eq!(slots[0].map(|item| item.word.as_str()), Some("整句 0"));
7130        assert_eq!(slots[1].map(|item| item.word.as_str()), Some("整句 1"));
7131        assert_eq!(
7132            slots[MAX_RERANK_PATHS - 1].map(|item| item.word.as_str()),
7133            Some("整句 11")
7134        );
7135        assert_eq!(allocations, 0);
7136    }
7137
7138    #[test]
7139    fn rerank_cache_match_uses_borrowed_rows_without_allocating() {
7140        let rows = [sentence("整句 0", 17), sentence("整句 1", 23)];
7141        let scored = [Some(&rows[0]), Some(&rows[1])];
7142        let entry = RerankEntry {
7143            context: "上文".to_owned(),
7144            sentences: rows
7145                .iter()
7146                .map(|row| (row.word.to_string(), comparable_weight(row)))
7147                .collect(),
7148            ranked: vec!["整句 1".to_owned(), "整句 0".to_owned()],
7149        };
7150        let (matches, allocations) = crate::ime::personal_rerank::allocations::count(|| {
7151            rerank_cache_matches(&entry, "上文", &scored)
7152        });
7153        assert!(matches);
7154        assert_eq!(allocations, 0);
7155        assert!(!rerank_cache_matches(&entry, "别的上下文", &scored));
7156    }
7157
7158    fn row_sources(candidates: &[WordItem]) -> Vec<(&str, CandidateSource)> {
7159        candidates
7160            .iter()
7161            .map(|item| (item.word.as_str(), item.source))
7162            .collect()
7163    }
7164
7165    /// 模型挑的整句改成 `NeuralKeyboard` 行,紧跟词网格最好的整句,规则同 `lattice::merge::reranked_block`。
7166    #[test]
7167    fn the_keyboard_pick_follows_the_best_lattice_sentence() {
7168        use CandidateSource::{Database, Generated, NeuralKeyboard};
7169        let list = || {
7170            vec![
7171                item("你好", "64426", 1000, Database),
7172                sentence("米好", -1000),
7173                sentence("你号", -2000),
7174                sentence("米号", -3000),
7175            ]
7176        };
7177        let ranked = |sentences: &[&str]| -> Vec<String> {
7178            sentences.iter().map(|word| (*word).to_owned()).collect()
7179        };
7180
7181        let mut candidates = list();
7182        place_keyboard_pick(
7183            &mut candidates,
7184            &ranked(&["米号", "米好", "你号"]),
7185            false,
7186            true,
7187        );
7188        assert_eq!(
7189            row_sources(&candidates),
7190            [
7191                ("你好", Database),
7192                ("米好", Generated),
7193                ("米号", NeuralKeyboard),
7194                ("你号", Generated)
7195            ]
7196        );
7197
7198        // 模型也最看好词网格那一句:默认不出模型行,`show_next_on_duplicate` 时接着取下一句。
7199        let mut candidates = list();
7200        place_keyboard_pick(
7201            &mut candidates,
7202            &ranked(&["米好", "你号", "米号"]),
7203            false,
7204            true,
7205        );
7206        assert_eq!(row_sources(&candidates), row_sources(&list()));
7207        let mut candidates = list();
7208        place_keyboard_pick(
7209            &mut candidates,
7210            &ranked(&["米好", "你号", "米号"]),
7211            true,
7212            true,
7213        );
7214        assert_eq!(candidates[2].word, "你号");
7215        assert_eq!(candidates[2].source, NeuralKeyboard);
7216
7217        // 词网格开关关着:只留模型那一行;模型没有重排时一行整句也不留。
7218        let mut candidates = list();
7219        place_keyboard_pick(
7220            &mut candidates,
7221            &ranked(&["米号", "米好", "你号"]),
7222            false,
7223            false,
7224        );
7225        assert_eq!(
7226            row_sources(&candidates),
7227            [("你好", Database), ("米号", NeuralKeyboard)]
7228        );
7229        // 模型也最看好词网格那一句时照样出模型行:词网格那一行不显示,与 `reranked_block` 不带词网格最好的整句时相同。
7230        let mut candidates = list();
7231        place_keyboard_pick(
7232            &mut candidates,
7233            &ranked(&["米好", "你号", "米号"]),
7234            false,
7235            false,
7236        );
7237        assert_eq!(
7238            row_sources(&candidates),
7239            [("你好", Database), ("米好", NeuralKeyboard)]
7240        );
7241        let mut candidates = list();
7242        place_keyboard_pick(&mut candidates, &[], false, false);
7243        assert_eq!(row_sources(&candidates), [("你好", Database)]);
7244    }
7245
7246    /// 77 拼不出任何音节:混输关着也给英文词,不然这串数字一行候选都没有。拼音有读法时混输开关照旧说了算。
7247    #[test]
7248    fn digits_without_a_reading_offer_english_even_with_mixing_off() {
7249        let fixture = fixture();
7250        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
7251        type_digits(&mut session, "77");
7252        assert_eq!(words(&session), ["QQ"]);
7253        session.command(Command::Cancel);
7254        type_digits(&mut session, "64426");
7255        assert!(!words(&session).contains(&"ogham".to_owned()));
7256    }
7257
7258    #[test]
7259    fn a_missing_english_dictionary_means_no_english_rows() {
7260        let fixture = fixture();
7261        std::fs::remove_file(fixture.paths.dictionary(assets::ENGLISH_DICTIONARY))
7262            .expect("remove msime-english.db");
7263        let mut session = open(&fixture.paths, false, mixed());
7264        type_digits(&mut session, "64");
7265        assert_eq!(words(&session), ["你", "米", "你好"]);
7266        session.set_english_only(true);
7267        assert!(words(&session).is_empty());
7268    }
7269
7270    const INITIALS_FIXTURE: &str = "CREATE TABLE tbl_1_m(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_m VALUES('mu','m','木',100);CREATE TABLE tbl_1_o(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_o VALUES('ou','o','欧',50);CREATE TABLE tbl_1_w(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_w VALUES('wo','w','我',100);CREATE TABLE tbl_1_d(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_d VALUES('di','d','滴',60);CREATE TABLE tbl_1_g(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_g VALUES('ge','g','个',100);CREATE TABLE tbl_1_t(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_t VALUES('tian','t','天',100);CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_n VALUES('na','n','呐',40);CREATE TABLE tbl_2_m(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_2_m VALUES('mei''tian','mt','每天',900);INSERT INTO tbl_2_m VALUES('ming''tian','mt','明天',800);CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_2_n VALUES('na''tian','nt','那天',300);CREATE TABLE tbl_2_w(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_2_w VALUES('wo''di','wd','我滴',30);CREATE TABLE tbl_3_g(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_3_g VALUES('ge''tian''na','gtn','个天呐',20);CREATE TABLE tbl_4_c(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_4_c VALUES('chi''fan''le''mei','cflm','吃饭了没',100);CREATE TABLE tbl_4_g(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_4_g VALUES('guan''guan''ju''jiu','ggjj','关关雎鸠',1605);CREATE TABLE tbl_5_w(key TEXT,jp TEXT,value TEXT,weight INTEGER);";
7271
7272    fn type_keys(session: &mut NineKeySession, keys: &str) {
7273        for key in keys.bytes() {
7274            assert!(session.character(key).handled, "key {key} unhandled");
7275        }
7276    }
7277
7278    const COMPLETION_FIXTURE: &str = "CREATE TABLE tbl_1_d(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_d VALUES('de','d','的',1000);CREATE TABLE tbl_1_e(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_e VALUES('e','e','额',900);CREATE TABLE tbl_1_c(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_c VALUES('chi','c','吃',500);CREATE TABLE tbl_1_a(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_a VALUES('ai','a','爱',800);CREATE TABLE tbl_1_g(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_g VALUES('ge','g','个',900);CREATE TABLE tbl_1_w(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_w VALUES('wo','w','我',700);CREATE TABLE tbl_2_c(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_2_c VALUES('chi''fan','cf','吃饭',1500);CREATE TABLE tbl_2_a(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_2_a VALUES('ai''ge','ag','挨个',1000);CREATE TABLE tbl_2_w(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_2_w VALUES('wo''de','wd','我的',150);INSERT INTO tbl_2_w VALUES('wo''e','we','我饿',100);";
7279
7280    /// 不打切分、首页里有拼满的多音节词时,补全按权重的 `COMPLETION_WEIGHT_RATIO` 分之一与完整读法比;拼不出多音节词时照旧按原权重比,锁定的音节不算。
7281    #[test]
7282    fn without_a_split_a_completion_is_discounted_beside_a_spelled_phrase() {
7283        let fixture = fixture_with(COMPLETION_FIXTURE);
7284        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
7285
7286        type_digits(&mut session, "3");
7287        assert_eq!(words(&session)[..2], ["的", "额"]);
7288        session.command(Command::Cancel);
7289
7290        // 2443:挨个 拼满 ai'ge;吃饭 要把 f 补成 fan,权重只比 挨个 高一点,排在后面但仍在首页。
7291        const { assert!(1500 < 1000 * COMPLETION_WEIGHT_RATIO) };
7292        type_digits(&mut session, "2443");
7293        let shown = words(&session);
7294        assert_eq!(shown[0], "挨个");
7295        assert!(
7296            shown
7297                .iter()
7298                .take(DEFAULT_FIRST_PAGE_ROWS)
7299                .any(|word| word == "吃饭"),
7300            "{shown:?}"
7301        );
7302        assert_eq!(session.snapshot().nine_key_reading, "ai'ge");
7303        session.command(Command::Cancel);
7304
7305        // 锁定 chi 之后剩下的数字只拼得出一个音节,与单独打 `3` 一样不折算,吃饭 凭权重排第一。
7306        type_digits(&mut session, "2443");
7307        let chi = session
7308            .snapshot()
7309            .nine_key_spellings
7310            .iter()
7311            .position(|spelling| spelling == "chi")
7312            .expect("chi offered");
7313        session.choose_spelling(chi);
7314        assert_eq!(words(&session)[0], "吃饭");
7315        session.command(Command::Cancel);
7316
7317        // 963:我饿 拼满 wo'e,没锁定时 我的 按折算排在后面;锁定 wo 之后同上,我的 凭权重领先。
7318        const { assert!(150 < 100 * COMPLETION_WEIGHT_RATIO) };
7319        type_digits(&mut session, "963");
7320        assert_eq!(words(&session)[..2], ["我饿", "我的"]);
7321        let wo = session
7322            .snapshot()
7323            .nine_key_spellings
7324            .iter()
7325            .position(|spelling| spelling == "wo")
7326            .expect("wo offered");
7327        session.choose_spelling(wo);
7328        assert_eq!(words(&session)[..2], ["我的", "我饿"]);
7329    }
7330
7331    /// 置顶一个被折算的补全:调频写进去的权重越不过折算,固定到第 1 位,下次打同样的数字它在首位。补全原本最重(调频不改权重)和原本较轻两种都要成立。
7332    #[test]
7333    fn pinning_a_discounted_completion_puts_it_first() {
7334        for chifan in [1500, 500] {
7335            let fixture = fixture_with(
7336                &COMPLETION_FIXTURE.replace("'吃饭',1500", &format!("'吃饭',{chifan}")),
7337            );
7338            let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
7339            type_digits(&mut session, "2443");
7340            assert_eq!(words(&session)[0], "挨个");
7341            let pinned = session.pin(index_of(&session, "吃饭"));
7342            assert!(pinned.handled);
7343            assert_eq!(pinned.diagnostic, None);
7344            assert_eq!(words(&session)[0], "吃饭", "weight {chifan}");
7345            session.command(Command::Cancel);
7346            let mut fresh = open(&fixture.paths, false, EnglishInputOptions::default());
7347            type_digits(&mut fresh, "2443");
7348            assert_eq!(words(&fresh)[0], "吃饭", "weight {chifan}");
7349        }
7350    }
7351
7352    /// 有权重的英文坐第二位,折算只在它周围的拼音行之间调序:被折算的补全仍在首页,首页里的行与不折算时相同。
7353    #[test]
7354    fn a_mixed_english_row_does_not_push_a_discounted_completion_off_the_first_page() {
7355        let fixture = fixture_with("CREATE TABLE tbl_2_c(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_2_c VALUES('chi''fan','cf','吃饭',1100);INSERT INTO tbl_2_c VALUES('ci''ge','cg','次格',700);CREATE TABLE tbl_2_a(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_2_a VALUES('ai''ge','ag','挨个',1000);INSERT INTO tbl_2_a VALUES('ai''he','ah','爱河',900);CREATE TABLE tbl_2_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_2_b VALUES('bi''he','bh','碧荷',800);INSERT INTO tbl_2_b VALUES('bi''ge','bg','笔格',600);");
7356        Connection::open(fixture.directory.path().join(assets::ENGLISH_DICTIONARY))
7357            .and_then(|db| {
7358                db.execute_batch("INSERT INTO english_words VALUES('chief','chief',1000);")
7359            })
7360            .expect("english row");
7361        let mut session = open(&fixture.paths, false, mixed());
7362        type_digits(&mut session, "2443");
7363        let shown = words(&session);
7364        assert_eq!(
7365            shown[..DEFAULT_FIRST_PAGE_ROWS],
7366            ["挨个", "chief", "爱河", "碧荷", "次格", "吃饭"],
7367            "{shown:?}"
7368        );
7369    }
7370
7371    /// #5640:每个数字当一个音节的首字母也能出词,词库里的词和用户自己存的词都算。
7372    #[test]
7373    fn digits_spell_words_by_their_initials() {
7374        let fixture = fixture_with(INITIALS_FIXTURE);
7375        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
7376
7377        // 不打切分时 68 也可能是一个音节(mu、ou),音节行在前,简拼行按权重跟在后面。
7378        type_digits(&mut session, "68");
7379        assert_eq!(words(&session), ["木", "欧", "每天", "明天", "那天"]);
7380        assert_eq!(session.snapshot().nine_key_reading, "mu");
7381        session.command(Command::Cancel);
7382
7383        // 每个数字之间都打了切分,说的就是简拼:简拼行排到前面,读音行显示首字母。
7384        type_keys(&mut session, "6'8");
7385        assert_eq!(words(&session)[..3], ["每天", "明天", "那天"]);
7386        assert_eq!(session.snapshot().nine_key_reading, "m't");
7387        let chosen = session.select(index_of(&session, "明天"));
7388        assert_eq!(chosen.commit.as_deref(), Some("明天"));
7389        assert!(!session.active(), "an initials row consumes every digit");
7390
7391        // 没有哪条音节切分能拼满 2356,简拼的 吃饭了没 直接排第一。
7392        type_digits(&mut session, "2356");
7393        assert_eq!(words(&session)[0], "吃饭了没");
7394        assert_eq!(session.snapshot().nine_key_reading, "c'f'l'm");
7395        session.command(Command::Cancel);
7396        type_digits(&mut session, "4455");
7397        assert_eq!(words(&session)[0], "关关雎鸠");
7398        session.command(Command::Cancel);
7399
7400        // 末尾的切分说 68 是一个音节,不是两个首字母。
7401        type_keys(&mut session, "68'");
7402        assert!(!words(&session).iter().any(|word| word == "每天"));
7403        session.command(Command::Cancel);
7404        // 6'84:84 是一个音节,也不按简拼查。
7405        type_keys(&mut session, "6'84");
7406        assert!(!words(&session).iter().any(|word| word.chars().count() == 3));
7407        session.command(Command::Cancel);
7408
7409        // 选过拼音之后不查简拼:锁定的音节已经说明了怎么切。
7410        type_digits(&mut session, "68");
7411        let mu = session
7412            .snapshot()
7413            .nine_key_spellings
7414            .iter()
7415            .position(|spelling| spelling == "mu")
7416            .expect("mu offered");
7417        session.choose_spelling(mu);
7418        assert!(!words(&session).iter().any(|word| word == "每天"));
7419    }
7420
7421    /// 选中过的单字也应在相同九键输入的下一次候选刷新中直接位于首位,而不依赖个人上下文模型。
7422    #[test]
7423    fn recently_selected_candidate_leads_the_same_nine_key_context() {
7424        let fixture = fixture_with(
7425            "CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);\
7426INSERT INTO tbl_1_n VALUES('neng','n','能',1),('neng','n','恁',100),('neng','n','嫩',90),('neng','n','嗯',80),('neng','n','𠮾',70),('neng','n','莻',60);",
7427        );
7428        let mut first = open(&fixture.paths, true, EnglishInputOptions::default());
7429        type_digits(&mut first, "6364");
7430        let selected = index_of(&first, "能");
7431        assert!(selected > 0, "fixture must require a non-first selection");
7432        assert_eq!(first.select(selected).commit.as_deref(), Some("能"));
7433
7434        let mut repeated = open(&fixture.paths, true, EnglishInputOptions::default());
7435        type_digits(&mut repeated, "6364");
7436        assert_eq!(words(&repeated)[0], "能");
7437        assert_eq!(repeated.snapshot().candidates[0].fixed_position, 1);
7438    }
7439
7440    const SPELLING_RECENT_FIXTURE: &str =
7441        "CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);\
7442INSERT INTO tbl_1_n VALUES('ni','n','你',1000);\
7443CREATE TABLE tbl_1_m(key TEXT,jp TEXT,value TEXT,weight INTEGER);\
7444INSERT INTO tbl_1_m VALUES('mi','m','米',100),('mi','m','谜',10),('mo','m','墨',100);\
7445CREATE TABLE tbl_1_h(key TEXT,jp TEXT,value TEXT,weight INTEGER);\
7446INSERT INTO tbl_1_h VALUES('hao','h','好',100);";
7447
7448    fn lock_spelling(session: &mut NineKeySession, spelling: &str) {
7449        let index = session
7450            .snapshot()
7451            .nine_key_spellings
7452            .iter()
7453            .position(|choice| choice == spelling)
7454            .expect("synthetic spelling offered");
7455        assert!(session.choose_spelling(index).handled);
7456    }
7457
7458    /// 显式选读法再确认候选,也要让下一次未锁定的数字输入记住这次选择。
7459    #[test]
7460    fn spelling_selection_remembers_first_and_later_candidates_without_a_lock() {
7461        for (word, selected_index) in [("米", 0), ("谜", 1)] {
7462            let fixture = fixture_with(SPELLING_RECENT_FIXTURE);
7463            let mut session =
7464                open_with_frequency(&fixture.paths, FrequencyAdjustmentMode::Disabled, 1, 1);
7465            type_digits(&mut session, "64");
7466            assert_eq!(words(&session)[0], "你");
7467            lock_spelling(&mut session, "mi");
7468            assert_eq!(index_of(&session, word), selected_index);
7469            let selected = session.select(selected_index);
7470            assert_eq!(selected.commit.as_deref(), Some(word));
7471            assert_eq!(selected.diagnostic, None);
7472            drop(session);
7473
7474            let mut repeated = open(&fixture.paths, false, EnglishInputOptions::default());
7475            type_digits(&mut repeated, "64");
7476            assert_eq!(words(&repeated)[0], word);
7477            assert_eq!(repeated.snapshot().candidates[0].fixed_position, 1);
7478            lock_spelling(&mut repeated, "ni");
7479            assert_eq!(words(&repeated)[0], "你");
7480        }
7481    }
7482
7483    /// 未显式完成整串选择时,不把读法筛选本身当作最近选词。
7484    #[test]
7485    fn spelling_recent_requires_learning_and_an_explicit_whole_selection() {
7486        for case in ["learning_off", "finish", "partial", "unlocked_first"] {
7487            let fixture = fixture_with(SPELLING_RECENT_FIXTURE);
7488            let mut session =
7489                open_with_frequency(&fixture.paths, FrequencyAdjustmentMode::Disabled, 1, 1);
7490            session.learning = case != "learning_off";
7491            let digits = if case == "partial" { "64426" } else { "64" };
7492            type_digits(&mut session, digits);
7493            if case != "unlocked_first" {
7494                lock_spelling(&mut session, "mi");
7495            }
7496            let word = if case == "unlocked_first" {
7497                "你"
7498            } else {
7499                "米"
7500            };
7501            let index = index_of(&session, word);
7502            let result = if case == "finish" {
7503                session.finish(index)
7504            } else {
7505                session.select(index)
7506            };
7507            assert_eq!(result.commit.as_deref(), Some(word), "{case}");
7508            assert_eq!(result.diagnostic, None, "{case}");
7509            if case == "partial" {
7510                assert!(session.active());
7511            }
7512            let journal = fixture.paths.user(assets::USER_JOURNAL);
7513            for (key, spelling, value) in
7514                [("64", "mi", "米"), (digits, "mi", "米"), ("64", "ni", "你")]
7515            {
7516                assert!(
7517                    !positions::is_fixed(&journal, &format!("nine-key:{key}:"), spelling, value),
7518                    "{case}"
7519                );
7520                assert!(
7521                    !positions::is_fixed(
7522                        &journal,
7523                        &format!("nine-key:{key}:{spelling}"),
7524                        spelling,
7525                        value
7526                    ),
7527                    "{case}"
7528                );
7529            }
7530        }
7531    }
7532
7533    /// 未锁定的最近候选变化后,再选已有锁定首位也能更新;不同数字与读法仍隔离。
7534    #[test]
7535    fn spelling_recent_replaces_the_previous_unlocked_pick() {
7536        let fixture = fixture_with(SPELLING_RECENT_FIXTURE);
7537        let journal = fixture.paths.user(assets::USER_JOURNAL);
7538        positions::set_fixed_position(&journal, "nine-key:64:mi", "mi", "米", 1).unwrap();
7539        for word in ["米", "你", "米"] {
7540            let mut session =
7541                open_with_frequency(&fixture.paths, FrequencyAdjustmentMode::Disabled, 1, 1);
7542            type_digits(&mut session, "64");
7543            if word == "米" {
7544                lock_spelling(&mut session, "mi");
7545                assert_eq!(session.snapshot().candidates[0].fixed_position, 1);
7546            }
7547            let index = index_of(&session, word);
7548            assert_eq!(session.select(index).commit.as_deref(), Some(word));
7549            let mut repeated = open(&fixture.paths, false, EnglishInputOptions::default());
7550            type_digits(&mut repeated, "64");
7551            assert_eq!(words(&repeated)[0], word);
7552            lock_spelling(&mut repeated, "ni");
7553            assert_eq!(words(&repeated), ["你"]);
7554            repeated.command(Command::Cancel);
7555            type_digits(&mut repeated, "66");
7556            assert_eq!(words(&repeated)[0], "墨");
7557            assert_eq!(repeated.snapshot().candidates[0].fixed_position, 0);
7558        }
7559    }
7560
7561    /// 读法内的位置不能覆盖未锁定上下文里同一词或其它词的手动槽位。
7562    #[test]
7563    fn spelling_recent_preserves_unlocked_manual_slots() {
7564        for selected_is_fixed in [false, true] {
7565            let fixture = fixture_with(SPELLING_RECENT_FIXTURE);
7566            let journal = fixture.paths.user(assets::USER_JOURNAL);
7567            positions::set_fixed_position(&journal, "nine-key:64:", "mi", "谜", 2).unwrap();
7568            if selected_is_fixed {
7569                positions::set_fixed_position(&journal, "nine-key:64:", "mi", "米", 3).unwrap();
7570            }
7571            let mut session =
7572                open_with_frequency(&fixture.paths, FrequencyAdjustmentMode::Disabled, 1, 1);
7573            type_digits(&mut session, "64");
7574            lock_spelling(&mut session, "mi");
7575            assert_eq!(session.select(0).commit.as_deref(), Some("米"));
7576            let mut repeated = open(&fixture.paths, false, EnglishInputOptions::default());
7577            type_digits(&mut repeated, "64");
7578            let snapshot = repeated.snapshot();
7579            let target = &snapshot.candidates[index_of(&repeated, "米")];
7580            assert_eq!(target.fixed_position, if selected_is_fixed { 3 } else { 1 });
7581            assert_eq!(snapshot.candidates[1].word, "谜");
7582            assert_eq!(snapshot.candidates[1].fixed_position, 2);
7583        }
7584    }
7585
7586    /// #5640:两位数字的音节行比候选上限还多时(出货词库里 `68` 有两百多个单字),不打切分的简拼行也不能被截掉:前三个音节行之后,其余音节行和简拼行按权重归并。
7587    #[test]
7588    fn initials_rows_survive_more_syllable_rows_than_the_limit() {
7589        let mut main = INITIALS_FIXTURE.to_owned();
7590        for index in 0..CANDIDATE_LIMIT + 72 {
7591            let weight = 2000 - 10 * index as i64;
7592            main.push_str(&format!(
7593                "INSERT INTO tbl_1_m VALUES('mu','m','木{index}',{weight});"
7594            ));
7595        }
7596        let fixture = fixture_with(&main);
7597        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
7598        type_digits(&mut session, "68");
7599        let listed = words(&session);
7600        assert_eq!(listed.len(), CANDIDATE_LIMIT);
7601        // 权重最高的三个单字留在最前;木3 的权重 1970 比每天高,仍在每天前面。
7602        assert_eq!(listed[..5], ["木0", "木1", "木2", "木3", "木4"]);
7603        let position = |word: &str| {
7604            listed
7605                .iter()
7606                .position(|candidate| candidate == word)
7607                .unwrap_or_else(|| panic!("{word} was cut from {listed:?}"))
7608        };
7609        // 每天 900、明天 800 排在权重比它们低的单字之前;权重相同时音节行在前。
7610        assert_eq!(position("每天"), position("木110") + 1);
7611        assert_eq!(position("明天"), position("木120") + 1);
7612        assert_eq!(session.snapshot().nine_key_reading, "mu");
7613        session.command(Command::Cancel);
7614
7615        // 打了切分时简拼行照旧排第一。
7616        type_keys(&mut session, "6'8");
7617        assert_eq!(words(&session)[..3], ["每天", "明天", "那天"]);
7618    }
7619
7620    /// #5640:九宫格里分段选出来的词和选中的整句会存成用户词,下次打简拼就能出来。
7621    #[test]
7622    fn learned_phrases_come_back_by_their_initials() {
7623        let fixture = fixture_with(INITIALS_FIXTURE);
7624        let mut session = open(&fixture.paths, true, EnglishInputOptions::default());
7625        // wo'di'ge'tian'na 分两段选:我滴 + 个天呐。
7626        type_digits(&mut session, "963443842662");
7627        let first = session.select(index_of(&session, "我滴"));
7628        assert_eq!(first.commit.as_deref(), Some("我滴"));
7629        assert_eq!(first.diagnostic, None);
7630        assert!(session.active());
7631        let rest = session.select(index_of(&session, "个天呐"));
7632        assert_eq!(rest.commit.as_deref(), Some("个天呐"));
7633        assert_eq!(rest.diagnostic, None);
7634        assert!(!session.active());
7635
7636        let mut later = open(&fixture.paths, false, EnglishInputOptions::default());
7637        type_keys(&mut later, "9'3'4'8'6");
7638        assert_eq!(words(&later)[0], "我滴个天呐");
7639        assert_eq!(later.snapshot().nine_key_reading, "w'd'g't'n");
7640        later.command(Command::Cancel);
7641        // 不打切分也找得到。
7642        type_digits(&mut later, "93486");
7643        assert!(words(&later).iter().any(|word| word == "我滴个天呐"));
7644    }
7645
7646    /// 简拼 9'7(y's)下有两百行比 隐私 重的词,按权重只取前 `INITIALS_ROW_LIMIT` 行时 隐私 被截掉(#6185 的出货词库里它排第 285 行)。
7647    fn crowded_initials_fixture() -> Fixture {
7648        let mut main = String::from(
7649            "CREATE TABLE tbl_1_y(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_y VALUES('yin','y','因',100);\
7650CREATE TABLE tbl_1_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_s VALUES('si','s','四',100);\
7651CREATE TABLE tbl_2_y(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_2_y VALUES('yin''si','ys','隐私',10);",
7652        );
7653        for index in 0..200 {
7654            main.push_str(&format!(
7655                "INSERT INTO tbl_2_y VALUES('ya''qi','yq','压{index}',{});",
7656                100_000 - index
7657            ));
7658        }
7659        fixture_with(&main)
7660    }
7661
7662    fn open_with_frequency(
7663        paths: &RuntimePaths,
7664        mode: FrequencyAdjustmentMode,
7665        trigger_count: i32,
7666        linear_step: i32,
7667    ) -> NineKeySession {
7668        NineKeySession::new(
7669            paths,
7670            true,
7671            FrequencyAdjustmentOptions {
7672                mode,
7673                trigger_count,
7674                linear_step,
7675            },
7676            FuzzyPinyinOptions::default(),
7677            EnglishInputOptions::default(),
7678            true,
7679            false,
7680        )
7681    }
7682
7683    /// 别处(26 键)提交过 `word` 一次:显式选词在个人上下文模型里记 `PERSONAL_PICK_TIMES` 次。
7684    fn record_use(paths: &RuntimePaths, word: &str) {
7685        crate::user_dictionary::ngram_store::PersonalNgramStore::for_journal(
7686            &paths.user(assets::USER_JOURNAL),
7687        )
7688        .record(&[PersonalTransition {
7689            earlier: String::new(),
7690            previous: String::new(),
7691            word: word.into(),
7692            times: PERSONAL_PICK_TIMES,
7693        }])
7694        .expect("record a use");
7695    }
7696
7697    fn personal_count(paths: &RuntimePaths, word: &str) -> u32 {
7698        crate::user_dictionary::ngram_store::PersonalNgramStore::for_journal(
7699            &paths.user(assets::USER_JOURNAL),
7700        )
7701        .model()
7702        .word_count(word)
7703    }
7704
7705    /// #6185:用过的简拼词不会被按权重的截断截掉,并按调频模式往前挪;默认的「提到前五」一次就进第五位,再用一次进第四位。
7706    #[test]
7707    fn used_initials_words_survive_the_row_limit_and_move_up() {
7708        let fixture = crowded_initials_fixture();
7709        let mut session =
7710            open_with_frequency(&fixture.paths, FrequencyAdjustmentMode::Promote, 1, 1);
7711        type_keys(&mut session, "9'7");
7712        assert!(!words(&session).iter().any(|word| word == "隐私"));
7713        session.command(Command::Cancel);
7714
7715        // 用全拼读音的数字选一次(它在 94674 下排第一,不触发词频调整),简拼列表照样认它。
7716        type_digits(&mut session, "94674");
7717        assert_eq!(index_of(&session, "隐私"), 0);
7718        let chosen = session.select(0);
7719        assert_eq!(chosen.commit.as_deref(), Some("隐私"));
7720        assert_eq!(chosen.diagnostic, None);
7721        assert_eq!(personal_count(&fixture.paths, "隐私"), PERSONAL_PICK_TIMES);
7722        type_keys(&mut session, "9'7");
7723        assert_eq!(index_of(&session, "隐私"), 4);
7724        assert_eq!(words(&session)[..4], ["压0", "压1", "压2", "压3"]);
7725        session.command(Command::Cancel);
7726
7727        record_use(&fixture.paths, "隐私");
7728        type_keys(&mut session, "9'7");
7729        assert_eq!(index_of(&session, "隐私"), 3);
7730        session.command(Command::Cancel);
7731        // 不打切分时 97 拼不成任何音节,同样是简拼行领头,挪法相同。
7732        type_digits(&mut session, "97");
7733        assert_eq!(index_of(&session, "隐私"), 3);
7734    }
7735
7736    /// 在简拼列表里选中挪上来的用过的词:只记进个人上下文模型,不再另调全局词频,否则一次选词挪两次(还会连带改掉 26 键里它的位置)。提到前五、触发一次:26 键用过一次在第五位,九键再选一次进第四位,和 26 键用两次的位置相同。
7737    #[test]
7738    fn picking_a_used_initials_word_moves_it_once() {
7739        let fixture = crowded_initials_fixture();
7740        record_use(&fixture.paths, "隐私");
7741        let mut session =
7742            open_with_frequency(&fixture.paths, FrequencyAdjustmentMode::Promote, 1, 1);
7743        type_keys(&mut session, "9'7");
7744        assert_eq!(index_of(&session, "隐私"), 4);
7745        let chosen = session.select(4);
7746        assert_eq!(chosen.commit.as_deref(), Some("隐私"));
7747        assert_eq!(chosen.diagnostic, None);
7748        assert_eq!(
7749            personal_count(&fixture.paths, "隐私"),
7750            2 * PERSONAL_PICK_TIMES
7751        );
7752        type_keys(&mut session, "9'7");
7753        assert_eq!(index_of(&session, "隐私"), 3);
7754        assert_eq!(words(&session)[..3], ["压0", "压1", "压2"]);
7755    }
7756
7757    /// 出货词库里大量词的权重同是 100:用过的词和前一张首字母表里的几百个同权重的词并列时,也不会因为合起来截断而丢掉(9'9'2'9 的 仔细查找)。
7758    #[test]
7759    fn a_used_word_tied_with_a_crowded_table_is_kept() {
7760        let mut main = String::from(
7761            "CREATE TABLE tbl_2_w(key TEXT,jp TEXT,value TEXT,weight INTEGER);\
7762CREATE TABLE tbl_2_y(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_2_y VALUES('yin''si','ys','隐私',100);",
7763        );
7764        for index in 0..INITIALS_SCAN_LIMIT + 10 {
7765            main.push_str(&format!(
7766                "INSERT INTO tbl_2_w VALUES('wa''pi','wp','瓦{index}',100);"
7767            ));
7768        }
7769        let fixture = fixture_with(&main);
7770        record_use(&fixture.paths, "隐私");
7771        // 调频关着也查得到;同权重的行里,先留下来的用过的词排在最前。
7772        let mut session =
7773            open_with_frequency(&fixture.paths, FrequencyAdjustmentMode::Disabled, 1, 1);
7774        type_keys(&mut session, "9'7");
7775        assert_eq!(index_of(&session, "隐私"), 0);
7776        assert_eq!(words(&session).len(), INITIALS_ROW_LIMIT);
7777    }
7778
7779    /// 置顶、减半、按步长三种模式和触发次数都照调频设置算。
7780    #[test]
7781    fn used_initials_words_follow_the_frequency_mode_and_trigger_count() {
7782        let position = |mode, trigger_count, linear_step, uses| {
7783            let fixture = crowded_initials_fixture();
7784            for _ in 0..uses {
7785                record_use(&fixture.paths, "隐私");
7786            }
7787            let mut session = open_with_frequency(&fixture.paths, mode, trigger_count, linear_step);
7788            type_keys(&mut session, "9'7");
7789            index_of(&session, "隐私")
7790        };
7791        // 截剩的 64 行里 隐私 原本在最后(第 64 位,下标 63)。
7792        assert_eq!(position(FrequencyAdjustmentMode::Pin, 1, 1, 1), 0);
7793        assert_eq!(position(FrequencyAdjustmentMode::Halve, 1, 1, 1), 31);
7794        assert_eq!(position(FrequencyAdjustmentMode::Halve, 1, 1, 2), 15);
7795        assert_eq!(position(FrequencyAdjustmentMode::Linear, 1, 3, 2), 57);
7796        // 触发次数 2:用一次还不挪,用两次才算一次触发。
7797        assert_eq!(position(FrequencyAdjustmentMode::Promote, 2, 1, 1), 63);
7798        assert_eq!(position(FrequencyAdjustmentMode::Promote, 2, 1, 2), 4);
7799    }
7800
7801    /// 关掉调频时用过的词只保证查得到,排在它按权重该在的位置;关掉学习时不读个人数据,列表与没用过时相同。
7802    #[test]
7803    fn used_initials_words_only_move_with_frequency_adjustment() {
7804        let fixture = crowded_initials_fixture();
7805        record_use(&fixture.paths, "隐私");
7806        let mut unadjusted =
7807            open_with_frequency(&fixture.paths, FrequencyAdjustmentMode::Disabled, 1, 1);
7808        type_keys(&mut unadjusted, "9'7");
7809        assert_eq!(index_of(&unadjusted, "隐私"), INITIALS_ROW_LIMIT - 1);
7810
7811        let mut quiet = open(&fixture.paths, false, EnglishInputOptions::default());
7812        type_keys(&mut quiet, "9'7");
7813        assert!(!words(&quiet).iter().any(|word| word == "隐私"));
7814    }
7815
7816    /// 关掉个人上下文时,模型里早先记下的使用也不再读,与 26 键的 `personal_context_applies` 一样:用过的词不再多扫出来,也不挪。
7817    #[test]
7818    fn earlier_uses_are_ignored_with_personal_context_off() {
7819        let fixture = crowded_initials_fixture();
7820        record_use(&fixture.paths, "隐私");
7821        let mut session = open_with_frequency(&fixture.paths, FrequencyAdjustmentMode::Pin, 1, 1);
7822        type_keys(&mut session, "9'7");
7823        assert_eq!(index_of(&session, "隐私"), 0);
7824        session.command(Command::Cancel);
7825
7826        session.set_personal_context_enabled(false);
7827        type_keys(&mut session, "9'7");
7828        assert!(!words(&session).iter().any(|word| word == "隐私"));
7829    }
7830
7831    /// 没打切分时数字也可能是音节:用过的简拼词挪不过前面留给最常用单字的位置;打了切分才挪到最前。
7832    #[test]
7833    fn used_initials_words_stay_behind_the_leading_syllable_rows() {
7834        let fixture = fixture_with(INITIALS_FIXTURE);
7835        record_use(&fixture.paths, "那天");
7836        let mut session = open_with_frequency(&fixture.paths, FrequencyAdjustmentMode::Pin, 1, 1);
7837        type_digits(&mut session, "68");
7838        assert_eq!(words(&session), ["木", "欧", "那天", "每天", "明天"]);
7839        session.command(Command::Cancel);
7840        type_keys(&mut session, "6'8");
7841        assert_eq!(words(&session)[..3], ["那天", "每天", "明天"]);
7842        session.command(Command::Cancel);
7843
7844        // 几个用过的词抢同一个位置时,原来靠前(权重高)的先占。
7845        record_use(&fixture.paths, "明天");
7846        type_keys(&mut session, "6'8");
7847        assert_eq!(words(&session)[..3], ["明天", "那天", "每天"]);
7848    }
7849
7850    /// 九键选中的多字词库词(首位也算)、分段连成的词组都记进个人上下文模型;单字不记,关掉个人上下文时不记。
7851    #[test]
7852    fn nine_key_picks_are_recorded_for_the_initials() {
7853        let fixture = fixture_with(INITIALS_FIXTURE);
7854        let mut session = open(&fixture.paths, true, EnglishInputOptions::default());
7855        type_digits(&mut session, "963443842662");
7856        session.select(index_of(&session, "我滴"));
7857        let rest = session.select(index_of(&session, "个天呐"));
7858        assert_eq!(rest.diagnostic, None);
7859        for word in ["我滴", "个天呐", "我滴个天呐"] {
7860            assert_eq!(
7861                personal_count(&fixture.paths, word),
7862                PERSONAL_PICK_TIMES,
7863                "{word}"
7864            );
7865        }
7866        type_digits(&mut session, "68");
7867        session.select(index_of(&session, "木"));
7868        assert_eq!(personal_count(&fixture.paths, "木"), 0);
7869
7870        session.set_personal_context_enabled(false);
7871        type_keys(&mut session, "6'8");
7872        session.select(index_of(&session, "每天"));
7873        assert_eq!(personal_count(&fixture.paths, "每天"), 0);
7874    }
7875
7876    /// 不学习或取消时不造词;选中词库里本来就有的整词也不重复写。
7877    #[test]
7878    fn only_chosen_pieces_become_words() {
7879        let fixture = fixture_with(INITIALS_FIXTURE);
7880        let mut quiet = open(&fixture.paths, false, EnglishInputOptions::default());
7881        type_digits(&mut quiet, "963443842662");
7882        quiet.select(index_of(&quiet, "我滴"));
7883        quiet.select(index_of(&quiet, "个天呐"));
7884        type_digits(&mut quiet, "93486");
7885        assert!(
7886            !words(&quiet).iter().any(|word| word == "我滴个天呐"),
7887            "learning off still stored"
7888        );
7889        quiet.command(Command::Cancel);
7890
7891        let mut session = open(&fixture.paths, true, EnglishInputOptions::default());
7892        type_digits(&mut session, "963443842662");
7893        session.select(index_of(&session, "我滴"));
7894        session.command(Command::Cancel);
7895        type_digits(&mut session, "843");
7896        session.command(Command::Cancel);
7897        type_digits(&mut session, "93486");
7898        assert!(
7899            !words(&session).iter().any(|word| word == "我滴个天呐"),
7900            "a cancelled composition stored a phrase"
7901        );
7902    }
7903
7904    /// 与全拼键盘的 `finish_composition` 相同:先选掉一段再打标点(`finish`),用户选的段和替他选的余下部分连成一个词存起来。
7905    #[test]
7906    fn finishing_stores_the_chosen_pieces_with_the_rest() {
7907        let fixture = fixture_with(INITIALS_FIXTURE);
7908        let mut session = open(&fixture.paths, true, EnglishInputOptions::default());
7909        type_digits(&mut session, "963443842662");
7910        session.select(index_of(&session, "我滴"));
7911        assert_eq!(words(&session)[0], "个天呐");
7912        let rest = session.finish(0);
7913        assert_eq!(rest.commit.as_deref(), Some("个天呐"));
7914        assert_eq!(rest.diagnostic, None);
7915        assert!(!session.active());
7916        type_keys(&mut session, "9'3'4'8'6");
7917        assert_eq!(words(&session)[0], "我滴个天呐");
7918    }
7919
7920    /// 会话不允许全拼时九宫格只拼英文:没有音节、没有拼音行,拼音词库也不打开。
7921    #[test]
7922    fn without_quanpin_the_grid_spells_english_only() {
7923        let fixture = fixture();
7924        let mut session = NineKeySession::new(
7925            &fixture.paths,
7926            false,
7927            FrequencyAdjustmentOptions::default(),
7928            FuzzyPinyinOptions::default(),
7929            mixed(),
7930            false,
7931            false,
7932        );
7933        type_digits(&mut session, "64");
7934        assert!(session.snapshot().nine_key_spellings.is_empty());
7935        assert!(!words(&session).contains(&"你".to_owned()));
7936        assert!(session.dictionary.is_none());
7937
7938        let mut pinyin = open(&fixture.paths, false, mixed());
7939        type_digits(&mut pinyin, "64");
7940        assert!(words(&pinyin).contains(&"你".to_owned()));
7941        assert!(pinyin.dictionary.is_some());
7942    }
7943
7944    // ---- emoji、颜文字混排(#5848) ----
7945
7946    /// 每个音节都有单字,再加上 美国、警告、香蕉 这几个词,让 `634486`、`5464426`、`94264` 都有覆盖整串数字的拼音候选;`54` 是 #5667 截图里的 里 李 鸡 几。
7947    const EXPRESSIVE_MAIN_FIXTURE: &str = "CREATE TABLE tbl_1_m(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_m VALUES('mei','m','美',300);CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_n VALUES('nei','n','内',200);CREATE TABLE tbl_1_g(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_g VALUES('guo','g','国',300),('gao','g','高',200);CREATE TABLE tbl_2_m(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_2_m VALUES('mei''guo','mg','美国',1000);CREATE TABLE tbl_1_j(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_j VALUES('jing','j','警',100),('ji','j','鸡',300),('ji','j','几',200);CREATE TABLE tbl_1_l(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_l VALUES('li','l','里',500),('li','l','李',400);CREATE TABLE tbl_2_j(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_2_j VALUES('jing''gao','jg','警告',900);CREATE TABLE tbl_1_x(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_x VALUES('xian','x','先',300),('xiang','x','香',200),('xi','x','西',250);CREATE TABLE tbl_2_x(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_2_x VALUES('xiang''jiao','xj','香蕉',800);";
7948
7949    /// 结构与随包 `msime-others.db` 相同的编码表和目录表,🇺🇲、🇺🇸、⚠️、🍌、🐔 的编码、次序和关键词取自随包数据。`警告` 也写成一个 emoji,`meihuo` 也指向 🇺🇸,用来验证与拼音候选重复的、跨读法重复的都只出现一次。
7950    const EXPRESSIVE_OTHERS_FIXTURE: &str = "CREATE TABLE emoji_pinyin(key TEXT,emoji TEXT,sort_order INTEGER);INSERT INTO emoji_pinyin VALUES('meiguo','🇺🇸',1893),('meihuo','🇺🇸',1893),('meiguobentuwaixiaodaoyu','🇺🇲',1891),('jinggao','警告',1),('jinggao','⚠️',1433),('xiangjiao','🍌',725),('ji','🐔',626),('jitou','🐔',626),('laugh','😀',10),('mei','🌸',2000);CREATE TABLE emoji(emoji TEXT PRIMARY KEY,keywords TEXT);INSERT INTO emoji VALUES('🇺🇸','美国 美利坚 美利坚合众国 星条旗 flag: united states'),('🇺🇲','美国本土外小岛屿 flag: u.s. outlying islands'),('⚠️','警告 注意 危险 预警 warning'),('🍌','香蕉 banana'),('🐔','鸡 鸡头 chicken'),('😀','笑脸 laugh'),('🌸','樱花 梅花 Mei');CREATE TABLE kaomoji(pinyin TEXT,jianpin TEXT,kaomoji TEXT,sort_order INTEGER);INSERT INTO kaomoji VALUES('meiguo','mg','(•̀ᴗ•́)و',10),('jinggao','jg','(゚Д゚≡゚д゚)!?',20);CREATE TABLE kaomoji_catalog(kaomoji TEXT PRIMARY KEY,keywords TEXT);INSERT INTO kaomoji_catalog VALUES('(•̀ᴗ•́)و','mei guo'),('(゚Д゚≡゚д゚)!?','jing gao 警告');";
7951
7952    const BOTH_EXPRESSIVE: MixedExpressiveOptions = MixedExpressiveOptions {
7953        emoji_candidates: true,
7954        kaomoji_candidates: true,
7955        symbol_candidates: false,
7956    };
7957
7958    fn expressive_fixture() -> Fixture {
7959        let fixture = fixture_with(EXPRESSIVE_MAIN_FIXTURE);
7960        Connection::open(fixture.paths.resource(assets::OTHER_DICTIONARY))
7961            .and_then(|db| db.execute_batch(EXPRESSIVE_OTHERS_FIXTURE))
7962            .expect("others fixture");
7963        fixture
7964    }
7965
7966    fn open_expressive(
7967        fixture: &Fixture,
7968        english: EnglishInputOptions,
7969        expressive: MixedExpressiveOptions,
7970    ) -> NineKeySession {
7971        let mut session = open(&fixture.paths, true, english);
7972        session.set_mixed_expressive(expressive);
7973        session
7974    }
7975
7976    fn sources(session: &NineKeySession) -> Vec<CandidateSource> {
7977        session.snapshot().candidate_sources
7978    }
7979
7980    fn has_expressive_rows(session: &NineKeySession) -> bool {
7981        sources(session)
7982            .iter()
7983            .any(|source| matches!(source, CandidateSource::Emoji | CandidateSource::Kaomoji))
7984    }
7985
7986    #[test]
7987    fn digits_put_emoji_and_kaomoji_after_the_words_they_depict() {
7988        let fixture = expressive_fixture();
7989        let mut session =
7990            open_expressive(&fixture, EnglishInputOptions::default(), BOTH_EXPRESSIVE);
7991
7992        // #5667:美国 后面紧跟 🇺🇸。🇺🇲 只是编码以 meiguo 开头,画的不是 美国,与接不上任何词的颜文字一起排在末尾(#5907);mei'guo 和 mei'huo 两种读法都查到 🇺🇸,只出现一次。
7993        type_digits(&mut session, "634486");
7994        let view = session.snapshot();
7995        let listed = words(&session);
7996        assert_eq!(listed[..2], ["美国", "🇺🇸"]);
7997        assert_eq!(view.candidate_sources[1], CandidateSource::Emoji);
7998        assert_eq!(listed[listed.len() - 2..], ["🇺🇲", "(•̀ᴗ•́)و"]);
7999        assert_eq!(
8000            view.candidate_sources[listed.len() - 1],
8001            CandidateSource::Kaomoji
8002        );
8003        assert_eq!(listed.iter().filter(|word| *word == "🇺🇸").count(), 1);
8004        assert!(view.candidate_answers_key[1]);
8005        assert_eq!(view.candidates[1].pinyin, "634486");
8006        session.command(Command::Cancel);
8007
8008        // #5667 的截图:警告 ⚠️,颜文字的关键词也有 警告,接在 emoji 后面;emoji 里的「警告」与拼音候选重复,不再出现。
8009        type_digits(&mut session, "5464426");
8010        assert_eq!(words(&session)[..3], ["警告", "⚠️", "(゚Д゚≡゚д゚)!?"]);
8011        assert_eq!(
8012            words(&session)
8013                .iter()
8014                .filter(|word| *word == "警告")
8015                .count(),
8016            1
8017        );
8018        session.command(Command::Cancel);
8019
8020        // #5667 的截图:54 是 里 李 鸡 🐔 几。🐔 的编码就是 ji,在 ji 开头的两百多行里按目录顺序靠后,靠编码完全相同的行先取才取得到。
8021        type_digits(&mut session, "54");
8022        assert_eq!(words(&session)[..4], ["里", "李", "鸡", "🐔"]);
8023        session.command(Command::Cancel);
8024
8025        // 只开 emoji:颜文字不出现。
8026        let mut emoji_only = open_expressive(
8027            &fixture,
8028            EnglishInputOptions::default(),
8029            MixedExpressiveOptions {
8030                emoji_candidates: true,
8031                kaomoji_candidates: false,
8032                symbol_candidates: false,
8033            },
8034        );
8035        type_digits(&mut emoji_only, "634486");
8036        assert_eq!(words(&emoji_only)[..2], ["美国", "🇺🇸"]);
8037        assert_eq!(words(&emoji_only).last().map(String::as_str), Some("🇺🇲"));
8038        assert!(!sources(&emoji_only).contains(&CandidateSource::Kaomoji));
8039    }
8040
8041    #[test]
8042    fn expressive_rows_follow_a_leading_english_word() {
8043        let fixture = expressive_fixture();
8044        Connection::open(fixture.paths.dictionary(assets::ENGLISH_DICTIONARY))
8045            .unwrap()
8046            .execute(
8047                "INSERT INTO english_words(word, display, weight) VALUES ('mei', 'Mei', 700)",
8048                [],
8049            )
8050            .unwrap();
8051        let mut session = open_expressive(&fixture, mixed(), BOTH_EXPRESSIVE);
8052        // 九宫格的英文首行有权重时占首选之后的位置;关键词里有这个英文词的 emoji 接在它后面,接不上的排在末尾。
8053        type_digits(&mut session, "634");
8054        let listed = words(&session);
8055        assert_eq!(listed[..3], ["美", "Mei", "🌸"]);
8056        assert_eq!(listed[listed.len() - 3..], ["🇺🇲", "🇺🇸", "(•̀ᴗ•́)و"]);
8057    }
8058
8059    #[test]
8060    fn both_switches_off_leave_the_list_unchanged() {
8061        let fixture = expressive_fixture();
8062        for digits in ["634486", "5464426", "94264", "63", "6"] {
8063            let mut off = open_expressive(&fixture, mixed(), MixedExpressiveOptions::default());
8064            let mut on = open_expressive(&fixture, mixed(), BOTH_EXPRESSIVE);
8065            type_digits(&mut off, digits);
8066            type_digits(&mut on, digits);
8067            assert!(!has_expressive_rows(&off), "{digits}");
8068            // 开关打开时去掉混入的行,剩下的与关闭时逐项相同:混排只插行,不动别的候选。
8069            let mut without_expressive = on.snapshot().candidates;
8070            without_expressive.retain(|item| {
8071                !matches!(
8072                    item.source,
8073                    CandidateSource::Emoji | CandidateSource::Kaomoji
8074                )
8075            });
8076            assert_eq!(without_expressive, off.snapshot().candidates, "{digits}");
8077        }
8078    }
8079
8080    #[test]
8081    fn expressive_rows_need_two_digits_and_pinyin() {
8082        let fixture = expressive_fixture();
8083        let mut session =
8084            open_expressive(&fixture, EnglishInputOptions::default(), BOTH_EXPRESSIVE);
8085        // 一个数字的读法 m 已经是 meiguo 的前缀,但与 26 键一样不足两个时不匹配。
8086        type_digits(&mut session, "6");
8087        assert!(!has_expressive_rows(&session));
8088        type_digits(&mut session, "3");
8089        assert!(words(&session).contains(&"🇺🇸".to_owned()));
8090        session.command(Command::Cancel);
8091
8092        // 九键纯英文模式只拼英文词。
8093        session.set_english_only(true);
8094        type_digits(&mut session, "634486");
8095        assert!(!has_expressive_rows(&session));
8096        session.set_english_only(false);
8097        session.command(Command::Cancel);
8098
8099        // 单字、笔画筛选针对的是汉字。
8100        type_digits(&mut session, "634486");
8101        assert!(session.set_filter(true, "").handled);
8102        assert!(!has_expressive_rows(&session));
8103        session.command(Command::Cancel);
8104
8105        // 会话不允许全拼时九宫格只拼英文。
8106        let mut english_grid = NineKeySession::new(
8107            &fixture.paths,
8108            false,
8109            FrequencyAdjustmentOptions::default(),
8110            FuzzyPinyinOptions::default(),
8111            mixed(),
8112            false,
8113            false,
8114        );
8115        english_grid.set_mixed_expressive(BOTH_EXPRESSIVE);
8116        type_digits(&mut english_grid, "634486");
8117        assert!(!has_expressive_rows(&english_grid));
8118    }
8119
8120    #[test]
8121    fn expressive_rows_follow_chosen_syllables_and_splits() {
8122        let fixture = expressive_fixture();
8123        let mut session =
8124            open_expressive(&fixture, EnglishInputOptions::default(), BOTH_EXPRESSIVE);
8125
8126        // 在拼音选择条上选定 nei:只剩 nei 开头的读法,🇺🇸 只对应 mei'guo,不再出现。
8127        type_digits(&mut session, "634486");
8128        let nei = spelling_index(&session, "nei");
8129        assert!(session.choose_spelling(nei).handled);
8130        assert!(!words(&session).contains(&"🇺🇸".to_owned()));
8131        session.command(Command::Cancel);
8132        type_digits(&mut session, "634486");
8133        let mei = spelling_index(&session, "mei");
8134        assert!(session.choose_spelling(mei).handled);
8135        assert!(words(&session).contains(&"🇺🇸".to_owned()));
8136        session.command(Command::Cancel);
8137
8138        // 94264 可以是 xiang,🍌(xiangjiao)出现。
8139        type_digits(&mut session, "94264");
8140        assert!(words(&session).contains(&"🍌".to_owned()));
8141        session.command(Command::Cancel);
8142        // 先选定 xian 再打 4:字母同样是 xiang,但 xian 之后的 gjiao 切不成音节,🍌 不出现。
8143        type_digits(&mut session, "9426");
8144        let xian = spelling_index(&session, "xian");
8145        assert!(session.choose_spelling(xian).handled);
8146        type_digits(&mut session, "4");
8147        assert!(!words(&session).contains(&"🍌".to_owned()));
8148        session.command(Command::Cancel);
8149        // 用「分词」键在 xian 后面切开也一样。
8150        type_keys(&mut session, "9426'4");
8151        assert!(!words(&session).contains(&"🍌".to_owned()));
8152    }
8153
8154    #[test]
8155    fn choosing_an_expressive_row_commits_it_and_ends_the_composition() {
8156        let fixture = expressive_fixture();
8157        let mut session =
8158            open_expressive(&fixture, EnglishInputOptions::default(), BOTH_EXPRESSIVE);
8159        type_digits(&mut session, "634486");
8160        let chosen = session.select(index_of(&session, "🇺🇸"));
8161        assert_eq!(chosen.commit.as_deref(), Some("🇺🇸"));
8162        assert_eq!(chosen.diagnostic, None);
8163        assert!(!session.active());
8164        // 不是词库行:不能置顶、删除或固定位置,也不会被学成拼音词。
8165        type_digits(&mut session, "634486");
8166        let index = index_of(&session, "🇺🇸");
8167        assert!(!session.pin(index).handled);
8168        assert!(!session.remove(index).handled);
8169        assert!(!session.set_position(index, 2).handled);
8170        assert_eq!(
8171            sources(&session)
8172                .iter()
8173                .zip(words(&session))
8174                .filter(|(_, word)| word == "🇺🇸")
8175                .map(|(source, _)| *source)
8176                .collect::<Vec<_>>(),
8177            [CandidateSource::Emoji]
8178        );
8179    }
8180
8181    #[test]
8182    fn expressive_readings_are_capped_and_led_by_the_candidates() {
8183        let fixture = expressive_fixture();
8184        let mut session =
8185            open_expressive(&fixture, EnglishInputOptions::default(), BOTH_EXPRESSIVE);
8186        type_digits(&mut session, "94264");
8187        let table = spelling_table();
8188        let mut every: Vec<String> = table
8189            .paths("94264")
8190            .into_iter()
8191            .map(|path| path.concat())
8192            .collect();
8193        every.sort();
8194        every.dedup();
8195        assert!(every.len() > EXPRESSIVE_READING_LIMIT, "{every:?}");
8196
8197        let readings: Vec<String> = session
8198            .expressive_readings(&table.paths("94264"), &[], &session.candidates)
8199            .into_iter()
8200            .map(|(reading, _)| reading)
8201            .collect();
8202        assert_eq!(readings.len(), EXPRESSIVE_READING_LIMIT);
8203        // 排在最前的拼音候选 香(xiang)支持的读法查在最前。
8204        assert_eq!(session.candidates[0].word, "香");
8205        assert_eq!(readings[0], "xiang");
8206        // 只拼了开头一个音节的路径不算读法。
8207        assert!(readings.iter().all(|reading| reading.len() == 5));
8208    }
8209
8210    #[test]
8211    fn shuangpin_readings_do_not_allocate_an_intermediate_boundary_table() {
8212        let fixture = fixture_with(SHUANGPIN_FIXTURE);
8213        let mut session = shuangpin_session(&fixture.paths, NineKeyShuangpinKind::Migala3);
8214        session.digits = "6447".into();
8215        let alternatives = session.grid.spelling_table().paths("6447");
8216        let candidates = session.candidates.clone();
8217        let boundaries = [2, 4];
8218        let (_, allocations) = crate::ime::personal_rerank::allocations::count(|| {
8219            session.expressive_readings(&alternatives, &boundaries, &candidates)
8220        });
8221        assert!(
8222            allocations <= 100,
8223            "shuangpin readings allocated an intermediate boundary table: {allocations}"
8224        );
8225    }
8226
8227    #[test]
8228    fn shuangpin_readings_materialize_boundaries_only_for_retained_readings() {
8229        let fixture = fixture_with(SHUANGPIN_FIXTURE);
8230        let mut session = shuangpin_session(&fixture.paths, NineKeyShuangpinKind::Migala3);
8231        session.digits = "94264".into();
8232        let alternatives = session.grid.spelling_table().paths("94264");
8233        let candidates = session.candidates.clone();
8234        let boundaries = [2, 4];
8235        let (_, allocations) = crate::ime::personal_rerank::allocations::count(|| {
8236            session.expressive_readings(&alternatives, &boundaries, &candidates)
8237        });
8238        assert!(
8239            allocations <= 100,
8240            "discarded shuangpin readings still materialized boundaries: {allocations}"
8241        );
8242    }
8243
8244    #[test]
8245    fn ordinary_nine_key_readings_borrow_shared_boundaries() {
8246        let fixture = expressive_fixture();
8247        let mut session =
8248            open_expressive(&fixture, EnglishInputOptions::default(), BOTH_EXPRESSIVE);
8249        session.digits = "634486".into();
8250        let alternatives = spelling_table().paths("634486");
8251        let candidates = session.candidates.clone();
8252        let boundaries = [3, 6];
8253        let (_, allocations) = crate::ime::personal_rerank::allocations::count(|| {
8254            session.expressive_readings(&alternatives, &boundaries, &candidates)
8255        });
8256        assert!(
8257            allocations <= 84,
8258            "ordinary readings copied shared boundaries: {allocations}"
8259        );
8260    }
8261
8262    #[test]
8263    fn expressive_readings_do_not_allocate_a_separate_locked_prefix() {
8264        let fixture = expressive_fixture();
8265        let mut session =
8266            open_expressive(&fixture, EnglishInputOptions::default(), BOTH_EXPRESSIVE);
8267        type_digits(&mut session, "634486");
8268        assert!(
8269            session
8270                .choose_spelling(spelling_index(&session, "mei"))
8271                .handled
8272        );
8273        let alternatives = session.grid.spelling_table().paths("486");
8274        let candidates = session.candidates.clone();
8275        let (_, allocations) = crate::ime::personal_rerank::allocations::count(|| {
8276            session.expressive_readings(&alternatives, &[], &candidates)
8277        });
8278        assert!(
8279            allocations <= 15,
8280            "locked expressive readings allocated {allocations} buffers"
8281        );
8282    }
8283
8284    // ---- 打拼音出符号 ----
8285
8286    /// 正负(zheng'fu)排在 政府 后面;派(pai)是单音节名称,前面另有三个 pai 的字。
8287    const SYMBOL_MAIN_FIXTURE: &str = "CREATE TABLE tbl_1_z(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_z VALUES('zheng','z','正',300),('zheng','z','政',200);CREATE TABLE tbl_1_f(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_f VALUES('fu','f','负',300),('fu','f','府',200);CREATE TABLE tbl_2_z(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_2_z VALUES('zheng''fu','zf','政府',1000),('zheng''fu','zf','正负',500);CREATE TABLE tbl_1_p(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_p VALUES('pai','p','派',900),('pai','p','排',800),('pai','p','牌',700),('pai','p','拍',600);";
8288
8289    const SYMBOLS_ONLY: MixedExpressiveOptions = MixedExpressiveOptions {
8290        emoji_candidates: false,
8291        kaomoji_candidates: false,
8292        symbol_candidates: true,
8293    };
8294
8295    fn has_symbol_row(session: &NineKeySession, symbol: &str) -> bool {
8296        sources(session)
8297            .iter()
8298            .zip(words(session))
8299            .any(|(source, word)| *source == CandidateSource::Emoji && word == symbol)
8300    }
8301
8302    #[test]
8303    fn digits_spelling_a_whole_symbol_name_add_its_symbol_after_the_name() {
8304        let fixture = fixture_with(SYMBOL_MAIN_FIXTURE);
8305        let mut session = open_expressive(&fixture, EnglishInputOptions::default(), SYMBOLS_ONLY);
8306        // 9436438 读作 zhengfu:± 紧跟在 正负 后面,不占首位。
8307        type_digits(&mut session, "9436438");
8308        let listed = words(&session);
8309        assert_eq!(listed[..3], ["政府", "正负", "±"]);
8310        assert_eq!(sources(&session)[2], CandidateSource::Emoji);
8311        assert_eq!(session.snapshot().candidates[2].pinyin, "9436438");
8312        session.command(Command::Cancel);
8313
8314        // 没打完整的名称不出。
8315        type_digits(&mut session, "943643");
8316        assert!(!has_symbol_row(&session, "±"));
8317        session.command(Command::Cancel);
8318
8319        // 单音节名称最早排第 4 位(下标 3)。
8320        type_digits(&mut session, "724");
8321        let listed = words(&session);
8322        assert_eq!(listed[0], "派");
8323        assert_eq!(listed[3], "π");
8324        session.command(Command::Cancel);
8325
8326        // 单字筛选时不出。
8327        type_digits(&mut session, "9436438");
8328        assert!(session.set_filter(true, "").handled);
8329        assert!(!has_symbol_row(&session, "±"));
8330        session.command(Command::Cancel);
8331
8332        // 开关关着时不出,其余候选逐项相同。
8333        let mut off = open_expressive(
8334            &fixture,
8335            EnglishInputOptions::default(),
8336            MixedExpressiveOptions::default(),
8337        );
8338        let mut on = open_expressive(&fixture, EnglishInputOptions::default(), SYMBOLS_ONLY);
8339        type_digits(&mut off, "9436438");
8340        type_digits(&mut on, "9436438");
8341        assert!(!has_symbol_row(&off, "±"));
8342        let mut without_symbols = on.snapshot().candidates;
8343        without_symbols.retain(|item| item.source != CandidateSource::Emoji);
8344        assert_eq!(without_symbols, off.snapshot().candidates);
8345    }
8346
8347    #[test]
8348    fn choosing_a_symbol_row_commits_it_and_eats_every_digit() {
8349        let fixture = fixture_with(SYMBOL_MAIN_FIXTURE);
8350        let mut session = open_expressive(&fixture, EnglishInputOptions::default(), SYMBOLS_ONLY);
8351        type_digits(&mut session, "9436438");
8352        let chosen = session.select(index_of(&session, "±"));
8353        assert_eq!(chosen.commit.as_deref(), Some("±"));
8354        assert!(!session.active());
8355        // 不能置顶、删除或固定位置。
8356        type_digits(&mut session, "9436438");
8357        let index = index_of(&session, "±");
8358        assert!(!session.pin(index).handled);
8359        assert!(!session.remove(index).handled);
8360        assert!(!session.set_position(index, 2).handled);
8361    }
8362
8363    /// 名称词被固定到后面的位置时,接在它后面的符号不能顶到首位。
8364    #[test]
8365    fn a_symbol_row_never_leads_after_its_name_is_fixed_further_down() {
8366        let fixture = fixture_with("CREATE TABLE tbl_1_d(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_d VALUES('da','d','大',300);CREATE TABLE tbl_1_y(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_y VALUES('yu','y','于',300);CREATE TABLE tbl_2_d(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_2_d VALUES('da''yu','dy','大于',900);");
8367        let mut session = open_expressive(&fixture, EnglishInputOptions::default(), SYMBOLS_ONLY);
8368        type_digits(&mut session, "3298");
8369        assert_eq!(words(&session)[..3], ["大于", ">", ">"]);
8370        assert!(session.set_position(0, 3).handled);
8371        // 名称词留在固定的第 3 位。
8372        let listed = words(&session);
8373        assert_ne!(sources(&session)[0], CandidateSource::Emoji, "{listed:?}");
8374        assert_eq!(listed[2], "大于", "{listed:?}");
8375        assert!(listed.contains(&">".to_owned()) && listed.contains(&">".to_owned()));
8376    }
8377
8378    #[test]
8379    fn symbols_only_does_not_allocate_an_unused_other_dictionary_path() {
8380        let fixture = fixture_with(SYMBOL_MAIN_FIXTURE);
8381        let mut session = open_expressive(&fixture, EnglishInputOptions::default(), SYMBOLS_ONLY);
8382        session.digits = "9436438".into();
8383        let alternatives = spelling_table().paths("9436438");
8384        assert!(!session
8385            .expressive_readings(&alternatives, &[], &[])
8386            .is_empty());
8387        let candidates = session.candidates.clone();
8388        let (_, allocations) = crate::ime::personal_rerank::allocations::count(|| {
8389            session.expressive_candidates(&alternatives, &candidates)
8390        });
8391        assert!(
8392            allocations <= 1150,
8393            "symbols-only expressive path allocated an unused resource path: {allocations}"
8394        );
8395    }
8396
8397    fn frozen_splits_into_syllables_at(key: &str, boundaries: &[usize]) -> bool {
8398        let mut start = 0;
8399        for &end in boundaries.iter().chain(std::iter::once(&key.len())) {
8400            let Some(piece) = key.get(start..end) else {
8401                return false;
8402            };
8403            if !piece.is_empty()
8404                && crate::pinyin::segment::cut_one_piece_min_segments(piece, true).is_empty()
8405            {
8406                return false;
8407            }
8408            start = end;
8409        }
8410        true
8411    }
8412
8413    #[test]
8414    fn expressive_boundary_check_matches_owned_cuts() {
8415        let mut inputs = vec![
8416            String::new(),
8417            "nihao".into(),
8418            "meiguo".into(),
8419            "xiangjiao".into(),
8420            "xi'an".into(),
8421            "ni''hao".into(),
8422            "'ni".into(),
8423            "ni'".into(),
8424            "你好".into(),
8425            "ni好".into(),
8426            "NIHAO".into(),
8427            "gamma".into(),
8428            "ni".repeat(32),
8429            "ni".repeat(33),
8430            "ni".repeat(33) + "h",
8431        ];
8432        for syllable in intact_pinyin_list() {
8433            for suffix in ["", "ni", "h", "'an", "好", "sahng", "zhonguo"] {
8434                inputs.push(format!("{syllable}{suffix}"));
8435            }
8436        }
8437        for key in inputs {
8438            let mut boundaries = vec![
8439                vec![],
8440                vec![0],
8441                vec![key.len()],
8442                vec![0, key.len()],
8443                vec![key.len(), key.len()],
8444                vec![key.len() + 1],
8445                vec![usize::MAX],
8446                vec![key.len(), 0],
8447            ];
8448            boundaries.extend((0..=key.len()).map(|at| vec![at, at]));
8449            for boundaries in boundaries {
8450                assert_eq!(
8451                    splits_into_syllables_at(&key, &boundaries),
8452                    frozen_splits_into_syllables_at(&key, &boundaries),
8453                    "编码 {key:?},边界 {boundaries:?}"
8454                );
8455            }
8456        }
8457    }
8458
8459    #[test]
8460    fn expressive_boundary_check_does_not_materialize_syllables() {
8461        use crate::ime::personal_rerank::allocations::count;
8462        assert!(splits_into_syllables_at("nihao", &[]));
8463        for (key, boundaries, expected_allocations) in [
8464            ("nihao".to_owned(), vec![], 0),
8465            ("meiguo".to_owned(), vec![3], 0),
8466            ("xiangjiao".to_owned(), vec![2, 5], 0),
8467            ("ni".repeat(32), vec![], 0),
8468            ("ni".repeat(33), vec![], 1),
8469        ] {
8470            let (expected, old) = count(|| frozen_splits_into_syllables_at(&key, &boundaries));
8471            let (actual, new) = count(|| splits_into_syllables_at(&key, &boundaries));
8472            assert!(expected);
8473            assert_eq!(actual, expected);
8474            eprintln!("表情边界:{} 字节 {boundaries:?},{old} → {new}", key.len());
8475            assert_eq!(new, expected_allocations, "只应保留超长片段的 DP 表分配");
8476            assert!(new < old);
8477        }
8478    }
8479
8480    #[test]
8481    fn expressive_symbol_query_preserves_rows_without_cut_results() {
8482        use crate::ime::personal_rerank::allocations::count;
8483        let readings = vec!["zhengfu".to_owned()];
8484        let boundaries = [5];
8485        let current = |key: &str| splits_into_syllables_at(key, &boundaries);
8486        let frozen = |key: &str| frozen_splits_into_syllables_at(key, &boundaries);
8487        assert!(!query_symbol_readings(&readings, &current).is_empty());
8488        let (expected, old) = count(|| query_symbol_readings(&readings, &frozen));
8489        let (actual, new) = count(|| query_symbol_readings(&readings, &current));
8490        assert_eq!(actual, expected);
8491        eprintln!("符号查询:{old} → {new}");
8492        assert!(new < old, "符号查询仍然物化了边界切分结果");
8493    }
8494
8495    #[test]
8496    fn expressive_keys_must_split_at_the_fixed_boundaries() {
8497        assert!(splits_into_syllables_at("xiangjiao", &[]));
8498        assert!(splits_into_syllables_at("meiguo", &[3]));
8499        assert!(splits_into_syllables_at("meiguo", &[6]));
8500        assert!(splits_into_syllables_at("meiguobentuwaixiaodaoyu", &[3, 6]));
8501        // 编码不记音节边界,xi'ang'jiao 也是一种切法。
8502        assert!(splits_into_syllables_at("xiangjiao", &[2]));
8503        assert!(!splits_into_syllables_at("xiangjiao", &[4]));
8504        assert!(!splits_into_syllables_at("meiguo", &[2]));
8505        assert!(!splits_into_syllables_at("laugh", &[2]));
8506        assert!(!splits_into_syllables_at("mei", &[4]));
8507        assert!(letters_start_with("mei'guo", "meig"));
8508        assert!(!letters_start_with("mei", "meig"));
8509    }
8510
8511    /// #6654:出货词库上打 `74`,左列最前面除了首选候选的读音,就是正好两键拼完的 pi、qi、ri、si,pian 这些要补键的排在它们后面。没有 `MSIME_EVAL_RESOURCES` 时跳过。
8512    #[test]
8513    fn real_dictionary_lists_exact_syllables_before_completions() {
8514        let Some(resources) = std::env::var_os("MSIME_EVAL_RESOURCES") else {
8515            eprintln!("skipped: MSIME_EVAL_RESOURCES is not set to a resource directory");
8516            return;
8517        };
8518        let user = tempfile::tempdir().expect("user directory");
8519        let resources = PathBuf::from(resources);
8520        let paths = RuntimePaths {
8521            resources: resources.clone(),
8522            user_data: user.path().to_path_buf(),
8523            cache: user.path().to_path_buf(),
8524            dictionaries: resources,
8525        };
8526        let mut session = open(&paths, false, mixed());
8527        type_digits(&mut session, "74");
8528        let spellings = session.snapshot().nine_key_spellings;
8529        let preferred = &spellings[0];
8530        let exact: Vec<&str> = spellings[1..]
8531            .iter()
8532            .map(String::as_str)
8533            .filter(|spelling| spelling != preferred)
8534            .take_while(|spelling| encode(spelling) == "74")
8535            .collect();
8536        let mut expected: Vec<&str> = ["pi", "qi", "ri", "si"]
8537            .into_iter()
8538            .filter(|spelling| spelling != preferred)
8539            .collect();
8540        let mut sorted = exact.clone();
8541        sorted.sort_unstable();
8542        expected.sort_unstable();
8543        assert_eq!(sorted, expected, "{spellings:?}");
8544    }
8545
8546    // ---- 全拼归组键盘:同一套解码替换码表,与键盘有关的判断按三种网格分别检查 ----
8547
8548    const GRIDS: [KeyGrid; 3] = [KeyGrid::NineKey, KeyGrid::FourteenKey, KeyGrid::EighteenKey];
8549
8550    /// 14 键的词库:nihao 和 buhao、xi'an 和 xian 在 14 键上是同一串组码。
8551    const FOURTEEN_FIXTURE: &str = "CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_n VALUES('ni','n','你',100);CREATE TABLE tbl_1_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_b VALUES('bu','b','不',90);CREATE TABLE tbl_1_h(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_h VALUES('hao','h','好',100);CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_2_n VALUES('ni''hao','nh','你好',1000);CREATE TABLE tbl_2_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_2_b VALUES('bu''hao','bh','不好',500);CREATE TABLE tbl_1_x(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_x VALUES('xi','x','西',100),('xian','x','先',300);CREATE TABLE tbl_1_a(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_a VALUES('an','a','安',100);CREATE TABLE tbl_2_x(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_2_x VALUES('xi''an','xa','西安',800);CREATE TABLE tbl_1_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_s VALUES('shi','s','是',1000),('shu','s','书',500);";
8552
8553    fn fourteen_key_session(paths: &RuntimePaths, english: EnglishInputOptions) -> NineKeySession {
8554        let mut session = open(paths, false, english);
8555        session.set_grid(KeyGrid::FourteenKey);
8556        session
8557    }
8558
8559    #[test]
8560    fn grid_codes_come_from_the_printed_keys() {
8561        for grid in GRIDS {
8562            // 每个字母恰好印在一个键上,它的组码就是那个键。
8563            let mut derived = [0u8; 26];
8564            let codes: Vec<u8> = match grid {
8565                KeyGrid::NineKey => (b'2'..=b'9').collect(),
8566                KeyGrid::FourteenKey => FOURTEEN_KEY_GROUPS
8567                    .iter()
8568                    .map(|group| group.as_bytes()[0])
8569                    .collect(),
8570                KeyGrid::EighteenKey => EIGHTEEN_KEY_GROUPS
8571                    .iter()
8572                    .map(|group| group.as_bytes()[0])
8573                    .collect(),
8574                KeyGrid::NineKeyShuangpin(_)
8575                | KeyGrid::FourteenKeyShuangpin(_)
8576                | KeyGrid::EighteenKeyShuangpin(_) => {
8577                    unreachable!("GRIDS 只有按字母归组的键盘")
8578                }
8579            };
8580            for &code in &codes {
8581                assert!(grid.is_code(code));
8582                for letter in grid.letters(code).bytes() {
8583                    let slot = &mut derived[usize::from(letter - b'a')];
8584                    assert_eq!(*slot, 0, "{grid:?}: {} on two keys", char::from(letter));
8585                    *slot = code;
8586                }
8587            }
8588            assert_eq!(Some(&derived), grid.keypad(), "{grid:?}");
8589            for letter in b'a'..=b'z' {
8590                let code = grid.code_of(letter).unwrap();
8591                assert!(grid.letters(code).as_bytes().contains(&letter));
8592            }
8593            assert_eq!(grid.code_of(b'A'), None);
8594            assert_eq!(grid.code_of(b'\''), None);
8595            assert!(!grid.is_code(b'\'') && !grid.is_code(b'A') && !grid.is_code(b'1'));
8596        }
8597        assert_eq!(FOURTEEN_KEYPAD, b"abcdedggujjlmbooqeatucqztz");
8598        assert_eq!(KeyGrid::FourteenKey.encode("ni'hao"), "bugao");
8599        assert_eq!(KeyGrid::FourteenKey.letters(b'l'), "l");
8600        assert_eq!(KeyGrid::FourteenKey.letters(b'w'), "", "w is not a code");
8601        assert!(KeyGrid::NineKey.offers_raw_key());
8602        assert!(!KeyGrid::FourteenKey.offers_raw_key());
8603    }
8604
8605    #[test]
8606    fn syllable_codes_per_grid() {
8607        for (grid, expected) in [(KeyGrid::NineKey, 218), (KeyGrid::FourteenKey, 314)] {
8608            let codes: HashSet<String> = intact_pinyin_list()
8609                .iter()
8610                .map(|syllable| grid.encode(syllable))
8611                .collect();
8612            assert_eq!(intact_pinyin_list().len(), 421);
8613            assert_eq!(codes.len(), expected, "{grid:?}");
8614        }
8615    }
8616
8617    #[test]
8618    fn fourteen_key_shares_codes_between_syllables() {
8619        let grid = KeyGrid::FourteenKey;
8620        for (code, syllables) in [
8621            ("bu", ["bi", "bu", "ni", "nu"]),
8622            ("zu", ["xi", "xu", "zi", "zu"]),
8623        ] {
8624            for syllable in syllables {
8625                assert_eq!(grid.encode(syllable), code, "{syllable}");
8626            }
8627        }
8628        assert_eq!(grid.encode("shi"), grid.encode("shu"));
8629        // 跨音节的歧义:sha 和 a'ga 是同一串组码。
8630        assert_eq!(grid.encode("sha"), grid.encode("a'ga"));
8631        let paths = grid
8632            .spelling_table()
8633            .split_paths("aga", &[], &SyllablePrior::default());
8634        assert!(paths.contains(&path(&["sha"])), "{paths:?}");
8635        assert!(paths.contains(&path(&["a", "ga"])), "{paths:?}");
8636        let table = grid.spelling_table();
8637        let spellings = table.spellings_for("agu", 0, None, &SyllablePrior::default());
8638        assert!(spellings.contains(&"shi".to_owned()) && spellings.contains(&"shu".to_owned()));
8639    }
8640
8641    #[test]
8642    fn grid_initials_expand_each_key_and_stop_at_keys_without_initials() {
8643        assert_eq!(
8644            super::initials_codes(KeyGrid::FourteenKey, "bg", 1024).unwrap(),
8645            ["bg", "bh", "ng", "nh"]
8646        );
8647        // UI 键上的 u、i 都不起头音节,含它的串没有简拼。
8648        assert!(super::initials_codes(KeyGrid::FourteenKey, "bug", 1024).is_none());
8649        // 两个字母一键,十个键正好 1024 个码。
8650        assert_eq!(
8651            super::initials_codes(KeyGrid::FourteenKey, "qqqqqqqqqq", 1024)
8652                .unwrap()
8653                .len(),
8654            1024
8655        );
8656        assert!(super::initials_codes(KeyGrid::FourteenKey, "qqqqqqqqqqq", 1024).is_none());
8657        // 九键的 1 键同样没有字母。
8658        assert!(super::initials_codes(KeyGrid::NineKey, "61", 1024).is_none());
8659    }
8660
8661    #[test]
8662    fn grid_letter_prefixes_and_english_matches() {
8663        assert_eq!(
8664            super::letter_prefixes(KeyGrid::FourteenKey, "oj", 64),
8665            ["oj", "ok", "pj", "pk"]
8666        );
8667        assert_eq!(super::letter_prefixes(KeyGrid::FourteenKey, "l", 64), ["l"]);
8668        assert!(super::word_matches_digits(KeyGrid::FourteenKey, "OK", "oj"));
8669        assert!(super::word_matches_digits(KeyGrid::FourteenKey, "ok", "o"));
8670        assert!(!super::word_matches_digits(
8671            KeyGrid::FourteenKey,
8672            "ok",
8673            "ob"
8674        ));
8675        assert!(!super::word_matches_digits(KeyGrid::NineKey, "ok", "oj"));
8676        assert!(super::keyword_spells_digits(
8677            KeyGrid::FourteenKey,
8678            "riqi",
8679            "euqu"
8680        ));
8681        assert!(super::letters_spell_code(KeyGrid::FourteenKey, "ni", "bu"));
8682        assert!(!super::letters_spell_code(KeyGrid::FourteenKey, "ni", "64"));
8683    }
8684
8685    #[test]
8686    fn fourteen_key_left_column_has_letters_but_no_raw_key() {
8687        let fixture = fixture_with(FOURTEEN_FIXTURE);
8688        let mut nine = open(&fixture.paths, false, EnglishInputOptions::default());
8689        let mut fourteen = fourteen_key_session(&fixture.paths, EnglishInputOptions::default());
8690        assert!(
8691            !fourteen.character(b'6').handled,
8692            "digits are not 14-key codes"
8693        );
8694        assert!(
8695            !fourteen.character(b'w').handled,
8696            "only the first letter is a code"
8697        );
8698        type_digits(&mut nine, "7");
8699        type_digits(&mut fourteen, "q");
8700        let spellings = fourteen.snapshot().nine_key_spellings;
8701        assert_eq!(spellings[spellings.len() - 2..], ["Q", "W"]);
8702        assert!(!spellings.contains(&"q".to_owned()), "{spellings:?}");
8703        assert_eq!(
8704            nine.snapshot()
8705                .nine_key_spellings
8706                .last()
8707                .map(String::as_str),
8708            Some("7")
8709        );
8710        fourteen.command(Command::Cancel);
8711        // UI 键上的字母都不起头音节:没有字母项,也没有原样上屏项。
8712        type_digits(&mut fourteen, "u");
8713        assert!(fourteen.snapshot().nine_key_spellings.is_empty());
8714        fourteen.command(Command::Cancel);
8715        // AS GH UI:选择条里有 shi 和 shu。
8716        type_digits(&mut fourteen, "agu");
8717        let spellings = fourteen.snapshot().nine_key_spellings;
8718        assert!(
8719            spellings.starts_with(&["shi".to_owned(), "shu".to_owned()]),
8720            "{spellings:?}"
8721        );
8722        assert_eq!(words(&fourteen)[..2], ["是", "书"]);
8723        assert_eq!(fourteen.snapshot().nine_key_reading, "shi");
8724    }
8725
8726    #[test]
8727    fn fourteen_key_types_words_sentences_and_splits() {
8728        let fixture = fixture_with(FOURTEEN_FIXTURE);
8729        let mut session = fourteen_key_session(&fixture.paths, EnglishInputOptions::default());
8730        // 你好:BN UI GH AS OP,与 不好 是同一串组码。
8731        type_digits(&mut session, "bugao");
8732        let view = session.snapshot();
8733        assert_eq!(view.editing_text, "bugao");
8734        assert_eq!(view.preedit, "bugao");
8735        assert_eq!(words(&session)[..2], ["你好", "不好"]);
8736        assert_eq!(view.nine_key_reading, "ni'hao");
8737        assert_eq!(view.candidates[0].pinyin, "bugao");
8738        let result = session.select(0);
8739        assert_eq!(result.commit.as_deref(), Some("你好"));
8740        assert!(!session.active());
8741
8742        // 不打切分时 zuab 也是 xian(先);用分词键切开就是 西安。
8743        type_digits(&mut session, "zu");
8744        assert!(session.character(b'\'').handled);
8745        type_digits(&mut session, "ab");
8746        assert_eq!(session.snapshot().preedit, "zu'ab");
8747        assert_eq!(words(&session).first().map(String::as_str), Some("西安"));
8748        assert_eq!(session.snapshot().nine_key_reading, "xi'an");
8749        assert!(!words(&session).contains(&"先".to_owned()));
8750        session.command(Command::Cancel);
8751        type_digits(&mut session, "zuab");
8752        assert!(words(&session).contains(&"先".to_owned()));
8753    }
8754
8755    #[test]
8756    fn fourteen_key_locks_undoes_and_backspaces() {
8757        let fixture = fixture_with(FOURTEEN_FIXTURE);
8758        let mut session = fourteen_key_session(&fixture.paths, EnglishInputOptions::default());
8759        type_digits(&mut session, "bu");
8760        let spellings = session.snapshot().nine_key_spellings;
8761        let ni = spellings
8762            .iter()
8763            .position(|spelling| spelling == "ni")
8764            .unwrap();
8765        assert!(session.choose_spelling(ni).handled);
8766        assert_eq!(session.snapshot().preedit, "ni");
8767        assert_eq!(words(&session).first().map(String::as_str), Some("你"));
8768        // 组码全部锁定时退格先撤销锁定。
8769        assert!(session.command(Command::Backspace).handled);
8770        assert_eq!(session.snapshot().preedit, "bu");
8771        assert!(session.locked.is_empty());
8772        assert!(session.command(Command::Backspace).handled);
8773        assert_eq!(session.snapshot().preedit, "b");
8774        // 锁定之后还有没锁定的组码:退格删的是组码。
8775        type_digits(&mut session, "ugao");
8776        let spellings = session.snapshot().nine_key_spellings;
8777        let bu = spellings
8778            .iter()
8779            .position(|spelling| spelling == "bu")
8780            .unwrap();
8781        assert!(session.choose_spelling(bu).handled);
8782        assert_eq!(session.snapshot().preedit, "bu'gao");
8783        assert_eq!(words(&session).first().map(String::as_str), Some("不好"));
8784        assert!(session.command(Command::Backspace).handled);
8785        assert_eq!(session.snapshot().preedit, "bu'ga");
8786        assert_eq!(session.locked, ["bu"]);
8787        // 选首字母:W 限定下一个音节以 w 开头,退格撤销它。
8788        session.command(Command::Cancel);
8789        type_digits(&mut session, "q");
8790        let spellings = session.snapshot().nine_key_spellings;
8791        let w = spellings
8792            .iter()
8793            .position(|spelling| spelling == "W")
8794            .unwrap();
8795        assert!(session.choose_spelling(w).handled);
8796        assert_eq!(session.snapshot().preedit, "w");
8797        assert!(session.command(Command::Backspace).handled);
8798        assert_eq!(session.snapshot().preedit, "q");
8799    }
8800
8801    #[test]
8802    fn fourteen_key_mixes_english_words_and_reads_them() {
8803        let fixture = fixture_with(FOURTEEN_FIXTURE);
8804        let mut session = fourteen_key_session(&fixture.paths, mixed());
8805        // OP JK:拼音读不通,出 ok,读音是这个词覆盖已打键数的字母。
8806        type_digits(&mut session, "oj");
8807        assert_eq!(words(&session).first().map(String::as_str), Some("ok"));
8808        assert_eq!(session.snapshot().nine_key_reading, "ok");
8809        session.command(Command::Cancel);
8810        // 英文词比已打的键长时只读已打的那几个:OP LL → ol,首行是 old。
8811        type_digits(&mut session, "ol");
8812        assert_eq!(words(&session).first().map(String::as_str), Some("old"));
8813        assert_eq!(session.snapshot().nine_key_reading, "ol");
8814        let result = session.select(0);
8815        assert_eq!(result.commit.as_deref(), Some("old"));
8816        assert!(!session.active());
8817    }
8818
8819    /// 读音行按首行的来源判定:九键下与按 `pinyin` 是否全是数字判定的结果相同,14 键下英文首行也有读音。
8820    #[test]
8821    fn reading_is_judged_by_where_the_leading_row_came_from() {
8822        let mut nine = detached();
8823        nine.digits = "94".into();
8824        let mut fourteen = detached();
8825        fourteen.grid = KeyGrid::FourteenKey;
8826        fourteen.digits = "zu".into();
8827        let pinyin_sources = [
8828            CandidateSource::Database,
8829            CandidateSource::UserDatabase,
8830            CandidateSource::Generated,
8831            CandidateSource::Fallback,
8832            CandidateSource::NeuralKeyboard,
8833        ];
8834        for source in pinyin_sources {
8835            assert_eq!(
8836                nine.reading_for(Some(&spelled("西", "94", "xi", 1, source))),
8837                "xi"
8838            );
8839            assert_eq!(
8840                fourteen.reading_for(Some(&spelled("西", "zu", "xi", 1, source))),
8841                "xi"
8842            );
8843            // 日期时间行是 `Generated`,没有规范读音。
8844            assert_eq!(nine.reading_for(Some(&item("2026年", "94", 1, source))), "");
8845        }
8846        for source in [CandidateSource::Emoji, CandidateSource::Kaomoji] {
8847            // emoji、颜文字行的 `pinyin` 是全部组码,没有规范读音。
8848            assert_eq!(nine.reading_for(Some(&item("😀", "94", 1, source))), "");
8849            assert_eq!(fourteen.reading_for(Some(&item("😀", "zu", 1, source))), "");
8850        }
8851        let english = item("xi", "xi", 1, CandidateSource::EnglishDictionary);
8852        // 英文行的 `pinyin` 是查询键:九键下不全是数字,没有读音;14 键下是覆盖已打键数的字母。
8853        assert_eq!(nine.reading_for(Some(&english)), "");
8854        assert_eq!(fourteen.reading_for(Some(&english)), "xi");
8855        let longer = item("Xian", "xian", 1, CandidateSource::EnglishDictionary);
8856        assert_eq!(fourteen.reading_for(Some(&longer)), "xi");
8857        assert_eq!(nine.reading_for(None), "");
8858    }
8859
8860    #[test]
8861    fn commit_raw_moves_the_digit_buffer_into_the_result() {
8862        let fixture = fixture();
8863        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
8864        type_digits(&mut session, "64426");
8865
8866        let (result, allocations) =
8867            crate::ime::personal_rerank::allocations::count(|| session.command(Command::CommitRaw));
8868        assert_eq!(result.commit.as_deref(), Some("64426"));
8869        assert!(!session.active());
8870        assert_eq!(
8871            allocations, 0,
8872            "九键原样上屏复制了数字缓冲:{allocations} 次分配"
8873        );
8874    }
8875
8876    #[test]
8877    fn switching_grids_ends_the_composition() {
8878        let fixture = fixture_with(FOURTEEN_FIXTURE);
8879        let mut session = open(&fixture.paths, false, EnglishInputOptions::default());
8880        type_digits(&mut session, "64426");
8881        assert!(!session.sentence_seeds.is_empty());
8882        session.set_grid(KeyGrid::FourteenKey);
8883        assert!(!session.active());
8884        assert!(session.sentence_seeds.is_empty());
8885        assert!(!session.character(b'6').handled);
8886        type_digits(&mut session, "bugao");
8887        assert_eq!(words(&session).first().map(String::as_str), Some("你好"));
8888    }
8889
8890    /// 原样上屏:九键上屏键入的数字;14 键的组码不是用户想写的字母(按「你好」的键是 `bugao`),改为结束组字取首选,锁定的音节照样算数;没有候选时只剩组码。
8891    #[test]
8892    fn fourteen_key_commit_raw_finishes_instead_of_writing_the_codes() {
8893        let fixture = fixture_with(FOURTEEN_FIXTURE);
8894        let mut nine = open(&fixture.paths, false, EnglishInputOptions::default());
8895        type_digits(&mut nine, "64426");
8896        assert_eq!(
8897            nine.command(Command::CommitRaw).commit.as_deref(),
8898            Some("64426")
8899        );
8900
8901        let mut session = fourteen_key_session(&fixture.paths, EnglishInputOptions::default());
8902        type_digits(&mut session, "bugao");
8903        assert_eq!(
8904            session.command(Command::CommitRaw).commit.as_deref(),
8905            Some("你好")
8906        );
8907        assert!(!session.active());
8908        type_digits(&mut session, "bugao");
8909        assert_eq!(session.finish(999).commit.as_deref(), Some("你好"));
8910        assert!(!session.active());
8911
8912        type_digits(&mut session, "bu");
8913        let bu = session
8914            .snapshot()
8915            .nine_key_spellings
8916            .iter()
8917            .position(|spelling| spelling == "bu")
8918            .unwrap();
8919        assert!(session.choose_spelling(bu).handled);
8920        type_digits(&mut session, "gao");
8921        assert_eq!(
8922            session.command(Command::CommitRaw).commit.as_deref(),
8923            Some("不好")
8924        );
8925
8926        type_digits(&mut session, "qqq");
8927        assert!(session.snapshot().candidates.is_empty());
8928        assert_eq!(
8929            session.command(Command::CommitRaw).commit.as_deref(),
8930            Some("qqq")
8931        );
8932        assert!(!session.active());
8933    }
8934
8935    /// 九键双拼的测试词库:米旮旯 3 下 你好 是 64 47,中国 是 81 46,安 和 班 都是 25,路 是 50(u 在 0 键),走过 的 z 在 9 键、中国 的 zh 在 8 键。
8936    const SHUANGPIN_FIXTURE: &str = "CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_n VALUES('ni','n','你',100);CREATE TABLE tbl_1_h(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_h VALUES('hao','h','好',100);CREATE TABLE tbl_1_z(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_z VALUES('zhong','z','中',300);CREATE TABLE tbl_1_g(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_g VALUES('guo','g','国',200);CREATE TABLE tbl_1_a(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_a VALUES('an','a','安',150);CREATE TABLE tbl_1_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_b VALUES('ban','b','班',120);CREATE TABLE tbl_1_l(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_l VALUES('lu','l','路',90);CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_2_n VALUES('ni''hao','nh','你好',1000);CREATE TABLE tbl_2_z(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_2_z VALUES('zhong''guo','zg','中国',900),('zou''guo','zg','走过',950);";
8937
8938    #[test]
8939    fn eighteen_key_shuangpin_locks_readings_and_finishes_real_pinyin() {
8940        let fixture = fixture_with(SHUANGPIN_FIXTURE);
8941        let mut session = open(&fixture.paths, false, mixed());
8942        let table = crate::nine_key_shuangpin::resolve_eighteen_key_shuangpin(
8943            crate::ShuangpinProfileKind::Xiaohe,
8944            None,
8945        )
8946        .unwrap();
8947        session.set_grid(KeyGrid::EighteenKeyShuangpin(table));
8948        type_digits(&mut session, "bi");
8949        let choices = session.snapshot().nine_key_spellings;
8950        assert!(choices.iter().any(|value| value == "ni"), "{choices:?}");
8951        assert!(choices.iter().any(|value| value == "bi"), "{choices:?}");
8952        assert!(
8953            session
8954                .choose_spelling(spelling_index(&session, "ni"))
8955                .handled
8956        );
8957        assert!(session.command(Command::Backspace).handled);
8958        assert_eq!(session.snapshot().editing_text, "bi");
8959        assert!(
8960            session
8961                .choose_spelling(spelling_index(&session, "ni"))
8962                .handled
8963        );
8964        type_digits(&mut session, "hx");
8965        assert!(words(&session).contains(&"你好".to_owned()));
8966        assert_eq!(session.snapshot().nine_key_reading, "ni'hao");
8967        assert_eq!(session.snapshot().scheme, SchemeType::Shuangpin);
8968        assert_eq!(
8969            session.command(Command::CommitRaw).commit.as_deref(),
8970            Some("你好")
8971        );
8972        assert!(!session.active());
8973    }
8974
8975    #[test]
8976    fn fourteen_key_shuangpin_locks_readings_and_finishes_real_pinyin() {
8977        let fixture = fixture_with(SHUANGPIN_FIXTURE);
8978        let mut session = open(&fixture.paths, false, mixed());
8979        let table = crate::nine_key_shuangpin::resolve_fourteen_key_shuangpin(
8980            crate::ShuangpinProfileKind::Xiaohe,
8981            None,
8982        )
8983        .unwrap();
8984        session.set_grid(KeyGrid::FourteenKeyShuangpin(table));
8985        type_digits(&mut session, "bu");
8986        let choices = session.snapshot().nine_key_spellings;
8987        assert!(choices.iter().any(|value| value == "ni"), "{choices:?}");
8988        assert!(choices.iter().any(|value| value == "bu"), "{choices:?}");
8989        let selected = spelling_index(&session, "ni");
8990        assert!(session.choose_spelling(selected).handled);
8991        assert!(session.command(Command::Backspace).handled);
8992        assert_eq!(session.snapshot().editing_text, "bu");
8993        assert!(
8994            session
8995                .choose_spelling(spelling_index(&session, "ni"))
8996                .handled
8997        );
8998        type_digits(&mut session, "gc");
8999        assert!(words(&session).contains(&"你好".to_owned()));
9000        assert_eq!(session.snapshot().nine_key_reading, "ni'hao");
9001        assert_eq!(
9002            session.command(Command::CommitRaw).commit.as_deref(),
9003            Some("你好")
9004        );
9005        assert!(!session.active());
9006    }
9007
9008    #[test]
9009    fn fourteen_key_shuangpin_queries_semicolon_and_alternate_codes() {
9010        let fixture = fixture_with("CREATE TABLE tbl_1_q(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_q VALUES('qing','q','青',100);CREATE TABLE tbl_1_j(key TEXT,jp TEXT,value TEXT,weight INTEGER);INSERT INTO tbl_1_j VALUES('ju','j','居',100);");
9011        let mut session = open(&fixture.paths, false, mixed());
9012        let table = crate::nine_key_shuangpin::resolve_fourteen_key_shuangpin(
9013            crate::ShuangpinProfileKind::Microsoft,
9014            None,
9015        )
9016        .unwrap();
9017        session.set_grid(KeyGrid::FourteenKeyShuangpin(table));
9018        for (code, word) in [("ql", "青"), ("ju", "居"), ("jt", "居")] {
9019            type_digits(&mut session, code);
9020            assert!(
9021                words(&session).contains(&word.to_owned()),
9022                "{code}: {:?}",
9023                words(&session)
9024            );
9025            assert_eq!(
9026                session.command(Command::CommitRaw).commit.as_deref(),
9027                Some(word)
9028            );
9029        }
9030    }
9031
9032    fn shuangpin_session(paths: &RuntimePaths, kind: NineKeyShuangpinKind) -> NineKeySession {
9033        let mut session = open(paths, false, mixed());
9034        let table = crate::nine_key_shuangpin::resolve(kind, None).unwrap();
9035        session.set_grid(KeyGrid::NineKeyShuangpin(table));
9036        session
9037    }
9038
9039    #[test]
9040    fn nine_key_shuangpin_types_two_digits_per_syllable() {
9041        let fixture = fixture_with(SHUANGPIN_FIXTURE);
9042        let mut session = shuangpin_session(&fixture.paths, NineKeyShuangpinKind::Migala3);
9043        type_digits(&mut session, "6447");
9044        let view = session.snapshot();
9045        assert_eq!(view.editing_text, "6447");
9046        assert_eq!(words(&session).first().map(String::as_str), Some("你好"));
9047        assert_eq!(view.nine_key_reading, "ni'hao");
9048        assert_eq!(view.candidates[0].pinyin, "6447");
9049        // 混入英文开着也没有英文行:九键双拼的数字不是字母的分组。
9050        assert!(view
9051            .candidate_sources
9052            .iter()
9053            .all(|source| *source != CandidateSource::EnglishDictionary));
9054        assert_eq!(session.select(0).commit.as_deref(), Some("你好"));
9055        assert!(!session.active());
9056
9057        // 只打了下一个音节的声母那一位:中国 是补全,读音行的 g 是声母。
9058        type_digits(&mut session, "814");
9059        assert_eq!(words(&session).first().map(String::as_str), Some("中国"));
9060        assert_eq!(session.snapshot().nine_key_reading, "zhong'g");
9061        session.command(Command::Cancel);
9062
9063        // u 在 0 键:0 也是组码。
9064        assert!(session.character(b'5').handled);
9065        assert!(session.character(b'0').handled);
9066        assert_eq!(words(&session).first().map(String::as_str), Some("路"));
9067        session.command(Command::Cancel);
9068        // 米旮旯 2 不用 0 键,0 不是组码。
9069        let mut migala2 = shuangpin_session(&fixture.paths, NineKeyShuangpinKind::Migala2);
9070        assert!(!migala2.character(b'0').handled);
9071        assert!(!migala2.active());
9072    }
9073
9074    #[test]
9075    fn nine_key_shuangpin_shares_codes_and_locks_one_reading() {
9076        let fixture = fixture_with(SHUANGPIN_FIXTURE);
9077        let mut session = shuangpin_session(&fixture.paths, NineKeyShuangpinKind::Migala3);
9078        // 零声母的 安 和 声母加韵母的 班 都是 25。
9079        type_digits(&mut session, "25");
9080        let found = words(&session);
9081        assert!(found.contains(&"安".to_owned()), "{found:?}");
9082        assert!(found.contains(&"班".to_owned()), "{found:?}");
9083        let spellings = session.snapshot().nine_key_spellings;
9084        for spelling in ["an", "ban", "ang", "can"] {
9085            assert!(spellings.contains(&spelling.to_owned()), "{spellings:?}");
9086        }
9087        // 锁定 ban:数字不变,读音只剩 班。
9088        assert!(
9089            session
9090                .choose_spelling(spelling_index(&session, "ban"))
9091                .handled
9092        );
9093        assert_eq!(session.snapshot().preedit, "ban");
9094        assert_eq!(session.snapshot().editing_text, "25");
9095        assert_eq!(words(&session), ["班"]);
9096        // 数字全部锁定时退格撤销锁定。
9097        assert!(session.command(Command::Backspace).handled);
9098        assert_eq!(session.snapshot().preedit, "25");
9099        assert!(session.locked.is_empty());
9100        assert!(session.command(Command::Backspace).handled);
9101        assert_eq!(session.snapshot().preedit, "2");
9102
9103        // 锁定的音节后面还有数字:锁定只占两位,后面的数字照常解。
9104        session.command(Command::Cancel);
9105        type_digits(&mut session, "8146");
9106        assert!(
9107            session
9108                .choose_spelling(spelling_index(&session, "zhong"))
9109                .handled
9110        );
9111        assert_eq!(session.snapshot().preedit, "zhong'46");
9112        assert_eq!(words(&session).first().map(String::as_str), Some("中国"));
9113        assert!(session.command(Command::Backspace).handled);
9114        assert_eq!(session.snapshot().preedit, "zhong'4");
9115        assert_eq!(session.locked, ["zhong"]);
9116        // 部分上屏:选 中 吃掉两位,剩下的 4 接着组字。
9117        session.command(Command::Cancel);
9118        type_digits(&mut session, "8146");
9119        let zhong = index_of(&session, "中");
9120        assert_eq!(session.select(zhong).commit.as_deref(), Some("中"));
9121        assert_eq!(session.snapshot().editing_text, "46");
9122        assert_eq!(words(&session).first().map(String::as_str), Some("国"));
9123    }
9124
9125    #[test]
9126    fn nine_key_shuangpin_initials_check_the_key_of_each_initial() {
9127        let fixture = fixture_with(SHUANGPIN_FIXTURE);
9128        let mut session = shuangpin_session(&fixture.paths, NineKeyShuangpinKind::Migala3);
9129        // 8 4 当两个声母:zh 在 8,z 在 9,所以 中国 是简拼,走过 不是,哪怕它更重。
9130        type_digits(&mut session, "84");
9131        let found = words(&session);
9132        assert!(found.contains(&"中国".to_owned()), "{found:?}");
9133        assert!(!found.contains(&"走过".to_owned()), "{found:?}");
9134        assert_eq!(session.snapshot().nine_key_reading, "z'g");
9135        session.command(Command::Cancel);
9136        type_digits(&mut session, "94");
9137        let found = words(&session);
9138        assert!(found.contains(&"走过".to_owned()), "{found:?}");
9139        assert!(!found.contains(&"中国".to_owned()), "{found:?}");
9140    }
9141
9142    #[test]
9143    fn nine_key_shuangpin_splits_and_letters_follow_the_table() {
9144        let fixture = fixture_with(SHUANGPIN_FIXTURE);
9145        let mut session = shuangpin_session(&fixture.paths, NineKeyShuangpinKind::Migala3);
9146        // 切开 8'4:两个声母,不是一个音节。
9147        assert!(session.character(b'8').handled);
9148        assert!(session.character(b'\'').handled);
9149        assert!(session.character(b'4').handled);
9150        assert_eq!(session.snapshot().preedit, "8'4");
9151        assert_eq!(words(&session).first().map(String::as_str), Some("中国"));
9152        session.command(Command::Cancel);
9153        // 左列的字母是这一位上的声母和零声母音节的首字母;数字本身仍可原样上屏。
9154        type_digits(&mut session, "2");
9155        let spellings = session.snapshot().nine_key_spellings;
9156        for letter in ["A", "B", "C", "E", "O", "2"] {
9157            assert!(spellings.contains(&letter.to_owned()), "{spellings:?}");
9158        }
9159        assert!(
9160            session
9161                .choose_spelling(spelling_index(&session, "A"))
9162                .handled
9163        );
9164        assert_eq!(session.snapshot().preedit, "a");
9165        assert_eq!(words(&session), ["安"]);
9166        // 原样上屏写的是数字。
9167        assert_eq!(
9168            session.command(Command::CommitRaw).commit.as_deref(),
9169            Some("2")
9170        );
9171    }
9172
9173    #[test]
9174    fn nine_key_shuangpin_does_not_change_the_nine_key_grid() {
9175        let fixture = fixture_with(SHUANGPIN_FIXTURE);
9176        let mut session = open(&fixture.paths, false, mixed());
9177        type_digits(&mut session, "64426");
9178        assert_eq!(words(&session).first().map(String::as_str), Some("你好"));
9179        // 米旮旯 2 的 ao 在 2 键:你好 是 64 42。
9180        let mut shuangpin = shuangpin_session(&fixture.paths, NineKeyShuangpinKind::Migala2);
9181        type_digits(&mut shuangpin, "6442");
9182        assert_eq!(words(&shuangpin).first().map(String::as_str), Some("你好"));
9183        assert!(KeyGrid::NineKey.spelling_table().longest_code > 2);
9184        assert_ne!(KeyGrid::NineKey, shuangpin.grid);
9185    }
9186
9187    #[test]
9188    fn nine_key_shuangpin_words_are_found_by_their_initial_keys() {
9189        let table =
9190            crate::nine_key_shuangpin::resolve(NineKeyShuangpinKind::Migala3, None).unwrap();
9191        let grid = KeyGrid::NineKeyShuangpin(table);
9192        // 两个音节不查;三个音节按 8、4、4 三位展开:zh 和 t、s 都在 8 键上,g、h、r 在 4 键上。
9193        assert_eq!(shuangpin_word_codes(grid, "8146"), None);
9194        let codes = shuangpin_word_codes(grid, "814643").unwrap();
9195        assert!(codes.contains(&"zgr".to_owned()));
9196        assert!(codes.contains(&"tgh".to_owned()));
9197        assert!(!codes.iter().any(|code| code.starts_with('x')));
9198        // 中国人 是 81 46 43;张国荣 的 zhang 是 85,声母那几位相同也不算。
9199        assert!(shuangpin_word_spells(table, "zhong'guo'ren", "814643"));
9200        assert!(!shuangpin_word_spells(table, "zhang'guo'rong", "814643"));
9201        // 末尾只打了声母那一位:三个音节的词照样算,四个音节的不算。
9202        assert!(shuangpin_word_spells(table, "zhong'guo'ren", "81464"));
9203        assert!(!shuangpin_word_spells(table, "zhong'guo'ren'min", "81464"));
9204        assert!(!shuangpin_word_spells(table, "zhong'guo", "81464"));
9205    }
9206}
9207
9208#[cfg(test)]
9209#[path = "nine_key/english_page_append_tests.rs"]
9210mod english_page_append_tests;