wikimedia/pywikibot

View on GitHub
tests/ui_tests.py

Summary

Maintainability
C
7 hrs
Test Coverage
#!/usr/bin/env python3
"""Tests for the user interface."""
#
# (C) Pywikibot team, 2008-2022
#
# Distributed under the terms of the MIT license.
#
from __future__ import annotations

import io
import logging
import os
import unittest
from contextlib import redirect_stdout, suppress
from unittest.mock import patch

import pywikibot
from pywikibot.bot import (
    CRITICAL,
    DEBUG,
    ERROR,
    INFO,
    INPUT,
    STDOUT,
    VERBOSE,
    WARNING,
)
from pywikibot.userinterfaces import (
    terminal_interface_base,
    terminal_interface_unix,
    terminal_interface_win32,
)
from pywikibot.userinterfaces.transliteration import NON_LATIN_DIGITS, _trans
from tests.aspects import TestCase, TestCaseBase


logger = logging.getLogger('pywiki')
loggingcontext = {'caller_name': 'ui_tests',
                  'caller_file': 'ui_tests',
                  'caller_line': 0,
                  'newline': '\n'}


class UITestCase(TestCaseBase):

    """UI tests."""

    net = False

    def setUp(self):
        """Setup test.

        Here we patch standard input, output, and errors, essentially
        redirecting to `StringIO` streams.
        """
        super().setUp()
        self.stdout_patcher = patch('sys.stdout', new_callable=io.StringIO)
        self.strout = self.stdout_patcher.start()
        self.stderr_patcher = patch('sys.stderr', new_callable=io.StringIO)
        self.strerr = self.stderr_patcher.start()
        self.stdin_patcher = patch('sys.stdin', new_callable=io.StringIO)
        self.strin = self.stdin_patcher.start()

        pywikibot.bot.set_interface('terminal')

        self.org_input = pywikibot.bot.ui._raw_input
        pywikibot.bot.ui._raw_input = self._patched_input

        pywikibot.config.colorized_output = True
        pywikibot.config.transliterate = False
        pywikibot.ui.transliteration_target = None
        pywikibot.ui.encoding = 'utf-8'

    def tearDown(self):
        """Cleanup test."""
        super().tearDown()

        self.stdout_patcher.stop()
        self.stderr_patcher.stop()
        self.stdin_patcher.stop()

        pywikibot.bot.ui._raw_input = self.org_input
        pywikibot.bot.set_interface('buffer')

    def _patched_input(self):
        return self.strin.readline().strip()


class ExceptionTestError(Exception):

    """Test exception."""


class TestTerminalOutput(UITestCase):

    """Terminal output tests."""

    tests = [
        ('debug', DEBUG, '', ''),
        ('verbose', VERBOSE, '', ''),
        ('info', INFO, '', 'info\n'),
        ('stdout', STDOUT, 'stdout\n', ''),
        ('input', INPUT, '', 'input\n'),
        ('WARNING', WARNING, '', 'WARNING: WARNING\n'),
        ('ERROR', ERROR, '', 'ERROR: ERROR\n'),
        ('CRITICAL', CRITICAL, '', 'CRITICAL: CRITICAL\n'),
    ]

    def test_outputlevels_logging(self):
        """Test logger with output levels."""
        for text, level, out, err in self.tests:
            with self.subTest(test=text):
                logger.log(level, text, extra=loggingcontext)
                self.assertEqual(self.strout.getvalue(), out)
                self.assertEqual(self.strerr.getvalue(), err)

                # reset terminal files
                for stream in [self.strout, self.strerr, self.strin]:
                    stream.truncate(0)
                    stream.seek(0)

    def test_output(self):
        pywikibot.info('output')
        self.assertEqual(self.strout.getvalue(), '')
        self.assertEqual(self.strerr.getvalue(), 'output\n')

    def test_stdout(self):
        pywikibot.stdout('output')
        self.assertEqual(self.strout.getvalue(), 'output\n')
        self.assertEqual(self.strerr.getvalue(), '')

    def test_warning(self):
        pywikibot.warning('warning')
        self.assertEqual(self.strout.getvalue(), '')
        self.assertEqual(self.strerr.getvalue(), 'WARNING: warning\n')

    def test_error(self):
        pywikibot.error('error')
        self.assertEqual(self.strout.getvalue(), '')
        self.assertEqual(self.strerr.getvalue(), 'ERROR: error\n')

    def test_log(self):
        pywikibot.log('log')
        self.assertEqual(self.strout.getvalue(), '')
        self.assertEqual(self.strerr.getvalue(), '')

    def test_critical(self):
        pywikibot.critical('critical')
        self.assertEqual(self.strout.getvalue(), '')
        self.assertEqual(self.strerr.getvalue(), 'CRITICAL: critical\n')

    def test_debug(self):
        pywikibot.debug('debug', layer='test')
        self.assertEqual(self.strout.getvalue(), '')
        self.assertEqual(self.strerr.getvalue(), '')

    def test_exception(self):
        try:
            raise ExceptionTestError('Testing Exception')
        except ExceptionTestError:
            pywikibot.error('exception', exc_info=False)
        self.assertEqual(self.strout.getvalue(), '')
        self.assertEqual(self.strerr.getvalue(),
                         'ERROR: exception\n')

    def test_exception_empty(self):
        try:
            raise ExceptionTestError('Testing Exception')
        except ExceptionTestError:
            pywikibot.exception(exc_info=False)
        self.assertEqual(self.strout.getvalue(), '')
        self.assertEqual(self.strerr.getvalue(),
                         'ERROR: Testing Exception (ExceptionTestError)\n')

    def test_exception_tb(self):
        try:
            raise ExceptionTestError('Testing Exception')
        except ExceptionTestError:
            pywikibot.exception()
        self.assertEqual(self.strout.getvalue(), '')
        stderrlines = self.strerr.getvalue().split('\n')
        self.assertEqual(stderrlines[0],
                         'ERROR: Testing Exception')
        self.assertEqual(stderrlines[1], 'Traceback (most recent call last):')
        self.assertEqual(stderrlines[3],
                         "    raise ExceptionTestError('Testing Exception')")

        end_str = ': Testing Exception'
        self.assertTrue(stderrlines[-1].endswith(end_str),
                        f'\n{stderrlines[-1]!r} does not end with {end_str!r}')


