From 70047df8a4b2623612babfc634af9024a70b1b0b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Fri, 26 Jun 2026 05:11:54 +0800 Subject: [PATCH] Add ime_state: live IME composition state for safe CJK entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing into a CJK field while the IME is composing corrupts entry — the candidate text isn't committed, so reads return half-formed glyphs and the next keystroke edits the composition. text_unicode is blind to this. Expose the focused window's live composition/conversion state (Windows IMM32, read-only) behind an injectable reader, with is_composing and a wait_for_composition_commit gate, so flows wait for commit first. --- WHATS_NEW.md | 6 + .../doc/new_features/v208_features_doc.rst | 57 +++++++ .../Zh/doc/new_features/v208_features_doc.rst | 51 +++++++ je_auto_control/__init__.py | 7 + .../gui/script_builder/command_schema.py | 28 ++++ .../utils/executor/action_executor.py | 31 ++++ je_auto_control/utils/ime_state/__init__.py | 10 ++ je_auto_control/utils/ime_state/ime_state.py | 134 +++++++++++++++++ .../utils/mcp_server/tools/_factories.py | 38 +++++ .../utils/mcp_server/tools/_handlers.py | 24 +++ .../headless/test_ime_state_batch.py | 142 ++++++++++++++++++ 11 files changed, 528 insertions(+) create mode 100644 docs/source/Eng/doc/new_features/v208_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v208_features_doc.rst create mode 100644 je_auto_control/utils/ime_state/__init__.py create mode 100644 je_auto_control/utils/ime_state/ime_state.py create mode 100644 test/unit_test/headless/test_ime_state_batch.py diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 0395fbb5..13c1a794 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -2,6 +2,12 @@ ## What's new (2026-06-26) +### Live IME State for Safe CJK Entry + +Wait for the input method to commit before reading a Japanese/Chinese/Korean field. Full reference: [`docs/source/Eng/doc/new_features/v208_features_doc.rst`](docs/source/Eng/doc/new_features/v208_features_doc.rst). + +- **`ime_state` / `is_composing` / `wait_for_composition_commit` / `decode_conversion_mode`** (`AC_ime_state`, `AC_is_composing`, `AC_wait_for_composition_commit`, `AC_decode_conversion_mode`): typing into a CJK field is unsafe while an IME is *composing* — the candidate text isn't committed, so reading the field back returns half-entered glyphs and the next keystroke edits the composition. `text_unicode` (`VK_PACKET`) is blind to this. `ime_state` exposes the focused window's live `{open, composing, composition, conversion}` (Windows IMM32, read-only) through an injectable `reader`; `is_composing` is the boolean gate; `wait_for_composition_commit` blocks until the IME commits (injectable `clock`/`sleep`/`reader`); `decode_conversion_mode` is the pure `IME_CMODE_*` bitmask decoder. All decode/wait logic is unit-tested without an IME. Sixth feature of the ROUND-15 cross-app OS lane. No `PySide6`. + ### Lock the Workstation + Wait for Unlock Lock the box at the end of a run, and block until a human unlocks it before resuming. Full reference: [`docs/source/Eng/doc/new_features/v207_features_doc.rst`](docs/source/Eng/doc/new_features/v207_features_doc.rst). diff --git a/docs/source/Eng/doc/new_features/v208_features_doc.rst b/docs/source/Eng/doc/new_features/v208_features_doc.rst new file mode 100644 index 00000000..cffcf275 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v208_features_doc.rst @@ -0,0 +1,57 @@ +Live IME State for Safe CJK Entry +================================= + +Typing into a CJK / Japanese / Korean field is unsafe while an IME (input method +editor) is *composing*: the candidate text has not been committed yet, so +reading the field back returns half-entered glyphs and the next keystroke edits +the composition instead of the field. ``text_unicode`` (``VK_PACKET``) is blind +to this. ``ime_state`` exposes the live composition and conversion state so a +flow can wait for the IME to commit before it reads or acts. + +* :func:`ime_state` — ``{open, composing, composition, conversion, + conversion_flags}`` for the focused window's IME, through an injectable + ``reader``. +* :func:`is_composing` — ``True`` while the IME has an uncommitted composition. +* :func:`wait_for_composition_commit` — block until composition ends (or a + timeout), with injectable ``clock`` / ``sleep`` / ``reader``. +* :func:`decode_conversion_mode` — pure: the IMM32 ``IME_CMODE_*`` conversion + bitmask to ``{native, katakana, full_shape, roman, char_code}``. + +The default ``reader`` queries Windows IMM32 (``ImmGetContext`` / +``ImmGetOpenStatus`` / ``ImmGetConversionStatus`` / ``ImmGetCompositionStringW``) +read-only; all decoding / waiting logic runs through the injectable seam, so it +is fully testable without an IME. Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import ( + ime_state, is_composing, wait_for_composition_commit, + ) + + # Before reading a CJK field, make sure the IME has committed + if wait_for_composition_commit(timeout_s=3): + value = read_field() + + is_composing() # True while candidate text is still on screen + ime_state() # {'open': True, 'composing': True, 'composition': 'あ', ...} + +For tests (or any non-Windows host) pass a ``reader`` — a +``() -> {open, conversion, composition}``: + +.. code-block:: python + + busy = lambda: {"open": True, "conversion": 0, "composition": "あ"} + is_composing(reader=busy) # True + ime_state(reader=busy)["composition"] # 'あ' + +Executor commands +----------------- + +``AC_ime_state`` (→ the full state), ``AC_is_composing`` (→ ``{composing}``), +``AC_wait_for_composition_commit`` (``timeout`` / ``interval`` → +``{committed}``) and ``AC_decode_conversion_mode`` (``flags`` → the decoded +modes). They are exposed as the matching read-only ``ac_*`` MCP tools and as +Script Builder commands under **Shell**. diff --git a/docs/source/Zh/doc/new_features/v208_features_doc.rst b/docs/source/Zh/doc/new_features/v208_features_doc.rst new file mode 100644 index 00000000..4ffafc32 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v208_features_doc.rst @@ -0,0 +1,51 @@ +即時 IME 狀態以利安全的 CJK 輸入 +================================ + +在 IME(輸入法)*組字中*對 CJK / 日文 / 韓文欄位輸入並不安全:候選字尚未送出,故讀回欄位會得到 +半成形的字,而下一個按鍵會編輯組字而非欄位。``text_unicode``(``VK_PACKET``)對此一無所知。 +``ime_state`` 暴露即時的組字與轉換狀態,讓流程能在讀取或操作前等待 IME 送出。 + +* :func:`ime_state` ——聚焦視窗 IME 的 ``{open, composing, composition, conversion, + conversion_flags}``,透過可注入的 ``reader``。 +* :func:`is_composing` ——當 IME 有尚未送出的組字時回傳 ``True``。 +* :func:`wait_for_composition_commit` ——阻塞直到組字結束(或逾時),``clock`` / ``sleep`` / + ``reader`` 皆可注入。 +* :func:`decode_conversion_mode` ——純函式:把 IMM32 ``IME_CMODE_*`` 轉換位元遮罩解碼為 + ``{native, katakana, full_shape, roman, char_code}``。 + +預設 ``reader`` 以唯讀方式查詢 Windows IMM32(``ImmGetContext`` / ``ImmGetOpenStatus`` / +``ImmGetConversionStatus`` / ``ImmGetCompositionStringW``);所有解碼 / 等待邏輯都透過可注入接縫 +執行,故能在沒有 IME 的情況下完整測試。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import ( + ime_state, is_composing, wait_for_composition_commit, + ) + + # 讀取 CJK 欄位前,先確認 IME 已送出 + if wait_for_composition_commit(timeout_s=3): + value = read_field() + + is_composing() # 候選字仍在畫面上時為 True + ime_state() # {'open': True, 'composing': True, 'composition': 'あ', ...} + +測試時(或任何非 Windows 主機)可傳入 ``reader`` ——一個 +``() -> {open, conversion, composition}``: + +.. code-block:: python + + busy = lambda: {"open": True, "conversion": 0, "composition": "あ"} + is_composing(reader=busy) # True + ime_state(reader=busy)["composition"] # 'あ' + +執行器指令 +---------- + +``AC_ime_state``(→ 完整狀態)、``AC_is_composing``(→ ``{composing}``)、 +``AC_wait_for_composition_commit``(``timeout`` / ``interval`` → ``{committed}``) +與 ``AC_decode_conversion_mode``(``flags`` → 解碼後的模式)。皆以對應的唯讀 ``ac_*`` MCP 工具 +及 Script Builder 指令(位於 **Shell** 分類下)形式提供。 diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 10a47b62..5934b1b7 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -110,6 +110,11 @@ classify_lock_transitions, lock_session, plan_lock_session, wait_for_lock, wait_for_unlock, ) +# Read the live IME composition / conversion state for safe CJK entry +from je_auto_control.utils.ime_state import ( + decode_conversion_mode, ime_state, is_composing, + wait_for_composition_commit, +) # Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set from je_auto_control.utils.clipboard_rich_formats import ( build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv, @@ -1727,6 +1732,8 @@ def start_autocontrol_gui(*args, **kwargs): "is_muted", "set_mute", "mute", "unmute", "toggle_mute", "lock_session", "plan_lock_session", "wait_for_unlock", "wait_for_lock", "classify_lock_transitions", + "ime_state", "is_composing", "wait_for_composition_commit", + "decode_conversion_mode", "build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows", "set_clipboard_rtf", "get_clipboard_rtf", "set_clipboard_csv", "get_clipboard_csv", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index e8b56871..a3fefd42 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -4373,6 +4373,34 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None: ), description="Reduce lock-state samples to lock / unlock events.", )) + specs.append(CommandSpec( + "AC_ime_state", "Shell", "IME State", + fields=(), + description="Read the focused window's live IME composition state.", + )) + specs.append(CommandSpec( + "AC_is_composing", "Shell", "Is IME Composing", + fields=(), + description="True while the IME has an uncommitted composition.", + )) + specs.append(CommandSpec( + "AC_wait_for_composition_commit", "Shell", "Wait for IME Commit", + fields=( + FieldSpec("timeout", FieldType.FLOAT, optional=True, default=5.0, + placeholder="timeout seconds"), + FieldSpec("interval", FieldType.FLOAT, optional=True, default=0.1, + placeholder="poll interval seconds"), + ), + description="Block until the IME finishes composing or timeout.", + )) + specs.append(CommandSpec( + "AC_decode_conversion_mode", "Shell", "Decode IME Conversion Mode", + fields=( + FieldSpec("flags", FieldType.INT, default=0, + placeholder="IMM32 conversion bitmask"), + ), + description="Decode an IMM32 conversion bitmask into named flags.", + )) specs.append(CommandSpec( "AC_normalize_ext", "Shell", "Normalize Extension", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 0f751890..d061a072 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2711,6 +2711,33 @@ def _classify_lock_transitions(states: Any) -> Dict[str, Any]: return {"events": classify_lock_transitions(samples)} +def _ime_state() -> Dict[str, Any]: + """Adapter: the focused window's live IME composition / conversion state.""" + from je_auto_control.utils.ime_state import ime_state + return ime_state() + + +def _is_composing() -> Dict[str, Any]: + """Adapter: whether the IME has an uncommitted composition.""" + from je_auto_control.utils.ime_state import is_composing + return {"composing": bool(is_composing())} + + +def _wait_for_composition_commit(timeout: Any = 5.0, interval: Any = 0.1 + ) -> Dict[str, Any]: + """Adapter: block until the IME finishes composing or timeout.""" + from je_auto_control.utils.ime_state import wait_for_composition_commit + committed = wait_for_composition_commit(timeout_s=float(timeout), + interval_s=float(interval)) + return {"committed": bool(committed)} + + +def _decode_conversion_mode(flags: Any) -> Dict[str, Any]: + """Adapter: decode an IMM32 conversion bitmask into named flags (pure).""" + from je_auto_control.utils.ime_state import decode_conversion_mode + return decode_conversion_mode(int(flags)) + + def _normalize_ext(target: str) -> Dict[str, Any]: """Adapter: the lowercased extension of a path / bare ext (pure).""" from je_auto_control.utils.file_assoc import normalize_ext @@ -6729,6 +6756,10 @@ def __init__(self): "AC_plan_lock_session": _plan_lock_session, "AC_wait_for_unlock": _wait_for_unlock, "AC_classify_lock_transitions": _classify_lock_transitions, + "AC_ime_state": _ime_state, + "AC_is_composing": _is_composing, + "AC_wait_for_composition_commit": _wait_for_composition_commit, + "AC_decode_conversion_mode": _decode_conversion_mode, "AC_normalize_ext": _normalize_ext, "AC_file_association": _file_association, "AC_get_control_text": _get_control_text, diff --git a/je_auto_control/utils/ime_state/__init__.py b/je_auto_control/utils/ime_state/__init__.py new file mode 100644 index 00000000..5613d1c6 --- /dev/null +++ b/je_auto_control/utils/ime_state/__init__.py @@ -0,0 +1,10 @@ +"""Read the live IME composition / conversion state for safe CJK entry.""" +from je_auto_control.utils.ime_state.ime_state import ( + decode_conversion_mode, ime_state, is_composing, + wait_for_composition_commit, +) + +__all__ = [ + "ime_state", "is_composing", "wait_for_composition_commit", + "decode_conversion_mode", +] diff --git a/je_auto_control/utils/ime_state/ime_state.py b/je_auto_control/utils/ime_state/ime_state.py new file mode 100644 index 00000000..6123af5e --- /dev/null +++ b/je_auto_control/utils/ime_state/ime_state.py @@ -0,0 +1,134 @@ +"""Read the live IME (input method editor) state for safe CJK entry. + +Typing into a CJK / Japanese / Korean field is unsafe while an IME is *composing*: +the candidate text has not been committed yet, so reading the field back returns +half-entered glyphs and the next keystroke edits the composition instead of the +field. ``text_unicode`` (``VK_PACKET``) is blind to this. ``ime_state`` exposes +the live composition and conversion state so a flow can wait for the IME to +commit before it reads or acts. + +* :func:`ime_state` — ``{open, composing, composition, conversion}`` for the + focused window's IME, through an injectable ``reader``. +* :func:`is_composing` — ``True`` while the IME has an uncommitted composition. +* :func:`wait_for_composition_commit` — block until composition ends (or a + timeout), with injectable ``clock`` / ``sleep`` / ``reader``. +* :func:`decode_conversion_mode` — pure: the IMM32 ``IME_CMODE_*`` conversion + bitmask to ``{native, katakana, full_shape, roman, char_code}``. + +The default ``reader`` queries Windows IMM32 (``ImmGetContext`` / +``ImmGetOpenStatus`` / ``ImmGetConversionStatus`` / ``ImmGetCompositionStringW``) +read-only; all decoding / waiting logic runs through the injectable seam, so it +is fully testable without an IME. Imports no ``PySide6``. +""" +import sys +import time +from typing import Any, Callable, Dict, Optional + +# IMM32 conversion-mode (IME_CMODE_*) bit flags. +IME_CMODE_NATIVE = 0x0001 +IME_CMODE_KATAKANA = 0x0002 +IME_CMODE_FULLSHAPE = 0x0008 +IME_CMODE_ROMAN = 0x0010 +IME_CMODE_CHARCODE = 0x0020 + +# A reader returns the raw IME state: {open, conversion, composition}. +ImeReader = Callable[[], Dict[str, Any]] + + +def decode_conversion_mode(flags: int) -> Dict[str, bool]: + """Decode an IMM32 ``IME_CMODE_*`` bitmask into named booleans (pure).""" + value = int(flags) + return { + "native": bool(value & IME_CMODE_NATIVE), + "katakana": bool(value & IME_CMODE_KATAKANA), + "full_shape": bool(value & IME_CMODE_FULLSHAPE), + "roman": bool(value & IME_CMODE_ROMAN), + "char_code": bool(value & IME_CMODE_CHARCODE), + } + + +def _normalize(raw: Dict[str, Any]) -> Dict[str, Any]: + """Turn a raw reader result into the public IME-state dict (pure).""" + composition = str(raw.get("composition") or "") + flags = int(raw.get("conversion") or 0) + return { + "open": bool(raw.get("open")), + "composing": bool(composition), + "composition": composition, + "conversion": decode_conversion_mode(flags), + "conversion_flags": flags, + } + + +def ime_state(*, reader: Optional[ImeReader] = None) -> Dict[str, Any]: + """Return the focused window's IME state. + + ``{open, composing, composition, conversion, conversion_flags}``. Pass + ``reader`` (a ``() -> {open, conversion, composition}``) to supply the + reading in tests; the default queries Windows IMM32. + """ + source = reader if reader is not None else _default_reader + return _normalize(source()) + + +def is_composing(*, reader: Optional[ImeReader] = None) -> bool: + """Return ``True`` while the IME has an uncommitted composition.""" + return bool(ime_state(reader=reader)["composing"]) + + +def wait_for_composition_commit( + *, reader: Optional[ImeReader] = None, timeout_s: float = 5.0, + interval_s: float = 0.1, clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep) -> bool: + """Block until the IME is no longer composing; ``True``, or ``False`` on timeout. + + ``clock`` / ``sleep`` / ``reader`` are injectable for deterministic tests. + """ + deadline = clock() + float(timeout_s) + while True: + if not is_composing(reader=reader): + return True + if clock() >= deadline: + return False + sleep(float(interval_s)) + + +# IMM32 composition-string flag: read the in-progress composition (GCS_COMPSTR). +_GCS_COMPSTR = 0x0008 + + +def _read_composition(imm32: Any, himc: int) -> str: + """Read the in-progress composition string from an IME context.""" + import ctypes + byte_len = imm32.ImmGetCompositionStringW(himc, _GCS_COMPSTR, None, 0) + if byte_len <= 0: + return "" + buffer = ctypes.create_unicode_buffer(byte_len // 2) + imm32.ImmGetCompositionStringW(himc, _GCS_COMPSTR, buffer, byte_len) + return buffer.value + + +def _default_reader() -> Dict[str, Any]: + """Read the focused window's IME state from Windows IMM32 (read-only).""" + if not sys.platform.startswith("win"): + raise RuntimeError( + "IME state has no OS reader on this platform; pass reader=") + import ctypes + user32 = ctypes.windll.user32 + imm32 = ctypes.windll.imm32 + hwnd = user32.GetForegroundWindow() + himc = imm32.ImmGetContext(hwnd) + if not himc: + return {"open": False, "conversion": 0, "composition": ""} + try: + conversion = ctypes.c_uint(0) + sentence = ctypes.c_uint(0) + imm32.ImmGetConversionStatus( + himc, ctypes.byref(conversion), ctypes.byref(sentence)) + return { + "open": bool(imm32.ImmGetOpenStatus(himc)), + "conversion": int(conversion.value), + "composition": _read_composition(imm32, himc), + } + finally: + imm32.ImmReleaseContext(hwnd, himc) diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 83df5f93..4e0ce256 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -2249,6 +2249,44 @@ def process_and_shell_tools() -> List[MCPTool]: handler=h.classify_lock_transitions, annotations=READ_ONLY, ), + MCPTool( + name="ac_ime_state", + description=("Read the focused window's live IME state (Windows " + "IMM32). Returns {open, composing, composition, " + "conversion, conversion_flags}."), + input_schema=schema({}), + handler=h.ime_state, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_is_composing", + description=("Whether the IME has an uncommitted composition " + "(unsafe to type / read the field). Returns " + "{composing}."), + input_schema=schema({}), + handler=h.is_composing, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_wait_for_composition_commit", + description=("Block until the IME finishes composing (or 'timeout' " + "seconds), polling every 'interval'. Returns " + "{committed}."), + input_schema=schema({"timeout": {"type": "number"}, + "interval": {"type": "number"}}), + handler=h.wait_for_composition_commit, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_decode_conversion_mode", + description=("Decode an IMM32 IME_CMODE_* conversion bitmask " + "('flags') into {native, katakana, full_shape, roman, " + "char_code} (pure)."), + input_schema=schema({"flags": {"type": "integer"}}, + required=["flags"]), + handler=h.decode_conversion_mode, + annotations=READ_ONLY, + ), ] diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 613adf2d..62b0f307 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -644,6 +644,30 @@ def classify_lock_transitions(states): return _classify_lock_transitions(states) +def ime_state(): + from je_auto_control.utils.executor.action_executor import _ime_state + return _ime_state() + + +def is_composing(): + from je_auto_control.utils.executor.action_executor import _is_composing + return _is_composing() + + +def wait_for_composition_commit(timeout=5.0, interval=0.1): + from je_auto_control.utils.executor.action_executor import ( + _wait_for_composition_commit, + ) + return _wait_for_composition_commit(timeout, interval) + + +def decode_conversion_mode(flags): + from je_auto_control.utils.executor.action_executor import ( + _decode_conversion_mode, + ) + return _decode_conversion_mode(flags) + + def normalize_ext(target): from je_auto_control.utils.executor.action_executor import _normalize_ext return _normalize_ext(target) diff --git a/test/unit_test/headless/test_ime_state_batch.py b/test/unit_test/headless/test_ime_state_batch.py new file mode 100644 index 00000000..15974f6b --- /dev/null +++ b/test/unit_test/headless/test_ime_state_batch.py @@ -0,0 +1,142 @@ +"""Headless tests for ime_state (injected reader / clock).""" +import sys + +import je_auto_control as ac +from je_auto_control.utils.ime_state import ( + decode_conversion_mode, ime_state, is_composing, + wait_for_composition_commit, +) +from je_auto_control.utils.ime_state.ime_state import ( + IME_CMODE_FULLSHAPE, IME_CMODE_NATIVE, IME_CMODE_ROMAN, +) + + +# --- pure conversion-mode decode ------------------------------------------ + +def test_decode_conversion_mode_native_roman(): + flags = IME_CMODE_NATIVE | IME_CMODE_ROMAN + decoded = decode_conversion_mode(flags) + assert decoded["native"] is True + assert decoded["roman"] is True + assert decoded["full_shape"] is False + assert decoded["katakana"] is False + + +def test_decode_conversion_mode_zero_all_false(): + decoded = decode_conversion_mode(0) + assert decoded == {"native": False, "katakana": False, "full_shape": False, + "roman": False, "char_code": False} + + +# --- state via injected reader -------------------------------------------- + +def test_ime_state_composing(): + state = ime_state(reader=lambda: {"open": True, + "conversion": IME_CMODE_NATIVE, + "composition": "あ"}) + assert state["open"] is True + assert state["composing"] is True + assert state["composition"] == "あ" + assert state["conversion"]["native"] is True + assert state["conversion_flags"] == IME_CMODE_NATIVE + + +def test_ime_state_idle_not_composing(): + state = ime_state( + reader=lambda: {"open": False, "conversion": 0, "composition": ""}) + assert state["composing"] is False + assert state["composition"] == "" + + +def test_ime_state_tolerates_missing_keys(): + state = ime_state(reader=dict) # empty dict + assert state["open"] is False + assert state["composing"] is False + assert state["conversion_flags"] == 0 + + +def test_is_composing_reflects_reader(): + assert is_composing( + reader=lambda: {"composition": "한", "conversion": 0, + "open": True}) is True + assert is_composing( + reader=lambda: {"composition": "", "conversion": 0, + "open": True}) is False + + +# --- wait for commit ------------------------------------------------------ + +def _reader_sequence(compositions): + state = {"i": 0} + + def reader(): + i = min(state["i"], len(compositions) - 1) + state["i"] += 1 + return {"open": True, "conversion": 0, "composition": compositions[i]} + + return reader + + +def test_wait_for_composition_commit_returns_true(): + reader = _reader_sequence(["typ", "typi", ""]) # commits on 3rd read + clock = iter([0.0, 1.0, 2.0, 3.0, 4.0]) + ok = wait_for_composition_commit(reader=reader, timeout_s=10.0, + interval_s=1.0, + clock=lambda: next(clock), + sleep=lambda _s: None) + assert ok is True + + +def test_wait_for_composition_commit_times_out(): + reader = _reader_sequence(["forever"]) # never commits + times = iter([0.0, 0.0, 5.0, 10.0]) + ok = wait_for_composition_commit(reader=reader, timeout_s=5.0, + interval_s=1.0, + clock=lambda: next(times), + sleep=lambda _s: None) + assert ok is False + + +# --- wiring --------------------------------------------------------------- + +def test_executor_pure_decode_path(): + from je_auto_control.utils.executor.action_executor import ( + _decode_conversion_mode, + ) + assert _decode_conversion_mode(IME_CMODE_FULLSHAPE)["full_shape"] is True + + +def test_default_reader_raises_off_windows(): + from je_auto_control.utils.ime_state.ime_state import _default_reader + if not sys.platform.startswith("win"): + try: + _default_reader() + raised = False + except RuntimeError: + raised = True + assert raised is True + + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert {"AC_ime_state", "AC_is_composing", + "AC_wait_for_composition_commit", + "AC_decode_conversion_mode"} <= known + from je_auto_control.utils.mcp_server.tools import ( + build_default_tool_registry, + ) + names = {t.name for t in build_default_tool_registry()} + assert {"ac_ime_state", "ac_is_composing", + "ac_wait_for_composition_commit", + "ac_decode_conversion_mode"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_ime_state", "AC_is_composing", + "AC_wait_for_composition_commit", + "AC_decode_conversion_mode"} <= specs + + +def test_facade_exports(): + for name in ("ime_state", "is_composing", "wait_for_composition_commit", + "decode_conversion_mode"): + assert hasattr(ac, name) and name in ac.__all__