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_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}[H]" 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}[H]" 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}[H]" 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 \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 "--- Software-Defined Radio" in tex def test_abbreviation_table_has_no_extra_column_padding() -> None: """Отбивка между колонками добавляется к \\linewidth и роняет строку за поле.""" tex = body(render(ABBR_MD)) assert "@{}p{\\dimexpr\\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