- Add\Rework UI - Add Split Table and Listing - Add Support Customazeble schems
This commit is contained in:
+398
-13
@@ -1,16 +1,35 @@
|
||||
"""Tests for diagram renderer and factory routing."""
|
||||
"""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,
|
||||
configure_diagrams,
|
||||
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
|
||||
@@ -26,16 +45,148 @@ def test_prepare_uml_wraps_startuml():
|
||||
assert dtype == "plantuml"
|
||||
|
||||
|
||||
def test_prepare_c4_adds_include():
|
||||
src, dtype = prepare_source("c4", "Person(user, \"User\")")
|
||||
assert "!include" in src
|
||||
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_bpmn():
|
||||
src, dtype = prepare_source("bpmn", "start -> end")
|
||||
assert dtype == "bpmn"
|
||||
assert "@startbpmn" in src.lower() or "start" 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():
|
||||
@@ -47,6 +198,45 @@ def test_caption_plus_listing():
|
||||
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
|
||||
@@ -77,8 +267,8 @@ def test_factory_routes_uml_to_diagram():
|
||||
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_png):
|
||||
out_png.write_bytes(png)
|
||||
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))
|
||||
@@ -86,8 +276,8 @@ def test_render_diagram_uses_cache(tmp_path):
|
||||
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 == path2
|
||||
assert open(path1, "rb").read().startswith(b"\x89PNG")
|
||||
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):
|
||||
@@ -98,7 +288,202 @@ def test_render_diagram_off_raises(tmp_path):
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user