#!/usr/bin/python3
#
# ibus-typing-booster - A completion input method for IBus
#
# Copyright (c) 2016 Mike FABIAN <mfabian@redhat.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>

'''
This file implements the test cases for the unit tests of ibus-typing-booster emulating ibus-m17n
'''


from typing import Any
from typing import Optional
from typing import Dict
from typing import List
import os
import glob
import sys
import re
import logging
import tempfile
import shutil
import unittest
import importlib
from unittest import mock

# pylint: disable=wrong-import-position
from gi import require_version
require_version('IBus', '1.0')
from gi.repository import IBus  # ty: ignore[unresolved-import]
require_version('GLib', '2.0')
from gi.repository import GLib # type: ignore
# pylint: enable=wrong-import-position

LOGGER = logging.getLogger('ibus-typing-booster')

# Get more verbose output in the test log:
os.environ['IBUS_TYPING_BOOSTER_DEBUG_LEVEL'] = '255'

# pylint: disable=import-error
from mock_engine import MockEngine
from mock_engine import MockLookupTable
from mock_engine import MockProperty
from mock_engine import MockPropList
from mock_engine import mock_glib_idle_add
from mock_engine import mock_glib_timeout_add
# pylint: enable=import-error

import testutils # pylint: disable=import-error

# Avoid failing test cases because of stuff in the users M17NDIR ('~/.m17n.d'):
# The environments needs to be changed *before* `import m17n_translit`
# since libm17n reads it at load time!
_ORIG_M17NDIR = os.environ.pop('M17NDIR', None)
_TEMPDIR = tempfile.TemporaryDirectory() # pylint: disable=consider-using-with
os.environ['M17NDIR'] = _TEMPDIR.name

# pylint: disable=wrong-import-order
sys.path.insert(0, "../engine")
# pylint: disable=import-error
from itb_gtk import Gdk
import hunspell_table
import tabsqlitedb
import itb_util_core
import m17n_translit
# pylint: enable=import-error
sys.path.pop(0)
# pylint: enable=wrong-import-order

# pylint: disable=missing-function-docstring
# pylint: disable=protected-access
# pylint: disable=line-too-long
# pylint: disable=invalid-name

