Files
md2gost/tests/test_render.py
T

561 lines
21 KiB
Python
Raw Normal View History

import pytest
from md2gost.handlers.table import _STRETCH
from md2gost.latex.titlepages import TITLEPAGES
from md2gost.parser import Parser
from md2gost.render import Renderer
FRONT_MATTER = "---\nназвание: Тестовая работа\nтип: Отчёт\n---\n\n"
def render(md: str) -> str:
parser = Parser(FRONT_MATTER + md)
return Renderer(parser.tokens, parser.front_matter).render()
def body(tex: str) -> str:
"""Тело документа без преамбулы: в преамбуле свои tabularx-макросы."""
return tex.split("\\setcounter{page}{2}", 1)[1]
def render_fm(extra: str, body: str = "Текст.\n") -> str:
head = f"---\nназвание: Тестовая работа\nтип: Отчёт\n{extra}---\n\n"
parser = Parser(head + body)
return Renderer(parser.tokens, parser.front_matter).render()
def test_heading_numbering() -> None:
tex = render("# Исследовательская часть\n\n## Обзор\n")
assert "\\section{Исследовательская часть}" in tex
assert "\\subsection{Обзор}" in tex
def test_unnumbered_heading() -> None:
tex = render("# ВВЕДЕНИЕ\n\nТекст.\n")
assert "\\section{" not in tex
assert "\\clearpage" in tex
assert "ВВЕДЕНИЕ" in tex
def test_mark_highlights_every_word() -> None:
tex = render("Обычный ==правленый текст== дальше.\n")
assert "\\hlw{правленый} \\hlw{текст}" in tex
assert "Обычный " in tex
assert " дальше." in tex
def test_mark_splits_compound_word_into_separate_boxes() -> None:
# Плашка неразрывна: составное слово одним боксом вылезает за поле.
tex = render("==аппаратно-программный комплекс==\n")
assert "\\hlw{аппаратно}\\hlw{-}\\allowbreak \\hlw{программный}" in tex
def test_mark_in_figure_caption() -> None:
tex = render("![[images/a.png]]\n\nРисунок 1 — ==Схема== комплекса\n")
assert "\\caption{\\hlw{Схема} комплекса}" in tex
def test_mark_in_heading_reaches_the_contents() -> None:
tex = render("## ==Контейнеризация серверного приложения==\n")
assert "\\subsection{\\hlw{Контейнеризация}" in tex
def test_comparison_in_listing_is_not_a_mark() -> None:
tex = body(render("```cpp\nif (a==b && c==d) return;\n```\n"))
assert "\\hlw" not in tex
assert "a==b" in tex
def test_list_punctuation() -> None:
tex = render("- один\n- два\n- три\n")
assert " \\item один;" in tex
assert " \\item три." in tex
def test_figure_with_caption(tmp_path) -> None:
tex = render(
"![[images/antenna.png]]\n\nРисунок 1 — Антенна\n\n"
"Данная антенна используется для ...\n"
)
assert "\\begin{figure}[htb]" in tex
assert "\\centering" in tex
assert (
"\\includegraphics[width=0.8\\textwidth]{\\detokenize{images/antenna.png}}"
in tex
)
assert "\\caption{Антенна}" in tex
assert "Данная антенна используется для ..." in tex
def test_figure_without_caption() -> None:
tex = render("![[images/photo.png]]\n\nОбычный абзац после картинки.\n")
assert "\\begin{figure}[htb]" in tex
assert "\\caption{" not in tex
assert "Обычный абзац после картинки." in tex
def test_figure_custom_width() -> None:
tex = render("![[images/scheme.png|50]]\n\nРисунок 2 —— Схема\n")
assert (
"\\includegraphics[width=0.5\\textwidth]{\\detokenize{images/scheme.png}}"
in tex
)
def test_caption_number_is_discarded() -> None:
tex = render("![[images/a.png]]\n\nРисунок 7 —— Что-то\n")
assert "7" not in _figure_block(tex)
def test_inline_image_skipped_with_warning(capsys) -> None:
tex = render("Текст с ![alt](img.png) внутри.\n")
assert "предупреждение: картинка внутри текста пропущена" in capsys.readouterr().err
assert "\\begin{figure}" not in tex
assert "img.png" not in tex
def test_toc_inserted_once() -> None:
tex = render("# СОДЕРЖАНИЕ\n\n# ВВЕДЕНИЕ\n\nТекст.\n\n# ЗАКЛЮЧЕНИЕ\n\nВыводы.\n")
assert tex.count("\\tableofcontents") == 1
def test_handler_must_advance_pos() -> None:
from markdown_it import MarkdownIt
tokens = MarkdownIt().parse(FRONT_MATTER + "# Заголовок\n")
renderer = Renderer(tokens, {"название": "Т", "тип": "Отчёт"})
renderer.render()
broken = Renderer(tokens, {"название": "Т", "тип": "Отчёт"})
from md2gost.registry import BLOCK
original = BLOCK["heading_open"]
BLOCK["heading_open"] = lambda r, tok: ""
try:
try:
broken.render()
raise AssertionError("ожидался RuntimeError")
except RuntimeError as e:
assert "heading_open" in str(e)
finally:
BLOCK["heading_open"] = original
def _figure_block(tex: str) -> str:
start = tex.index("\\begin{figure}")
end = tex.index("\\end{figure}") + len("\\end{figure}")
return tex[start:end]
TABLE_MD = (
"Таблица 1 —— Сравнение скорости\n\n"
"| № Запуска | Python (сек) | C++ (сек) |\n"
"| :--- | :---: | ---: |\n"
"| **1** | 0.027852 | 0.000285 |\n"
)
def test_table_with_caption() -> None:
tex = render(TABLE_MD)
assert "\\begin{table}[htb]" in tex
assert "\\caption{Сравнение скорости}" in tex
assert "\\begin{tabularx}{\\textwidth}" in tex
assert "\\textbf{1} & 0.027852 & 0.000285 \\\\" in tex
def test_table_caption_number_is_discarded() -> None:
tex = render(TABLE_MD)
block = _table_block(tex)
assert "Таблица 1" not in block
def test_table_without_caption_stays_bare() -> None:
tex = render("| a | b |\n| --- | --- |\n| 1 | 2 |\n")
assert "\\begin{table}" not in tex
assert "\\begin{tabularx}{\\textwidth}" in tex
def test_table_caption_paragraph_is_not_duplicated() -> None:
tex = render(TABLE_MD)
assert tex.count("Сравнение скорости") == 1
def _table_block(tex: str) -> str:
start = tex.index("\\begin{table}")
end = tex.index("\\end{table}") + len("\\end{table}")
return tex[start:end]
def test_titlepage_default_is_used_when_field_absent() -> None:
tex = render("Текст.\n")
assert "\\begin{titlepage}" in tex
assert "ОТЧЁТ ПО УЧЕБНОЙ ПРАКТИКЕ" not in tex
def test_titlepage_registry_lists_three_layouts() -> None:
assert set(TITLEPAGES) == {"default", "ptp-report", "ptp-task"}
def test_titlepage_unknown_name_raises() -> None:
with pytest.raises(ValueError, match="ptp-report"):
render_fm("титульник: несуществующий\n")
PTP_REPORT_FM = (
"титульник: ptp-report\n"
"факультет: Информатика и системы управления (ИУ)\n"
"кафедра: Теоретическая информатика и компьютерные технологии (ИУ9)\n"
"студент: И. И. Камалетдинов\n"
"студент_полностью: Камалетдинов Ильяс Илнарович\n"
"группа: ИУ9-22Б\n"
"тип_практики: проектно-технологическая практика\n"
"предприятие: МГТУ имени Н. Э. Баумана\n"
"команда: Система мониторинга радиоэфира\n"
"хранилище: https://monitor.yss.su/IU9/radio-serv\n"
"руководитель: Д. П. Посевин\n"
"год: 2026\n"
)
def test_ptp_report_has_blank_headings() -> None:
tex = render_fm(PTP_REPORT_FM)
for heading in (
"ОТЧЁТ ПО УЧЕБНОЙ ПРАКТИКЕ",
"Тип практики",
"Название предприятия",
"Команда",
"Оценка",
"Руководитель практики",
):
assert heading in tex
def test_ptp_report_fills_all_fields() -> None:
tex = render_fm(PTP_REPORT_FM)
for value in (
"Камалетдинов Ильяс Илнарович",
"И. И. Камалетдинов",
"ИУ9-22Б",
"проектно-технологическая практика",
"МГТУ имени Н. Э. Баумана",
"Д. П. Посевин",
"2026",
):
assert value in tex
def test_ptp_report_team_line_includes_repository() -> None:
tex = render_fm(PTP_REPORT_FM)
assert "Система мониторинга радиоэфира (хранилище" in tex
assert "monitor.yss.su/IU9/radio-serv" in tex
def test_ptp_report_team_line_without_repository() -> None:
fm = PTP_REPORT_FM.replace("хранилище: https://monitor.yss.su/IU9/radio-serv\n", "")
tex = render_fm(fm)
assert "хранилище" not in tex
def test_ptp_report_has_no_hardcoded_names() -> None:
"""Имена, группы и предприятия приходят только из front matter."""
import inspect
from md2gost.latex.titlepages import ptp_report
source = inspect.getsource(ptp_report)
for forbidden in ("Посевин", "Коновалов", "Камалетдинов", "ИУ9-2"):
assert forbidden not in source
PTP_TASK_FM = (
"титульник: ptp-task\n"
"кафедра: Теоретическая информатика и компьютерные технологии\n"
"кафедра_индекс: ИУ9\n"
"студент: Булдаков А. С.\n"
"студент_полностью: Булдаков Арсений Сергеевич\n"
"группа: ИУ9-22Б\n"
"предприятие: МГТУ имени Н. Э. Баумана\n"
"команда: Система мониторинга радиоэфира\n"
"хранилище: https://monitor.yss.su/IU9/radio-serv\n"
"руководитель: Посевин Д. П.\n"
"дата_выдачи_день: 29\n"
"дата_выдачи_месяц: июня\n"
"дата_выдачи_год: 2026\n"
"пункт_1: Изучить принципы построения систем пеленгации.\n"
"пункт_2: Получить задание в составе команды:\n"
"пункт_10: Написать отчёт.\n"
)
def test_ptp_task_renders_items_in_numeric_order() -> None:
"""Пункт 10 идёт после пункта 2, а не между 1 и 2 как при сортировке строк."""
tex = render_fm(PTP_TASK_FM)
first = tex.index("Изучить принципы построения систем пеленгации.")
second = tex.index("Получить задание в составе команды:")
third = tex.index("Написать отчёт.")
assert first < second < third
def test_ptp_task_requires_items() -> None:
with pytest.raises(ValueError, match="пункт_1"):
render_fm("титульник: ptp-task\nстудент: Булдаков А. С.\n")
def test_ptp_task_has_blank_furniture() -> None:
tex = render_fm(PTP_TASK_FM)
for fragment in (
"УТВЕРЖДАЮ",
"Заведующий кафедрой",
"З\\,А\\\\,А\\,Н\\\\,Е",
"на прохождение учебной практики",
"Дата выдачи задания",
"Руководитель практики от кафедры",
):
assert fragment in tex
def test_ptp_task_student_line_carries_group() -> None:
tex = render_fm(PTP_TASK_FM)
assert "Булдаков Арсений Сергеевич, ИУ9-22Б" in tex
def test_ptp_task_team_line_follows_item_with_colon() -> None:
tex = render_fm(PTP_TASK_FM)
colon_item = tex.index("Получить задание в составе команды:")
team = tex.index("Система мониторинга радиоэфира (хранилище")
next_item = tex.index("Написать отчёт.")
assert colon_item < team < next_item
def test_ptp_task_has_no_emblem() -> None:
"""В бланке задания эмблемы нет."""
tex = render_fm(PTP_TASK_FM)
titlepage = tex[tex.index("\\begin{titlepage}") : tex.index("\\end{titlepage}")]
assert "emblem" not in titlepage
def test_ptp_task_has_no_hardcoded_assignment_text() -> None:
"""Пункты задания и имена приходят только из front matter."""
import inspect
from md2gost.latex.titlepages import ptp_task
source = inspect.getsource(ptp_task)
for forbidden in (
"Web",
"2D-игра",
"GitFlic",
"Javascript",
"Посевин",
"Коновалов",
):
assert forbidden not in source
LISTING_MD = (
"Листинг 1 —— Потокобезопасная очередь\n\n"
"```cpp\n"
"template <typename T>\n"
"class ThreadSafeQueue {};\n"
"```\n"
)
def test_listing_with_caption() -> None:
tex = render(LISTING_MD)
assert "caption={Потокобезопасная очередь}" in tex
assert "language={[modern]C++}" in tex
assert "class ThreadSafeQueue {};" in tex
def test_listing_caption_number_is_discarded() -> None:
tex = render(LISTING_MD)
assert "Листинг 1" not in tex
def test_listing_caption_paragraph_is_not_duplicated() -> None:
tex = render(LISTING_MD)
assert tex.count("Потокобезопасная очередь") == 1
def test_listing_without_caption_stays_bare() -> None:
tex = render("```cpp\nint main() {}\n```\n")
assert "\\begin{lstlisting}[language={[modern]C++}]" in tex
assert "caption=" not in tex
def test_listing_caption_without_language() -> None:
tex = render("Листинг 2 —— Формат строки\n\n```\nid,freq,power\n```\n")
assert "caption={Формат строки}" in tex
assert "language={" not in tex
def test_paragraph_starting_with_listing_word_is_not_a_caption() -> None:
tex = render("Листинг приведён ниже.\n\n```cpp\nint x;\n```\n")
assert "caption=" not in tex
assert "Листинг приведён ниже." in tex
def test_listing_language_is_mapped_to_listings_name() -> None:
"""listings не знает 'cpp', только 'C++'."""
tex = render("```cpp\nint x;\n```\n")
assert "language={[modern]C++}" in tex
def test_listing_unsupported_language_falls_back_to_plain() -> None:
tex = render("```yaml\nkey: value\n```\n")
assert "language={" not in tex
assert "key: value" in tex
WIDE_TABLE_MD = (
"Таблица 1 —— Сценарии интеграции\n\n"
"| Сценарий | Событие | Что делает |\n"
"| :--- | :--- | :--- |\n"
"| build.yml | отправка изменений | Собирает образ Docker целиком |\n"
)
def test_wide_table_uses_tabularx_so_it_fits_the_page() -> None:
"""Колонки с текстом должны переноситься, а не вылезать за поле."""
tex = render(WIDE_TABLE_MD)
assert "\\begin{tabularx}{\\textwidth}" in tex
assert "\\begin{tabular}{" not in tex
def test_table_left_columns_become_stretchable() -> None:
"""Растягиваются колонки с текстом; «Сценарий» короткая и остаётся узкой."""
tex = render(WIDE_TABLE_MD)
assert tex.count(">{\\raggedright\\arraybackslash}X") == 2
def test_table_keeps_center_columns_narrow() -> None:
"""Все колонки короткие: растягивать нечего, таблица идёт по содержимому."""
tex = body(render(TABLE_MD))
assert "\\begin{tabular}{|l|c|r|}" in tex
assert "\\begin{tabularx}" not in tex
def test_table_without_left_columns_stays_plain_tabular() -> None:
tex = body(render("| a | b |\n| :---: | :---: |\n| 1 | 2 |\n"))
assert "\\begin{tabular}{|c|c|}" in tex
assert "\\begin{tabularx}" not in tex
LONG_TOKEN_TABLE_MD = (
"| Направление | Формат | Назначение |\n"
"| :--- | :--- | :--- |\n"
"| сервер | HackRFTestingServer:идентификатор | Приветствие, подтверждает |\n"
)
def test_long_token_in_cell_gets_break_points() -> None:
"""Иначе токен не переносится и печатается поверх соседней колонки."""
tex = body(render(LONG_TOKEN_TABLE_MD))
assert "HackRFTestingServer:\\allowbreak" in tex
def test_short_cells_are_left_alone() -> None:
tex = body(render("| a | b |\n| :--- | :--- |\n| ping | сервер |\n"))
assert "\\allowbreak" not in tex
def test_break_points_do_not_touch_latex_commands() -> None:
"""В ячейке с разметкой есть команды; ломать их вставками нельзя."""
tex = body(
render(
"| Поле | Значение |\n| :--- | :--- |\n"
"| **оченьдлинноежирноеслово_подчёркивание** | x |\n"
)
)
assert "\\textbf{" in tex
assert "\\textbf\\allowbreak" not in tex
MIXED_WIDTH_TABLE_MD = (
"| Сценарий | Что делает |\n"
"| :--- | :--- |\n"
"| build.yml | Собирает образ Docker целиком и публикует его в реестр |\n"
"| test.yml | Собирает проект через CMake и прогоняет набор тестов |\n"
)
def test_narrow_column_is_not_stretched() -> None:
"""Колонка из коротких значений не должна занимать половину ширины."""
tex = body(render(MIXED_WIDTH_TABLE_MD))
assert f"{{|l|{_STRETCH}|}}" in tex
def test_all_short_columns_stay_plain_tabular() -> None:
tex = body(render("| a | b |\n| :--- | :--- |\n| ping | сервер |\n"))
assert "\\begin{tabular}{|l|l|}" in tex
assert "\\begin{tabularx}" not in tex
ABBR_MD = (
"# ПЕРЕЧЕНЬ ПРИНЯТЫХ СОКРАЩЕНИЙ\n\n"
"SDR — Software-Defined Radio — программно-определяемая радиосистема\n"
"PSO — Particle Swarm Optimisation — метод роевой оптимизации\n"
)
def test_abbreviation_dash_is_single() -> None:
"""Шесть дефисов дают в LaTeX два длинных тире подряд вместо одного."""
tex = body(render(ABBR_MD))
assert "------" not in tex
assert "SDR & Software-Defined Radio" in tex
def test_abbreviation_table_has_no_extra_column_padding() -> None:
"""Отбивка между колонками добавляется к \\linewidth и роняет строку за поле."""
tex = body(render(ABBR_MD))
assert "{@{}l@{\\ ---\\ }X@{}}" in tex
def test_abbreviation_column_is_as_wide_as_the_abbreviation() -> None:
"""Колонка l берёт ширину по содержимому: до тире остаётся пробел."""
tex = body(render(ABBR_MD))
assert "p{" not in tex
assert "\\begin{tabularx}{\\linewidth}" in tex
def test_cpp_uses_extended_dialect() -> None:
"""Встроенный C++ в listings не знает auto, nullptr и override."""
tex = body(render("```cpp\nauto x = nullptr;\n```\n"))
assert "language={[modern]C++}" in tex
def test_captioned_listing_floats() -> None:
"""Неплавающий листинг рвётся между страницами с незамкнутой рамкой."""
tex = body(render(LISTING_MD))
assert "float=htbp" in tex
def test_bare_listing_does_not_float() -> None:
"""Фрагмент без подписи — часть абзаца, ему плавать незачем."""
tex = body(render("```cpp\nint x;\n```\n"))
assert "float=" not in tex
def test_initials_are_not_split_across_lines() -> None:
"""«/ И.» на одной строке и «В.» на следующей — ошибка оформления."""
tex = body(render("Гилев, И. В. Использование технологии SDR.\n"))
assert "И.~В. Использование" in tex
def test_lowercase_abbreviation_is_left_alone() -> None:
tex = body(render("В 3 т., Воронеж, 2025.\n"))
assert "т.~Воронеж" not in tex
def test_compound_word_allows_hyphenation() -> None:
"""TeX не переносит слово с дефисом, и абзац разгоняется пробелами."""
tex = body(render("Разработка программно-аппаратного комплекса.\n"))
assert 'программно"=аппаратного' in tex
def test_latin_hyphen_is_left_alone() -> None:
"""Шорткат babel рассчитан на русский; латинские дефисы не трогаем."""
tex = body(render("Инструмент clang-format и Wayland-сессия.\n"))
assert "clang-format" in tex
assert 'clang"=format' not in tex