Skip to main content

geulbus_core/
config.rs

1//! `nalgaeset.xml`(날개셋 입력 설정) 파싱과 엔진용 컴파일.
2//!
3//! 문서 구조: `EditContextSetting > {EditorLayer, InputLayer > InputEntry*}`.
4//! 각 InputEntry 는 `InputSchemeSetting`(KeyTable)과 `GeneratorSetting`
5//! (UnitMix/VirtualUnit/Automata/Bksp)을 가진다.
6//! 참고: `research/01-nalgaeset-format.md`, `research/02-config-decode.md`.
7
8use std::collections::HashMap;
9
10use quick_xml::events::{BytesStart, Event};
11use quick_xml::Reader;
12use thiserror::Error;
13
14use crate::expr::{Expr, ExprError};
15use crate::unit::{self, Category, Jamo, Unit};
16
17#[derive(Debug, Error)]
18pub enum ConfigError {
19    #[error("XML 파싱 오류: {0}")]
20    Xml(#[from] quick_xml::Error),
21    #[error("XML 속성 오류: {0}")]
22    Attr(#[from] quick_xml::events::attributes::AttrError),
23    #[error("키 {at:?} 의 값-식 파싱 실패: {source}")]
24    KeyExpr { at: String, source: ExprError },
25    #[error("정수 파싱 실패: {0:?}")]
26    BadInt(String),
27    #[error("알 수 없는 낱자 갈래 {0:?} (CHO/JUNG/JONG 이어야 함)")]
28    BadCategory(String),
29    #[error("UnitMix/VirtualUnit 의 낱자 {0:?} 해석 실패")]
30    BadUnit(String),
31    #[error("InputEntry 인덱스 {0} 없음")]
32    NoEntry(usize),
33    #[error("오토마타 상태 {state} 의 식 파싱 실패: {source}")]
34    AutomataExpr { state: i64, source: ExprError },
35}
36
37// ── 파싱된(raw) 모델 ─────────────────────────────────────────────────────────
38
39/// 전체 설정.
40#[derive(Debug, Clone)]
41pub struct Config {
42    pub version: String,
43    pub editor: EditorLayer,
44    pub default_entry: usize,
45    pub current_entry: usize,
46    pub entries: Vec<InputEntry>,
47}
48
49#[derive(Debug, Clone, Default)]
50pub struct EditorLayer {
51    pub flags: Vec<String>,
52    pub shortcuts: Vec<Shortcut>,
53    /// 조합용/옛 자모 → 호환 자모 (홑낱자 출력용).
54    pub final_conv: HashMap<u32, u32>,
55}
56
57#[derive(Debug, Clone)]
58pub struct Shortcut {
59    pub key: String,
60    pub modifier: Vec<String>,
61    pub usage: String,
62    pub value: String,
63}
64
65#[derive(Debug, Clone, Default)]
66pub struct InputEntry {
67    pub scheme_object: String,
68    pub generator_object: String,
69    pub key_table: Option<KeyTable>,
70    pub unit_mix: Vec<UnitMix>,
71    pub virtual_units: Vec<VirtualUnit>,
72    pub automata_default: String,
73    pub automata: Vec<AutomataRow>,
74    pub bksp: Vec<Bksp>,
75}
76
77#[derive(Debug, Clone)]
78pub struct KeyTable {
79    pub name: String,
80    pub from: u32,
81    pub to: u32,
82    /// ASCII at(0x21..0x7E) → 파싱된 값-식.
83    pub keys: HashMap<u32, KeyDef>,
84}
85
86#[derive(Debug, Clone)]
87pub struct KeyDef {
88    pub raw: String,
89    pub expr: Expr,
90}
91
92#[derive(Debug, Clone)]
93pub struct UnitMix {
94    pub unit: Category,
95    pub a: String,
96    pub b: String,
97    pub to: String,
98}
99
100#[derive(Debug, Clone)]
101pub struct VirtualUnit {
102    pub unit: Category,
103    pub from: u32,
104    pub to: String,
105}
106
107#[derive(Debug, Clone)]
108pub struct AutomataRow {
109    pub state: i64,
110    pub value: String,
111    pub default: String,
112    pub remark: String,
113}
114
115#[derive(Debug, Clone)]
116pub struct Bksp {
117    pub key: u32,
118    pub value1: String,
119    pub value2: String,
120    pub condition1: String,
121    pub condition2: String,
122}
123
124// ── 컴파일된(engine-ready) 모델 ──────────────────────────────────────────────
125
126/// 컴파일된 오토마타 상태 한 칸. H3| 낱자 입력 시 현재 상태의 `value` 식을 평가해
127/// 다음 상태/동작 코드를 얻는다. 식이 적용되지 않는 상황이면 `default` 를 쓴다.
128/// (변수·결과값 의미는 `research/ngs-automata-help.txt` 참고.)
129#[derive(Debug, Clone)]
130pub struct AutomataState {
131    pub value: Expr,
132    pub default: Expr,
133}
134
135/// 백스페이스 한 번에 지우는 단위(날개셋 Bksp 수식값 0~3).
136/// 참고: `research/ngs-automata-help.txt` 의 Bksp 설명(cp_bkspset).
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
138pub enum BkspUnit {
139    /// 0: 직전에 입력된 한 타만 취소(겹낱자/토글 한 단계). 기존 기본 동작.
140    #[default]
141    LastKey,
142    /// 1: 최하위 낱자(종성→중성→초성 순)의 직전 한 타.
143    LowestLastKey,
144    /// 2: 최하위 낱자 전체(그 낱자를 몇 타에 넣었든 통째로).
145    LowestWhole,
146    /// 3: 글자 전체(한 타에 음절 통째).
147    Syllable,
148}
149
150/// 컴파일된 백스페이스 동작(한 Bksp 슬롯). 조합 중일 때(제1동작)와 그렇지 않을 때
151/// (제2동작, 앞의 완성 글자)에 각각 어느 단위로 지울지와, 글자를 다 지운 뒤 앞 한글에
152/// 달라붙어 재조합할지(BkspAttach)를 담는다.
153#[derive(Debug, Clone, Default)]
154pub struct BkspBehavior {
155    /// 조합 중 삭제 단위(제1동작, value1).
156    pub composing: BkspUnit,
157    /// 비조합 상태에서 앞 완성 글자 삭제 단위(제2동작, value2).
158    pub idle: BkspUnit,
159    /// 글자를 다 지운 뒤 앞 한글에 달라붙어 재조합(BkspAttach).
160    pub attach: bool,
161}
162
163/// Bksp value 문자열(예 `"ByUnitStep|BkspAttach"`, `"BySyllable"`, `"0"`)을 (단위, attach)로.
164/// 플래그명과 정수(0~3)를 인식한다. 알 수 없으면 기본(LastKey, attach 없음).
165fn parse_bksp_value(s: &str) -> (BkspUnit, bool) {
166    let mut unit = BkspUnit::LastKey;
167    let mut attach = false;
168    for tok in s.split('|') {
169        match tok.trim() {
170            "BkspAttach" => attach = true,
171            "ByUnitStep" | "0" => unit = BkspUnit::LastKey,
172            "1" => unit = BkspUnit::LowestLastKey,
173            "2" => unit = BkspUnit::LowestWhole,
174            "BySyllable" | "3" => unit = BkspUnit::Syllable,
175            // 날개셋 "ReverseJLTRN" 판단 기준: 모아치기에서 입력 순서와 무관하게 종성→
176            // 중성→초성 역순으로(받침부터) 지운다 = 최하위 낱자의 직전 한 타.
177            "ReverseJLTRN" => unit = BkspUnit::LowestLastKey,
178            _ => {}
179        }
180    }
181    (unit, attach)
182}
183
184/// 한 입력 항목을 엔진이 바로 쓰도록 컴파일한 배열.
185#[derive(Debug, Clone)]
186pub struct Layout {
187    pub name: String,
188    /// ASCII at → 값-식.
189    pub keys: HashMap<u32, Expr>,
190    /// (갈래, a 코드포인트, b 코드포인트 또는 TOGGLE) → 결과 코드포인트.
191    pub combine: HashMap<(Category, u32, u32), u32>,
192    /// 가상 단위 id → 자모.
193    pub virtual_units: HashMap<u32, Jamo>,
194    /// 조합용/옛 자모 → 호환 자모.
195    pub final_conv: HashMap<u32, u32>,
196    /// 에디터 레이어의 단축글쇠(한/영 전환·한자 등). 프런트엔드가 해석한다.
197    pub shortcuts: Vec<Shortcut>,
198    /// 오토마타: 상태 id → 컴파일된 전이 규칙. 비어 있으면 엔진이 기본 휴리스틱을 쓴다.
199    pub automata: HashMap<i64, AutomataState>,
200    /// 오토마타 시작 상태(AutomataTable 의 default 속성). 보통 0.
201    pub automata_start: i64,
202    /// 백스페이스 동작(`<Bksp key="1">` 슬롯). 물리 Backspace 하나에 대응.
203    pub bksp: BkspBehavior,
204}
205
206impl Layout {
207    /// 두 낱자(또는 a+토글)의 조합 결과를 찾는다.
208    pub fn combine(&self, cat: Category, a_cp: u32, b_cp: u32) -> Option<u32> {
209        self.combine.get(&(cat, a_cp, b_cp)).copied()
210    }
211
212    /// 홑낱자 출력용 호환 자모: 설정의 FinalConv 우선, 없으면 기본 호환표.
213    pub fn standalone(&self, j: Jamo) -> Option<char> {
214        let compat = self
215            .final_conv
216            .get(&j.cp)
217            .copied()
218            .or_else(|| j.default_compat());
219        compat.and_then(char::from_u32)
220    }
221}
222
223impl Config {
224    /// `nalgaeset.xml` 문자열을 파싱한다.
225    pub fn parse(xml: &str) -> Result<Config, ConfigError> {
226        parse_config(xml)
227    }
228
229    /// 지정한 입력 항목을 엔진용 `Layout` 으로 컴파일한다.
230    pub fn compile(&self, entry_idx: usize) -> Result<Layout, ConfigError> {
231        let entry = self
232            .entries
233            .get(entry_idx)
234            .ok_or(ConfigError::NoEntry(entry_idx))?;
235
236        let keys = entry
237            .key_table
238            .as_ref()
239            .map(|kt| {
240                kt.keys
241                    .iter()
242                    .map(|(&at, kd)| (at, kd.expr.clone()))
243                    .collect()
244            })
245            .unwrap_or_default();
246
247        let mut combine = HashMap::new();
248        for m in &entry.unit_mix {
249            let a = resolve_jamo(&m.a, m.unit)?;
250            let to = resolve_jamo(&m.to, m.unit)?;
251            let b_cp = match unit::resolve_operand(&m.b, Some(m.unit))
252                .ok_or_else(|| ConfigError::BadUnit(m.b.clone()))?
253            {
254                Unit::Toggle => unit::TOGGLE,
255                Unit::Jamo(j) => j.cp,
256                Unit::Virtual(_) => return Err(ConfigError::BadUnit(m.b.clone())),
257            };
258            combine.insert((m.unit, a.cp, b_cp), to.cp);
259        }
260
261        let mut virtual_units = HashMap::new();
262        for v in &entry.virtual_units {
263            let j = resolve_jamo(&v.to, v.unit)?;
264            virtual_units.insert(v.from, j);
265        }
266
267        // 오토마타 상태 식들을 컴파일한다(value=전이식, default=폴백식).
268        let mut automata = HashMap::new();
269        for row in &entry.automata {
270            let value = Expr::parse(&row.value).map_err(|source| ConfigError::AutomataExpr {
271                state: row.state,
272                source,
273            })?;
274            let default =
275                Expr::parse(&row.default).map_err(|source| ConfigError::AutomataExpr {
276                    state: row.state,
277                    source,
278                })?;
279            automata.insert(row.state, AutomataState { value, default });
280        }
281        let automata_start = entry.automata_default.trim().parse().unwrap_or(0);
282
283        // 백스페이스: 물리 Backspace 하나이므로 key="1" 슬롯을 쓴다(없으면 기본).
284        // value1=조합 중(제1동작), value2=비조합(제2동작). attach 는 둘 중 하나라도 켜지면.
285        let bksp = entry
286            .bksp
287            .iter()
288            .find(|b| b.key == 1)
289            .map(|b| {
290                let (mut composing, a1) = parse_bksp_value(&b.value1);
291                let (idle, a2) = parse_bksp_value(&b.value2);
292                // 삭제 단위가 value 가 아니라 condition(판단 기준)에 들어가는 경우가 있다.
293                // 특히 "ReverseJLTRN"(받침부터 역순) 은 condition1 에 실리므로 함께 본다.
294                if b.condition1.contains("ReverseJLTRN") {
295                    composing = BkspUnit::LowestLastKey;
296                }
297                BkspBehavior {
298                    composing,
299                    idle,
300                    attach: a1 || a2,
301                }
302            })
303            .unwrap_or_default();
304
305        Ok(Layout {
306            name: entry
307                .key_table
308                .as_ref()
309                .map(|k| k.name.clone())
310                .unwrap_or_default(),
311            keys,
312            combine,
313            virtual_units,
314            final_conv: self.editor.final_conv.clone(),
315            shortcuts: self.editor.shortcuts.clone(),
316            automata,
317            automata_start,
318            bksp,
319        })
320    }
321
322    /// 한글 조합 항목(자동자/생성기가 한글)으로 보이는 첫 번째 인덱스.
323    pub fn first_hangul_entry(&self) -> Option<usize> {
324        self.entries
325            .iter()
326            .position(|e| e.generator_object.starts_with("CNgsIme") && e.key_table.is_some())
327    }
328}
329
330fn resolve_jamo(s: &str, cat: Category) -> Result<Jamo, ConfigError> {
331    match unit::resolve_operand(s, Some(cat)) {
332        Some(Unit::Jamo(j)) => Ok(j),
333        _ => Err(ConfigError::BadUnit(s.to_string())),
334    }
335}
336
337fn category_of(s: &str) -> Result<Category, ConfigError> {
338    match s {
339        "CHO" => Ok(Category::Cho),
340        "JUNG" => Ok(Category::Jung),
341        "JONG" => Ok(Category::Jong),
342        other => Err(ConfigError::BadCategory(other.to_string())),
343    }
344}
345
346fn parse_int(s: &str) -> Result<u32, ConfigError> {
347    unit::parse_int(s).ok_or_else(|| ConfigError::BadInt(s.to_string()))
348}
349
350/// 시작/빈 태그의 속성을 (이름→값, unescape 적용) 맵으로 모은다.
351fn attrs(e: &BytesStart) -> Result<HashMap<String, String>, ConfigError> {
352    let mut m = HashMap::new();
353    for a in e.attributes() {
354        let a = a?;
355        let key = String::from_utf8_lossy(a.key.as_ref()).into_owned();
356        let val = a.unescape_value()?.into_owned();
357        m.insert(key, val);
358    }
359    Ok(m)
360}
361
362fn get<'a>(m: &'a HashMap<String, String>, k: &str) -> &'a str {
363    m.get(k).map(String::as_str).unwrap_or("")
364}
365
366fn parse_config(xml: &str) -> Result<Config, ConfigError> {
367    let mut reader = Reader::from_str(xml);
368    reader.config_mut().trim_text(true);
369
370    let mut cfg = Config {
371        version: String::new(),
372        editor: EditorLayer::default(),
373        default_entry: 0,
374        current_entry: 0,
375        entries: Vec::new(),
376    };
377
378    // 현재 어느 InputEntry 의 어느 섹션을 채우는 중인지.
379    #[derive(PartialEq)]
380    enum Section {
381        None,
382        Scheme,
383        Generator,
384    }
385    let mut section = Section::None;
386    let mut in_input_layer = false;
387
388    loop {
389        match reader.read_event()? {
390            Event::Start(e) | Event::Empty(e) => {
391                let name = e.name();
392                let tag = name.as_ref();
393                let a = attrs(&e)?;
394                match tag {
395                    b"EditContextSetting" => cfg.version = get(&a, "version").to_string(),
396                    b"EditorLayer" => {
397                        cfg.editor.flags = split_flags(get(&a, "flag"));
398                    }
399                    b"Shortcut" => cfg.editor.shortcuts.push(Shortcut {
400                        key: get(&a, "key").to_string(),
401                        modifier: split_flags(get(&a, "modifier")),
402                        usage: get(&a, "usage").to_string(),
403                        value: get(&a, "value").to_string(),
404                    }),
405                    b"FinalConv" => {
406                        let from = parse_int(get(&a, "from"))?;
407                        let to = parse_int(get(&a, "to"))?;
408                        cfg.editor.final_conv.insert(from, to);
409                    }
410                    b"InputLayer" => {
411                        in_input_layer = true;
412                        cfg.default_entry = parse_int(get(&a, "default")).unwrap_or(0) as usize;
413                        cfg.current_entry = parse_int(get(&a, "current")).unwrap_or(0) as usize;
414                    }
415                    b"InputEntry" => {
416                        cfg.entries.push(InputEntry::default());
417                    }
418                    b"InputSchemeSetting" => {
419                        section = Section::Scheme;
420                        if let Some(en) = cfg.entries.last_mut() {
421                            en.scheme_object = get(&a, "object").to_string();
422                        }
423                    }
424                    b"GeneratorSetting" => {
425                        section = Section::Generator;
426                        if let Some(en) = cfg.entries.last_mut() {
427                            en.generator_object = get(&a, "object").to_string();
428                        }
429                    }
430                    b"KeyTable" => {
431                        if let Some(en) = cfg.entries.last_mut() {
432                            en.key_table = Some(KeyTable {
433                                name: get(&a, "name").to_string(),
434                                from: parse_int(get(&a, "from")).unwrap_or(33),
435                                to: parse_int(get(&a, "to")).unwrap_or(126),
436                                keys: HashMap::new(),
437                            });
438                        }
439                    }
440                    b"Key" if section == Section::Scheme => {
441                        let at_s = get(&a, "at");
442                        let val = get(&a, "value");
443                        let at = parse_int(at_s)?;
444                        let expr = Expr::parse(val).map_err(|source| ConfigError::KeyExpr {
445                            at: at_s.to_string(),
446                            source,
447                        })?;
448                        if let Some(en) = cfg.entries.last_mut() {
449                            if let Some(kt) = en.key_table.as_mut() {
450                                kt.keys.insert(
451                                    at,
452                                    KeyDef {
453                                        raw: val.to_string(),
454                                        expr,
455                                    },
456                                );
457                            }
458                        }
459                    }
460                    b"UnitMix" => {
461                        let unit = category_of(get(&a, "unit"))?;
462                        if let Some(en) = cfg.entries.last_mut() {
463                            en.unit_mix.push(UnitMix {
464                                unit,
465                                a: get(&a, "a").to_string(),
466                                b: get(&a, "b").to_string(),
467                                to: get(&a, "to").to_string(),
468                            });
469                        }
470                    }
471                    b"VirtualUnit" => {
472                        let unit = category_of(get(&a, "unit"))?;
473                        if let Some(en) = cfg.entries.last_mut() {
474                            en.virtual_units.push(VirtualUnit {
475                                unit,
476                                from: parse_int(get(&a, "from"))?,
477                                to: get(&a, "to").to_string(),
478                            });
479                        }
480                    }
481                    b"AutomataTable" => {
482                        if let Some(en) = cfg.entries.last_mut() {
483                            en.automata_default = get(&a, "default").to_string();
484                        }
485                    }
486                    b"Automata" => {
487                        if let Some(en) = cfg.entries.last_mut() {
488                            en.automata.push(AutomataRow {
489                                state: parse_int(get(&a, "state")).unwrap_or(0) as i64,
490                                value: get(&a, "value").to_string(),
491                                default: get(&a, "default").to_string(),
492                                remark: get(&a, "remark").to_string(),
493                            });
494                        }
495                    }
496                    b"Bksp" => {
497                        if let Some(en) = cfg.entries.last_mut() {
498                            en.bksp.push(Bksp {
499                                key: parse_int(get(&a, "key")).unwrap_or(0),
500                                value1: get(&a, "value1").to_string(),
501                                value2: get(&a, "value2").to_string(),
502                                condition1: get(&a, "condition1").to_string(),
503                                condition2: get(&a, "condition2").to_string(),
504                            });
505                        }
506                    }
507                    _ => {}
508                }
509            }
510            Event::End(e) => {
511                let name = e.name();
512                match name.as_ref() {
513                    b"InputSchemeSetting" | b"GeneratorSetting" => section = Section::None,
514                    b"InputLayer" => in_input_layer = false,
515                    _ => {}
516                }
517                let _ = in_input_layer; // (현재는 정보용)
518            }
519            Event::Eof => break,
520            _ => {}
521        }
522    }
523
524    Ok(cfg)
525}
526
527fn split_flags(s: &str) -> Vec<String> {
528    if s.is_empty() {
529        Vec::new()
530    } else {
531        s.split('|').map(|p| p.trim().to_string()).collect()
532    }
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    /// 파서 단위 테스트용 작은 합성 설정.
540    const MINI: &str = r#"<?xml version="1.0" encoding="utf-8"?>
541<EditContextSetting version="0x500">
542  <EditorLayer flag="DEL_MOVE|ARROW_MOVE">
543    <ShortcutTable>
544      <Shortcut key="VK_HANGUL" usage="IME_SWITCH" value="!A"/>
545      <Shortcut key="VK_HANJA" usage="KEYCHAR" value="C0|0x82"/>
546    </ShortcutTable>
547    <FinalConvTable>
548      <FinalConv from="0x1100" to="0x3131"/>
549      <FinalConv from="0x1161" to="0x314F"/>
550      <FinalConv from="0x11A8" to="0x3131"/>
551    </FinalConvTable>
552  </EditorLayer>
553  <InputLayer default="0" current="2">
554    <InputEntry>
555      <InputSchemeSetting flag="0" object="CBasicInputScheme">
556        <KeyTable name="mini" flag="0" from="33" to="126">
557          <Key at="0x6B" value="H3|G_"/>
558          <Key at="0x66" value="H3|A_"/>
559          <Key at="0x78" value="H3|_G"/>
560          <Key at="0x24" value="T ? H3|0x1F4 : 0x24"/>
561        </KeyTable>
562      </InputSchemeSetting>
563      <GeneratorSetting flag="0" object="CNgsImeEx" flagex="1">
564        <UnitMixTable>
565          <UnitMix unit="CHO" a="G_" b="G_" to="GG"/>
566          <UnitMix unit="CHO" a="G_" b="500" to="GG"/>
567          <UnitMix unit="JUNG" a="O_" b="A_" to="WA"/>
568          <UnitMix unit="JONG" a="R_" b="S_" to="RS"/>
569        </UnitMixTable>
570        <VirtualUnitTable>
571          <VirtualUnit unit="JUNG" from="128" to="O_"/>
572          <VirtualUnit unit="JUNG" from="130" to="EU"/>
573        </VirtualUnitTable>
574        <AutomataTable default="0">
575          <Automata state="0" value="1" default="0" remark="초기 상태"/>
576          <Automata state="2" value="A&amp;&amp;A!=500 ? 0 : B||C||A==500 ? 2 : -2" default="0" remark="완성"/>
577        </AutomataTable>
578      </GeneratorSetting>
579    </InputEntry>
580    <InputEntry>
581      <InputSchemeSetting object="CInputScheme"/>
582      <GeneratorSetting flag="0" object="CIme"/>
583    </InputEntry>
584  </InputLayer>
585</EditContextSetting>"#;
586
587    #[test]
588    fn parse_mini() {
589        let cfg = Config::parse(MINI).unwrap();
590        assert_eq!(cfg.version, "0x500");
591        assert_eq!(cfg.default_entry, 0);
592        assert_eq!(cfg.current_entry, 2);
593        assert_eq!(cfg.entries.len(), 2);
594        assert_eq!(cfg.editor.shortcuts.len(), 2);
595        assert_eq!(cfg.editor.final_conv.get(&0x1100), Some(&0x3131));
596
597        let e0 = &cfg.entries[0];
598        assert_eq!(e0.scheme_object, "CBasicInputScheme");
599        assert_eq!(e0.generator_object, "CNgsImeEx");
600        let kt = e0.key_table.as_ref().unwrap();
601        assert_eq!(kt.name, "mini");
602        assert_eq!(kt.keys.len(), 4);
603        assert_eq!(e0.unit_mix.len(), 4);
604        assert_eq!(e0.virtual_units.len(), 2);
605        assert_eq!(e0.automata.len(), 2);
606        assert_eq!(e0.automata_default, "0");
607
608        // 두 번째 항목은 패스스루
609        assert_eq!(cfg.entries[1].scheme_object, "CInputScheme");
610        assert!(cfg.entries[1].key_table.is_none());
611
612        assert_eq!(cfg.first_hangul_entry(), Some(0));
613    }
614
615    #[test]
616    fn compile_mini() {
617        let cfg = Config::parse(MINI).unwrap();
618        let layout = cfg.compile(0).unwrap();
619        assert_eq!(layout.name, "mini");
620        assert_eq!(layout.keys.len(), 4);
621
622        // 갈마들이/된소리 조합: ㄱ초성 + ㄱ초성 → ㄲ초성, ㄱ + 토글 → ㄲ
623        assert_eq!(layout.combine(Category::Cho, 0x1100, 0x1100), Some(0x1101));
624        assert_eq!(
625            layout.combine(Category::Cho, 0x1100, unit::TOGGLE),
626            Some(0x1101)
627        );
628        // 겹모음 ㅗ+ㅏ→ㅘ
629        assert_eq!(layout.combine(Category::Jung, 0x1169, 0x1161), Some(0x116A));
630        // 겹받침 ㄹ+ㅅ→ㄽ (종성)
631        assert_eq!(layout.combine(Category::Jong, 0x11AF, 0x11BA), Some(0x11B3));
632
633        // 가상 단위
634        assert_eq!(
635            layout.virtual_units.get(&128),
636            Some(&Jamo::new(Category::Jung, 0x1169))
637        );
638        assert_eq!(
639            layout.virtual_units.get(&130),
640            Some(&Jamo::new(Category::Jung, 0x1173))
641        );
642
643        // 홑낱자 출력
644        assert_eq!(
645            layout.standalone(Jamo::new(Category::Cho, 0x1100)),
646            Some('ㄱ')
647        );
648        assert_eq!(
649            layout.standalone(Jamo::new(Category::Jong, 0x11A8)),
650            Some('ㄱ')
651        );
652
653        // 오토마타: AutomataTable 의 두 상태(0,2)가 컴파일돼 들어왔는지.
654        assert_eq!(layout.automata.len(), 2);
655        assert_eq!(layout.automata_start, 0);
656        // 상태 2 식(A&&A!=500 ? 0 : ...): 입력이 초성 ㄱ(A=서열, !=500)이면 0(새 글자).
657        use crate::expr::{Ctx, Value};
658        let st2 = layout.automata.get(&2).expect("state 2");
659        assert_eq!(
660            st2.value.eval(&Ctx {
661                a: 1,
662                ..Default::default()
663            }),
664            Ok(Value::Int(0))
665        );
666    }
667
668    #[test]
669    fn compile_bksp_behavior() {
670        // Bksp key=1 value1=ByUnitStep|BkspAttach value2=BySyllable 컴파일 확인.
671        let xml = r#"<?xml version="1.0" encoding="utf-8"?>
672<EditContextSetting version="0x500">
673  <EditorLayer flag="0"><FinalConvTable/></EditorLayer>
674  <InputLayer default="0" current="0">
675    <InputEntry>
676      <InputSchemeSetting object="CBasicInputScheme">
677        <KeyTable name="t" flag="0" from="33" to="126"><Key at="0x6B" value="H3|G_"/></KeyTable>
678      </InputSchemeSetting>
679      <GeneratorSetting object="CNgsImeEx">
680        <UnitMixTable/><VirtualUnitTable/><AutomataTable default="0"/>
681        <Extra>
682          <Bksp key="1" value1="ByUnitStep|BkspAttach" value2="BySyllable" condition1="0" condition2="0"/>
683          <Bksp key="2" value1="0" value2="BySyllable" condition1="0" condition2="0"/>
684        </Extra>
685      </GeneratorSetting>
686    </InputEntry>
687  </InputLayer>
688</EditContextSetting>"#;
689        let layout = Config::parse(xml).unwrap().compile(0).unwrap();
690        assert_eq!(layout.bksp.composing, BkspUnit::LastKey); // ByUnitStep
691        assert_eq!(layout.bksp.idle, BkspUnit::Syllable); // BySyllable
692        assert!(layout.bksp.attach); // BkspAttach
693    }
694
695    #[test]
696    fn compile_bksp_default_when_absent() {
697        // Extra/Bksp 없으면 기본(LastKey, attach 없음).
698        let layout = Config::parse(MINI).unwrap().compile(0).unwrap();
699        assert_eq!(layout.bksp.composing, BkspUnit::LastKey);
700        assert!(!layout.bksp.attach);
701    }
702}