Files
md_to_gost/tests/test_diagram_renderer.py
T
Igor20264 f7136bd8bd
Example document / build (push) Has been cancelled
Python application / build (push) Has been cancelled
v0.5.3
- Мелкие исправления увеличивающие работу конвертера
2026-09-10 10:50:15 +03:00

815 lines
29 KiB
Python

"""Tests for diagram renderer, schemes, and include cache."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import json
import pytest
from md2gost.diagram_includes import (
include_cache_index_path,
reset_include_cache,
resolve_url,
rewrite_http_includes_in_source,
)
from md2gost.diagram_renderer import (
DIAGRAM_LANGS,
PLANTUML_CACHE_PREFIX,
PLANTUML_VERSION,
_cache_paths,
_PumlJob,
_plantuml_run,
configure_diagrams,
diagram_engine_status,
is_diagram_lang,
prepare_source,
register_pending_diagram,
render_diagram,
reset_diagram_batch,
resolve_plantuml_jar,
)
from md2gost.diagram_schemes import (
DiagramScheme,
apply_scheme,
bundled_schemes_path,
claim_user_scheme_if_diverged,
configure_schemes,
ensure_user_schemes,
load_schemes_from_path,
migrate_user_schemes,
prepare_with_schemes,
scheme_id_from_lang,
user_schemes_path,
)
from md2gost.extended_markdown import markdown
from md2gost.extended_markdown.caption import Caption as CapEl
from md2gost.renderable.caption import CaptionInfo
from md2gost.renderable.diagram import DiagramFigure
from md2gost.renderable.listing import Listing
from md2gost.renderable_factory import RenderableFactory
def test_prepare_uml_wraps_startuml():
src, dtype = prepare_source("uml", "A -> B")
assert "@startuml" in src
assert dtype == "plantuml"
def test_prepare_c4_uses_scheme(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
ensure_user_schemes(tmp_path)
configure_schemes(base=tmp_path)
def fake_resolve(ref, *, base=None, schemes_dir=None):
return str(tmp_path / "C4_Container.puml")
with patch("md2gost.diagram_schemes.resolve_include_ref", side_effect=fake_resolve):
src, dtype = prepare_with_schemes("c4", 'Person(user, "U")', base=tmp_path)
assert dtype == "plantuml"
assert "Person(user" in src
assert "@startuml" in src.lower()
assert "!include" in src
def test_prepare_uml_c4_alias(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
ensure_user_schemes(tmp_path)
configure_schemes(base=tmp_path)
def fake_resolve(ref, *, base=None, schemes_dir=None):
return str(tmp_path / "stub.puml")
with patch("md2gost.diagram_schemes.resolve_include_ref", side_effect=fake_resolve):
src, dtype = prepare_with_schemes("uml-c4", 'Person(user, "U")', base=tmp_path)
assert dtype == "plantuml"
assert "Person(user" in src
def test_unknown_scheme_raises(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
ensure_user_schemes(tmp_path)
configure_schemes(base=tmp_path)
with pytest.raises(ValueError, match="не найдена"):
prepare_with_schemes("uml-nosuch", "A -> B", base=tmp_path)
def test_bundled_c4_survives_user_file_without_it(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
path = ensure_user_schemes(tmp_path)
path.write_text('{"mine":{"title":"x","prefix":"","postfix":""}}\n', encoding="utf-8")
configure_schemes(base=tmp_path)
assert is_diagram_lang("mine")
assert is_diagram_lang("c4")
def test_scheme_id_from_lang():
assert scheme_id_from_lang("uml") is None
assert scheme_id_from_lang("uml-c4") == "c4"
assert scheme_id_from_lang("c4") == "c4"
assert scheme_id_from_lang("usecase") == "usecase"
assert scheme_id_from_lang("uml-usecase") == "usecase"
def test_ensure_user_schemes_once(tmp_path):
path = ensure_user_schemes(tmp_path)
assert path.is_file()
assert "c4" in path.read_text(encoding="utf-8")
path.write_text('{"mine":{"title":"x","prefix":"","postfix":""}}\n', encoding="utf-8")
again = ensure_user_schemes(tmp_path)
assert again == path
text = path.read_text(encoding="utf-8")
assert "mine" in text
assert "c4" in text # migration re-adds bundled ids
assert path == user_schemes_path(tmp_path)
def test_migrate_removes_orphaned_builtin_and_adds_missing(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
path = user_schemes_path(tmp_path)
path.write_text(
json.dumps(
{
"bpmn": {
"title": "BPMN 2.0",
"author": "md2gost",
"prefix": "@startuml\n",
"postfix": "\n@enduml",
"includes": ["BPMN.puml"],
},
"mine": {"title": "x", "author": "", "prefix": "", "postfix": ""},
},
ensure_ascii=False,
)
+ "\n",
encoding="utf-8",
)
result = migrate_user_schemes(tmp_path)
assert result.written
assert "bpmn" in result.removed
assert "c4" in result.added
schemes = load_schemes_from_path(path)
assert "bpmn" not in schemes
assert "mine" in schemes
assert schemes["mine"].title == "x"
bundled = load_schemes_from_path(bundled_schemes_path())
for sid in bundled:
assert sid in schemes
def test_migrate_preserves_user_override_of_builtin(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
path = user_schemes_path(tmp_path)
path.write_text(
json.dumps(
{
"c4": {
"title": "My C4",
"author": "",
"docs": "custom docs",
"prefix": "@startuml\n",
"postfix": "\n@enduml",
"includes": [],
}
},
ensure_ascii=False,
)
+ "\n",
encoding="utf-8",
)
result = migrate_user_schemes(tmp_path)
schemes = load_schemes_from_path(path)
assert schemes["c4"].docs == "custom docs"
assert schemes["c4"].author == ""
assert "c4" not in result.updated
assert "usecase" in schemes # other bundled ids still added
def test_migrate_updates_app_owned_builtin(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
path = user_schemes_path(tmp_path)
path.write_text(
json.dumps(
{
"c4": {
"title": "Old C4",
"author": "md2gost",
"version": "0.0.1",
"docs": "stale",
"prefix": "@startuml\n",
"postfix": "\n@enduml",
"includes": [],
}
},
ensure_ascii=False,
)
+ "\n",
encoding="utf-8",
)
result = migrate_user_schemes(tmp_path)
assert "c4" in result.updated
schemes = load_schemes_from_path(path)
bundled = load_schemes_from_path(bundled_schemes_path())
assert schemes["c4"].to_dict() == bundled["c4"].to_dict()
def test_migrate_idempotent(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
ensure_user_schemes(tmp_path)
path = user_schemes_path(tmp_path)
before = path.read_text(encoding="utf-8")
mtime1 = path.stat().st_mtime_ns
result = migrate_user_schemes(tmp_path)
assert not result.written
assert not result.changed
assert path.read_text(encoding="utf-8") == before
assert path.stat().st_mtime_ns == mtime1
def test_claim_user_scheme_if_diverged():
bundled = load_schemes_from_path(bundled_schemes_path())
stock = bundled["c4"]
same = DiagramScheme.from_dict("c4", stock.to_dict())
claimed_same = claim_user_scheme_if_diverged(same, bundled=bundled)
assert claimed_same.author == "md2gost"
edited = DiagramScheme.from_dict(
"c4",
{**stock.to_dict(), "docs": "my docs"},
)
claimed = claim_user_scheme_if_diverged(edited, bundled=bundled)
assert claimed.author == ""
assert claimed.docs == "my docs"
def test_apply_scheme_no_double_start():
scheme = DiagramScheme(
id="t",
prefix="@startuml\nskinparam monochrome true\n",
postfix="\n@enduml",
includes=[],
)
out = apply_scheme(scheme, "@startuml\nA -> B\n@enduml")
assert out.lower().count("@startuml") == 1
assert "skinparam monochrome true" in out
def test_archimate_scheme_includes_stdlib():
bundled = load_schemes_from_path(bundled_schemes_path())
scheme = bundled["archimate"]
assert scheme.title == "ArchiMate 3.2"
out = apply_scheme(scheme, 'Business_Actor(a, "X")\n')
assert "!include <archimate/Archimate>" in out
assert "Business_Actor(a, \"X\")" in out
assert out.strip().lower().startswith("@startuml")
assert "@enduml" in out.lower()
assert out.lower().count("@startuml") == 1
def test_include_cache_index_no_overwrite(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
calls = []
def fake_get(url, timeout=60):
calls.append(url)
class Resp:
content = f"' from {url}\n".encode("utf-8")
def raise_for_status(self):
return None
return Resp()
with patch("md2gost.diagram_includes.requests.get", side_effect=fake_get):
p1 = resolve_url("https://example.com/a.puml", base=tmp_path)
p2 = resolve_url("https://example.com/b.puml", base=tmp_path)
assert p1 != p2
assert p1.is_file() and p2.is_file()
content1 = p1.read_bytes()
# second resolve same URL — no new download
p1b = resolve_url("https://example.com/a.puml", base=tmp_path)
assert p1b == p1
assert p1.read_bytes() == content1
assert calls.count("https://example.com/a.puml") == 1
assert include_cache_index_path(tmp_path).is_file()
removed = reset_include_cache(tmp_path)
assert removed >= 2
assert not include_cache_index_path(tmp_path).is_file()
def test_rewrite_http_in_source(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
def fake_get(url, timeout=60):
class Resp:
content = b"rectangle x\n"
def raise_for_status(self):
return None
return Resp()
with patch("md2gost.diagram_includes.requests.get", side_effect=fake_get):
src = "@startuml\n!include https://example.com/x.puml\nA->B\n@enduml"
out = rewrite_http_includes_in_source(src, base=tmp_path)
assert "https://example.com" not in out
assert "!include " in out
def test_caption_plus_listing():
doc = markdown.parse("%fig Demo +listing\n\n```uml\nA->B\n```\n")
caps = [c for c in doc.children if isinstance(c, CapEl)]
assert len(caps) == 1
assert caps[0].with_listing is True
assert caps[0].text == "Demo"
assert caps[0].unique_name == "fig"
def test_caption_plus_landscape():
doc = markdown.parse("%wide Big scheme +landscape\n\n```uml\nA->B\n```\n")
caps = [c for c in doc.children if isinstance(c, CapEl)]
assert caps[0].landscape is True
assert caps[0].with_listing is False
assert caps[0].text == "Big scheme"
doc2 = markdown.parse("%t1 Широкая +listing +landscape\n\n|a|b|\n|-|-|\n|1|2|\n")
caps2 = [c for c in doc2.children if isinstance(c, CapEl)]
assert caps2[0].landscape is True
assert caps2[0].with_listing is True
assert caps2[0].text == "Широкая"
def test_caption_interrupts_paragraph():
"""% line right after text (no blank line) must still be Caption, not plain text."""
from marko.block import FencedCode, Paragraph
doc = markdown.parse(
"Пишите так:\n"
"%testuml test +landscape\n"
"\n"
"```uml\nA->B\n```\n"
)
caps = [c for c in doc.children if isinstance(c, CapEl)]
assert len(caps) == 1
assert caps[0].landscape is True
assert caps[0].unique_name == "testuml"
joined = ""
for p in doc.children:
if not isinstance(p, Paragraph):
continue
for ch in getattr(p, "children", []) or []:
if hasattr(ch, "children") and isinstance(ch.children, str):
joined += ch.children
assert "%testuml" not in joined
assert any(isinstance(c, FencedCode) and c.lang == "uml" for c in doc.children)
def test_factory_routes_uml_to_diagram():
from docx import Document
from md2gost import package_dir
import os
md = "%d1 Use case +listing\n\n```uml\n@startuml\nA -> B\n@enduml\n```\n"
parsed = markdown.parse(md)
doc = Document(os.path.join(package_dir(), "Template.docx"))
factory = RenderableFactory(doc._body)
caption = None
renderables = []
for el in parsed.children:
from marko.block import BlankLine
if isinstance(el, BlankLine):
continue
if isinstance(el, CapEl):
caption = CaptionInfo(el.unique_name, el.text, el.with_listing)
continue
renderables.append(factory.create(el, caption))
caption = None
assert len(renderables) == 1
assert isinstance(renderables[0], DiagramFigure)
assert renderables[0].listing is not None
assert isinstance(renderables[0].listing, Listing)
def test_render_diagram_uses_cache(tmp_path):
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20
def fake_kroki(source, diagram_type, base_url, out_path, fmt="png"):
out_path.write_bytes(png if fmt == "png" else b"<svg xmlns='http://www.w3.org/2000/svg'/>")
return True
configure_diagrams(fallback="remote", cache_dir=str(tmp_path))
with patch("md2gost.diagram_renderer._render_plantuml_jar", return_value=False), \
patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki):
path1 = render_diagram("uml", "A -> B", cache_dir=str(tmp_path))
path2 = render_diagram("uml", "A -> B", cache_dir=str(tmp_path))
assert path1.png_path == path2.png_path
assert open(path1.png_path, "rb").read().startswith(b"\x89PNG")
def test_render_diagram_off_raises(tmp_path):
configure_diagrams(fallback="off", cache_dir=str(tmp_path))
with patch("md2gost.diagram_renderer._render_plantuml_jar", return_value=False), \
patch("md2gost.diagram_renderer._render_kroki", return_value=False):
with pytest.raises(RuntimeError):
render_diagram("uml", "A -> B", fallback="off", cache_dir=str(tmp_path))
def test_prepare_mermaid_no_startuml():
src, dtype = prepare_source("mermaid", "flowchart LR\nA-->B")
assert "@startuml" not in src.lower()
assert dtype == "mermaid"
assert "flowchart LR" in src
src2, dtype2 = prepare_source("mmd", "sequenceDiagram\nA->>B: hi")
assert dtype2 == "mermaid"
assert "@startuml" not in src2.lower()
def test_plantuml_scale_injected_into_render_cache(tmp_path):
from md2gost.diagram_renderer import _ensure_plantuml_png_scale
boosted = _ensure_plantuml_png_scale("@startuml\nA -> B\n@enduml")
assert "scale 2" in boosted
# author override kept
custom = _ensure_plantuml_png_scale("@startuml\nscale 1.5\nA -> B\n@enduml")
assert custom.count("scale") == 1
assert "scale 1.5" in custom
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20
seen = []
def fake_kroki(source, diagram_type, base_url, out_path, fmt="png"):
seen.append(source)
out_path.write_bytes(png if fmt == "png" else b"<svg/>")
return True
configure_diagrams(fallback="remote", cache_dir=str(tmp_path), diagram_scale=3)
with patch("md2gost.diagram_renderer._render_plantuml_jar", return_value=False), \
patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki):
result = render_diagram("uml", "A -> B", cache_dir=str(tmp_path))
assert seen and "scale 3" in seen[0]
assert result.pixel_scale == 3
def test_diagram_scale_one_skips_inject(tmp_path):
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20
seen = []
def fake_kroki(source, diagram_type, base_url, out_path, fmt="png"):
seen.append(source)
out_path.write_bytes(png)
return True
configure_diagrams(fallback="remote", cache_dir=str(tmp_path), diagram_scale=1)
with patch("md2gost.diagram_renderer._render_plantuml_jar", return_value=False), \
patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki):
result = render_diagram("uml", "A -> B", cache_dir=str(tmp_path), diagram_scale=1)
assert seen and "scale " not in seen[0].split("@startuml", 1)[-1].split("A -> B")[0]
assert result.pixel_scale == 1
def test_is_diagram_lang_mermaid():
assert is_diagram_lang("mermaid")
assert is_diagram_lang("mmd")
assert "mermaid" in DIAGRAM_LANGS
assert "mmd" in DIAGRAM_LANGS
def test_render_mermaid_skips_jar(tmp_path):
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20
calls = {"jar": 0, "kroki": []}
def fake_jar(*args, **kwargs):
calls["jar"] += 1
return False
def fake_local(*args, **kwargs):
return False
def fake_kroki(source, diagram_type, base_url, out_path, fmt="png"):
calls["kroki"].append((diagram_type, fmt, base_url))
if fmt == "png":
out_path.write_bytes(png)
else:
out_path.write_bytes(b"<svg xmlns='http://www.w3.org/2000/svg'/>")
return True
configure_diagrams(fallback="remote", cache_dir=str(tmp_path))
with patch("md2gost.diagram_renderer._render_plantuml_jar", side_effect=fake_jar), \
patch("md2gost.mermaid_renderer.write_mermaid_outputs", side_effect=fake_local), \
patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki):
result = render_diagram("mermaid", "flowchart LR\nA-->B", cache_dir=str(tmp_path))
assert calls["jar"] == 0
assert any(t == "mermaid" and f == "png" for t, f, _ in calls["kroki"])
assert Path(result.png_path).read_bytes().startswith(b"\x89PNG")
assert result.svg_path is None
def test_render_diagram_svg_asks_both_formats(tmp_path):
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20
svg = b'<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'
fmts = []
def fake_kroki(source, diagram_type, base_url, out_path, fmt="png"):
fmts.append(fmt)
out_path.write_bytes(png if fmt == "png" else svg)
return True
configure_diagrams(fallback="remote", cache_dir=str(tmp_path), diagram_format="svg")
with patch("md2gost.diagram_renderer._render_plantuml_jar", return_value=False), \
patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki):
result = render_diagram(
"uml", "A -> B", cache_dir=str(tmp_path), diagram_format="svg",
)
assert "png" in fmts and "svg" in fmts
assert result.svg_path is not None
assert Path(result.svg_path).read_bytes().startswith(b"<svg")
assert Path(result.png_path).read_bytes().startswith(b"\x89PNG")
def test_attach_svg_blip():
from docx import Document
from md2gost.docx_svg import attach_svg_blip, SVG_BLIP_URI
doc = Document()
run = doc.add_paragraph().add_run()
# minimal valid-ish PNG (1x1)
from io import BytesIO
from PIL import Image as PILImage
buf = BytesIO()
PILImage.new("RGB", (8, 8), color=(255, 0, 0)).save(buf, format="PNG")
buf.seek(0)
picture = run.add_picture(buf)
import tempfile
with tempfile.TemporaryDirectory() as tmp:
svg = Path(tmp) / "d.svg"
svg.write_text(
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">'
'<rect width="10" height="10"/></svg>',
encoding="utf-8",
)
assert attach_svg_blip(run, picture, svg)
xml = picture._inline.xml
assert "svgBlip" in xml
assert SVG_BLIP_URI in xml
# two image relationships on the document part
image_rels = [
r for r in doc.part.rels.values()
if "image" in (r.reltype or "")
]
assert len(image_rels) >= 2
class _FakeProc:
returncode = 0
stdout = ""
stderr = ""
def _fake_java(*, out_stem: str = "src", fail: bool = False):
"""Stand-in for `java -jar plantuml.jar`: writes what the real jar would."""
calls: list[list[str]] = []
def run(cmd, **kwargs):
calls.append(list(cmd))
if fail:
proc = _FakeProc()
proc.returncode = 1
proc.stderr = "boom"
return proc
fmt = next(a[2:] for a in cmd if a.startswith("-t"))
payload = (
b"\x89PNG\r\n\x1a\n" + b"\x00" * 32 if fmt == "png"
else b"<svg xmlns='http://www.w3.org/2000/svg'/>"
)
for arg in cmd:
if not str(arg).endswith(".puml"):
continue
out_dir = Path(arg).parent / "out"
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / f"{out_stem}.{fmt}").write_bytes(payload)
return _FakeProc()
return run, calls
def _dummy_jar(tmp_path: Path) -> Path:
jar = tmp_path / "plantuml.jar"
jar.write_bytes(b"PK" + b"\x00" * 2000)
return jar
def test_plantuml_run_batches_sources_into_one_jvm(tmp_path):
jar = _dummy_jar(tmp_path)
jobs = [_PumlJob(f"@startuml\nA{i} -> B{i}\n@enduml", tmp_path / f"{i}.png")
for i in range(3)]
run, calls = _fake_java()
with patch("md2gost.diagram_renderer.subprocess.run", side_effect=run), \
patch("md2gost.diagram_renderer._java_available", return_value=True):
done = _plantuml_run(jobs, str(jar), "png")
assert done == [True, True, True]
assert len(calls) == 1
assert sum(1 for a in calls[0] if str(a).endswith(".puml")) == 3
for job in jobs:
assert job.out_path.read_bytes().startswith(b"\x89PNG")
def test_plantuml_run_keeps_named_diagrams_apart(tmp_path):
"""`@startuml Name` renames the output file — each source needs its own -o dir."""
jar = _dummy_jar(tmp_path)
jobs = [_PumlJob("@startuml Name\nA -> B\n@enduml", tmp_path / f"{i}.png")
for i in range(2)]
run, calls = _fake_java(out_stem="Name")
with patch("md2gost.diagram_renderer.subprocess.run", side_effect=run), \
patch("md2gost.diagram_renderer._java_available", return_value=True):
done = _plantuml_run(jobs, str(jar), "png")
assert done == [True, True]
assert calls[0].count("-o") == 1
src_dirs = {Path(a).parent for a in calls[0] if str(a).endswith(".puml")}
assert len(src_dirs) == 2
assert all(job.out_path.is_file() for job in jobs)
def test_plantuml_run_failure_reports_every_job(tmp_path):
jar = _dummy_jar(tmp_path)
jobs = [_PumlJob("@startuml\nA -> B\n@enduml", tmp_path / f"{i}.png")
for i in range(2)]
run, _calls = _fake_java(fail=True)
with patch("md2gost.diagram_renderer.subprocess.run", side_effect=run), \
patch("md2gost.diagram_renderer._java_available", return_value=True):
done = _plantuml_run(jobs, str(jar), "png")
assert done == [False, False]
assert not any(job.out_path.exists() for job in jobs)
def test_plantuml_run_without_java_never_spawns(tmp_path):
jar = _dummy_jar(tmp_path)
run, calls = _fake_java()
with patch("md2gost.diagram_renderer.subprocess.run", side_effect=run), \
patch("md2gost.diagram_renderer._java_available", return_value=False):
done = _plantuml_run([_PumlJob("@startuml\nA -> B\n@enduml", tmp_path / "a.png")],
str(jar), "png")
assert done == [False]
assert calls == []
def test_batch_prepass_draws_whole_document_in_one_jvm(tmp_path):
jar = _dummy_jar(tmp_path)
configure_diagrams(
fallback="off", cache_dir=str(tmp_path), plantuml_jar=str(jar), diagram_scale=1,
)
register_pending_diagram("uml", "A -> B")
register_pending_diagram("uml", "C -> D")
run, calls = _fake_java()
with patch("md2gost.diagram_renderer.subprocess.run", side_effect=run), \
patch("md2gost.diagram_renderer._java_available", return_value=True):
first = render_diagram("uml", "A -> B")
second = render_diagram("uml", "C -> D")
assert len(calls) == 1
assert first.png_path != second.png_path
for result in (first, second):
assert Path(result.png_path).read_bytes().startswith(b"\x89PNG")
def test_batch_prepass_svg_mode_uses_two_jvms_not_four(tmp_path):
jar = _dummy_jar(tmp_path)
configure_diagrams(
fallback="off", cache_dir=str(tmp_path), plantuml_jar=str(jar),
diagram_scale=1, diagram_format="svg",
)
register_pending_diagram("uml", "A -> B")
register_pending_diagram("uml", "C -> D")
run, calls = _fake_java()
with patch("md2gost.diagram_renderer.subprocess.run", side_effect=run), \
patch("md2gost.diagram_renderer._java_available", return_value=True):
first = render_diagram("uml", "A -> B")
second = render_diagram("uml", "C -> D")
assert sorted(next(a for a in c if a.startswith("-t")) for c in calls) == \
["-tpng", "-tsvg"]
assert first.svg_path and second.svg_path
assert Path(first.svg_path).read_bytes().startswith(b"<svg")
def test_batch_prepass_skips_cached_diagrams(tmp_path):
jar = _dummy_jar(tmp_path)
configure_diagrams(
fallback="off", cache_dir=str(tmp_path), plantuml_jar=str(jar), diagram_scale=1,
)
cached_src, _ = prepare_source("uml", "A -> B")
cached_png, _ = _cache_paths(
cached_src, str(tmp_path), cache_prefix=PLANTUML_CACHE_PREFIX,
)
cached_png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"cached")
register_pending_diagram("uml", "A -> B")
register_pending_diagram("uml", "C -> D")
run, calls = _fake_java()
with patch("md2gost.diagram_renderer.subprocess.run", side_effect=run), \
patch("md2gost.diagram_renderer._java_available", return_value=True):
hit = render_diagram("uml", "A -> B")
render_diagram("uml", "C -> D")
assert len(calls) == 1
assert sum(1 for a in calls[0] if str(a).endswith(".puml")) == 1
assert Path(hit.png_path).read_bytes().endswith(b"cached")
def test_batch_prepass_not_triggered_for_explicit_overrides(tmp_path):
configure_diagrams(fallback="remote", cache_dir=str(tmp_path))
register_pending_diagram("uml", "A -> B")
def fake_kroki(source, diagram_type, base_url, out_path, fmt="png"):
out_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 20)
return True
with patch("md2gost.diagram_renderer._flush_plantuml_batch") as flush, \
patch("md2gost.diagram_renderer._render_plantuml_jar", return_value=False), \
patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki):
render_diagram("uml", "A -> B", cache_dir=str(tmp_path))
flush.assert_not_called()
reset_diagram_batch()
def test_diagram_figure_registers_for_batch():
from docx import Document
from md2gost import package_dir
import md2gost.diagram_renderer as dr
import os
reset_diagram_batch()
doc = Document(os.path.join(package_dir(), "Template.docx"))
DiagramFigure(doc._body, "uml", "A -> B")
DiagramFigure(doc._body, "uml", "A -> B")
DiagramFigure(doc._body, "mermaid", "flowchart LR\nA-->B")
assert dr._PENDING == [("uml", "A -> B"), ("mermaid", "flowchart LR\nA-->B")]
reset_diagram_batch()
assert dr._PENDING == []
def test_plantuml_cache_key_carries_jar_version(tmp_path):
assert PLANTUML_VERSION in PLANTUML_CACHE_PREFIX
prepared, dtype = prepare_source("uml", "A -> B")
assert dtype == "plantuml"
versioned, _ = _cache_paths(
prepared, str(tmp_path), cache_prefix=PLANTUML_CACHE_PREFIX,
)
legacy, _ = _cache_paths(prepared, str(tmp_path))
assert versioned != legacy
def test_stale_unversioned_cache_is_a_miss_not_a_crash(tmp_path):
"""Files cached before the jar version entered the key must be re-rendered."""
configure_diagrams(fallback="remote", cache_dir=str(tmp_path), diagram_scale=1)
prepared, _ = prepare_source("uml", "A -> B")
legacy, _ = _cache_paths(prepared, str(tmp_path))
legacy.write_bytes(b"\x89PNG\r\n\x1a\n" + b"stale")
def fake_kroki(source, diagram_type, base_url, out_path, fmt="png"):
out_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"fresh")
return True
with patch("md2gost.diagram_renderer._render_plantuml_jar", return_value=False), \
patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki):
result = render_diagram(
"uml", "A -> B", cache_dir=str(tmp_path), diagram_scale=1,
)
assert Path(result.png_path) != legacy
assert Path(result.png_path).read_bytes().endswith(b"fresh")
assert legacy.read_bytes().endswith(b"stale")
def test_resolve_plantuml_jar_explicit(tmp_path):
jar = tmp_path / "plantuml.jar"
jar.write_bytes(b"PK" + b"\x00" * 2000)
assert resolve_plantuml_jar(str(jar), download=False) == str(jar)
missing = tmp_path / "nope.jar"
assert resolve_plantuml_jar(str(missing), download=False) is None or (
resolve_plantuml_jar(str(missing), download=False) != str(missing)
)
def test_diagram_engine_status_mentions_java_or_kroki():
text = diagram_engine_status()
assert "Java" in text
assert "PlantUML" in text
def test_diagram_langs():
assert "uml" in DIAGRAM_LANGS
assert "c4" in DIAGRAM_LANGS
assert "usecase" in DIAGRAM_LANGS
assert "idef0" in DIAGRAM_LANGS
assert "dfd" in DIAGRAM_LANGS
assert is_diagram_lang("uml")
assert is_diagram_lang("uml-c4")
assert is_diagram_lang("uml-usecase")
assert is_diagram_lang("idef0")
assert is_diagram_lang("uml-idef0")
assert is_diagram_lang("dfd")
assert is_diagram_lang("uml-dfd")
assert is_diagram_lang("data-flow-diagram")
assert is_diagram_lang("yourdon")