268 lines
8.4 KiB
Python
268 lines
8.4 KiB
Python
"""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()
|