Ctrl K
ComunidadGuías…

tests/TestLanguage.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (c) 2008-2026 Andrew Ziem.
#
# This work is licensed under the terms of the GNU GPL, version 3 or
# later.  See the COPYING file in the top-level directory.


import contextlib
import ctypes
import os
import unittest
from unittest import mock

from bleachbit.Language import _UNSET, \
    find_supported_language_code, \
    get_active_language_code, \
    get_supported_language_codes, \
    get_text, \
    LocaleCode, \
    normalize_locale_code, \
    setup_translation, \
    get_supported_language_code_name_dict
from bleachbit.Options import options
from tests import common


def skipIfMissingPo(f):
    """Skip unit test if missing translations"""
    lang_count = len(get_supported_language_codes())
    return unittest.skipIf(lang_count < 3, 'missing translations: run `make -C po local`')(f)


class LanguageTestCase(common.BleachbitTestCase):

    """Test case for module Language"""

    def setUp(self):
        super().setUp()
        options.set('auto_detect_lang', False)
        options.set('forced_language', '')

    def tearDown(self):
        super().tearDown()
        # reset
        options.set('forced_language', 'en')
        setup_translation()

    def test_get_active_language_code(self):
        """Test get_active_language_code()"""
        lang_id = get_active_language_code()
        self.assertIsLanguageCode(lang_id)

    def test_get_supported_language_codes(self):
        """Test get_supported_language_codes()"""
        slangs = get_supported_language_codes()
        self.assertTrue(isinstance(slangs, list))
        self.assertGreater(len(slangs), 1)
        for slang in slangs:
            if slang in common.SKIP_ALIAS_CODES:
                continue
            self.assertIsLanguageCode(slang)
        self.assertIn('en_US', slangs)
        if len(get_supported_language_codes()) < 3:
            self.skipTest('missing translations')
        self.assertIn('es', slangs)

    def test_get_supported_language_codes_damaged_catalog(self):
        """A damaged catalog of one language is skipped, not raised"""
        locale_dir = self.mkdtemp()
        for lang, content in (('de', b''), ('fr', b'not a catalog')):
            messages_dir = self.mkdir(
                os.path.join(locale_dir, lang, 'LC_MESSAGES'))
            self.write_file(
                os.path.join(messages_dir, 'bleachbit.mo'), content)
        with mock.patch('bleachbit.locale_dir', locale_dir), \
                self.assertLogs('bleachbit.Language', level='WARNING'):
            self.assertEqual(['en', 'en_US'], get_supported_language_codes())

    def test_find_supported_language_code(self):
        """Test find_supported_language_code()

        The detected language code may differ from the supported code:
        Windows may return a hyphen like 'en-US' or a region like
        'hi_IN' while only 'hi' is supported.

        https://github.com/bleachbit/bleachbit/issues/1799
        https://github.com/bleachbit/bleachbit/issues/1800
        """
        supported = ['en', 'en_US', 'es', 'hi', 'pt_BR']
        # Exact match.
        self.assertEqual(
            find_supported_language_code('en_US', supported), 'en_US')
        self.assertEqual(
            find_supported_language_code('es', supported), 'es')
        # Hyphen instead of underscore.
        self.assertEqual(
            find_supported_language_code('en-US', supported), 'en_US')
        # Region falls back to primary language subtag.
        self.assertEqual(
            find_supported_language_code('hi_IN', supported), 'hi')
        self.assertEqual(
            find_supported_language_code('es_419', supported), 'es')
        # Primary language subtag falls back to a regional variant.
        self.assertEqual(
            find_supported_language_code('pt', supported), 'pt_BR')
        self.assertEqual(
            find_supported_language_code('en_GB', supported), 'en')
        # Case-insensitive match.
        self.assertEqual(
            find_supported_language_code('EN-us', supported), 'en_US')
        # Strip codeset and modifier.
        self.assertEqual(
            find_supported_language_code('de_DE.UTF-8', ['de_DE']), 'de_DE')
        self.assertEqual(
            find_supported_language_code('de@euro', ['de']), 'de')
        self.assertEqual(
            find_supported_language_code('sr_RS@latin', ['sr']), 'sr')
        # Regional variant prefers the same or script-implied region:
        # 'zh_Hant', 'zh_HK', and 'zh_MO' are Traditional Chinese.
        supported_zh = ['zh_CN', 'zh_TW']
        self.assertEqual(
            find_supported_language_code('zh_Hant_TW', supported_zh), 'zh_TW')
        self.assertEqual(
            find_supported_language_code('zh_Hant', supported_zh), 'zh_TW')
        self.assertEqual(
            find_supported_language_code('zh_HK', supported_zh), 'zh_TW')
        self.assertEqual(
            find_supported_language_code('zh_MO', supported_zh), 'zh_TW')
        self.assertEqual(
            find_supported_language_code('zh_Hans', supported_zh), 'zh_CN')
        self.assertEqual(
            find_supported_language_code('zh', supported_zh), 'zh_CN')
        # No match.
        self.assertIsNone(find_supported_language_code('de', supported))
        self.assertIsNone(find_supported_language_code('C', supported))
        self.assertIsNone(find_supported_language_code('', supported))
        self.assertIsNone(find_supported_language_code('.UTF-8', supported))
        self.assertIsNone(find_supported_language_code('en', []))

    def test_normalize_locale_code(self):
        """Test normalize_locale_code()"""
        tests = [
            ('ca@valencia', 'ca'),
            ('de_DE.utf8@euro', 'de_DE'),
            ('en_US.UTF-8', 'en_US'),
            ('en-US', 'en_US'),
            ('ko_KR.eucKR', 'ko_KR'),
            ('sr_RS@latin', 'sr_RS'),
            ('zh-Hant-TW', 'zh_Hant_TW'),
            ('C.UTF-8', 'C'),
            ('.UTF-8', ''),
            ('@', ''),
            ('@euro', ''),
        ]
        same_cases = ['en_US', 'en_001', 'es_419', 'it_CARES', 'C', '']
        tests.extend((c, c) for c in same_cases)
        for raw, expected in tests:
            with self.subTest(raw=raw):
                self.assertEqual(normalize_locale_code(raw), expected)

    def test_localecode_parse(self):
        """Test LocaleCode parsing, including @ before . for locale.alias"""
        tests = [
            # raw, language, territory, encoding, modifier
            ('en', 'en', None, None, None),
            ('de-CH', 'de', 'CH', None, None),
            ('es_419', 'es', '419', None, None),
            ('de_DE.utf8@euro', 'de', 'DE', 'utf8', 'euro'),
            ('en_US@piglatin', 'en', 'US', None, 'piglatin'),
            ('C.UTF-8', 'C', None, 'UTF-8', None),
            ('en@boldquot.header', 'en', None, None, 'boldquot.header'),
        ]
        for raw, language, territory, encoding, modifier in tests:
            with self.subTest(raw=raw):
                loc = LocaleCode(raw)
                self.assertEqual(loc.language, language)
                self.assertEqual(loc.territory, territory)
                self.assertEqual(loc.encoding, encoding)
                self.assertEqual(loc.modifier, modifier)
                self.assertEqual(str(loc), raw)

    def test_localecode_is_utf8(self):
        """Test LocaleCode.is_utf8"""
        for raw in ('en_US.utf8', 'en_US.UTF-8', 'de_DE.utf8@euro',
                    'C.utf8', 'C.UTF-8'):
            with self.subTest(raw=raw):
                self.assertTrue(LocaleCode(raw).is_utf8)
        for raw in ('de_DE.iso88591', 'de_DE', 'sr_RS@latin', 'en_US',
                    'nb_NO.ISO-8859-1'):
            with self.subTest(raw=raw):
                self.assertFalse(LocaleCode(raw).is_utf8)

    def test_get_supported_language_code_name_dict_unknown_code(self):
        with mock.patch('bleachbit.Language.get_supported_language_codes', return_value=['en', 'es', 'foo@bar']):
            supported_langs = get_supported_language_code_name_dict()
            self.assertIn('en', supported_langs)
            self.assertIn('es', supported_langs)
            self.assertIn('foo@bar', supported_langs)

    @skipIfMissingPo
    def test_switch_language_twice(self):
        """English should still work after switching twice"""

        options.set('auto_detect_lang', True)
        options.set('forced_language', '')
        setup_translation()
        self.assertEqual(get_text('Preview'), 'Preview')

        options.set('auto_detect_lang', False)
        options.set('forced_language', 'es')
        setup_translation()
        self.assertIn(get_text('Preview'), ('Vista previa', 'Previsualizar'))

        options.set('forced_language', 'en')
        setup_translation()
        self.assertEqual(get_text('Preview'), 'Preview')

    def test_get_text_all(self):
        """Test get_text across all languages"""
        for lang_id in get_supported_language_codes():
            if lang_id in common.SKIP_ALIAS_CODES:
                continue
            options.set('forced_language', lang_id)
            self.assertEqual(get_active_language_code(), lang_id)
            setup_translation()
            text = get_text('Preview')
            self.assertIsInstance(text, str)
            self.assertGreater(len(text), 0)

    @skipIfMissingPo
    def test_get_text_au(self):
        """Test Australian English

        It should not get confused with American English.
        """
        options.set('forced_language', 'en_AU')
        self.assertEqual(get_active_language_code(), 'en_AU')
        setup_translation()
        self.assertEqual(get_text('Localizations'), 'Localisations')

    @skipIfMissingPo
    def test_get_text_fallback(self):
        """Language code x_Y should fall back to x"""
        for lang_id in ('es', 'es_XX', 'es_ES', 'es_ES.UTF-8', 'es_1235'):
            options.set('forced_language', lang_id)
            self.assertEqual(get_active_language_code(),
                             normalize_locale_code(lang_id))
            setup_translation()
            self.assertIn(get_text('Preview'),
                          ('Vista previa', 'Previsualizar'))

    @contextlib.contextmanager
    def cleared_env(self, **env):
        """Run with exactly the given environment variables; all
        others are cleared."""
        with mock.patch.dict(os.environ, env, clear=True):
            yield

    @common.skipIfWindows
    def test_get_active_language_code_posix_env_not_setlocale(self):
        """The environment wins over the locale set by setlocale()."""
        options.set('auto_detect_lang', True)
        options.set('forced_language', '')
        with self.cleared_env(LANG='en_US.UTF-8'), \
                mock.patch('locale.getlocale',
                           return_value=('es_ES', 'UTF-8')):
            self.assertEqual(get_active_language_code(), 'en_US')

    @common.skipUnlessLinux
    def test_get_active_language_code_posix_env_unset(self):
        """With no locale env vars, fall back to locale.getlocale()."""
        options.set('auto_detect_lang', True)
        options.set('forced_language', '')
        with self.cleared_env(), \
                mock.patch('bleachbit.Language._locale_fallback', _UNSET), \
                mock.patch('locale.getlocale',
                           return_value=('fr_FR', 'UTF-8')):
            self.assertEqual(get_active_language_code(), 'fr_FR')

    @common.skipIfWindows
    def test_get_active_language_code_posix_env_strips_codeset(self):
        """Strip the codeset ('.UTF-8') and modifier ('@latin')."""
        options.set('auto_detect_lang', True)
        options.set('forced_language', '')
        for raw, expected in (('de_DE.UTF-8', 'de_DE'),
                              ('de_DE@euro', 'de_DE'),
                              ('sr_RS.UTF-8@latin', 'sr_RS'),
                              ('C.UTF-8', 'C')):
            with self.subTest(raw=raw), \
                    self.cleared_env(LANG=raw):
                self.assertEqual(get_active_language_code(), expected)

    @common.skipIfWindows
    def test_get_active_language_code_posix_env_precedence(self):
        """LC_ALL wins over LC_MESSAGES, which wins over LANG, and an
        empty value counts as unset."""
        options.set('auto_detect_lang', True)
        options.set('forced_language', '')
        with self.subTest('all set'), \
                self.cleared_env(LC_ALL='es_ES.UTF-8',
                                 LC_MESSAGES='fr_FR.UTF-8',
                                 LANG='en_US.UTF-8'):
            self.assertEqual(get_active_language_code(), 'es_ES')
        with self.subTest('empty LC_ALL'), \
                self.cleared_env(LC_ALL='', LANG='en_US.UTF-8'):
            self.assertEqual(get_active_language_code(), 'en_US')
        with self.subTest('malformed LC_ALL skipped'), \
                self.cleared_env(LC_ALL='x', LANG='en_US.UTF-8'):
            self.assertEqual(get_active_language_code(), 'en_US')
        with self.subTest('malformed LANG'), \
                self.cleared_env(LANG='x'):
            self.assertEqual(get_active_language_code(), 'C')

    @common.skipUnlessLinux
    def test_get_active_language_code_locale_fallback_cached(self):
        """The fallback is captured once, not re-read after setlocale()."""
        options.set('auto_detect_lang', True)
        options.set('forced_language', '')
        with self.cleared_env(), \
                mock.patch('bleachbit.Language._locale_fallback', _UNSET), \
                mock.patch('locale.getlocale',
                           side_effect=[('fr_FR', 'UTF-8'),
                                        ('es_ES', 'UTF-8')]) as getlocale:
            # Second call returns the captured value despite the changed locale.
            self.assertEqual(get_active_language_code(), 'fr_FR')
            self.assertEqual(get_active_language_code(), 'fr_FR')
            self.assertEqual(getlocale.call_count, 1)

    @common.skipUnlessLinux
    def test_get_active_language_code_getlocale_valueerror(self):
        """An unparseable getlocale() result falls back to 'C'."""
        options.set('auto_detect_lang', True)
        options.set('forced_language', '')
        with self.cleared_env(), \
                mock.patch('bleachbit.Language._locale_fallback', _UNSET), \
                mock.patch('locale.getlocale', side_effect=ValueError):
            self.assertEqual(get_active_language_code(), 'C')

    def test_options_import_failure(self):
        """Test handling of failed Options import in language detection"""
        with mock.patch.dict('sys.modules', {'bleachbit.Options': None}):
            with self.assertLogs(level='ERROR') as log_context:
                result = get_active_language_code()
            self.assertIn("Failed to get language options",
                          log_context.output[0])

        self.assertIsLanguageCode(result)


