1use 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;
50const SENTENCE_SEED_LIMIT: usize = 4;
52const SEED_RETAIN_LIMIT: usize = 8;
54const SEED_TAIL_LIMIT: usize = 3;
56const SEEDED_TRUNCATE_DIGITS: usize = 12;
58const SEEDED_PATH_LIMIT: usize = 24;
59const QUERY_PATH_LIMIT: usize = 24;
61const SYLLABLE_FREQUENCY: &str = include_str!("nine_key/syllable_frequency.tsv");
63const SMALL_QUERY_KEY_BATCH: usize = 64;
65const SMALL_CANDIDATE_DEDUP: usize = 64;
67const SMALL_CANDIDATE_SORT: usize = 32;
69
70const KEYPAD: &[u8; 26] = b"22233344455566677778889999";
72const DIGIT_LETTERS: [&str; 10] = [
74 "", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz",
75];
76const FOURTEEN_KEYPAD: &[u8; 26] = b"abcdedggujjlmbooqeatucqztz";
78const FOURTEEN_KEY_GROUPS: [&str; 14] = [
80 "qw", "er", "ty", "ui", "op", "as", "df", "gh", "jk", "l", "zx", "cv", "bn", "m",
81];
82const 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
89pub(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
98const ENGLISH_PREFIX_BUDGET: usize = 64;
100const ENGLISH_LIMIT: usize = 5;
101const ENGLISH_CANDIDATE_CAPACITY: usize = ENGLISH_PREFIX_BUDGET * ENGLISH_LIMIT;
102const ENGLISH_CACHED_PREFIX_ROWS: usize = 2048;
104const INITIALS_CODE_LIMIT: usize = 1024;
106const INITIALS_ROW_LIMIT: usize = 64;
108const SHUANGPIN_WORD_MIN_SYLLABLES: usize = 3;
110const SHUANGPIN_WORD_SCAN_LIMIT: usize = 1024;
112const INITIALS_SCAN_LIMIT: usize = 512;
114pub(crate) const PERSONAL_PICK_TIMES: u32 = 2;
116const RERANK_CACHE_ENTRIES: usize = 16;
118const SYLLABLE_ROWS_BEFORE_INITIALS: usize = 3;
120const COMPLETION_WEIGHT_RATIO: i64 = 6;
122const EXPRESSIVE_READING_LIMIT: usize = 8;
124pub(crate) const MAX_LEARNED_SENTENCE_SYLLABLES: usize = 7;
126
127#[derive(Clone, Copy, Debug)]
131pub enum KeyGrid {
132 NineKey,
134 FourteenKey,
136 EighteenKey,
138 NineKeyShuangpin(&'static NineKeyShuangpin),
140 FourteenKeyShuangpin(&'static NineKeyShuangpin),
142 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 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 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 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 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 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 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 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 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 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 pub fn uses_digits(self) -> bool {
321 matches!(self, Self::NineKey | Self::NineKeyShuangpin(_))
322 }
323
324 fn spells_english(self) -> bool {
326 self.shuangpin_table().is_none()
327 }
328
329 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 expressive: MixedExpressiveOptions,
363 mixed_candidates_enabled: bool,
365 inline_date_time: Option<LocalClock>,
367 sentence_association: SentenceAssociationOptions,
369 sentence_alternatives: bool,
371 first_page_rows: FirstPageRows,
373 rescoring_context: String,
375 keyboard: Option<Option<NeuralReranker>>,
377 rerank_cache: Vec<RerankEntry>,
379 personal_context: bool,
381 remember_recent: bool,
383 completions_discounted: bool,
385 digits: String,
386 locked: Vec<String>,
387 splits: Vec<usize>,
389 spellings: Vec<String>,
390 spelling_match_indices: Vec<usize>,
392 reading: String,
394 candidates: Vec<WordItem>,
395 sentence_seeds: Vec<String>,
397 seed_buffer: Vec<String>,
399 path_buffer: Vec<Path>,
401 path_scratch: PathScratch<'static>,
403 english_only: bool,
404 phrase_pinyin: String,
406 phrase_word: String,
407 phrase_storable: bool,
408 caret: Option<usize>,
410 pinyin: bool,
412 single_character_only: bool,
414 dictionary: Option<QuanpinDictionary>,
416 prior: Option<SyllablePrior<'static>>,
418 english: Option<EnglishDictionary>,
419 english_pattern: String,
421 english_rows: Option<EnglishKeypadCache>,
423 lock_undo: Vec<Option<LockUndo>>,
425 initial: Option<Initial>,
427 single_character: bool,
429 strokes: String,
431 stroke_dictionary: PathBuf,
433 stroke: Option<LanguageDictionary>,
435 stroke_texts: HashSet<char>,
437 grid: KeyGrid,
439}
440
441struct LockUndo {
443 replaced: String,
445 splits: [usize; DIGIT_LIMIT],
447 split_count: usize,
448 initial: Option<Initial>,
450 choices: Vec<String>,
452}
453
454#[derive(Clone, Copy)]
455struct Initial {
456 letter: u8,
457 typed: usize,
459 splits: usize,
460}
461
462enum 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 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 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 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 pub fn set_mixed_expressive(&mut self, options: MixedExpressiveOptions) {
551 self.expressive = options;
552 }
553
554 pub fn set_mixed_candidates_enabled(&mut self, enabled: bool) {
556 self.mixed_candidates_enabled = enabled;
557 }
558
559 pub fn set_inline_date_time(&mut self, clock: Option<LocalClock>) {
561 self.inline_date_time = clock;
562 }
563
564 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 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 pub fn set_personal_context_enabled(&mut self, enabled: bool) {
605 self.personal_context = enabled;
606 }
607
608 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 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 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 pub fn active(&self) -> bool {
640 !self.digits.is_empty()
641 }
642
643 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 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 pub fn character(&mut self, digit: u8) -> KeyResult {
661 if digit == b'\'' {
662 if !self.active() || self.english_only || !self.pinyin {
664 return KeyResult::unhandled();
665 }
666 let caret = self.caret_position();
667 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 self.initial = None;
689 }
690 self.digits.insert(caret, char::from(digit));
691 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 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 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 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 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 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 self.consume(1);
761 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 self.clear_composition();
1146 }
1147 self.refresh();
1148 KeyResult::handled()
1149 }
1150
1151 fn clear_composition(&mut self) {
1153 self.path_scratch.pages.clear();
1154 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 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 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 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 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 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 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 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 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 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 self.candidates = self.english_candidates(false);
1390 return;
1391 }
1392 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 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 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 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 let mut leading = LeadingMap::default();
1473 let prefer_exact = !self.splits.is_empty();
1475 let initials =
1478 self.locked.is_empty() && initial.is_none() && initials_apply(remaining.len(), splits);
1479 let initials_lead = initials && !splits.is_empty();
1480 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 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 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 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 let Some(covered) = grid.matching_code_prefix_len(matched, &self.digits) else {
1557 continue;
1558 };
1559 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 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 let personal =
1610 self.learning && self.personal_context && !dictionary.personal_model().is_empty();
1611 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 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 rows.sort_by_key(|row| !used.contains_key(&row.word));
1645 rows.truncate(INITIALS_ROW_LIMIT);
1646 }
1647 }
1648 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 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 let (emoji, kaomoji, symbols) = if filtering {
1694 (Vec::new(), Vec::new(), Vec::new())
1695 } else {
1696 self.expressive_candidates(&alternatives, &candidates)
1697 };
1698
1699 let unanswered = candidates.is_empty();
1701 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 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 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 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 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 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 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 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 self.rerank_cache[index].ranked.as_slice()
1814 } else {
1815 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 if front.fuzzy {
2139 return;
2140 }
2141 if front.source == CandidateSource::EnglishDictionary
2142 && matches!(self.grid, KeyGrid::FourteenKey | KeyGrid::EighteenKey)
2143 {
2144 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 if front.canonical_pinyin.is_empty() || !reads_pinyin(front.source) {
2153 return;
2154 }
2155 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 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 fn english_candidates(&mut self, unanswered: bool) -> Vec<WordItem> {
2222 if !self.grid.spells_english() {
2224 return Vec::new();
2225 }
2226 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 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 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 fn consume(&mut self, count: usize) {
2362 let count = count.min(self.digits.len());
2363 self.digits.drain(..count);
2364 self.english_rows = None;
2366 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 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 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 Err(_) => Some(diagnostics::NINE_KEY_FREQUENCY_NOT_PERSISTED.to_string()),
2437 }
2438 }
2439
2440 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
2459struct RerankEntry {
2461 context: String,
2462 sentences: Vec<(String, i64)>,
2463 ranked: Vec<String>,
2464}
2465
2466fn 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#[derive(Clone)]
2482struct DictionaryOptions {
2483 association: SentenceAssociationOptions,
2485 alternatives: bool,
2486 first_page_rows: FirstPageRows,
2487}
2488
2489fn 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
2498fn is_lattice_sentence(item: &WordItem) -> bool {
2500 matches!(
2501 item.source,
2502 CandidateSource::Generated | CandidateSource::Collocation
2503 ) && item.sentence_association
2504}
2505
2506fn 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
2525fn 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 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 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
2566fn seed_prefix(seed: &str) -> &str {
2568 seed.rfind('\'').map_or("", |end| &seed[..end])
2569}
2570
2571fn 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
2592fn 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
2610fn 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
2654fn 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 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 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
2721fn 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
2756fn 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
2770fn 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
2779fn 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
2785fn 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 if !piece.is_empty() && (piece.contains('\'') || !is_complete_pinyin_input(piece)) {
2796 return false;
2797 }
2798 start = end;
2799 }
2800 true
2801}
2802
2803fn 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
2834type 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
2913fn 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
2928fn 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
2951fn 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 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#[derive(Clone, Copy, PartialEq, Eq)]
3029enum DiscountSeat {
3030 Row,
3032 Boundary,
3034 Skip,
3036 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
3064fn locked_syllables(locked_key: &str) -> usize {
3066 if locked_key.is_empty() {
3067 0
3068 } else {
3069 locked_key.split('\'').count()
3070 }
3071}
3072
3073fn comparable_weight(item: &WordItem) -> i64 {
3075 comparable_weight_for_reading(item, &item.canonical_pinyin)
3076}
3077
3078fn 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 item.weight - (PHRASE_LENGTH_BONUS * 1000.0 * syllables as f64) as i64
3090}
3091
3092fn is_personal_word(word: &str) -> bool {
3094 count_utf8_chars(word) >= 2 && is_all_han(word)
3095}
3096
3097fn reads_pinyin(source: CandidateSource) -> bool {
3099 source.is_dictionary()
3100 || source.is_generated_or_fallback()
3101 || source == CandidateSource::NeuralKeyboard
3102}
3103
3104fn 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
3116fn 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
3131fn 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
3140fn 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
3146fn 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
3153fn initials_codes(grid: KeyGrid, digits: &str, limit: usize) -> Option<Vec<String>> {
3155 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
3189fn 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
3227fn 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
3269fn 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
3292struct InitialsBoost {
3294 used: HashMap<SharedString, u32>,
3296 frequency: FrequencyAdjustmentOptions,
3297}
3298
3299fn 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 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 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 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
3366fn 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 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
3457fn 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#[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
3500fn 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
3516fn 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
3536fn 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
3549fn 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
3579pub(crate) struct SpellingTable {
3581 grid: KeyGrid,
3583 syllables: Vec<(String, String)>,
3585 by_code: HashMap<String, Vec<(String, bool)>>,
3587 longest_code: usize,
3588}
3589
3590struct EnglishKeypadCache {
3592 grid: KeyGrid,
3593 depth: usize,
3595 digits: String,
3597 rows: Option<Vec<Vec<KeypadRow>>>,
3599}
3600
3601#[derive(Default)]
3603struct PathScratch<'a> {
3604 pages: Vec<Vec<SpellingPath<'a>>>,
3605}
3606
3607#[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 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 fn starts_syllable(&self, letter: u8) -> bool {
3698 self.syllables
3699 .iter()
3700 .any(|(syllable, _)| syllable.as_bytes().first() == Some(&letter))
3701 }
3702
3703 #[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 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 #[cfg(test)]
3789 fn paths(&self, digits: &str) -> Vec<Path> {
3790 self.split_paths(digits, &[], &SyllablePrior::default())
3791 }
3792
3793 #[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 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 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 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 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 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 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#[derive(Default)]
3960struct SyllablePrior<'a> {
3961 scores: HashMap<&'a str, f64>,
3962 syllables: HashMap<&'a str, f64>,
3964}
3965
3966impl<'a> SyllablePrior<'a> {
3967 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 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
4010fn 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 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 #[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 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 assert_eq!(
4224 table.paths("64"),
4225 [vec!["mi"], vec!["ni"], vec!["o", "g"], vec!["o", "h"]]
4226 );
4227 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 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 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 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 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 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 #[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 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 full("我们一批", "wo'men'yi'pi"),
4386 item("我们", "96636", 100, CandidateSource::Database),
4388 full("我们洗漆", "wo'men'xi'qi"),
4389 ];
4390 let previous = vec![
4391 "wo'men'yi'qi".to_owned(),
4393 "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 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 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 &["wo'men'yi".to_owned(), "xi'men'yi".to_owned()],
4432 &remaining,
4433 &table,
4434 &prior,
4435 );
4436 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 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 #[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 #[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 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 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 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 #[test]
4528 fn seed_tails_keep_a_syllable_that_mostly_occurs_inside_words() {
4529 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 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 assert!(!tails(&characters).contains(&wanted));
4555 assert!(tails(shipped_syllable_frequency()).contains(&wanted));
4556 }
4557
4558 #[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 assert_eq!(
4590 table.spellings_for("426", 2, None, &prior),
4591 ["gan", "gao", "han", "hao", "gang", "hang", "ga", "ha"]
4592 );
4593 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 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 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 assert_eq!(
6560 session.finish(0).commit.as_deref(),
6561 Some("7".repeat(DIGIT_LIMIT).as_str())
6562 );
6563
6564 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 assert!(diagnostics::NINE_KEY_DIGIT_LIMIT.contains(&DIGIT_LIMIT.to_string()));
6576 session.command(Command::Cancel);
6577 }
6578
6579 #[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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 words_only.set_english_only(false);
7003 type_digits(&mut words_only, "64");
7004 assert_eq!(words(&words_only), ["你", "米", "你好"]);
7006 assert!(!words_only.snapshot().nine_key_spellings.is_empty());
7007 assert!(!words_only.snapshot().dedicated_english);
7008 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 #[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 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 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 #[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 #[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 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 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 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 #[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 #[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 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 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 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 #[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 #[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 #[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 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 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 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 type_keys(&mut session, "68'");
7402 assert!(!words(&session).iter().any(|word| word == "每天"));
7403 session.command(Command::Cancel);
7404 type_keys(&mut session, "6'84");
7406 assert!(!words(&session).iter().any(|word| word.chars().count() == 3));
7407 session.command(Command::Cancel);
7408
7409 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 type_keys(&mut session, "6'8");
7617 assert_eq!(words(&session)[..3], ["每天", "明天", "那天"]);
7618 }
7619
7620 #[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 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 type_digits(&mut later, "93486");
7643 assert!(words(&later).iter().any(|word| word == "我滴个天呐"));
7644 }
7645
7646 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 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 #[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 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 type_digits(&mut session, "97");
7733 assert_eq!(index_of(&session, "隐私"), 3);
7734 }
7735
7736 #[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 #[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 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 #[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 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 assert_eq!(position(FrequencyAdjustmentMode::Promote, 2, 1, 1), 63);
7798 assert_eq!(position(FrequencyAdjustmentMode::Promote, 2, 1, 2), 4);
7799 }
7800
7801 #[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 #[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 #[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 record_use(&fixture.paths, "明天");
7846 type_keys(&mut session, "6'8");
7847 assert_eq!(words(&session)[..3], ["明天", "那天", "每天"]);
7848 }
7849
7850 #[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 #[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 #[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 #[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 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 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 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 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 type_digits(&mut session, "54");
8022 assert_eq!(words(&session)[..4], ["里", "李", "鸡", "🐔"]);
8023 session.command(Command::Cancel);
8024
8025 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 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 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 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 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 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 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 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 type_digits(&mut session, "94264");
8140 assert!(words(&session).contains(&"🍌".to_owned()));
8141 session.command(Command::Cancel);
8142 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 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 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 assert_eq!(session.candidates[0].word, "香");
8205 assert_eq!(readings[0], "xiang");
8206 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 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 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 type_digits(&mut session, "943643");
8316 assert!(!has_symbol_row(&session, "±"));
8317 session.command(Command::Cancel);
8318
8319 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 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 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 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 #[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 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, ¤t).is_empty());
8488 let (expected, old) = count(|| query_symbol_readings(&readings, &frozen));
8489 let (actual, new) = count(|| query_symbol_readings(&readings, ¤t));
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 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 #[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 const GRIDS: [KeyGrid; 3] = [KeyGrid::NineKey, KeyGrid::FourteenKey, KeyGrid::EighteenKey];
8549
8550 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 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 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 assert!(super::initials_codes(KeyGrid::FourteenKey, "bug", 1024).is_none());
8649 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 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 type_digits(&mut fourteen, "u");
8713 assert!(fourteen.snapshot().nine_key_spellings.is_empty());
8714 fourteen.command(Command::Cancel);
8715 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 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 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 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 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 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 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 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 #[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 assert_eq!(nine.reading_for(Some(&item("2026年", "94", 1, source))), "");
8845 }
8846 for source in [CandidateSource::Emoji, CandidateSource::Kaomoji] {
8847 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(shuangpin_word_spells(table, "zhong'guo'ren", "814643"));
9200 assert!(!shuangpin_word_spells(table, "zhang'guo'rong", "814643"));
9201 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;