@unittest.skipIf(Gdk.Display.open('') is None, 'Display cannot be opened.')
class ItbM17nEmuTestCase(unittest.TestCase):
    '''
    Test cases for ibus-typing-booster emulating ibus-m17n
    '''
    engine_patcher = mock.patch.object(
        IBus, 'Engine', new=MockEngine)
    lookup_table_patcher = mock.patch.object(
        IBus, 'LookupTable', new=MockLookupTable)
    property_patcher = mock.patch.object(
        IBus, 'Property', new=MockProperty)
    prop_list_patcher = mock.patch.object(
        IBus, 'PropList', new=MockPropList)
    glib_idle_add_patcher = mock.patch.object(
        GLib, 'idle_add', new=mock_glib_idle_add)
    glib_timeout_add_patcher = mock.patch.object(
        GLib, 'timeout_add', new=mock_glib_timeout_add)
    ibus_engine = IBus.Engine
    ibus_lookup_table = IBus.LookupTable
    ibus_property = IBus.Property
    ibus_prop_list = IBus.PropList
    glib_idle_add = GLib.idle_add
    glib_timeout_add = GLib.timeout_add

    _tempdir: Optional[tempfile.TemporaryDirectory] = None # type: ignore[type-arg]
    # Python 3.12+: _tempdir: Optional[tempfile.TemporaryDirectory[str]] = None
    _orig_m17ndir: Optional[str] = None
    _m17ndir: Optional[str] = None
    _m17n_config_file: Optional[str] = None
    _orig_xcomposefile: Optional[str] = None

    orig_disable_in_terminals = False
    orig_ascii_digits = False
    orig_word_prediction_mode = True
    orig_emoji_prediction_mode = False
    orig_unicode_data_all_mode = False
    orig_off_the_record_mode = False
    orig_record_mode = 0
    orig_emoji_trigger_characters = '_'
    orig_emoji_style = 'emoji'
    orig_auto_commit_characters = ''
    orig_tab_enable = False
    orig_inline_completion = 0
    orig_auto_capitalize = False
    orig_auto_select_candidate = 0
    orig_remember_last_used_preedit_ime = False
    orig_page_size = 6
    orig_lookup_table_orientation = 1
    orig_min_char_complete = 1
    orig_show_number_of_candidates = False
    orig_show_status_info_in_auxiliary_text = False
    orig_add_space_on_commit = True
    orig_current_imes: List[str] = []
    orig_dictionary_names: List[str] = []
    orig_avoid_forward_key_event = False
    orig_keybindings: Dict[str, List[str]] = {}
    orig_use_ibus_keymap = False
    orig_ibus_keymap = 'in'

    @classmethod
    def setUpClass(cls) -> None:
        cls._tempdir = _TEMPDIR
        cls._orig_m17ndir = _ORIG_M17NDIR
        cls._m17ndir = cls._tempdir.name
        cls._m17n_config_file = os.path.join(cls._m17ndir, 'config.mic')
        # Copy test input methods into M17NDIR
        for mim_path in glob.glob(os.path.join(os.path.dirname(__file__), '*.mim')):
            shutil.copy(mim_path, cls._m17ndir)
        # Avoid failing test cases because of stuff in the users '~/.XCompose' file.
        cls._orig_xcomposefile = os.environ.pop('XCOMPOSEFILE', None)
        os.environ['XCOMPOSEFILE'] = os.path.join(cls._tempdir.name, 'XCompose')
        shutil.copy('XCompose', cls._tempdir.name)
        # List contents of Temporary directory used for m17n files and XCompose:
        m17n_dir_files = [os.path.join(cls._m17ndir, name)
                          for name in os.listdir(cls._m17ndir)]
        for path in m17n_dir_files:
            LOGGER.info('M17NDIR content: %r', path)

    @classmethod
    def tearDownClass(cls) -> None:
        if cls._orig_m17ndir is not None:
            os.environ['M17NDIR'] = cls._orig_m17ndir
        else:
            _value = os.environ.pop('M17NDIR', None)
        if cls._orig_xcomposefile is not None:
            os.environ['XCOMPOSEFILE'] = cls._orig_xcomposefile
        else:
            _value = os.environ.pop('XCOMPOSEFILE', None)
        if cls._tempdir is not None:
            cls._tempdir.cleanup()

    @property
    def m17n_config_file(self) -> str:
        assert self.__class__._m17n_config_file is not None # pylint: disable=protected-access
        return self.__class__._m17n_config_file # pylint: disable=protected-access

    def setUp(self) -> None:
        # Patch the IBus stuff with the mock classes:
        self.__class__.engine_patcher.start()
        self.__class__.lookup_table_patcher.start()
        self.__class__.property_patcher.start()
        self.__class__.prop_list_patcher.start()
        self.__class__.glib_idle_add_patcher.start()
        self.__class__.glib_timeout_add_patcher.start()
        assert IBus.Engine is not self.__class__.ibus_engine
        assert IBus.Engine is MockEngine # type: ignore[comparison-overlap]
        assert IBus.LookupTable is not self.__class__.ibus_lookup_table
        assert IBus.LookupTable is MockLookupTable # type: ignore[comparison-overlap]
        assert IBus.Property is not self.__class__.ibus_property
        assert IBus.Property is MockProperty # type: ignore[comparison-overlap]
        assert IBus.PropList is not self.__class__.ibus_prop_list
        assert IBus.PropList is MockPropList # type: ignore[comparison-overlap]
        assert GLib.idle_add is not self.__class__.glib_idle_add
        assert GLib.idle_add is mock_glib_idle_add
        assert GLib.timeout_add is not self.__class__.glib_timeout_add
        assert GLib.timeout_add is mock_glib_timeout_add
        # Reload the hunspell_table module so that the patches
        # are applied to TypingBoosterEngine:
        sys.path.insert(0, "../engine")
        importlib.reload(hunspell_table)
        sys.path.pop(0)
        self.bus = IBus.Bus()
        self.database: Optional[tabsqlitedb.TabSqliteDb] = None
        self.engine: Optional[hunspell_table.TypingBoosterEngine] = None

    def tearDown(self) -> None:
        if self.engine is not None:
            self.restore_original_settings()
        self.engine = None
        if self.database is not None:
            self.database.database.close()
        self.database = None
        # Remove the patches from the IBus stuff:
        self.__class__.engine_patcher.stop()
        self.__class__.lookup_table_patcher.stop()
        self.__class__.property_patcher.stop()
        self.__class__.prop_list_patcher.stop()
        self.__class__.glib_idle_add_patcher.stop()
        self.__class__.glib_timeout_add_patcher.stop()
        assert IBus.Engine is self.__class__.ibus_engine
        assert IBus.Engine is not MockEngine # type: ignore[comparison-overlap]
        assert IBus.LookupTable is self.__class__.ibus_lookup_table
        assert IBus.LookupTable is not MockLookupTable # type: ignore[comparison-overlap]
        assert IBus.Property is self.__class__.ibus_property
        assert IBus.Property is not MockProperty # type: ignore[comparison-overlap]
        assert IBus.PropList is self.__class__.ibus_prop_list
        assert IBus.PropList is not MockPropList # type: ignore[comparison-overlap]
        assert GLib.idle_add is self.__class__.glib_idle_add
        assert GLib.idle_add is not mock_glib_idle_add
        assert GLib.timeout_add is self.__class__.glib_timeout_add
        assert GLib.timeout_add is not mock_glib_timeout_add

    def backup_original_settings(self) -> None:
        if self.engine is None:
            return
        self.orig_disable_in_terminals = (
            self.engine.get_disable_in_terminals())
        self.orig_ascii_digits = (
            self.engine.get_ascii_digits())
        self.orig_word_prediction_mode = (
            self.engine.get_word_prediction_mode())
        self.orig_emoji_prediction_mode = (
            self.engine.get_emoji_prediction_mode())
        self.orig_unicode_data_all_mode = (
            self.engine.get_unicode_data_all_mode())
        self.orig_off_the_record_mode = (
            self.engine.get_off_the_record_mode())
        self.orig_record_mode = (
            self.engine.get_record_mode())
        self.orig_emoji_trigger_characters = (
            self.engine.get_emoji_trigger_characters())
        self.orig_emoji_style = (
            self.engine.get_emoji_style())
        self.orig_auto_commit_characters = (
            self.engine.get_auto_commit_characters())
        self.orig_tab_enable = (
            self.engine.get_tab_enable())
        self.orig_inline_completion = (
            self.engine.get_inline_completion())
        self.orig_auto_capitalize = (
            self.engine.get_auto_capitalize())
        self.orig_auto_select_candidate = (
            self.engine.get_auto_select_candidate())
        self.orig_remember_last_used_preedit_ime = (
            self.engine.get_remember_last_used_preedit_ime())
        self.orig_page_size = (
            self.engine.get_page_size())
        self.orig_lookup_table_orientation = (
            self.engine.get_lookup_table_orientation())
        self.orig_min_char_complete = (
            self.engine.get_min_char_complete())
        self.orig_show_number_of_candidates = (
            self.engine.get_show_number_of_candidates())
        self.orig_show_status_info_in_auxiliary_text = (
            self.engine.get_show_status_info_in_auxiliary_text())
        self.orig_add_space_on_commit = (
            self.engine.get_add_space_on_commit())
        self.orig_current_imes = (
            self.engine.get_current_imes())
        self.orig_dictionary_names = (
            self.engine.get_dictionary_names())
        self.orig_avoid_forward_key_event = (
            self.engine.get_avoid_forward_key_event())
        self.orig_keybindings = (
            self.engine.get_keybindings())
        self.orig_use_ibus_keymap = (
            self.engine.get_use_ibus_keymap())
        self.orig_ibus_keymap = (
            self.engine.get_ibus_keymap())

    def restore_original_settings(self) -> None:
        if self.engine is None:
            return
        self.engine.set_disable_in_terminals(
            self.orig_disable_in_terminals,
            update_gsettings=False)
        self.engine.set_ascii_digits(
            self.orig_ascii_digits,
            update_gsettings=False)
        self.engine.set_word_prediction_mode(
            self.orig_word_prediction_mode,
            update_gsettings=False)
        self.engine.set_emoji_prediction_mode(
            self.orig_emoji_prediction_mode,
            update_gsettings=False)
        self.engine.set_unicode_data_all_mode(
            self.orig_unicode_data_all_mode,
            update_gsettings=False)
        self.engine.set_off_the_record_mode(
            self.orig_off_the_record_mode,
            update_gsettings=False)
        self.engine.set_record_mode(
            self.orig_record_mode,
            update_gsettings=False)
        self.engine.set_emoji_trigger_characters(
            self.orig_emoji_trigger_characters,
            update_gsettings=False)
        self.engine.set_emoji_style(
            self.orig_emoji_style,
            update_gsettings=False)
        self.engine.set_auto_commit_characters(
            self.orig_auto_commit_characters,
            update_gsettings=False)
        self.engine.set_tab_enable(
            self.orig_tab_enable,
            update_gsettings=False)
        self.engine.set_inline_completion(
            self.orig_inline_completion,
            update_gsettings=False)
        self.engine.set_auto_capitalize(
            self.orig_auto_capitalize,
            update_gsettings=False)
        self.engine.set_auto_select_candidate(
            self.orig_auto_select_candidate,
            update_gsettings=False)
        self.engine.set_remember_last_used_preedit_ime(
            self.orig_remember_last_used_preedit_ime,
            update_gsettings=False)
        self.engine.set_page_size(
            self.orig_page_size,
            update_gsettings=False)
        self.engine.set_lookup_table_orientation(
            self.orig_lookup_table_orientation,
            update_gsettings=False)
        self.engine.set_min_char_complete(
            self.orig_min_char_complete,
            update_gsettings=False)
        self.engine.set_show_number_of_candidates(
            self.orig_show_number_of_candidates,
            update_gsettings=False)
        self.engine.set_show_status_info_in_auxiliary_text(
            self.orig_show_status_info_in_auxiliary_text,
            update_gsettings=False)
        self.engine.set_add_space_on_commit(
            self.orig_add_space_on_commit,
            update_gsettings=False)
        self.engine.set_current_imes(
            self.orig_current_imes,
            update_gsettings=False)
        self.engine.set_dictionary_names(
            self.orig_dictionary_names,
            update_gsettings=False)
        self.engine.set_avoid_forward_key_event(
            self.orig_avoid_forward_key_event,
            update_gsettings=False)
        self.engine.set_keybindings(
            self.orig_keybindings,
            update_gsettings=False)
        self.engine.set_use_ibus_keymap(
            self.orig_use_ibus_keymap,
            update_gsettings=False)
        self.engine.set_ibus_keymap(
            self.orig_ibus_keymap,
            update_gsettings=False)

    def set_default_settings(self) -> None:
        if self.engine is None:
            return
        for key in self.engine._settings_dict:
            if 'set_function' in self.engine._settings_dict[key]:
                self.engine._settings_dict[key]['set_function'](
                    self.engine._settings_dict[key]['default'],
                    update_gsettings=False)
        return

    def init_engine(self, engine_name: str = 'typing-booster') -> None:
        self.database = tabsqlitedb.TabSqliteDb(user_db_file=':memory:')
        engine_path = ('/com/redhat/IBus/engines/typing_booster/'
                       f"{re.sub(r'[^a-zA-Z0-9_/]', '_', engine_name)}"
                       '/engine/')
        if engine_name != 'typing-booster':
            match = itb_util_core.M17N_ENGINE_NAME_PATTERN.search(engine_name)
            if not match:
                raise ValueError('Invalid engine name.')
            m17n_ime_lang = match.group('lang')
            m17n_ime_name = match.group('name')
            self.get_transliterator_or_skip(f'{m17n_ime_lang}-{m17n_ime_name}')
        engine_id = 0
        self.engine = hunspell_table.TypingBoosterEngine(
            self.bus,
            engine_path + str(engine_id),
            self.database,
            engine_name=engine_name,
            unit_test=True)
        if self.engine is None:
            self.skipTest('Failed to init engine.')
        self.backup_original_settings()
        self.set_default_settings()

    def get_transliterator_or_skip(self, ime: str) -> Optional[Any]:
        try:
            sys.stderr.write(f'ime "{ime}" ... ')
            trans = m17n_translit.Transliterator(ime)
        except ValueError as error:
            trans = None
            self.skipTest(error)
        except Exception as error: # pylint: disable=broad-except
            sys.stderr.write('Unexpected exception!')
            trans = None
            self.skipTest(error)
        return trans

    def test_dummy(self) -> None:
        self.init_engine()
        self.assertEqual(True, True)

    @unittest.expectedFailure
    def test_expected_failure(self) -> None:
        self.init_engine()
        self.assertEqual(False, True)

    @unittest.skipUnless(
        itb_util_core.get_hunspell_dictionary_wordlist('en_US')[0],
        'Skipping because no en_US hunspell dictionary could be found.')
    @unittest.skipUnless(
        testutils.get_hunspell_dictionary_length('en_US') >= 10000,
        'Skipping because en_US dictionary is suspiciously small, '
        'see: https://bugzilla.redhat.com/show_bug.cgi?id=2218460')
    def test_typing_booster_normal(self) -> None:
        self.init_engine(engine_name='typing-booster')
        if self.engine is None:
            self.skipTest('Failed to init engine.')
        self.engine.set_current_imes(
            ['t-latn-post', 'NoIME'], update_gsettings=False)
        self.engine.set_dictionary_names(
            ['en_US'], update_gsettings=False)
        self.engine.do_process_key_event(IBus.KEY_a, 0, 0)
        # Normal typing booster should have some candidates now:
        self.assertNotEqual([], self.engine._candidates)
        self.engine.do_process_key_event(IBus.KEY_quotedbl, 0, 0)
        self.engine.do_process_key_event(IBus.KEY_space, 0, 0)
        self.assertEqual(self.engine.mock_committed_text, 'ä ')

    def test_tb_t_latn_post(self) -> None:
        self.init_engine(engine_name='tb:t:latn-post')
        if self.engine is None:
            self.skipTest('Failed to init engine.')
        self.assertEqual(self.engine._engine_name, 'tb:t:latn-post')
        self.assertEqual(self.engine._dictionary_names, ['None'])
        self.assertEqual(self.engine._current_imes, ['t-latn-post'])
        self.assertEqual(self.engine._tab_enable, False) # normal default
        self.assertEqual(self.engine._word_predictions, False)
        self.assertEqual(self.engine._emoji_predictions, False)
        self.assertEqual(self.engine._off_the_record, True)
        self.assertEqual(self.engine._preedit_underline, 0)
        self.assertEqual(self.engine._keybindings['toggle_input_mode_on_off'], [])
        self.assertEqual(self.engine._keybindings['enable_lookup'], ['Tab', 'ISO_Left_Tab']) # normal default
        self.assertEqual(self.engine._keybindings['commit_and_forward_key'],
                         ['Left', 'Control+Left', 'Mod1+Left'])
        self.engine.do_process_key_event(IBus.KEY_a, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, 'a')
        self.assertEqual(self.engine.mock_committed_text, '')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 0)
        # Restricted typing booster should have *no* candidates now:
        self.assertEqual([], self.engine._candidates)
        self.engine.do_process_key_event(IBus.KEY_quotedbl, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, 'ä')
        self.assertEqual(self.engine.mock_committed_text, '')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 0)
        self.engine.do_process_key_event(IBus.KEY_space, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, 'ä ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 2)
        # Type more and commit with 'Left':
        self.engine.do_process_key_event(IBus.KEY_n, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, 'n')
        self.assertEqual(self.engine.mock_committed_text, 'ä ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 2)
        self.engine.do_process_key_event(IBus.KEY_asciitilde, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, 'ñ')
        self.assertEqual(self.engine.mock_committed_text, 'ä ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 2)
        self.engine.do_process_key_event(IBus.KEY_Left, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, 'ä ñ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 2)
        # Type more and commit with space again:
        self.engine.do_process_key_event(IBus.KEY_a, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, 'a')
        self.assertEqual(self.engine.mock_committed_text, 'ä ñ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 2)
        self.engine.do_process_key_event(IBus.KEY_slash, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, 'å')
        self.assertEqual(self.engine.mock_committed_text, 'ä ñ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 2)
        self.engine.do_process_key_event(IBus.KEY_space, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, 'ä å ñ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 4)

    def test_tb_zh_py(self) -> None:
        self.init_engine(engine_name='tb:zh:py')
        if self.engine is None:
            self.skipTest('Failed to init engine.')
        self.assertEqual(self.engine._engine_name, 'tb:zh:py')
        self.assertEqual(self.engine._dictionary_names, ['None'])
        self.assertEqual(self.engine._current_imes, ['zh-py'])
        self.assertEqual(self.engine._tab_enable, False) # normal default
        self.assertEqual(self.engine._word_predictions, False)
        self.assertEqual(self.engine._emoji_predictions, False)
        self.assertEqual(self.engine._off_the_record, True)
        self.assertEqual(self.engine._preedit_underline, 0)
        self.assertEqual(self.engine._keybindings['toggle_input_mode_on_off'], [])
        self.assertEqual(self.engine._keybindings['enable_lookup'],
                         ['Tab', 'ISO_Left_Tab']) # normal default
        self.assertEqual(self.engine._keybindings['commit_and_forward_key'],
                         ['Left', 'Control+Left', 'Mod1+Left'])
        self.engine.do_process_key_event(IBus.KEY_a, 0, 0)
        self.engine.do_process_key_event(IBus.KEY_i, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '爱')
        self.assertEqual(self.engine.mock_committed_text, '')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 0)
        # There should be candidates now
        self.assertNotEqual([], self.engine._candidates)
        # One `>` commits:
        self.engine.do_process_key_event(IBus.KEY_greater, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '>')
        self.assertEqual(self.engine.mock_committed_text, '爱')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 1)
        # There should be no candidates now
        self.assertEqual([], self.engine._candidates)
        # The single `>` did not switch to fullwidth mode and
        # it gets early committed in _handle_m17n_candidates()
        # when the following `a` is typed.
        self.engine.do_process_key_event(IBus.KEY_a, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '啊')
        self.assertEqual(self.engine.mock_committed_text, '爱>')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 2)
        # There should be candidates now
        self.assertNotEqual([], self.engine._candidates)
        # One `>` commits:
        self.engine.do_process_key_event(IBus.KEY_greater, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '>')
        self.assertEqual(self.engine.mock_committed_text, '爱>啊')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 3)
        # There should be no candidates now
        self.assertEqual([], self.engine._candidates)
        # The second `>` switches to fullwidth-mode (`>>` should
        # switch to fullwidth-mode) and because this just did the mode switch
        # the `>>` should disappear input and not show up in the preedit:
        self.engine.do_process_key_event(IBus.KEY_greater, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '爱>啊')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 3)
        # There should be no candidates now
        self.assertEqual([], self.engine._candidates)
        # Fullwidth a:
        self.engine.do_process_key_event(IBus.KEY_a, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '爱>啊ａ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 4)
        # There should be no candidates now
        self.assertEqual([], self.engine._candidates)
        # Fullwidth space
        self.engine.do_process_key_event(IBus.KEY_space, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '爱>啊ａ\u3000')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 5)
        # There should be no candidates now
        self.assertEqual([], self.engine._candidates)
        # Fullwidth b:
        self.engine.do_process_key_event(IBus.KEY_b, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '爱>啊ａ\u3000ｂ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 6)
        # There should be no candidates now
        self.assertEqual([], self.engine._candidates)
        # One `<` does not exit fullwidth mode, it is shown in fullwidth
        # in the preedit:
        self.engine.do_process_key_event(IBus.KEY_less, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '＜')
        self.assertEqual(self.engine.mock_committed_text, '爱>啊ａ\u3000ｂ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 6)
        # There should be no candidates now
        self.assertEqual([], self.engine._candidates)
        # When the following `c` is typed, '＜ｃ' gets
        # early committed in _process_key_event()
        self.engine.do_process_key_event(IBus.KEY_c, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '爱>啊ａ\u3000ｂ＜ｃ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 8)
        # Exit fullwidth mode with `<<`:
        self.engine.do_process_key_event(IBus.KEY_less, 0, 0)
        # The first `<` did not switch yet, so it is still shown in
        # fullwidth in the preedit:
        self.assertEqual(self.engine.mock_preedit_text, '＜')
        self.assertEqual(self.engine.mock_committed_text, '爱>啊ａ\u3000ｂ＜ｃ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 8)
        self.engine.do_process_key_event(IBus.KEY_less, 0, 0)
        # The second `<` does the switch and the preedit becomes empty
        # because the `<<` did just the mode switch:
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '爱>啊ａ\u3000ｂ＜ｃ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 8)
        # There should be no candidates now
        self.assertEqual([], self.engine._candidates)
        # a produces a Chinese character again:
        self.engine.do_process_key_event(IBus.KEY_a, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '啊')
        self.assertEqual(self.engine.mock_committed_text, '爱>啊ａ\u3000ｂ＜ｃ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 8)
        # There should be candidates now
        self.assertNotEqual([], self.engine._candidates)
        # `Z` commits:
        self.engine.do_process_key_event(IBus.KEY_Z, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '爱>啊ａ\u3000ｂ＜ｃ啊')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 9)
        # There should be no candidates now
        self.assertEqual([], self.engine._candidates)
        # `Za` should switch to single-fullwidth-mode (i.e. fullwidth-mode
        # just for the next character) That does not work at the moment
        # because each transliteration in m17n_translit.py is finallized
        # by appending Mnil, that counts as the next character. So the mode
        # switch by `Z` does not survive the commit. That is hard to fix
        # but maybe not so important.
        # Currently, the `a` produces a Chinese character again:
        self.engine.do_process_key_event(IBus.KEY_a, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '啊')
        self.assertEqual(self.engine.mock_committed_text, '爱>啊ａ\u3000ｂ＜ｃ啊')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 9)
        # There should be candidates now
        self.assertNotEqual([], self.engine._candidates)
        # Commit with a space:
        self.engine.do_process_key_event(IBus.KEY_space, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '爱>啊ａ\u3000ｂ＜ｃ啊啊')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 10)
        # There should be no candidates now
        self.assertEqual([], self.engine._candidates)
        # `Za` when there is no pending input commits a fullwidth a immediately:
        self.engine.do_process_key_event(IBus.KEY_Z, 0, 0)
        self.engine.do_process_key_event(IBus.KEY_a, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '爱>啊ａ\u3000ｂ＜ｃ啊啊ａ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 11)
        # There should be no candidates now
        self.assertEqual([], self.engine._candidates)
        # `Z ` when there is no pending input commits a fullwidth space immediately
        self.engine.do_process_key_event(IBus.KEY_Z, 0, 0)
        self.engine.do_process_key_event(IBus.KEY_space, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '爱>啊ａ\u3000ｂ＜ｃ啊啊ａ\u3000')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 12)

    def test_tb_ko_romaja(self) -> None:
        self.init_engine(engine_name='tb:ko:romaja')
        if self.engine is None:
            self.skipTest('Failed to init engine.')
        self.assertEqual(self.engine._engine_name, 'tb:ko:romaja')
        self.assertEqual(self.engine._dictionary_names, ['None'])
        self.assertEqual(self.engine._current_imes, ['ko-romaja'])
        self.assertEqual(self.engine._tab_enable, False) # normal default
        self.assertEqual(self.engine._word_predictions, False)
        self.assertEqual(self.engine._emoji_predictions, False)
        self.assertEqual(self.engine._off_the_record, True)
        self.assertEqual(self.engine._preedit_underline, 0)
        self.assertEqual(self.engine._keybindings['toggle_input_mode_on_off'], [])
        self.assertEqual(self.engine._keybindings['enable_lookup'],
                         ['Tab', 'ISO_Left_Tab']) # normal default
        self.assertEqual(self.engine._keybindings['commit_and_forward_key'],
                         ['Left', 'Control+Left', 'Mod1+Left'])
        self.engine.do_process_key_event(IBus.KEY_h, 0, 0)
        self.engine.do_process_key_event(IBus.KEY_a, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '하')
        self.assertEqual(self.engine.mock_committed_text, '')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 0)
        # `I` commits and switches from normal syllable mode to
        # isolated jamo mode:
        self.engine.do_process_key_event(IBus.KEY_I, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '하')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 1)
        self.engine.do_process_key_event(IBus.KEY_h, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 2)
        self.engine.do_process_key_event(IBus.KEY_a, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, 'ㅏ')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 2)
        self.engine.do_process_key_event(IBus.KEY_space, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 4)
        self.engine.do_process_key_event(IBus.KEY_h, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 5)
        self.engine.do_process_key_event(IBus.KEY_a, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, 'ㅏ')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 5)
        self.engine.do_process_key_event(IBus.KEY_i, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎㅐ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 6)
        self.engine.do_process_key_event(IBus.KEY_i, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, 'ㅣ')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎㅐ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 6)
        # `I` commits and switches from back to normal syllable mode from
        # isolated jamo mode:
        self.engine.do_process_key_event(IBus.KEY_I, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎㅐㅣ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 7)
        self.engine.do_process_key_event(IBus.KEY_h, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, 'ㅎ')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎㅐㅣ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 7)
        self.engine.do_process_key_event(IBus.KEY_a, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '하')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎㅐㅣ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 7)
        self.engine.do_process_key_event(IBus.KEY_i, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '해')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎㅐㅣ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 7)
        self.engine.do_process_key_event(IBus.KEY_n, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '핸')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎㅐㅣ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 7)
        self.engine.do_process_key_event(IBus.KEY_h, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎㅐㅣ핺')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 8)
        # `Z Za` inserts a fullwidth space and a fullwidth a:
        self.engine.do_process_key_event(IBus.KEY_Z, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎㅐㅣ핺')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 8)
        self.engine.do_process_key_event(IBus.KEY_space, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎㅐㅣ핺\u3000')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 9)
        self.engine.do_process_key_event(IBus.KEY_Z, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎㅐㅣ핺\u3000')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 9)
        self.engine.do_process_key_event(IBus.KEY_a, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎㅐㅣ핺\u3000ａ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 10)
        # `>>` switches to fullwidth mode:
        self.engine.do_process_key_event(IBus.KEY_greater, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '>')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎㅐㅣ핺\u3000ａ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 10)
        self.engine.do_process_key_event(IBus.KEY_greater, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎㅐㅣ핺\u3000ａ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 10)
        self.engine.do_process_key_event(IBus.KEY_a, 0, 0)
        self.engine.do_process_key_event(IBus.KEY_space, 0, 0)
        self.engine.do_process_key_event(IBus.KEY_b, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎㅐㅣ핺\u3000ａａ　ｂ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 13)
        # `<<` switches back to normal mode:
        self.engine.do_process_key_event(IBus.KEY_less, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '＜') # still in fullwidth mode!
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎㅐㅣ핺\u3000ａａ　ｂ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 13)
        self.engine.do_process_key_event(IBus.KEY_less, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎㅐㅣ핺\u3000ａａ　ｂ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 13)
        self.engine.do_process_key_event(IBus.KEY_H, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, 'ㅎ')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎㅐㅣ핺\u3000ａａ　ｂ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 13)
        self.engine.do_process_key_event(IBus.KEY_a, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '하')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎㅐㅣ핺\u3000ａａ　ｂ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 13)
        self.engine.do_process_key_event(IBus.KEY_n, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '한')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎㅐㅣ핺\u3000ａａ　ｂ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 13)
        # `G` starts a new syllable (`g` would add to the syllable)
        self.engine.do_process_key_event(IBus.KEY_G, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, 'ㄱ')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎㅐㅣ핺\u3000ａａ　ｂ한')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 14)
        self.engine.do_process_key_event(IBus.KEY_space, 0, 0)
        self.assertEqual(self.engine.mock_preedit_text, '')
        self.assertEqual(self.engine.mock_committed_text, '하ㅎㅏ ㅎㅐㅣ핺\u3000ａａ　ｂ한ㄱ ')
        self.assertEqual(self.engine.mock_committed_text_cursor_pos, 16)

if __name__ == '__main__':
    LOG_HANDLER = logging.StreamHandler(stream=sys.stderr)
    LOGGER.setLevel(logging.DEBUG)
    # Activate this to see a lot of logging when running the tests
    # manually:
    # LOGGER.addHandler(LOG_HANDLER)
    unittest.main()