class SetupTranslationEnvironTestCase(common.BleachbitTestCase):
    """Test case for the LANGUAGE environment variable side effect
    of setup_translation()"""

    def setUp(self):
        super().setUp()
        self._language_env_backup = os.environ.get('LANGUAGE')
        # setup_translation() reassigns this plain module-level
        # global (used by every _()/get_text() call throughout the
        # whole codebase) as a real side effect of calling the real
        # gettext.translation() -- mocking that call prevents the
        # reassignment in the first place, but capture/restore the
        # global directly too as a second line of defense, since
        # this specific global is the actual, confirmed mechanism by
        # which an earlier, less careful version of this same test
        # leaked a real Italian translation into unrelated later
        # tests' own log messages for the rest of the test process.
        from bleachbit import Language as _language_module
        self._t_backup = _language_module.t

    def tearDown(self):
        from bleachbit import Language as _language_module
        _language_module.t = self._t_backup
        if self._language_env_backup is None:
            os.environ.pop('LANGUAGE', None)
        else:
            os.environ['LANGUAGE'] = self._language_env_backup
        super().tearDown()

    def test_setup_translation_bumps_generation(self):
        """Each setup_translation() call bumps translation_generation"""
        from bleachbit import Language as _language_module
        before = _language_module.translation_generation
        setup_translation()
        self.assertEqual(_language_module.translation_generation, before + 1)

    @common.skipIfWindows
    def test_setup_translation_sets_language_env_on_posix(self):
        """Regression test: GLib's g_get_language_names(), used
        by Gtk.Builder to translate .ui files such as the hamburger
        menu, reads LANGUAGE directly from the environment with
        top priority -- it does not consult locale.setlocale()'s C
        locale state at all, so without setting it, a
        Gtk.Builder-loaded menu stays frozen in whatever locale
        was in the environment at process launch (e.g. the real
        macOS AppleLocale system preference), never following a
        later in-app language change.

        Confirmed by hand on the real .app: without this, the
        hamburger menu stayed in Spanish through three different
        manually-selected languages and a full app restart, on a
        machine whose real System Settings > Language is Spanish;
        setting LANGUAGE made it follow the selected language
        immediately, without even restarting the app.

        Only LANGUAGE is checked here, deliberately not LANG/
        LC_ALL: an earlier version of this fix also set those,
        which crashed a subprocess Python interpreter started
        later with 'Fatal Python error:
        config_get_locale_encoding: ... nl_langinfo(CODESET)
        failed', since a bare code without an explicit encoding
        (required for GLib's own parsing of these variables
        specifically -- a '.UTF-8' suffix breaks it, verified by
        hand) is not a well-formed LC_ALL/LANG value for every
        other locale-aware consumer in the process.
        """
        os.environ.pop('LANGUAGE', None)
        with mock.patch('bleachbit.Language.get_active_language_code',
                        return_value='it_IT'), \
                mock.patch('locale.setlocale'), \
                mock.patch('gettext.find', return_value='bleachbit.mo'), \
                mock.patch('gettext.translation'), \
                mock.patch('bleachbit.Unix.find_best_locale',
                           return_value='it_IT'):
            setup_translation()
        self.assertEqual(os.environ.get('LANGUAGE'), 'it_IT')

    @common.skipIfWindows
    def test_setup_translation_regional_variant(self):
        """zh_HK uses the shipped zh_TW catalog"""
        locale_dir = os.path.join(self.mkdtemp(), 'locale')
        mo_dir = os.path.join(locale_dir, 'zh_TW', 'LC_MESSAGES')
        os.makedirs(mo_dir)
        with open(os.path.join(mo_dir, 'bleachbit.mo'), 'wb'):
            pass
        with mock.patch('bleachbit.locale_dir', locale_dir), \
                mock.patch('bleachbit.Language.get_active_language_code',
                           return_value='zh_HK'), \
                mock.patch('locale.setlocale'), \
                mock.patch('locale.bindtextdomain', create=True), \
                mock.patch('locale.textdomain', create=True), \
                mock.patch('gettext.translation') as mock_translation, \
                mock.patch('bleachbit.Unix.find_best_locale',
                           return_value='zh_HK'):
            setup_translation()
        self.assertEqual(mock_translation.call_args.kwargs['languages'],
                         ['zh_TW'])
        self.assertEqual(os.environ.get('LANGUAGE'), 'zh_TW')

    @common.skipUnlessLinux
    def test_setup_translation_captures_locale_before_setlocale(self):
        """A language forced at startup must not poison auto-detection.

        setup_translation() returns early on a forced language without
        calling get_active_language_code()'s fallback, so it must capture
        the system locale before setlocale() changes what getlocale() sees.
        """
        options.set('auto_detect_lang', False)
        options.set('forced_language', 'es')
        with mock.patch('bleachbit.Language._locale_fallback', _UNSET), \
                mock.patch.dict(os.environ, {}, clear=True), \
                mock.patch('locale.setlocale'), \
                mock.patch('gettext.translation'), \
                mock.patch('bleachbit.Unix.find_best_locale',
                           return_value='es_ES'):
            with mock.patch('locale.getlocale',
                            return_value=('fr_FR', 'UTF-8')):
                setup_translation()
            # Re-enable auto-detection; getlocale() now reports the forced
            # language, but detection must still see the captured one.
            options.set('auto_detect_lang', True)
            options.set('forced_language', '')
            with mock.patch('locale.getlocale',
                            return_value=('es_ES', 'UTF-8')):
                self.assertEqual(get_active_language_code(), 'fr_FR')

    @common.skipIfWindows
    def test_setup_translation_does_not_set_lang_or_lc_all_on_posix(self):
        """LANG/LC_ALL must be left untouched on POSIX -- see the
        comment in setup_translation() and in the test above for
        why setting them crashed a subprocess Python interpreter.
        """
        lang_backup = os.environ.get('LANG')
        lc_all_backup = os.environ.get('LC_ALL')
        try:
            os.environ.pop('LANG', None)
            os.environ.pop('LC_ALL', None)
            with mock.patch('bleachbit.Language.get_active_language_code',
                            return_value='es_ES'), \
                    mock.patch('locale.setlocale'), \
                    mock.patch('gettext.translation'), \
                    mock.patch('bleachbit.Unix.find_best_locale',
                               return_value='es_ES'):
                setup_translation()
            self.assertIsNone(os.environ.get('LANG'))
            self.assertIsNone(os.environ.get('LC_ALL'))
        finally:
            if lang_backup is None:
                os.environ.pop('LANG', None)
            else:
                os.environ['LANG'] = lang_backup
            if lc_all_backup is None:
                os.environ.pop('LC_ALL', None)
            else:
                os.environ['LC_ALL'] = lc_all_backup


