v0.6.0 BigUpdate
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
"""DFD Гейна–Сарсона: локальный Pillow, без Graphviz."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from md2gost.dfd_gs import (
|
||||
SAMPLE,
|
||||
DfdGsParseError,
|
||||
_arrange,
|
||||
parse_dfd_gs,
|
||||
render_dfd_gs,
|
||||
)
|
||||
from md2gost.diagram_renderer import (
|
||||
DIAGRAM_LANGS,
|
||||
configure_diagrams,
|
||||
is_diagram_lang,
|
||||
prepare_source,
|
||||
render_diagram,
|
||||
)
|
||||
|
||||
|
||||
def test_fence_aliases_do_not_steal_yourdon():
|
||||
assert is_diagram_lang("dfd-gs")
|
||||
assert is_diagram_lang("uml-dfd-gs")
|
||||
assert is_diagram_lang("gane-sarson")
|
||||
assert is_diagram_lang("uml-gane-sarson")
|
||||
assert "dfd-gs" in DIAGRAM_LANGS
|
||||
assert prepare_source("dfd-gs", SAMPLE)[1] == "dfd-gs"
|
||||
assert prepare_source("gane-sarson", SAMPLE)[1] == "dfd-gs"
|
||||
assert prepare_source("uml-dfd-gs", SAMPLE)[1] == "dfd-gs"
|
||||
assert prepare_source("dfd", "entity A Клиент\n")[1] == "dfd"
|
||||
assert prepare_source("uml-dfd", "entity A Клиент\n")[1] == "dfd"
|
||||
assert prepare_source("yourdon", "entity A Клиент\n")[1] == "dfd"
|
||||
|
||||
|
||||
def test_badges_and_colon_label():
|
||||
diagram = parse_dfd_gs(
|
||||
"""
|
||||
entity A Художник
|
||||
entity B Заказчик
|
||||
process Accept Принять заявку
|
||||
process 3.1 Проверить срок
|
||||
store Orders Заказы
|
||||
store D4 Акты
|
||||
A --> Accept : файлы работы
|
||||
"""
|
||||
)
|
||||
by_id = {node.id: node for node in diagram.nodes}
|
||||
assert by_id["A"].badge == "1"
|
||||
assert by_id["B"].badge == "2"
|
||||
assert by_id["Accept"].badge == "1"
|
||||
assert by_id["3.1"].badge == "3.1"
|
||||
assert by_id["Orders"].badge == "D1"
|
||||
assert by_id["D4"].badge == "D4"
|
||||
assert diagram.flows[0].label == "файлы работы"
|
||||
assert diagram.form == "kit"
|
||||
assert diagram.title == ""
|
||||
|
||||
|
||||
def test_store_code_skips_taken_numbers():
|
||||
diagram = parse_dfd_gs(
|
||||
"""
|
||||
store D1 Заказы
|
||||
store Orders Журнал
|
||||
"""
|
||||
)
|
||||
badges = {node.id: node.badge for node in diagram.nodes}
|
||||
assert badges == {"D1": "D1", "Orders": "D2"}
|
||||
|
||||
|
||||
def test_parse_errors():
|
||||
with pytest.raises(DfdGsParseError, match="пустой"):
|
||||
parse_dfd_gs(" \n\n")
|
||||
with pytest.raises(DfdGsParseError, match="без узлов"):
|
||||
parse_dfd_gs("# только комментарий\n")
|
||||
with pytest.raises(DfdGsParseError, match="без узлов"):
|
||||
parse_dfd_gs("title Только шапка\n")
|
||||
with pytest.raises(DfdGsParseError, match="повтор"):
|
||||
parse_dfd_gs("entity A Один\nprocess A Два\n")
|
||||
with pytest.raises(DfdGsParseError, match="неизвестный узел"):
|
||||
parse_dfd_gs("entity A Один\nA --> B поток\n")
|
||||
with pytest.raises(DfdGsParseError, match="в себя"):
|
||||
parse_dfd_gs("entity A Один\nA --> A петля\n")
|
||||
with pytest.raises(DfdGsParseError, match="form"):
|
||||
parse_dfd_gs("form fancy\nentity A Один\n")
|
||||
with pytest.raises(DfdGsParseError, match="нет подписи"):
|
||||
parse_dfd_gs("process 1\n")
|
||||
|
||||
|
||||
def test_columns_and_straight_arrow():
|
||||
diagram = parse_dfd_gs(
|
||||
"""
|
||||
entity A Художник
|
||||
process P Принять
|
||||
store S Файлы
|
||||
A --> P заявка
|
||||
P --> S файл
|
||||
"""
|
||||
)
|
||||
layout = _arrange(diagram, 1)
|
||||
assert layout.placed["A"].x < layout.placed["P"].x < layout.placed["S"].x
|
||||
straight = _arrange(
|
||||
parse_dfd_gs("entity A Художник\nprocess P Принять\nA --> P заявка\n"),
|
||||
1,
|
||||
)
|
||||
pts = straight.routes[0].pts
|
||||
assert len(pts) == 2
|
||||
assert abs(pts[0][1] - pts[1][1]) < 1
|
||||
|
||||
|
||||
def test_opposite_flows_do_not_share_a_port():
|
||||
diagram = parse_dfd_gs(
|
||||
"""
|
||||
process P Принять
|
||||
store S Заказы
|
||||
P --> S статус
|
||||
S --> P обновление
|
||||
"""
|
||||
)
|
||||
layout = _arrange(diagram, 1)
|
||||
y1 = layout.routes[0].pts[0][1]
|
||||
y2 = layout.routes[1].pts[0][1]
|
||||
assert abs(y1 - y2) > 10
|
||||
|
||||
|
||||
def test_stacked_processes_link_vertically():
|
||||
diagram = parse_dfd_gs(
|
||||
"""
|
||||
process A Один
|
||||
process B Два
|
||||
A --> B дальше
|
||||
"""
|
||||
)
|
||||
pts = _arrange(diagram, 1).routes[0].pts
|
||||
assert len(pts) == 2
|
||||
assert abs(pts[0][0] - pts[1][0]) < 1
|
||||
assert pts[0][1] < pts[1][1]
|
||||
|
||||
|
||||
def test_skipped_process_is_an_elbow():
|
||||
diagram = parse_dfd_gs(
|
||||
"""
|
||||
process A Один
|
||||
process B Два
|
||||
process C Три
|
||||
A --> C дальше
|
||||
"""
|
||||
)
|
||||
pts = _arrange(diagram, 1).routes[0].pts
|
||||
assert len(pts) >= 3
|
||||
|
||||
|
||||
def test_render_png_and_svg_without_graphviz():
|
||||
png, svg = render_dfd_gs(parse_dfd_gs(SAMPLE), scale=1)
|
||||
assert png.startswith(b"\x89PNG")
|
||||
assert svg is not None
|
||||
assert "USED AT" in svg
|
||||
assert "Тарасов" in svg
|
||||
assert "A44" in svg
|
||||
assert "rx=" in svg
|
||||
assert "Художник" in svg
|
||||
assert "rotate(-90" in svg
|
||||
assert "D2" in svg
|
||||
|
||||
plain = parse_dfd_gs("form plain\nentity A Художник\nprocess P Принять\nA --> P заявка\n")
|
||||
_png, svg_plain = render_dfd_gs(plain, scale=1)
|
||||
assert "USED AT" not in (svg_plain or "")
|
||||
assert "rx=" in (svg_plain or "")
|
||||
|
||||
|
||||
def test_render_diagram_skips_jar_and_kroki(tmp_path):
|
||||
calls = {"jar": 0, "kroki": 0}
|
||||
|
||||
def fake_jar(*_args, **_kwargs):
|
||||
calls["jar"] += 1
|
||||
return False
|
||||
|
||||
def fake_kroki(*_args, **_kwargs):
|
||||
calls["kroki"] += 1
|
||||
return False
|
||||
|
||||
configure_diagrams(fallback="off", cache_dir=str(tmp_path), diagram_scale=1, diagram_format="svg")
|
||||
with patch("md2gost.diagram_renderer._render_plantuml_jar", side_effect=fake_jar), \
|
||||
patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki):
|
||||
result = render_diagram(
|
||||
"dfd-gs", SAMPLE, cache_dir=str(tmp_path), diagram_scale=1, diagram_format="svg",
|
||||
)
|
||||
assert calls["jar"] == 0
|
||||
assert calls["kroki"] == 0
|
||||
assert Path(result.png_path).read_bytes().startswith(b"\x89PNG")
|
||||
assert result.svg_path and Path(result.svg_path).read_text(encoding="utf-8").startswith("<svg")
|
||||
assert result.pixel_scale == 1
|
||||
|
||||
|
||||
def test_render_diagram_bad_source(tmp_path):
|
||||
configure_diagrams(fallback="off", cache_dir=str(tmp_path))
|
||||
with pytest.raises(RuntimeError, match="пустой"):
|
||||
render_diagram("gane-sarson", " ", cache_dir=str(tmp_path))
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Output path when the GUI switches between MD → DOCX and DOCX → MD."""
|
||||
|
||||
import os
|
||||
|
||||
from md2gost.gui import (
|
||||
coerce_docx_output,
|
||||
import_output_path,
|
||||
next_import_output,
|
||||
next_markdown_output,
|
||||
output_tracks_source,
|
||||
)
|
||||
from md2gost.pipeline import default_output_path
|
||||
|
||||
|
||||
def test_import_markdown_counts_as_automatic(tmp_path):
|
||||
docx = tmp_path / "шаблон таблицы.docx"
|
||||
imported = import_output_path(str(docx))
|
||||
assert imported.endswith(".md")
|
||||
assert output_tracks_source(str(docx), imported)
|
||||
# default_output_path appends .docx and must not be the import target
|
||||
assert not output_tracks_source(str(docx), default_output_path(str(docx)))
|
||||
|
||||
|
||||
def test_markdown_after_import_uses_docx(tmp_path):
|
||||
"""MD → DOCX, then DOCX → MD, then another .md must not keep the .md output."""
|
||||
report = tmp_path / "1исп.md"
|
||||
imported = import_output_path(str(tmp_path / "шаблон таблицы.docx"))
|
||||
|
||||
out, auto = next_markdown_output(str(report), imported, auto=True)
|
||||
assert out == default_output_path(str(report))
|
||||
assert auto is True
|
||||
|
||||
# Old GUI cleared the auto flag after import, so the .md path stuck.
|
||||
out, auto = next_markdown_output(str(report), imported, auto=False)
|
||||
assert out == default_output_path(str(report))
|
||||
assert out.lower().endswith(".docx")
|
||||
assert auto is True
|
||||
|
||||
|
||||
def test_custom_docx_is_kept_when_switching_markdown(tmp_path):
|
||||
other = tmp_path / "other.md"
|
||||
custom = str(tmp_path / "pinned.docx")
|
||||
out, auto = next_markdown_output(str(other), custom, auto=False)
|
||||
assert out == custom
|
||||
assert auto is False
|
||||
|
||||
|
||||
def test_import_replaces_previous_docx_target(tmp_path):
|
||||
src = tmp_path / "шаблон таблицы.docx"
|
||||
previous = str(tmp_path / "report.docx")
|
||||
out, auto = next_import_output(str(src), previous, auto=False)
|
||||
assert os.path.normcase(out) == os.path.normcase(import_output_path(str(src)))
|
||||
assert auto is True
|
||||
|
||||
|
||||
def test_convert_does_not_reuse_import_markdown(tmp_path):
|
||||
report = tmp_path / "1исп.md"
|
||||
stale = str(tmp_path / "шаблон таблицы.md")
|
||||
assert coerce_docx_output(str(report), stale) == default_output_path(str(report))
|
||||
keep = str(tmp_path / "keep.docx")
|
||||
assert coerce_docx_output(str(report), keep) == keep
|
||||
@@ -330,3 +330,121 @@ def test_landscape_listing_deferred_after_section(tmp_path, monkeypatch):
|
||||
texts = [p.text for p in doc.paragraphs]
|
||||
listing_caps = [t for t in texts if t.startswith("Листинг")]
|
||||
assert listing_caps, f"no listing caption in {texts!r}"
|
||||
|
||||
|
||||
def _empty_section_break_pairs(doc) -> int:
|
||||
"""Pairs of next-page breaks with no body content between them (a blank page)."""
|
||||
children = list(doc.element.body)
|
||||
pairs = 0
|
||||
prev_empty_break = False
|
||||
for child in children:
|
||||
if child.tag == f"{W_NS}sectPr":
|
||||
continue
|
||||
is_break = _is_break_paragraph(child)
|
||||
if is_break:
|
||||
if prev_empty_break:
|
||||
pairs += 1
|
||||
prev_empty_break = True
|
||||
continue
|
||||
prev_empty_break = False
|
||||
return pairs
|
||||
|
||||
|
||||
def _fresh_doc():
|
||||
doc = Document(os.path.join(package_dir(), "Template.docx"))
|
||||
body = doc._body._element
|
||||
for child in list(body):
|
||||
if child.tag.endswith("}sectPr"):
|
||||
continue
|
||||
body.remove(child)
|
||||
return doc
|
||||
|
||||
|
||||
def _small_table(doc, name: str, *, landscape: bool) -> Table:
|
||||
table = Table(doc._body, 2, 2, CaptionInfo(name, name, landscape=landscape))
|
||||
for r in range(2):
|
||||
for c in range(2):
|
||||
table.add_paragraph_to_cell(r, c).add_run(f"{name}{r}{c}")
|
||||
return table
|
||||
|
||||
|
||||
def test_consecutive_landscape_tables_share_section():
|
||||
"""Two +landscape tables in a row must not put a blank portrait page between them."""
|
||||
doc = _fresh_doc()
|
||||
Renderer(doc, skip_numbering=True).process([
|
||||
_small_table(doc, "A", landscape=True),
|
||||
_small_table(doc, "B", landscape=True),
|
||||
])
|
||||
assert _empty_section_break_pairs(doc) == 0
|
||||
assert [s.orientation for s in doc.sections] == [
|
||||
WD_ORIENT.PORTRAIT,
|
||||
WD_ORIENT.LANDSCAPE,
|
||||
WD_ORIENT.PORTRAIT,
|
||||
]
|
||||
assert doc.sections[-1].orientation == WD_ORIENT.PORTRAIT
|
||||
|
||||
|
||||
def test_landscape_then_text_then_landscape_returns_to_portrait():
|
||||
doc = _fresh_doc()
|
||||
from md2gost.renderable.paragraph import Paragraph
|
||||
|
||||
middle = Paragraph(doc._body)
|
||||
middle.add_run("Между таблицами.")
|
||||
Renderer(doc, skip_numbering=True).process([
|
||||
_small_table(doc, "A", landscape=True),
|
||||
middle,
|
||||
_small_table(doc, "B", landscape=True),
|
||||
])
|
||||
assert _empty_section_break_pairs(doc) == 0
|
||||
assert [s.orientation for s in doc.sections] == [
|
||||
WD_ORIENT.PORTRAIT,
|
||||
WD_ORIENT.LANDSCAPE,
|
||||
WD_ORIENT.PORTRAIT,
|
||||
WD_ORIENT.LANDSCAPE,
|
||||
WD_ORIENT.PORTRAIT,
|
||||
]
|
||||
|
||||
|
||||
def test_consecutive_landscape_photos_share_section(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
png = tmp_path / "photo.png"
|
||||
PILImage.new("RGB", (80, 40), color=(0, 128, 255)).save(png)
|
||||
os.environ["WORKING_DIR"] = str(tmp_path)
|
||||
|
||||
from md2gost.renderable.paragraph import Paragraph
|
||||
|
||||
doc = _fresh_doc()
|
||||
first = Paragraph(doc._body)
|
||||
first.add_image(str(png), CaptionInfo("p1", "Фото 1", landscape=True))
|
||||
second = Paragraph(doc._body)
|
||||
second.add_image(str(png), CaptionInfo("p2", "Фото 2", landscape=True))
|
||||
Renderer(doc, skip_numbering=True).process([first, second])
|
||||
|
||||
assert _empty_section_break_pairs(doc) == 0
|
||||
assert [s.orientation for s in doc.sections] == [
|
||||
WD_ORIENT.PORTRAIT,
|
||||
WD_ORIENT.LANDSCAPE,
|
||||
WD_ORIENT.PORTRAIT,
|
||||
]
|
||||
|
||||
|
||||
def test_second_landscape_photo_is_not_shrunk_into_leftover(tmp_path, monkeypatch):
|
||||
"""A follower that does not fit the rest of the page keeps its page size."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
png = tmp_path / "tall.png"
|
||||
PILImage.new("RGB", (2000, 2000), color=(0, 128, 255)).save(png)
|
||||
os.environ["WORKING_DIR"] = str(tmp_path)
|
||||
|
||||
doc = _fresh_doc()
|
||||
first = Image(doc._body, str(png), CaptionInfo("p1", "Фото 1", landscape=True))
|
||||
second = Image(doc._body, str(png), CaptionInfo("p2", "Фото 2", landscape=True))
|
||||
Renderer(doc, skip_numbering=True).process([first, second])
|
||||
|
||||
A = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
|
||||
heights = []
|
||||
for child in doc.element.body:
|
||||
ext = child.find(f".//{A}ext")
|
||||
if ext is not None:
|
||||
heights.append(int(ext.get("cy")))
|
||||
assert len(heights) == 2
|
||||
assert heights[1] > heights[0] * 0.9
|
||||
|
||||
+51
-1
@@ -17,6 +17,7 @@ from md2gost.office import (
|
||||
fix_continuations_with_engine,
|
||||
resolve_office_engine,
|
||||
)
|
||||
from md2gost.office.detect import clear_office_engine_cache
|
||||
from md2gost.office import uno_fix
|
||||
from md2gost.office import uno_profile
|
||||
from md2gost.office.uno_cli import soffice_cli_path
|
||||
@@ -49,6 +50,13 @@ def test_parser_office_engine_libre():
|
||||
assert req.office_engine == "libre"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_office_cache():
|
||||
clear_office_engine_cache()
|
||||
yield
|
||||
clear_office_engine_cache()
|
||||
|
||||
|
||||
@patch("md2gost.office.detect._detect_word")
|
||||
@patch("md2gost.office.detect._find_soffice_install")
|
||||
def test_resolve_auto_prefers_word(mock_lo, mock_word):
|
||||
@@ -112,6 +120,29 @@ def test_detect_libre_from_custom_path(tmp_path):
|
||||
assert install.python_path == str(python_bin)
|
||||
|
||||
|
||||
@patch("md2gost.office.detect._word_com_available", return_value=True)
|
||||
def test_detect_word_without_starting_com(_mock_avail):
|
||||
install = detect_office_install("word")
|
||||
assert install is not None
|
||||
assert install.engine == "word"
|
||||
|
||||
|
||||
@patch("md2gost.office.detect._detect_word")
|
||||
@patch("md2gost.office.detect._find_soffice_install")
|
||||
def test_resolve_auto_cached_on_second_call(mock_lo, mock_word):
|
||||
mock_word.return_value = OfficeInstall(engine="word", label="Microsoft Word")
|
||||
mock_lo.return_value = OfficeInstall(
|
||||
engine="libre",
|
||||
label="LibreOffice",
|
||||
soffice_path=r"C:\LibreOffice\program\soffice.exe",
|
||||
python_path=r"C:\LibreOffice\program\python.exe",
|
||||
)
|
||||
first = resolve_office_engine("auto")
|
||||
second = resolve_office_engine("auto")
|
||||
assert first == second == ("word", mock_word.return_value)
|
||||
mock_word.assert_called_once()
|
||||
|
||||
|
||||
@patch("md2gost.office.dispatch.fix_continuations")
|
||||
@patch("md2gost.office.dispatch.resolve_office_engine")
|
||||
def test_dispatch_word_backend(mock_resolve, mock_word_fix):
|
||||
@@ -122,6 +153,21 @@ def test_dispatch_word_backend(mock_resolve, mock_word_fix):
|
||||
mock_word_fix.assert_called_once()
|
||||
|
||||
|
||||
@patch("md2gost.office.dispatch.fix_continuations")
|
||||
@patch("md2gost.office.dispatch.resolve_office_engine")
|
||||
def test_dispatch_uses_preresolved_install(mock_resolve, mock_word_fix):
|
||||
install = OfficeInstall(engine="word", label="Microsoft Word")
|
||||
mock_word_fix.return_value = FixResult(True, splits=1, message="ok")
|
||||
result = fix_continuations_with_engine(
|
||||
"report.docx",
|
||||
engine="word",
|
||||
install=install,
|
||||
)
|
||||
assert result.ok is True
|
||||
mock_resolve.assert_not_called()
|
||||
mock_word_fix.assert_called_once()
|
||||
|
||||
|
||||
@patch("md2gost.office.dispatch.fix_continuations_uno")
|
||||
@patch("md2gost.office.dispatch.resolve_office_engine")
|
||||
def test_dispatch_libre_backend(mock_resolve, mock_uno):
|
||||
@@ -199,7 +245,9 @@ def test_uno_call_method_or_attribute():
|
||||
assert uno_call(_DocAttr(), "Text") == "attr"
|
||||
|
||||
|
||||
def test_uno_worker_env_includes_program_dir():
|
||||
def test_uno_worker_env_includes_program_dir(monkeypatch):
|
||||
monkeypatch.setenv("PYTHONHOME", r"C:\Python310")
|
||||
monkeypatch.setenv("PYTHONPATH", r"C:\Python310\Lib")
|
||||
install = OfficeInstall(
|
||||
engine="openoffice",
|
||||
label="OpenOffice",
|
||||
@@ -209,6 +257,8 @@ def test_uno_worker_env_includes_program_dir():
|
||||
worker_dir = Path(r"C:\temp\worker")
|
||||
with patch.object(uno_fix, "_office_program_dir", return_value=Path(r"C:\OpenOffice 4\program")):
|
||||
env, cwd = uno_fix._uno_worker_env(worker_dir, install)
|
||||
assert "PYTHONHOME" not in env
|
||||
assert r"C:\Python310\Lib" not in env["PYTHONPATH"]
|
||||
assert r"C:\OpenOffice 4\program" in env["PYTHONPATH"]
|
||||
assert str(worker_dir) in env["PYTHONPATH"]
|
||||
assert cwd == Path(r"C:\OpenOffice 4\program")
|
||||
|
||||
@@ -62,6 +62,39 @@ def test_parse_colspan_markers():
|
||||
assert data[2].merge_h == "none"
|
||||
|
||||
|
||||
SAMPLE_LT_HEADER = """\
|
||||
| Ситуация | < | < | < |
|
||||
|---|---|---|---|
|
||||
| Шаг | a | b | c |
|
||||
"""
|
||||
|
||||
SAMPLE_LT_BODY = """\
|
||||
| A | B | C |
|
||||
|---|---|---|
|
||||
| wide | < | Z |
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_lt_colspan_in_header():
|
||||
doc = markdown.parse(SAMPLE_LT_HEADER)
|
||||
t = next(c for c in doc.children if isinstance(c, MdTable))
|
||||
header = t.children[0].children
|
||||
assert header[0].header is True
|
||||
assert header[0].merge_h == "restart"
|
||||
assert [c.merge_h for c in header[1:]] == ["continue", "continue", "continue"]
|
||||
body = t.children[1].children
|
||||
assert [c.merge_h for c in body] == ["none", "none", "none", "none"]
|
||||
|
||||
|
||||
def test_parse_lt_colspan_in_body():
|
||||
doc = markdown.parse(SAMPLE_LT_BODY)
|
||||
t = next(c for c in doc.children if isinstance(c, MdTable))
|
||||
data = t.children[1].children
|
||||
assert data[0].merge_h == "restart"
|
||||
assert data[1].merge_h == "continue"
|
||||
assert data[2].merge_h == "none"
|
||||
|
||||
|
||||
def test_apply_cell_merge_oxml():
|
||||
doc = Document()
|
||||
table = create_table(doc, 0, 2, doc.sections[0].page_width)
|
||||
@@ -153,6 +186,17 @@ def test_checker_merge_col_error():
|
||||
def test_checker_merge_ok():
|
||||
issues = check_table_merge(SAMPLE_ROWSPAN)
|
||||
assert issues == []
|
||||
assert check_table_merge(SAMPLE_LT_HEADER) == []
|
||||
|
||||
|
||||
def test_checker_lt_first_column():
|
||||
bad = """\
|
||||
| A | B |
|
||||
|---|---|
|
||||
| < | 2 |
|
||||
"""
|
||||
issues = check_table_merge(bad)
|
||||
assert any(i.id == "table.merge_col" for i in issues)
|
||||
|
||||
|
||||
def test_table_continuation_modes():
|
||||
|
||||
@@ -14,8 +14,11 @@ from md2gost.word_fix import (
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -61,6 +64,68 @@ def test_find_page_break_row():
|
||||
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
|
||||
@@ -104,6 +169,68 @@ def test_fix_continuations_missing_file():
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user