"""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"") 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"") 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"") 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'' 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"' '', 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