Files
md_to_gost/tests/test_diagram_renderer.py
Igor20264 510f7e7adf
Python application / build (push) Waiting to run
v0.5.2
Что то сделал
2026-09-08 19:37:54 +03:00

592 lines
20 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,
configure_diagrams,
diagram_engine_status,
is_diagram_lang,
prepare_source,
render_diagram,
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
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")