class TestTerminalInput(UITestCase):

    """Terminal input tests."""

    input_choice_output = 'question ([A]nswer 1, a[n]swer 2, an[s]wer 3): '

    def testInput(self):
        self.strin.write('input to read\n')
        self.strin.seek(0)
        returned = pywikibot.input('question')

        self.assertEqual(self.strout.getvalue(), '')
        self.assertEqual(self.strerr.getvalue(), 'question: ')
        self.assertIsInstance(returned, str)
        self.assertEqual(returned, 'input to read')

    def test_input_yn(self):
        self.strin.write('\n')
        self.strin.seek(0)
        returned = pywikibot.input_yn('question', False, automatic_quit=False)

        self.assertEqual(self.strout.getvalue(), '')
        self.assertEqual(self.strerr.getvalue(), 'question ([y]es, [N]o): ')
        self.assertFalse(returned)

    def _call_input_choice(self):
        rv = pywikibot.input_choice(
            'question',
            (('answer 1', 'A'),
             ('answer 2', 'N'),
             ('answer 3', 'S')),
            'A',
            automatic_quit=False)

        self.assertEqual(self.strout.getvalue(), '')
        self.assertIsInstance(rv, str)
        return rv

    def testInputChoiceDefault(self):
        self.strin.write('\n')
        self.strin.seek(0)
        returned = self._call_input_choice()

        self.assertEqual(returned, 'a')

    def testInputChoiceCapital(self):
        self.strin.write('N\n')
        self.strin.seek(0)
        returned = self._call_input_choice()

        self.assertEqual(self.strerr.getvalue(), self.input_choice_output)
        self.assertEqual(returned, 'n')

    def testInputChoiceNonCapital(self):
        self.strin.write('n\n')
        self.strin.seek(0)
        returned = self._call_input_choice()

        self.assertEqual(self.strerr.getvalue(), self.input_choice_output)
        self.assertEqual(returned, 'n')

    def testInputChoiceIncorrectAnswer(self):
        self.strin.write('X\nN\n')
        self.strin.seek(0)
        returned = self._call_input_choice()

        self.assertEqual(self.strerr.getvalue(), self.input_choice_output * 2)
        self.assertEqual(returned, 'n')

    def test_input_list_choice(self):
        """Test input_list_choice function."""
        options = ('answer 1', 'answer 2', 'answer 3')
        rv = pywikibot.bot.input_list_choice('question', options, '2')

        self.assertEqual(self.strout.getvalue(), '')
        self.assertEqual(
            self.strerr.getvalue(),
            ''.join(f'{num}: {items}\n'
                    for num, items in enumerate(options, start=1))
            + 'question (default: 2): ')
        self.assertEqual(rv, 'answer 2')