class WindowsGettextCacheTestCase(common.BleachbitTestCase):
    """Windows: libintl must reload .mo files after an in-process language change.

    Regression for https://github.com/bleachbit/bleachbit/issues/1801 :
    without incrementing _nl_msg_cat_cntr, dgettext/Gtk.Builder keep
    serving the first non-English catalog for the rest of the process.
    """

    @common.skipUnlessWindows
    @skipIfMissingPo
    def test_setup_translation_reloads_libintl_catalog(self):
        from bleachbit import Language as language_module
        from bleachbit.Windows import load_i18n_dll

        libintl = load_i18n_dll()
        if not libintl:
            self.skipTest('intl-8.dll not available')

        libintl.dgettext.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
        libintl.dgettext.restype = ctypes.c_char_p
        msgid = b'_Shred Files'
        domain = b'bleachbit'

        with common.set_temporary_env('LANG', os.environ.get('LANG')), \
                common.set_temporary_env('LANGUAGE', os.environ.get('LANGUAGE')):
            t_backup = language_module.t
            try:
                translations = {}
                for lang in ('es', 'it', 'de', 'fr'):
                    # setup_translation() binds the libintl domain only
                    # when GTK is loaded; this test exercises the bind
                    # itself, so pretend it is.
                    with mock.patch(
                            'bleachbit.Language.get_active_language_code',
                            return_value=lang), \
                            mock.patch(
                            'bleachbit.GtkShim.is_gtk_loaded',
                            return_value=True):
                        setup_translation()
                    translated = libintl.dgettext(domain, msgid)
                    translations[lang] = translated.decode('utf-8')

                # Distinct languages must not all freeze on the first catalog.
                self.assertNotEqual(translations['es'], translations['it'])
                self.assertNotEqual(translations['es'], translations['de'])
                self.assertNotEqual(translations['es'], translations['fr'])
                self.assertIn('archivos', translations['es'].lower())
                self.assertIn('dateien', translations['de'].lower())
            finally:
                language_module.t = t_backup
                # Restore process gettext state for later tests, after the
                # environment variables have been restored to their
                # pre-test values.
                setup_translation()


