1use crate::dict::PinyinDict;
9use crate::engine::PinyinEngine;
10use crate::fuzzy::FuzzyConfig;
11
12pub struct Session<'e> {
16 engine: &'e PinyinEngine,
17 input: String,
18 cand_buf: Vec<String>,
20}
21
22impl<'e> Session<'e> {
23 pub fn new(engine: &'e PinyinEngine) -> Self {
27 Self {
28 engine,
29 input: String::new(),
30 cand_buf: Vec::with_capacity(16),
31 }
32 }
33
34 pub fn input_char(&mut self, c: char) {
38 if c.is_ascii_alphabetic() {
39 self.input.push(c.to_ascii_lowercase());
40 }
41 }
42
43 pub fn backspace(&mut self) -> bool {
46 self.input.pop().is_some()
47 }
48
49 pub fn reset(&mut self) {
51 self.input.clear();
52 }
53
54 pub fn input(&self) -> &str {
56 &self.input
57 }
58
59 pub fn candidates(&mut self) -> &[String] {
65 if self.input.is_empty() {
66 self.cand_buf.clear();
67 return &self.cand_buf;
68 }
69 let input = self.input.clone();
70 Self::lookup_with_fuzzy(self.engine, &input, &mut self.cand_buf);
71 &self.cand_buf
72 }
73
74 pub fn lookup_into(&self, out: &mut Vec<String>) {
78 if self.input.is_empty() {
79 out.clear();
80 return;
81 }
82 Self::lookup_with_fuzzy(self.engine, &self.input, out);
83 }
84
85 fn lookup_with_fuzzy(engine: &PinyinEngine, input: &str, out: &mut Vec<String>) {
86 out.clear();
87 let dict = engine.dict();
88 let fuzzy = engine.fuzzy();
89 let alternates = expand_full_input(fuzzy, input);
95 let mut local = Vec::with_capacity(8);
96 for variant in alternates {
97 dict.lookup_into(&variant, &mut local);
98 for w in local.drain(..) {
99 if !out.contains(&w) {
100 out.push(w);
101 }
102 }
103 }
104 }
105
106 pub fn commit(&mut self, idx: usize) -> Option<String> {
116 let cands = self.candidates();
117 let word = cands.get(idx).cloned()?;
118 self.engine.dict().record_pick(&self.input, &word);
120 self.input.clear();
121 self.cand_buf.clear();
122 Some(word)
123 }
124}
125
126fn expand_full_input(fuzzy: FuzzyConfig, input: &str) -> Vec<String> {
127 if matches!(
129 fuzzy,
130 FuzzyConfig {
131 z_zh: false,
132 c_ch: false,
133 s_sh: false,
134 n_l: false,
135 f_h: false,
136 r_l: false,
137 in_ing: false,
138 en_eng: false,
139 an_ang: false
140 }
141 ) {
142 return vec![input.to_string()];
143 }
144 fuzzy.expand(input)
145}
146
147#[allow(dead_code)]
150fn quick_lookup(dict: &PinyinDict, pinyin: &str) -> Vec<String> {
151 dict.lookup(pinyin)
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn input_and_lookup_zhongguo() {
160 let engine = PinyinEngine::new();
161 let mut session = Session::new(&engine);
162 for c in "zhongguo".chars() {
163 session.input_char(c);
164 }
165 let cands = session.candidates();
166 assert_eq!(cands.first().map(String::as_str), Some("中国"));
167 }
168
169 #[test]
170 fn backspace_shrinks_input() {
171 let engine = PinyinEngine::new();
172 let mut session = Session::new(&engine);
173 for c in "abc".chars() {
174 session.input_char(c);
175 }
176 assert_eq!(session.input(), "abc");
177 assert!(session.backspace());
178 assert_eq!(session.input(), "ab");
179 assert!(session.backspace());
180 assert!(session.backspace());
181 assert!(!session.backspace()); }
183
184 #[test]
185 fn commit_returns_word_and_resets() {
186 let engine = PinyinEngine::new();
187 let mut session = Session::new(&engine);
188 for c in "wo".chars() {
189 session.input_char(c);
190 }
191 let committed = session.commit(0);
192 assert_eq!(committed.as_deref(), Some("我"));
193 assert_eq!(session.input(), "");
194 assert!(session.candidates().is_empty());
195 }
196
197 #[test]
198 fn commit_out_of_range_is_noop() {
199 let engine = PinyinEngine::new();
200 let mut session = Session::new(&engine);
201 for c in "wo".chars() {
202 session.input_char(c);
203 }
204 assert!(session.commit(999_999).is_none());
208 assert_eq!(session.input(), "wo");
209 }
210
211 #[test]
212 fn input_char_filters_non_ascii() {
213 let engine = PinyinEngine::new();
214 let mut session = Session::new(&engine);
215 session.input_char('z');
216 session.input_char('中'); session.input_char('h');
218 assert_eq!(session.input(), "zh");
219 }
220
221 #[test]
222 fn fuzzy_expands_lookup() {
223 let engine = PinyinEngine::with_fuzzy(FuzzyConfig {
225 z_zh: true,
226 ..FuzzyConfig::default()
227 });
228 let mut session = Session::new(&engine);
229 for c in "zong".chars() {
230 session.input_char(c);
231 }
232 let cands = session.candidates();
234 assert!(
235 cands.iter().any(|w| w == "中"),
236 "expected fuzzy z→zh to find 中, got {cands:?}"
237 );
238 }
239
240 #[test]
241 fn empty_input_no_candidates() {
242 let engine = PinyinEngine::new();
243 let mut session = Session::new(&engine);
244 assert!(session.candidates().is_empty());
245 }
246
247 #[test]
248 fn reset_clears_input() {
249 let engine = PinyinEngine::new();
250 let mut session = Session::new(&engine);
251 for c in "abc".chars() {
252 session.input_char(c);
253 }
254 session.reset();
255 assert_eq!(session.input(), "");
256 }
257
258 #[cfg(not(feature = "bootstrap_only"))]
263 #[test]
264 fn commit_feeds_l0_pin_promotion() {
265 use crate::ranking::PROMOTE_THRESHOLD;
266 let engine = PinyinEngine::new();
267
268 let target = "时";
272 for _ in 0..PROMOTE_THRESHOLD {
273 let mut s = Session::new(&engine);
274 for c in "shi".chars() {
275 s.input_char(c);
276 }
277 let cands = s.candidates();
278 let idx = cands
279 .iter()
280 .position(|w| w == target)
281 .expect("时 should be a 'shi' candidate");
282 assert_eq!(s.commit(idx).as_deref(), Some(target));
283 }
284
285 let mut probe = Session::new(&engine);
287 for c in "shi".chars() {
288 probe.input_char(c);
289 }
290 assert_eq!(
291 probe.candidates().first().map(String::as_str),
292 Some(target),
293 "expected L0 to pin 时 after {PROMOTE_THRESHOLD} picks via Session::commit"
294 );
295 }
296}