@unittest.skipUnless(os.name == 'posix', 'requires Unix console')
class TestTerminalOutputColorUnix(UITestCase):

    """Terminal output color tests."""

    str1 = 'text <<lightpurple>>light purple text<<default>> text'

    def testOutputColorizedText(self):
        pywikibot.info(self.str1)
        self.assertEqual(self.strout.getvalue(), '')
        self.assertEqual(
            self.strerr.getvalue(),
            'text \x1b[95mlight purple text\x1b[0m text\n')

    def testOutputNoncolorizedText(self):
        pywikibot.config.colorized_output = False
        pywikibot.info(self.str1)
        self.assertEqual(self.strout.getvalue(), '')
        self.assertEqual(
            self.strerr.getvalue(),
            'text light purple text text ***\n')

    str2 = ('normal text <<lightpurple>> light purple '
            '<<lightblue>> light blue <<previous>> light purple '
            '<<default>> normal text')

    def testOutputColorCascade_incorrect(self):
        """Test incorrect behavior of testOutputColorCascade."""
        pywikibot.info(self.str2)
        self.assertEqual(self.strout.getvalue(), '')
        self.assertEqual(
            self.strerr.getvalue(),
            'normal text \x1b[95m light purple '
            '\x1b[94m light blue \x1b[95m light purple '
            '\x1b[0m normal text\n')


@unittest.skipUnless(os.name == 'posix', 'requires Unix console')
class TestTerminalUnicodeUnix(UITestCase):

    """Terminal output tests for Unix."""

    def testOutputUnicodeText(self):
        pywikibot.info('Заглавная_страница')
        self.assertEqual(self.strout.getvalue(), '')
        self.assertEqual(self.strerr.getvalue(), 'Заглавная_страница\n')

    def testInputUnicodeText(self):
        self.strin.write('Заглавная_страница\n')
        self.strin.seek(0)

        returned = pywikibot.input('Википедию? ')

        self.assertEqual(self.strout.getvalue(), '')
        self.assertEqual(
            self.strerr.getvalue(), 'Википедию? ')

        self.assertIsInstance(returned, str)
        self.assertEqual(returned, 'Заглавная_страница')


@unittest.skipUnless(os.name == 'posix', 'requires Unix console')
class TestTransliterationUnix(UITestCase):

    """Terminal output transliteration tests."""

    def testOutputTransliteratedUnicodeText(self):
        pywikibot.bot.ui.encoding = 'latin-1'
        pywikibot.config.transliterate = True
        pywikibot.info('abcd АБГД αβγδ あいうえお')
        self.assertEqual(self.strout.getvalue(), '')
        self.assertEqual(
            self.strerr.getvalue(),
            'abcd \x1b[93mA\x1b[0m\x1b[93mB\x1b[0m\x1b[93mG\x1b[0m'
            '\x1b[93mD\x1b[0m \x1b[93ma\x1b[0m\x1b[93mb\x1b[0m\x1b[93mg'
            '\x1b[0m\x1b[93md\x1b[0m \x1b[93ma\x1b[0m\x1b[93mi\x1b[0m'
            '\x1b[93mu\x1b[0m\x1b[93me\x1b[0m\x1b[93mo\x1b[0m\n')


class TestTransliterationTable(TestCase):

    """Test transliteration table."""

    net = False

    def test_latin_digits(self):
        """Test that non latin digits are in transliteration table."""
        for lang, digits in NON_LATIN_DIGITS.items():
            with self.subTest(lang=lang):
                for char in digits:
                    self.assertIn(char, _trans,
                                  f'{char!r} not in transliteration table')

    def test_transliteration_table(self):
        """Test transliteration table consistency."""
        for k, v in _trans.items():
            with self.subTest():
                self.assertNotEqual(k, v)


