"""Tests for Word COM table/listing continuation post-process (pure + optional COM).""" from __future__ import annotations import os import sys import tempfile import pytest from md2gost.profiles import LISTING_CONTINUATION_MODES, TABLE_CONTINUATION_MODES from md2gost.word_fix import ( continuation_label, find_page_break_row, fix_continuations, is_caption_orphaned, continuation_pass_limit, is_orphan_header_break, locate_break_row, parse_caption_text, resume_page_before_cut, split_vmerge_spans_at_break, ) def test_word_mode_in_profiles(): assert "word" in TABLE_CONTINUATION_MODES assert "word" in LISTING_CONTINUATION_MODES @pytest.mark.parametrize( "text, kind, number, cont", [ ("Таблица 2.1 — Название", "table", "2.1", False), ("Таблица 1", "table", "1", False), ("Продолжение Таблицы 2.1", "table", "2.1", True), ("Листинг 3 — Код", "listing", "3", False), ("Продолжение Листинга 3", "listing", "3", True), ("Таблицы 1.2", "table", "1.2", False), ], ) def test_parse_caption_text(text, kind, number, cont): info = parse_caption_text(text) assert info is not None assert info.kind == kind assert info.number == number assert info.is_continuation is cont def test_parse_caption_rejects_noise(): assert parse_caption_text("") is None assert parse_caption_text("Рисунок 1 — x") is None assert parse_caption_text("просто текст") is None def test_find_page_break_row(): assert find_page_break_row([]) is None assert find_page_break_row([1]) is None assert find_page_break_row([1, 1, 1]) is None assert find_page_break_row([1, 1, 2, 2]) == 3 assert find_page_break_row([2, 2, 3]) == 3 assert find_page_break_row([1, 2]) == 2 # typical vMerge table pages after cell-based detection assert find_page_break_row([14, 14, 14, 14, 14, 14, 15, 15]) == 7 def test_resume_page_before_cut(): assert resume_page_before_cut(1) == 1 assert resume_page_before_cut(2) == 1 assert resume_page_before_cut(15) == 14 def test_locate_break_row_matches_full_scan_from_page_1(): samples = [ [1, 1, 2, 2], [2, 2, 3], [1, 2], [14, 14, 14, 14, 14, 14, 15, 15], [1, 1, 1], [8] * 30 + [9] * 10 + [12] * 5, ] for pages in samples: def page_at(row, pages=pages): return pages[row - 1] found, cut = locate_break_row(len(pages), page_at, 1) assert found == find_page_break_row(pages) if found: assert cut == pages[found - 1] def test_locate_break_row_starts_at_page_before_cut(): """A long table is not read from row 1 when the window starts near the cut.""" pages = [10] * 80 + [14] * 8 + [15] * 120 probes: list[int] = [] def page_at(row): probes.append(row) return pages[row - 1] found, cut = locate_break_row(len(pages), page_at, min_page=14) assert found == 81 assert cut == 14 assert len(probes) < 40 continuation = [15] * 12 + [16] * 200 cont_probes: list[int] = [] def continuation_at(row): cont_probes.append(row) return continuation[row - 1] found, cut = locate_break_row( len(continuation), continuation_at, min_page=resume_page_before_cut(15), ) assert (found, cut) == (13, 16) assert len(cont_probes) < 30 def test_continuation_pass_limit(): assert continuation_pass_limit(0, 0) == 10 assert continuation_pass_limit(3, 0) == 13 assert continuation_pass_limit(3, 1) == 14 assert continuation_pass_limit(3, 5) == 18 def test_orphan_header_and_caption_guards(): """Method guide: no title-only / title+column-headers alone at page end.""" # Split would leave only heading row on previous page assert is_orphan_header_break(2, had_header=True) is True assert is_orphan_header_break(2, had_header=False) is False assert is_orphan_header_break(3, had_header=True) is False assert is_orphan_header_break(None, had_header=True) is False # Caption on earlier page than first table row assert is_caption_orphaned(5, 6) is True assert is_caption_orphaned(5, 5) is False assert is_caption_orphaned(None, 6) is False def test_split_vmerge_spans_at_break(): # (row, col, span, text) — merge rows 2..6 (span 5), break at row 7 → all in first spans = [(2, 1, 5, "A"), (7, 1, 2, "B")] first, cont = split_vmerge_spans_at_break(spans, 7) assert first == [(2, 1, 5, "A")] assert cont == [(1, 1, 2, "B")] # merge crosses break: rows 5..8, break at 7 first, cont = split_vmerge_spans_at_break([(5, 1, 4, "X")], 7) assert first == [(5, 1, 2, "X")] assert cont == [(1, 1, 2, "X")] def test_continuation_label(): assert continuation_label("table", "2.1") == "Продолжение Таблицы 2.1" assert continuation_label("listing", "4") == "Продолжение Листинга 4" def test_word_paged_modes_include_word(): from md2gost.renderable.table import _WORD_PAGED_MODES as t from md2gost.renderable.listing import _WORD_PAGED_MODES as L assert "word" in t assert "word" in L def test_fix_continuations_missing_file(): r = fix_continuations(os.path.join(tempfile.gettempdir(), "md2gost-no-such.docx")) assert r.ok is False assert "не найден" in r.message.lower() or "Файл" in r.message def test_word_com_apartment_pairs_init(monkeypatch): import types calls = [] fake = types.ModuleType("pythoncom") fake.CoInitialize = lambda: calls.append("init") fake.CoUninitialize = lambda: calls.append("uninit") monkeypatch.setitem(sys.modules, "pythoncom", fake) from md2gost.word_fix import word_com_apartment with word_com_apartment(): calls.append("body") assert calls == ["init", "body", "uninit"] def test_word_com_apartment_skips_uninit_when_init_fails(monkeypatch): import types calls = [] fake = types.ModuleType("pythoncom") def boom(): raise RuntimeError("coinit") fake.CoInitialize = boom fake.CoUninitialize = lambda: calls.append("uninit") monkeypatch.setitem(sys.modules, "pythoncom", fake) from md2gost.word_fix import word_com_apartment with pytest.raises(RuntimeError, match="coinit"): with word_com_apartment(): calls.append("body") assert calls == [] @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") def test_second_thread_can_dispatch_com(): """A fresh thread must call CoInitialize itself; module import does it once.""" try: import win32com.client # noqa: F401 except ImportError: pytest.skip("pywin32 not installed") import threading from md2gost.word_fix import word_com_apartment errors: list[BaseException] = [] def work(): try: with word_com_apartment(): win32com.client.Dispatch("Scripting.Dictionary") except Exception as exc: errors.append(exc) for _ in range(2): thread = threading.Thread(target=work) thread.start() thread.join() assert errors == [] @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") @pytest.mark.skipif(os.environ.get("MD2GOST_TEST_WORD") != "1", reason="set MD2GOST_TEST_WORD=1 to run Word COM smoke") def test_fix_continuations_com_smoke(tmp_path): """Build a tall table DOCX via python-docx, then run Word fix (opt-in).""" try: import win32com.client # noqa: F401 except ImportError: pytest.skip("pywin32 not installed") from docx import Document from docx.shared import Pt path = tmp_path / "tall_table.docx" doc = Document() p = doc.add_paragraph("Таблица 1 — Длинная") try: p.style = "Caption" except KeyError: pass table = doc.add_table(rows=1, cols=2) table.rows[0].cells[0].text = "A" table.rows[0].cells[1].text = "B" for i in range(80): row = table.add_row() row.cells[0].text = f"row {i}" row.cells[1].text = "x" * 20 for cell in row.cells: for para in cell.paragraphs: para.paragraph_format.space_after = Pt(6) doc.save(str(path)) result = fix_continuations(str(path), tables=True, listings=False) assert result.ok, result.message assert path.is_file()