inputx_nihongo/engine.rs
1//! JP engine state machine — parallel in shape to `inputx-wubi`'s
2//! `WubiEngine` and `inputx-pinyin`'s segmenter so the composite layer
3//! can plug it in next to the existing engines without bespoke wiring.
4//!
5//! State per session:
6//! - `buffer`: raw ASCII romaji as the user types (preedit source)
7//! - `candidates`: rebuilt on every keystroke, ordered hiragana →
8//! katakana → kanji-matching-current-on-yomi
9//!
10//! No auto-commit / freq-tuning / pin learning — those are wubi/pinyin
11//! concerns and would only add ambiguity to a passthrough-style JP
12//! engine at this MVP stage.
13
14use crate::jukugo;
15use crate::kanji;
16use crate::romaji;
17
18/// One JP candidate with its sub-source category, so the host UI can
19/// optionally render a hint (kana vs kanji) on top of the W/P/J source
20/// dot at the composite layer.
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct Candidate {
23 pub word: String,
24 pub kind: KanaKind,
25 /// 0–100 frequency score. Higher = more common in modern JP. Used
26 /// by the composite-layer scoring (`japanese_adapter::candidates_with_scores`)
27 /// to lift high-frequency JP entries above rare Chinese candidates
28 /// per the user's design rule: JP-base < wubi/pinyin bases, but
29 /// JP-high-freq must beat 中文难检字 + 生僻词组. For kana entries
30 /// (mechanical romaji → kana rendering) freq is 0.
31 pub freq: u32,
32 /// `true` for `compose_sentence` products — mechanical (content +
33 /// particle/copula) sentence guesses like 私は or, for non-Japanese
34 /// romaji, junk like 時へ時. These are LOW confidence: unlike a real
35 /// dictionary 熟語 (新宿) they must never be treated as jukugo nor
36 /// trigger the full-match promote, and must score below real Chinese
37 /// words so they don't pollute Chinese pinyin input. See
38 /// `japanese_adapter::candidates_with_scores`.
39 pub composed: bool,
40 /// Prefix-prediction proximity in thousandths: 1000 = the buffer is the
41 /// full reading (a normal/exact candidate); < 1000 = this is a PREDICTED
42 /// candidate whose reading the buffer is only a prefix of (shinjuk → 新宿
43 /// at 7/8 = 875). `japanese_adapter` decays the freq contribution by
44 /// `(proximity/1000)^K` and excludes < 1000 from the full-match promote
45 /// (the buffer isn't the complete word yet). See PLAN-prefix-prediction.md.
46 pub proximity_milli: u16,
47}
48
49#[derive(Copy, Clone, Debug, PartialEq, Eq)]
50pub enum KanaKind {
51 Hiragana,
52 Katakana,
53 Kanji,
54}
55
56/// JP engine. One per session. Cheap to construct — all data is in
57/// const tables in [`crate::romaji`] and [`crate::kanji`].
58pub struct JapaneseEngine {
59 buffer: Vec<u8>,
60 candidates: Vec<Candidate>,
61}
62
63impl Default for JapaneseEngine {
64 fn default() -> Self {
65 Self::new()
66 }
67}
68
69impl JapaneseEngine {
70 pub fn new() -> Self {
71 Self {
72 buffer: Vec::with_capacity(8),
73 candidates: Vec::with_capacity(8),
74 }
75 }
76
77 /// Push one ASCII letter into the buffer and re-render candidates.
78 /// Returns `true` if the letter was accepted; rejected letters leave
79 /// state unchanged so the IME controller can route the byte elsewhere
80 /// (locale punct mapping, raw passthrough).
81 ///
82 /// Accepted bytes:
83 /// - `a-z`, `A-Z` (the romaji alphabet — lowercased on push)
84 /// - `-` *only when the buffer is non-empty* — chōonpu (ー / long-vowel
85 /// mark). `romaji::TABLE` maps `-` to `ー` so a buffer of `"koohi-"`
86 /// renders as `コーヒー`. Empty-buffer `-` is rejected so a stranded
87 /// hyphen still routes to the host as ASCII punctuation rather than
88 /// becoming a leading `ー` with no preceding syllable. User-reported
89 /// 2026-05-27: `-` must be typeable as JP chōonpu, otherwise long-
90 /// vowel words like コーヒー are unreachable.
91 pub fn handle_letter(&mut self, c: u8) -> bool {
92 if c == b'-' {
93 if self.buffer.is_empty() {
94 return false;
95 }
96 self.buffer.push(b'-');
97 self.refresh_candidates();
98 return true;
99 }
100 if !c.is_ascii_alphabetic() {
101 return false;
102 }
103 self.buffer.push(c.to_ascii_lowercase());
104 self.refresh_candidates();
105 true
106 }
107
108 /// Pop one byte off the buffer. Returns `true` if a byte was
109 /// removed (i.e. buffer was non-empty), `false` if there was
110 /// nothing to pop.
111 pub fn backspace(&mut self) -> bool {
112 if self.buffer.is_empty() {
113 return false;
114 }
115 self.buffer.pop();
116 self.refresh_candidates();
117 true
118 }
119
120 /// Drop the buffer entirely. Returns `true` if something was
121 /// dropped, `false` if buffer was already empty.
122 pub fn escape(&mut self) -> bool {
123 if self.buffer.is_empty() {
124 return false;
125 }
126 self.buffer.clear();
127 self.candidates.clear();
128 true
129 }
130
131 /// Read-only buffer view as a string (for the preedit display).
132 pub fn preedit(&self) -> &str {
133 // Safe: buffer only ever contains ASCII bytes per handle_letter.
134 std::str::from_utf8(&self.buffer).unwrap_or("")
135 }
136
137 pub fn is_composing(&self) -> bool {
138 !self.buffer.is_empty()
139 }
140
141 pub fn candidates(&self) -> &[Candidate] {
142 &self.candidates
143 }
144
145 /// Commit the candidate at `index`. Returns the committed text and
146 /// clears the buffer. `None` if `index` is out of range (state
147 /// unchanged).
148 pub fn commit_index(&mut self, index: usize) -> Option<String> {
149 let text = self.candidates.get(index)?.word.clone();
150 self.buffer.clear();
151 self.candidates.clear();
152 Some(text)
153 }
154
155 fn refresh_candidates(&mut self) {
156 self.candidates.clear();
157 let s = std::str::from_utf8(&self.buffer).unwrap_or("");
158
159 // Order within the JP block (the host appends after wubi/pinyin
160 // via the composite layer; this is the *relative* order JP
161 // candidates land in within the merged list):
162 //
163 // 1. 熟語 compound match (whole-buffer, e.g. nihon → 日本)
164 // 2. Single-kanji by on/kun reading (e.g. watashi → 私)
165 // 3. Hiragana of the full buffer
166 // 4. Katakana of the full buffer
167 //
168 // Kanji forms come first because that's the actual conversion
169 // target most users care about — kana is always available via
170 // the row 3 / 4 fallback, but if 日本 appears at position 9
171 // (after both kana variants) users rightly feel it's buried.
172 // Standard JP IME workflow is: type romaji, see kanji
173 // candidates immediately, fall back to kana via the lower
174 // slots if none of the kanji matches intent.
175
176 // Sentence-level segmentation FIRST: try splitting buffer into
177 // (content-word prefix + particle/copula suffix). When both
178 // halves match the data tables, emit a composed candidate like
179 // "私は" for buffer "watashiwa". This is what makes JP feel
180 // like a real IME instead of a romaji→kana renderer.
181 for composed in compose_sentence(s) {
182 self.candidates.push(composed);
183 }
184
185 // Jukugo + single-kanji come WITH freq from the data tables.
186 // Sort each group by freq desc so within-group ordering reflects
187 // commonality (高 freq 88 before 香 freq 35 even though both
188 // match "kou"). Cross-group ordering is then "jukugo, then
189 // single-kanji, then kana" — the high-conviction kinds first.
190 let mut jukugo_hits: Vec<(&str, u32)> = jukugo::lookup_by_reading(s).collect();
191 jukugo_hits.sort_by(|a, b| b.1.cmp(&a.1));
192 let had_exact_jukugo = !jukugo_hits.is_empty();
193 for (compound, freq) in jukugo_hits {
194 self.candidates.push(Candidate {
195 word: compound.to_string(),
196 kind: KanaKind::Kanji,
197 freq,
198 composed: false,
199 proximity_milli: 1000,
200 });
201 }
202
203 // Prefix PREDICTION (PLAN-prefix-prediction.md CP-A): the user is
204 // mid-typing toward a jukugo — shinjuk → 新宿 (しんじゅく). Fire only
205 // when there's NO exact jukugo (an exact match means the word is
206 // complete; adding its extensions would be noise — same principle as
207 // pinyin's lianxiang→联想), and only for buffers long enough that
208 // proximity carries signal. Each predicted candidate records its
209 // proximity so japanese_adapter can decay the freq by proximity^K and
210 // keep it below an exact/full match.
211 if !had_exact_jukugo && s.len() >= 3 {
212 let mut pred: Vec<(&str, u32, usize)> =
213 jukugo::lookup_by_reading_prefix(s).collect();
214 pred.sort_by(|a, b| b.1.cmp(&a.1));
215 for (kanji, freq, reading_len) in pred.into_iter().take(8) {
216 let proximity_milli = ((s.len() * 1000) / reading_len.max(1)) as u16;
217 self.candidates.push(Candidate {
218 word: kanji.to_string(),
219 kind: KanaKind::Kanji,
220 freq,
221 composed: false,
222 proximity_milli,
223 });
224 }
225 }
226
227 let mut kanji_hits: Vec<(char, u32)> = kanji::lookup_by_reading(s).collect();
228 kanji_hits.sort_by(|a, b| b.1.cmp(&a.1));
229 for (kanji_char, freq) in kanji_hits {
230 self.candidates.push(Candidate {
231 word: kanji_char.to_string(),
232 kind: KanaKind::Kanji,
233 freq,
234 composed: false,
235 proximity_milli: 1000,
236 });
237 }
238
239 // Hiragana / katakana fallback. Freq drives where these land in
240 // the cross-engine merge (composite::japanese_adapter uses
241 // `base + freq * multiplier`). Short buffers (1-2 chars) get
242 // high freq — typing `e` or `ka` should surface `え` / `か` in
243 // the visible top, not bury them under every pinyin variant.
244 // Long buffers get lower freq — for `konnichi`, the user
245 // probably wants `今日` not `こんにち`.
246 let kana_freq: u32 = if s.len() <= 2 { 100 } else { 30 };
247 let h = romaji::to_hiragana(s);
248 if !h.is_empty() && h != s {
249 self.candidates.push(Candidate {
250 word: h.clone(),
251 kind: KanaKind::Hiragana,
252 freq: kana_freq,
253 composed: false,
254 proximity_milli: 1000,
255 });
256 }
257 let k = romaji::to_katakana(s);
258 if !k.is_empty() && k != s && k != h {
259 self.candidates.push(Candidate {
260 word: k,
261 kind: KanaKind::Katakana,
262 freq: kana_freq,
263 composed: false,
264 proximity_milli: 1000,
265 });
266 }
267 }
268}
269
270/// Particle / copula suffixes for sentence-level segmentation. Longer
271/// suffixes first so greedy prefix-stripping picks `dewanai` before
272/// `wa`. Each entry is (romaji_suffix, kana_form).
273///
274/// This is the minimum surface needed to make "私は" / "学校で" /
275/// "明日です" appear as direct conversions of `watashiwa` / `gakkoude`
276/// / `ashitadesu`. Without this, the user gets only mechanical kana
277/// (わたしわ — note the wa rendered as わ, not は) and has to
278/// manually compose.
279const SENTENCE_SUFFIXES: &[(&str, &str)] = &[
280 // longest first
281 ("dewanaikatta", "ではなかった"),
282 ("dewaarimasen", "ではありません"),
283 ("dewanaiyou", "ではないよう"),
284 ("dewanakatta", "ではなかった"),
285 ("dewanai", "ではない"),
286 ("deshita", "でした"),
287 ("dewashita", "ではした"),
288 ("deshou", "でしょう"),
289 ("darou", "だろう"),
290 ("datta", "だった"),
291 ("desu", "です"),
292 ("dewa", "では"),
293 ("kara", "から"),
294 ("made", "まで"),
295 ("yori", "より"),
296 ("nado", "など"),
297 ("toka", "とか"),
298 ("nimo", "にも"),
299 ("demo", "でも"),
300 ("masu", "ます"),
301 ("masen", "ません"),
302 ("mashita", "ました"),
303 ("mashou", "ましょう"),
304 ("wa", "は"),
305 ("ga", "が"),
306 ("wo", "を"),
307 ("ni", "に"),
308 ("de", "で"),
309 ("to", "と"),
310 ("mo", "も"),
311 ("no", "の"),
312 ("ka", "か"),
313 ("e", "へ"),
314 ("ya", "や"),
315];
316
317/// Productive category-suffix kanji for "jukugo + suffix" composition
318/// (東京+都 = 東京都, 大阪+府, 横浜+市, 新宿+区, 神奈川+県…). These admin /
319/// category endings are NOT exhaustively in the dict (東京都 isn't even in
320/// mozc — it's 拼 not 词), so we compose them. Whitelisted to keep the
321/// composition from emitting junk like 東京渡 (渡 also reads `to`). The
322/// prefix MUST be a real jukugo (see compose_sentence) so 都+市 single-kanji
323/// noise can't form. (reading_romaji, suffix_kanji).
324const KANJI_SUFFIXES: &[(&str, &str)] = &[
325 ("to", "都"), ("fu", "府"), ("ken", "県"), ("shi", "市"),
326 ("ku", "区"), ("chou", "町"), ("son", "村"), ("mura", "村"),
327 ("shima", "島"), ("gun", "郡"), ("jin", "人"), ("go", "語"),
328];
329
330/// Single-segment compose: (content_word, particle/copula_suffix).
331/// Returns (composed_word_string, content_freq) pairs.
332///
333/// For `watashiwa`: prefix=`watashi`, suffix=`wa` → 私+は.
334/// For `nihondesu`: prefix=`nihon`, suffix=`desu` → 日本+です.
335fn compose_one_segment(buffer: &str) -> Vec<(String, u32)> {
336 let mut out: Vec<(String, u32)> = Vec::new();
337 for (s_reading, s_kana) in SENTENCE_SUFFIXES {
338 if let Some(prefix) = buffer.strip_suffix(s_reading) {
339 if prefix.is_empty() {
340 continue;
341 }
342 for (compound, freq) in jukugo::lookup_by_reading(prefix) {
343 out.push((format!("{compound}{s_kana}"), freq));
344 }
345 for (ch, freq) in kanji::lookup_by_reading(prefix) {
346 out.push((format!("{ch}{s_kana}"), freq));
347 }
348 }
349 }
350 out
351}
352
353/// Try every (prefix, suffix) split of `buffer`. Returns sentence
354/// candidates from both 1-segment and 2-segment compositions:
355///
356/// * 1-segment: (content + particle/copula). Handles "watashiwa".
357/// * 2-segment: split buffer at every position; left → 1seg, right →
358/// 1seg; concatenate. Handles "watashiwagakuseidesu" → 私は + 学生
359/// です = 私は学生です.
360///
361/// Capped at 30 results; freq for multi-segment compositions is min
362/// of segment freqs × 0.7 (multi-segment penalty so single-word direct
363/// matches still rank higher when present).
364fn compose_sentence(buffer: &str) -> Vec<Candidate> {
365 let mut hits: Vec<(String, u32)> = Vec::new();
366
367 // 1-segment
368 for (word, freq) in compose_one_segment(buffer) {
369 hits.push((word, freq));
370 }
371
372 // jukugo + category-suffix kanji (東京+都 = 東京都). Productive admin /
373 // category compounds the dict doesn't (and shouldn't) enumerate. Prefix
374 // MUST be a real jukugo so single-kanji noise (都+市) can't form; suffix
375 // is whitelisted (KANJI_SUFFIXES) so 東京渡-style junk can't form either.
376 for (sfx_read, sfx_kanji) in KANJI_SUFFIXES {
377 if let Some(prefix) = buffer.strip_suffix(sfx_read) {
378 if prefix.is_empty() {
379 continue;
380 }
381 for (compound, freq) in jukugo::lookup_by_reading(prefix) {
382 hits.push((format!("{compound}{sfx_kanji}"), freq));
383 }
384 }
385 }
386
387 // 1-segment WITH bare content tail (no final particle/copula).
388 // Handles "watashinonihon": (私+の) + 日本 where the right half is
389 // a bare content word, no suffix. Look up every split where left
390 // is a 1-seg compose AND right is directly a kanji/jukugo entry.
391 for split in 2..buffer.len() {
392 let left = &buffer[..split];
393 let right = &buffer[split..];
394 let lefts = compose_one_segment(left);
395 if lefts.is_empty() {
396 continue;
397 }
398 // Right: direct jukugo or kanji lookup (no particle suffix).
399 let mut right_hits: Vec<(String, u32)> = Vec::new();
400 for (compound, freq) in jukugo::lookup_by_reading(right) {
401 right_hits.push((compound.to_string(), freq));
402 }
403 for (ch, freq) in kanji::lookup_by_reading(right) {
404 right_hits.push((ch.to_string(), freq));
405 }
406 for (lw, lf) in &lefts {
407 for (rw, rf) in &right_hits {
408 let combined = format!("{lw}{rw}");
409 let combined_freq = ((*lf.min(rf) as f64) * 0.65) as u32;
410 hits.push((combined, combined_freq));
411 }
412 }
413 }
414
415 // 2-segment: walk every split point. Only proceed when both halves
416 // produce SOMETHING — bails early on barren splits to keep cost
417 // bounded for nonsense input.
418 if buffer.len() >= 4 {
419 // Skip first/last few chars — single-letter halves can't form
420 // a meaningful (content + particle) composition.
421 for split in 2..buffer.len() - 1 {
422 let left = &buffer[..split];
423 let right = &buffer[split..];
424 let lefts = compose_one_segment(left);
425 if lefts.is_empty() {
426 continue;
427 }
428 let rights = compose_one_segment(right);
429 if rights.is_empty() {
430 continue;
431 }
432 for (lw, lf) in &lefts {
433 for (rw, rf) in &rights {
434 let combined = format!("{lw}{rw}");
435 let combined_freq =
436 ((*lf.min(rf) as f64) * 0.7) as u32;
437 hits.push((combined, combined_freq));
438 }
439 }
440 }
441 }
442
443 // Dedup by word, keeping highest freq.
444 let mut best: std::collections::HashMap<String, u32> =
445 std::collections::HashMap::new();
446 for (w, f) in hits {
447 let entry = best.entry(w).or_insert(0);
448 if f > *entry {
449 *entry = f;
450 }
451 }
452 let mut sorted: Vec<(String, u32)> = best.into_iter().collect();
453 sorted.sort_by(|a, b| b.1.cmp(&a.1));
454 sorted.truncate(30);
455 sorted
456 .into_iter()
457 .map(|(word, freq)| Candidate {
458 word,
459 kind: KanaKind::Kanji,
460 // Composed candidates already include their own penalty
461 // (0.7 for multi-seg, raw for 1-seg). Apply a final 0.85
462 // mild penalty here so a direct-jukugo whole-buffer match
463 // (no compose, raw freq from data) still wins ties.
464 freq: ((freq as f64) * 0.85) as u32,
465 composed: true,
466 proximity_milli: 1000,
467 })
468 .collect()
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 #[test]
476 fn chouonpu_hyphen_extends_buffer_when_composing() {
477 // Mozc-standard romaji for コーヒー is `ko-hi-` (chōonpu enters
478 // literally as `-` → ー). Buffer "ko-hi-" renders コーヒー /
479 // こーひー via romaji::TABLE entry e("-", "ー", "ー").
480 // Bare-letter `koohi` would render コオヒー (literal oo→オオ),
481 // which is correct kana for that romaji but not the chōonpu form.
482 let mut e = JapaneseEngine::new();
483 for b in b"ko" { assert!(e.handle_letter(*b)); }
484 assert!(e.handle_letter(b'-'), "`-` must be accepted as chouonpu mid-composition");
485 for b in b"hi" { assert!(e.handle_letter(*b)); }
486 assert!(e.handle_letter(b'-'), "trailing `-` also accepted");
487 let cands = e.candidates();
488 assert!(cands.iter().any(|c| c.word == "コーヒー"),
489 "expected コーヒー (katakana with chouonpu) among candidates, got {:?}",
490 cands.iter().map(|c| &c.word).collect::<Vec<_>>());
491 assert!(cands.iter().any(|c| c.word == "こーひー"),
492 "expected こーひー (hiragana with chouonpu) among candidates");
493 }
494
495 #[test]
496 fn chouonpu_hyphen_literal_oo_no_auto_chouonpu() {
497 // Sanity guard for the chōonpu rule: `koo` does NOT auto-convert
498 // double-o to ー (matches mozc/Google IME behavior — chōonpu must
499 // be entered explicitly via `-`). User typing `koohi` gets
500 // コオヒ / こおひ, not コーヒ.
501 let mut e = JapaneseEngine::new();
502 for b in b"koo" { assert!(e.handle_letter(*b)); }
503 let cands = e.candidates();
504 assert!(cands.iter().any(|c| c.word == "コオ"),
505 "expected コオ for `koo`; got {:?}", cands.iter().map(|c| &c.word).collect::<Vec<_>>());
506 assert!(!cands.iter().any(|c| c.word.contains("コー") && c.word.chars().count() <= 2),
507 "double-o must not auto-convert to chōonpu");
508 }
509
510 #[test]
511 fn chouonpu_hyphen_rejected_on_empty_buffer() {
512 // Empty-buffer `-` must reject so the IME controller routes it as
513 // punctuation, not as a stranded ー.
514 let mut e = JapaneseEngine::new();
515 assert!(!e.handle_letter(b'-'), "empty-buffer `-` must reject");
516 assert_eq!(e.preedit(), "", "rejected `-` must leave buffer empty");
517 }
518
519 #[test]
520 fn single_letter_a_gives_hiragana_katakana() {
521 let mut e = JapaneseEngine::new();
522 assert!(e.handle_letter(b'a'));
523 let cands = e.candidates();
524 assert!(cands.iter().any(|c| c.word == "あ" && c.kind == KanaKind::Hiragana));
525 assert!(cands.iter().any(|c| c.word == "ア" && c.kind == KanaKind::Katakana));
526 }
527
528 #[test]
529 fn kanji_match_on_full_buffer_on_yomi() {
530 let mut e = JapaneseEngine::new();
531 for b in b"kou" {
532 e.handle_letter(*b);
533 }
534 let cands = e.candidates();
535 // 高 must appear in the kanji portion.
536 assert!(
537 cands.iter().any(|c| c.word == "高" && c.kind == KanaKind::Kanji),
538 "expected 高 in candidates for 'kou', got {:?}",
539 cands
540 );
541 // Hiragana fallback こう must always be available.
542 assert!(
543 cands.iter().any(|c| c.word == "こう" && c.kind == KanaKind::Hiragana),
544 "expected こう (hiragana) in candidates for 'kou', got {:?}",
545 cands
546 );
547 }
548
549 #[test]
550 fn multi_syllable_finds_jukugo() {
551 // 'nihon' is a high-freq jukugo (日本); kanji form should appear
552 // alongside the kana renderings.
553 let mut e = JapaneseEngine::new();
554 for b in b"nihon" {
555 e.handle_letter(*b);
556 }
557 let cands = e.candidates();
558 assert!(
559 cands.iter().any(|c| c.word == "日本" && c.kind == KanaKind::Kanji),
560 "expected 日本 (kanji jukugo) for 'nihon', got {:?}",
561 cands
562 );
563 assert!(cands.iter().any(|c| c.word == "にほん"));
564 assert!(cands.iter().any(|c| c.word == "ニホン"));
565 }
566
567 #[test]
568 fn backspace_pops_and_re_renders() {
569 let mut e = JapaneseEngine::new();
570 for b in b"kou" {
571 e.handle_letter(*b);
572 }
573 assert!(e.backspace());
574 assert_eq!(e.preedit(), "ko");
575 // 'ko' renders to こ + コ + any 'ko' on-yomi kanji.
576 let cands = e.candidates();
577 assert!(cands.iter().any(|c| c.word == "こ"));
578 }
579
580 #[test]
581 fn backspace_on_empty_is_false() {
582 let mut e = JapaneseEngine::new();
583 assert!(!e.backspace());
584 }
585
586 #[test]
587 fn escape_drops_buffer() {
588 let mut e = JapaneseEngine::new();
589 e.handle_letter(b'k');
590 assert!(e.is_composing());
591 assert!(e.escape());
592 assert!(!e.is_composing());
593 assert_eq!(e.preedit(), "");
594 }
595
596 #[test]
597 fn commit_clears_state() {
598 let mut e = JapaneseEngine::new();
599 for b in b"kou" {
600 e.handle_letter(*b);
601 }
602 let committed = e.commit_index(0).expect("commit");
603 assert_eq!(committed, "こう");
604 assert!(!e.is_composing());
605 assert_eq!(e.candidates().len(), 0);
606 }
607
608 #[test]
609 fn commit_out_of_range_returns_none() {
610 let mut e = JapaneseEngine::new();
611 e.handle_letter(b'a');
612 assert!(e.commit_index(999).is_none());
613 // State unchanged.
614 assert!(e.is_composing());
615 }
616
617 #[test]
618 fn non_letter_rejected_without_state_change() {
619 let mut e = JapaneseEngine::new();
620 assert!(!e.handle_letter(b'5'));
621 assert!(!e.handle_letter(b' '));
622 assert_eq!(e.preedit(), "");
623 }
624
625 /// Counter-word coverage — the Round-8 additions. Each input should
626 /// produce the corresponding counter-word kanji *as a direct jukugo
627 /// hit* (not via sentence segmentation), which means it has to be in
628 /// the jukugo table after the counters TSV is merged.
629 #[test]
630 fn counter_words_round8() {
631 let cases: &[(&[u8], &str)] = &[
632 (b"ikko", "一個"),
633 (b"hitori", "一人"),
634 (b"futari", "二人"),
635 (b"sannin", "三人"),
636 (b"yonin", "四人"),
637 (b"mikka", "三日"),
638 (b"yokka", "四日"),
639 (b"tsuitachi", "一日"),
640 (b"futsuka", "二日"),
641 (b"ippon", "一本"),
642 (b"sanbon", "三本"),
643 (b"ichimai", "一枚"),
644 (b"sanbiki", "三匹"),
645 (b"ittou", "一頭"),
646 (b"ikkai", "一回"),
647 (b"isshuukan", "一週間"),
648 (b"ikkagetsu", "ヶ月"), // checks the ヶ月 suffix (一ヶ月 below)
649 (b"yoji", "四時"),
650 (b"kuji", "九時"),
651 (b"ippun", "一分"),
652 (b"juppun", "十分"),
653 (b"ippai", "一杯"),
654 (b"issatsu", "一冊"),
655 (b"hitotsu", "一つ"),
656 (b"mittsu", "三つ"),
657 (b"daiichi", "第一"),
658 (b"hatachi", "二十歳"),
659 ];
660 for (input, expected_substr) in cases {
661 let mut e = JapaneseEngine::new();
662 for b in *input {
663 e.handle_letter(*b);
664 }
665 let cands = e.candidates();
666 assert!(
667 cands.iter().any(|c| c.word.contains(expected_substr)),
668 "expected a candidate containing `{}` for input `{}`, got {:?}",
669 expected_substr,
670 std::str::from_utf8(input).unwrap(),
671 cands.iter().map(|c| &c.word).collect::<Vec<_>>()
672 );
673 }
674 }
675
676 /// The full kanji form (一ヶ月) should appear specifically, not only the
677 /// suffix — the test above accepts substring "ヶ月" so it doesn't
678 /// false-pass on bare suffix; verify the leading 一 explicitly here.
679 #[test]
680 fn ikkagetsu_renders_full_kanji() {
681 let mut e = JapaneseEngine::new();
682 for b in b"ikkagetsu" {
683 e.handle_letter(*b);
684 }
685 let cands = e.candidates();
686 assert!(
687 cands.iter().any(|c| c.word == "一ヶ月"),
688 "expected `一ヶ月` for `ikkagetsu`, got {:?}",
689 cands.iter().map(|c| &c.word).collect::<Vec<_>>()
690 );
691 }
692
693 /// Brand / chain / SNS / IT-platform coverage — the Round-9 wrap-up
694 /// to land Inputx at "basically on par with Simeji" on the data side.
695 /// Each input must surface the corresponding form somewhere in the
696 /// candidate list.
697 #[test]
698 fn brands_round9() {
699 let cases: &[(&[u8], &str)] = &[
700 (b"sutaba", "スタバ"),
701 (b"yunikuro", "ユニクロ"),
702 (b"amazon", "アマゾン"),
703 (b"sebun", "セブン"),
704 (b"famima", "ファミマ"),
705 (b"rouson", "ローソン"),
706 (b"donki", "ドンキ"),
707 (b"toyota", "トヨタ"),
708 (b"sonii", "ソニー"),
709 (b"nintendou", "任天堂"),
710 (b"yuuchuubu", "ユーチューブ"),
711 (b"insuta", "インスタ"),
712 (b"tikkutokku", "ティックトック"),
713 (b"merukari", "メルカリ"),
714 (b"peipei", "ペイペイ"),
715 (b"rain", "ライン"),
716 (b"netofuri", "ネトフリ"),
717 (b"shinkansen", "新幹線"),
718 (b"makku", "マック"),
719 (b"yoshinoya", "吉野家"),
720 (b"sukiya", "すき家"),
721 (b"ichiran", "一蘭"),
722 (b"jiburi", "ジブリ"),
723 (b"pokemon", "ポケモン"),
724 (b"suika", "スイカ"),
725 ];
726 for (input, expected) in cases {
727 let mut e = JapaneseEngine::new();
728 for b in *input {
729 e.handle_letter(*b);
730 }
731 let cands = e.candidates();
732 assert!(
733 cands.iter().any(|c| c.word == *expected),
734 "expected `{}` for `{}`, got {:?}",
735 expected,
736 std::str::from_utf8(input).unwrap(),
737 cands.iter().map(|c| &c.word).collect::<Vec<_>>()
738 );
739 }
740 }
741
742 /// Counter words must rank ABOVE the bare kana renderings — typing
743 /// `hitori` should surface 一人 at the top, not the hiragana ひとり as
744 /// candidate #0. Without this, the polish round is invisible to users
745 /// who hit Space (which commits #0).
746 #[test]
747 fn counter_word_outranks_kana() {
748 let mut e = JapaneseEngine::new();
749 for b in b"hitori" {
750 e.handle_letter(*b);
751 }
752 let cands = e.candidates();
753 let one_person_idx = cands.iter().position(|c| c.word == "一人");
754 let hiragana_idx = cands.iter().position(|c| c.word == "ひとり");
755 assert!(
756 one_person_idx.is_some(),
757 "no `一人` candidate for `hitori`, got {:?}",
758 cands.iter().map(|c| &c.word).collect::<Vec<_>>()
759 );
760 if let (Some(o), Some(h)) = (one_person_idx, hiragana_idx) {
761 assert!(
762 o < h,
763 "`一人` (#{o}) should rank above `ひとり` (#{h}), got {:?}",
764 cands.iter().map(|c| &c.word).collect::<Vec<_>>()
765 );
766 }
767 }
768}