class MacGettextBindTestCase(common.BleachbitTestCase):
    """macOS: bind the libintl domain when Python lacks locale.bindtextdomain().

    Without it the Gtk.Builder strings (hamburger menu) stay in English
    on Python builds such as Homebrew's 3.12.
    """

    @common.skipUnlessMac
    def test_setup_translation_binds_libintl_on_mac(self):
        import locale
        from bleachbit import Language as language_module
        from bleachbit.Mac import bind_libintl_domain

        # Simulate a Python build without bindtextdomain() in case the
        # running macOS Python build provides it.
        had_bindtextdomain = hasattr(locale, 'bindtextdomain')
        saved_bindtextdomain = getattr(locale, 'bindtextdomain', None)
        if had_bindtextdomain:
            del locale.bindtextdomain
        with common.set_temporary_env('LANG', os.environ.get('LANG')), \
                common.set_temporary_env('LANGUAGE', os.environ.get('LANGUAGE')):
            t_backup = language_module.t
            try:
                with mock.patch('bleachbit.Language.get_active_language_code',
                                return_value='es'), \
                        mock.patch('bleachbit.GtkShim.is_gtk_loaded',
                                   return_value=True), \
                        mock.patch('bleachbit.Mac.bind_libintl_domain',
                                   wraps=bind_libintl_domain) as bind, \
                        self.assertLogs('bleachbit.Mac', level='DEBUG') as cm:
                    setup_translation()
                bind.assert_called_once()
                self.assertEqual(bind.call_args[0][0], 'bleachbit')
                self.assertTrue(
                    any('libintl text domain bleachbit bound to' in msg
                        for msg in cm.output),
                    cm.output)
            finally:
                if had_bindtextdomain:
                    locale.bindtextdomain = saved_bindtextdomain
                language_module.t = t_backup
                setup_translation()