Skip to main content

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}