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