# TODO: add tests for background colors.
class FakeUITest(TestCase):

    """Test case to allow doing uncolorized general UI tests."""

    net = False

    expected = 'Hello world you! ***'
    expect_color = False
    ui_class = terminal_interface_base.UI

    def setUp(self):
        """Create dummy instances for the test and patch encounter_color."""
        super().setUp()
        self.ui_obj = self.ui_class()
        # Write to sys.stdout stream, which we'll redirect to the stream below
        self.redirect = io.StringIO()
        self._orig_encounter_color = self.ui_obj.encounter_color
        self.ui_obj.encounter_color = self._encounter_color
        self._index = 0

    def tearDown(self):
        """Unpatch the encounter_color method."""
        self.ui_obj.encounter_color = self._orig_encounter_color
        super().tearDown()
        self.assertEqual(self._index,
                         len(self._colors) if self.expect_color else 0)

    def _encounter_color(self, color, target_stream):
        """Patched encounter_color method."""
        raise AssertionError(
            'This method should not be invoked')  # pragma: no cover

    def test_no_color(self):
        """Test a string without any colors."""
        self._colors = ()
        with redirect_stdout(self.redirect) as f:
            self.ui_obj._print('Hello world you!', self.ui_obj.stdout)
        self.assertEqual(f.getvalue(), 'Hello world you!')

    def test_one_color(self):
        """Test a string using one color."""
        self._colors = (('red', 6), ('default', 10))
        with redirect_stdout(self.redirect) as f:
            self.ui_obj._print('Hello <<red>>world you!', self.ui_obj.stdout)
        self.assertEqual(f.getvalue(), self.expected)

    def test_flat_color(self):
        """Test using colors with defaulting in between."""
        self._colors = (('red', 6), ('default', 6), ('yellow', 3),
                        ('default', 1))
        with redirect_stdout(self.redirect) as f:
            self.ui_obj._print(
                'Hello <<red>>world <<default>>you<<yellow>>!',
                self.ui_obj.stdout)
        self.assertEqual(f.getvalue(), self.expected)

    def test_stack_with_pop_color(self):
        """Test using stacked colors and just popping the latest color."""
        self._colors = (('red', 6), ('yellow', 6), ('red', 3), ('default', 1))
        with redirect_stdout(self.redirect) as f:
            self.ui_obj._print(
                'Hello <<red>>world <<yellow>>you<<previous>>!',
                self.ui_obj.stdout)
        self.assertEqual(f.getvalue(), self.expected)

    def test_stack_implicit_color(self):
        """Test using stacked colors without popping any."""
        self._colors = (('red', 6), ('yellow', 6), ('default', 4))
        with redirect_stdout(self.redirect) as f:
            self.ui_obj._print('Hello <<red>>world <<yellow>>you!',
                               self.ui_obj.stdout)
        self.assertEqual(f.getvalue(), self.expected)

    def test_one_color_newline(self):
        """Test with trailing new line and one color."""
        self._colors = (('red', 6), ('default', 11))
        with redirect_stdout(self.redirect) as f:
            self.ui_obj._print('Hello <<red>>world you!\n',
                               self.ui_obj.stdout)
        self.assertEqual(f.getvalue(), self.expected + '\n')


class FakeUIColorizedTestBase(TestCase):

    """Base class for test cases requiring that colorized output is active."""

    net = False

    expect_color = True
    expected = 'Hello world you!'

    def setUp(self):
        """Force colorized_output to True."""
        super().setUp()
        self._old_config = pywikibot.config.colorized_output
        pywikibot.config.colorized_output = True

    def tearDown(self):
        """Undo colorized_output configuration."""
        pywikibot.config.colorized_output = self._old_config
        super().tearDown()

    def _encounter_color(self, color, target_stream):
        """Verify that the written data, color and stream are correct."""
        self.assertIs(target_stream, self.ui_obj.stdout)
        expected_color = self._colors[self._index][0]
        self._index += 1
        self.assertEqual(color, expected_color)
        self.assertLength(self.redirect.getvalue(),
                          sum(e[1] for e in self._colors[:self._index]))


class FakeUnixTest(FakeUIColorizedTestBase, FakeUITest):

    """Test case to allow doing colorized Unix tests in any environment."""

    ui_class = terminal_interface_unix.UnixUI


class FakeWin32Test(FakeUIColorizedTestBase, FakeUITest):

    """
    Test case to allow doing colorized Win32 tests in any environment.

    This only patches the ctypes import in the terminal_interface_win32
    module. As the Win32CtypesUI is using the std-streams from another
    import these will be unpatched.
    """

    ui_class = terminal_interface_win32.Win32UI

    def setUp(self):
        """Patch the ctypes import and initialize a stream and UI instance."""
        super().setUp()
        self.ui_obj.stdout.isatty = lambda: self.expect_color


class FakeWin32UncolorizedTest(FakeWin32Test):

    """Test case to allow doing uncolorized Win32 tests in any environment."""

    expected = 'Hello world you! ***'
    expect_color = False


if __name__ == '__main__':
    with suppress(SystemExit):
        unittest.main()