b38661f588
Python application / build (push) Has been cancelled
- Add\Rework UI - Add Split Table and Listing - Add Support Customazeble schems
490 lines
17 KiB
Python
490 lines
17 KiB
Python
"""Tests for diagram renderer, schemes, and include cache."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
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,
|
|
configure_schemes,
|
|
ensure_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_bpmn_scheme_prepare(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
ensure_user_schemes(tmp_path)
|
|
configure_schemes(base=tmp_path)
|
|
assert is_diagram_lang("bpmn")
|
|
assert is_diagram_lang("uml-bpmn")
|
|
src, dtype = prepare_with_schemes("bpmn", "Start(s)\nEnd(e)\nFlow(s, e)", base=tmp_path)
|
|
assert dtype == "plantuml"
|
|
assert "Start(s)" in src
|
|
assert "!include" in src
|
|
assert "BPMN.puml" in src.replace("\\", "/")
|
|
assert src.lower().count("@startuml") == 1
|
|
|
|
|
|
def test_bundled_bpmn_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("bpmn")
|
|
|
|
|
|
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("bpmn") == "bpmn"
|
|
assert scheme_id_from_lang("uml-bpmn") == "bpmn"
|
|
|
|
|
|
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
|
|
assert "mine" in path.read_text(encoding="utf-8")
|
|
assert path == user_schemes_path(tmp_path)
|
|
|
|
|
|
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_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_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.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 "bpmn" in DIAGRAM_LANGS
|
|
assert is_diagram_lang("uml")
|
|
assert is_diagram_lang("uml-c4")
|
|
assert is_diagram_lang("uml-bpmn")
|
|
|
|
|
|
def test_render_bpmn_with_jar(tmp_path, monkeypatch):
|
|
import shutil
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
ensure_user_schemes(tmp_path)
|
|
configure_schemes(base=tmp_path)
|
|
jar = resolve_plantuml_jar(download=False)
|
|
if not shutil.which("java") or not jar:
|
|
pytest.skip("нужны Java и plantuml.jar")
|
|
configure_diagrams(fallback="off", cache_dir=str(tmp_path), plantuml_jar=jar)
|
|
body = "\n".join(
|
|
[
|
|
"Start(s)",
|
|
"StartMessage(sm, \"msg\")",
|
|
"UserTask(t, \"Шаг\")",
|
|
"XOR(gw)",
|
|
"AND(p)",
|
|
"OR(o)",
|
|
"End(e)",
|
|
"EndTerminate(et)",
|
|
"Flow(s, t)",
|
|
"Flow(t, gw)",
|
|
"CondFlow(gw, e, \"да\")",
|
|
"DefaultFlow(gw, et)",
|
|
]
|
|
)
|
|
result = render_diagram("bpmn", body, cache_dir=str(tmp_path), fallback="off")
|
|
data = Path(result.png_path).read_bytes()
|
|
assert data.startswith(b"\x89PNG")
|
|
assert len(data) > 500
|