# tests/test_hanging.py # Тесты для модуля висячей пунктуации (HangingPunctuationProcessor). import pytest from bs4 import BeautifulSoup from etpgrf.hanging import HangingPunctuationProcessor from etpgrf.config import ( CHAR_RU_QUOT1_OPEN, CHAR_RU_QUOT1_CLOSE, CHAR_EN_QUOT1_OPEN, CHAR_EN_QUOT1_CLOSE, CHAR_LPAR, CHAR_LSQB, CHAR_LCUB, CHAR_RPAR, CHAR_RSQB, CHAR_RCUB, CHAR_SHY, CHAR_THIN_SP, CHAR_HAIR_SP, CHAR_MED_SP, CHAR_EN_SP, CHAR_EM_SP, CHAR_NULL_SP, CHAR_THIN_NBSP, CHAR_NBSP, CHAR_ZWNJ, CHAR_PUNT_SP ) # Вспомогательная функция для создания soup def make_soup(html_str): return BeautifulSoup(html_str, 'html.parser') # Набор тестовых случаев в формате: # (режим, входной_html, ожидаемый_html) HANGING_TEST_CASES = [ # --- Режим 'left' (только левая висячая пунктуация) --- ('left', f'
{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}
', f'{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}
'), ('left', f'{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE} вначале текста
', f'{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE} вначале текста
'), ('left', f'А вот это {CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE} внутри текста
', f'А вот это {CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE} внутри текста
'), ('left', f'А вот это {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста
', f'А вот это {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста
'), ('left', f'А вот это {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста
', f'А вот это {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста
'),\ ('left', f'А вот это {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста
', f'А вот это {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста
'), ('left', f'А вот это {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста
', f'А вот это {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста
'), ('left', f'А вот{CHAR_NBSP}это {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста
', f'А вот{CHAR_NBSP}это {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста
'), # Английские кавычки "лапки" ('left', f'This is some {CHAR_EN_QUOT1_OPEN}wisdom quote{CHAR_EN_QUOT1_CLOSE} for the test.
', f'This is some {CHAR_EN_QUOT1_OPEN}wisdom quote{CHAR_EN_QUOT1_CLOSE} for the test.
'), # Неразрывные пробелы и символы, отменяющие перенос, не должны оборачиваться, но должны сохраняться в тексте ('left', f'Неразрывный пробел перед{CHAR_NBSP}{CHAR_RU_QUOT1_OPEN}Цитатой{CHAR_RU_QUOT1_CLOSE} внутри текста
', f'Неразрывный пробел перед{CHAR_NBSP}{CHAR_RU_QUOT1_OPEN}Цитатой{CHAR_RU_QUOT1_CLOSE} внутри текста
'), ('left', f'Неразрывный пробел перед{CHAR_ZWNJ}{CHAR_RU_QUOT1_OPEN}Цитатой{CHAR_RU_QUOT1_CLOSE} внутри текста
', f'Неразрывный пробел перед{CHAR_ZWNJ}{CHAR_RU_QUOT1_OPEN}Цитатой{CHAR_RU_QUOT1_CLOSE} внутри текста
'), ('left', f'Неразрывный пробел перед{CHAR_THIN_NBSP}{CHAR_RU_QUOT1_OPEN}Цитатой{CHAR_RU_QUOT1_CLOSE} внутри текста
', f'Неразрывный пробел перед{CHAR_THIN_NBSP}{CHAR_RU_QUOT1_OPEN}Цитатой{CHAR_RU_QUOT1_CLOSE} внутри текста
'), # Примеры "круглая скобка" ('left', '(Скобки)
', '(Скобки)
'), ('left', 'Висячая пунктуация оборачивает (Скобки)
', 'Висячая пунктуация оборачивает (Скобки)
'), ('left', 'Висячая пунктуация оборачивает (Круглые скобки) вот так.
', 'Висячая пунктуация оборачивает (Круглые скобки) вот так.
'), ('left', 'Перечисли, (элемент списка)
', 'Перечисли, (элемент списка)
'), # Примеры "квадратная скобка" ('left', '[Скобки]
', '[Скобки]
'), ('left', 'Висячая пунктуация оборачивает [Скобки]
', 'Висячая пунктуация оборачивает [Скобки]
'), ('left', 'Висячая пунктуация оборачивает [Квадратные скобки] вот так.
', 'Висячая пунктуация оборачивает [Квадратные скобки] вот так.
'), # Примеры "фигурная скобка" ('left', '{Скобки}
', '{Скобки}
'), ('left', 'Висячая пунктуация оборачивает {Скобки}
', 'Висячая пунктуация оборачивает {Скобки}
'), ('left', 'Висячая пунктуация оборачивает {Фигурные скобки} вот так.
', 'Висячая пунктуация оборачивает {Фигурные скобки} вот так.
'), # Обычный текст, в котором нет символов для висячей пунктуации, не должен изменяться ('left', 'Текст.
', 'Текст.
'), ('left', 'Пароль `89(fg#_Wfgq0[89`
', 'Пароль `89(fg#_Wfgq0[89`
'), # Проверка на альтернативный разрывной символ (не пробел) ('left', 'Висячая пунктуация оборачивает\t{Скобки}
', 'Висячая пунктуация оборачивает\t{Скобки}
'), ('left', f'Висячая пунктуация оборачивает{CHAR_SHY}{{Скобки}}
', f'Висячая пунктуация оборачивает{CHAR_SHY}{{Скобки}}
'), ('left', f'Висячая пунктуация оборачивает{CHAR_PUNT_SP}{{Скобки}}
', f'Висячая пунктуация оборачивает{CHAR_PUNT_SP}{{Скобки}}
'), ('left', f'Висячая пунктуация оборачивает\n{{Скобки}}
', f'Висячая пунктуация оборачивает\n{{Скобки}}
'), ('left', f'Висячая пунктуация обора{CHAR_SHY}чивает {{Скобки}}
', f'Висячая пунктуация обора{CHAR_SHY}чивает {{Скобки}}
'), # --- Режим 'right' (только правая пунктуация) --- ('right', f'{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}
', f'{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}
'), ('right', f'Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}
', f'Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}
'), ('right', f'Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста
', f'Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста
'), ('right', f'Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста.
', f'Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста.
'), ('right', f'Right {CHAR_EN_QUOT1_OPEN}long quote{CHAR_EN_QUOT1_CLOSE} within the text.
', f'Right {CHAR_EN_QUOT1_OPEN}long quote{CHAR_EN_QUOT1_CLOSE} within the text.
'), ## С границами узлов ('right', f'Right {CHAR_EN_QUOT1_OPEN}long quote{CHAR_EN_QUOT1_CLOSE} within the text.
', f'Right {CHAR_EN_QUOT1_OPEN}long quote{CHAR_EN_QUOT1_CLOSE} within the text.
'), ('right', f'Right {CHAR_EN_QUOT1_OPEN}long quote{CHAR_EN_QUOT1_CLOSE} within the text.
', f'Right {CHAR_EN_QUOT1_OPEN}long quote{CHAR_EN_QUOT1_CLOSE} within the text.
'), # Неразрывные пробелы и символы, отменяющие перенос, не должны оборачиваться, но должны сохраняться в тексте ('right', f'Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_NBSP}внутри текста 2
', f'Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_NBSP}внутри текста 2
'), ('right', f'Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_ZWNJ}внутри текста 2
', f'Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_ZWNJ}внутри текста 2
'), ('right', f'Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_THIN_NBSP}внутри текста 2
', f'Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_THIN_NBSP}внутри текста 2
'), ('right', f'Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_NBSP}внутри текста 2
', f'Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_NBSP}внутри текста 2
'), #('right', 'Текст.
', 'Текст.
'), #('right', '(Скобки)
', '(Скобки)
'), ('right', 'End.
', 'End.
'), # Внутри цифр и текста висячие символы не должны оборачиваться, так как они не являются висячими в этом контексте ('right', '3.14
', '3.14
'), ('right', '3.14', '3.14'), ('right', '3,14
', '3,14
'), ('right', 'Переменная self.right_chars
Переменная self.right_chars
{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}
', f'{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}
'), (False, f'{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}
', f'{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}
'), ] @pytest.mark.parametrize("mode, input_html, expected_html", HANGING_TEST_CASES) def test_hanging_punctuation_processor(mode, input_html, expected_html): """ Проверяет работу HangingPunctuationProcessor в различных режимах. """ # Arrange processor = HangingPunctuationProcessor(mode=mode) soup = make_soup(input_html) # Act processor.process(soup) actual_html = str(soup) # Assert assert actual_html == expected_html def test_hanging_punctuation_target_tags(): """ Отдельный тест для проверки работы со списком целевых тегов. """ mode = ['blockquote', 'h1'] input_html = (f'{CHAR_RU_QUOT1_OPEN}Обработка{CHAR_RU_QUOT1_CLOSE}' f'
{CHAR_RU_QUOT1_OPEN}Обработка{CHAR_RU_QUOT1_CLOSE}' f'
{open_char}Текст{close_char}
' expected_html = f'{open_char}Текст{close_char}
' processor = HangingPunctuationProcessor(mode='left') soup = make_soup(input_html) processor.process(soup) assert str(soup) == expected_html SPACE_VARIANTS = [ ' ', CHAR_SHY, CHAR_THIN_SP, CHAR_HAIR_SP, CHAR_MED_SP, CHAR_EN_SP, CHAR_EM_SP, CHAR_NULL_SP, ] @pytest.mark.parametrize("space", SPACE_VARIANTS) def test_hanging_left_mode_compensates_different_spaces(space): """Проверяем компенсацию для разных типов разрывных пробелов.""" text = f'Проба{space}{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}
' expected_html = (f'Проба{space}' f'{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}
') processor = HangingPunctuationProcessor(mode='left') soup = make_soup(text) processor.process(soup) assert str(soup) == expected_html @pytest.mark.parametrize("separator", [CHAR_NBSP, CHAR_THIN_NBSP, CHAR_ZWNJ]) def test_hanging_left_mode_honors_cancellation(separator): """Символы, отменяющие перенос, остаются без обёрток.""" input_html = f'Неразрывный{separator}{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}
' processor = HangingPunctuationProcessor(mode='left') soup = make_soup(input_html) processor.process(soup) assert str(soup) == input_html