"""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, PLANTUML_CACHE_PREFIX, PLANTUML_VERSION, _cache_paths, _PumlJob, _plantuml_run, configure_diagrams, diagram_engine_status, is_diagram_lang, prepare_source, register_pending_diagram, render_diagram, reset_diagram_batch, 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, save_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_keeps_custom_schemes_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 result.removed == [] assert "c4" in result.added schemes = load_schemes_from_path(path) assert schemes["bpmn"].title == "BPMN 2.0" assert schemes["bpmn"].author == "md2gost" 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_keeps_edited_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" not in result.updated assert "c4" not in result.added schemes = load_schemes_from_path(path) assert schemes["c4"].title == "Old C4" assert schemes["c4"].docs == "stale" assert schemes["c4"].author == "md2gost" assert "usecase" in schemes def test_migrate_does_not_overwrite_broken_json(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) path = user_schemes_path(tmp_path) original = "{\n \"c4\": {\"title\": \"x\",}\n}\n" path.write_text(original, encoding="utf-8") result = migrate_user_schemes(tmp_path) assert not result.written assert not result.changed assert path.read_text(encoding="utf-8") == original def test_migrate_reads_utf8_bom(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) path = user_schemes_path(tmp_path) body = json.dumps( { "c4": { "title": "My C4", "author": "md2gost", "docs": "mine", "prefix": "", "postfix": "", } }, ensure_ascii=False, ) path.write_bytes(b"\xef\xbb\xbf" + body.encode("utf-8")) result = migrate_user_schemes(tmp_path) assert "usecase" in result.added schemes = load_schemes_from_path(path) assert schemes["c4"].title == "My C4" assert schemes["c4"].docs == "mine" text = path.read_text(encoding="utf-8-sig") assert "mine" in text def test_save_schemes_keeps_unknown_entries(tmp_path): path = user_schemes_path(tmp_path) path.write_text( json.dumps( { "my-scheme": {"title": "hyphen", "note": "keep"}, "mine": {"title": "x", "author": "", "extra": "field", "prefix": "A"}, } ) + "\n", encoding="utf-8", ) loaded = load_schemes_from_path(path) assert "mine" in loaded assert "my-scheme" not in loaded loaded["mine"] = DiagramScheme( id="mine", title="y", author="", prefix="A", ) save_schemes(loaded, path, removed_ids=set()) raw = json.loads(path.read_text(encoding="utf-8")) assert raw["my-scheme"]["title"] == "hyphen" assert raw["my-scheme"]["note"] == "keep" assert raw["mine"]["title"] == "y" assert raw["mine"]["extra"] == "field" del loaded["mine"] save_schemes(loaded, path, removed_ids={"mine"}) raw = json.loads(path.read_text(encoding="utf-8")) assert "mine" not in raw assert raw["my-scheme"]["note"] == "keep" 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 class _FakeProc: returncode = 0 stdout = "" stderr = "" def _fake_java(*, out_stem: str = "src", fail: bool = False): """Stand-in for `java -jar plantuml.jar`: writes what the real jar would.""" calls: list[list[str]] = [] def run(cmd, **kwargs): calls.append(list(cmd)) if fail: proc = _FakeProc() proc.returncode = 1 proc.stderr = "boom" return proc fmt = next(a[2:] for a in cmd if a.startswith("-t")) payload = ( b"\x89PNG\r\n\x1a\n" + b"\x00" * 32 if fmt == "png" else b"" ) for arg in cmd: if not str(arg).endswith(".puml"): continue out_dir = Path(arg).parent / "out" out_dir.mkdir(parents=True, exist_ok=True) (out_dir / f"{out_stem}.{fmt}").write_bytes(payload) return _FakeProc() return run, calls def _dummy_jar(tmp_path: Path) -> Path: jar = tmp_path / "plantuml.jar" jar.write_bytes(b"PK" + b"\x00" * 2000) return jar def test_plantuml_run_batches_sources_into_one_jvm(tmp_path): jar = _dummy_jar(tmp_path) jobs = [_PumlJob(f"@startuml\nA{i} -> B{i}\n@enduml", tmp_path / f"{i}.png") for i in range(3)] run, calls = _fake_java() with patch("md2gost.diagram_renderer.subprocess.run", side_effect=run), \ patch("md2gost.diagram_renderer._java_available", return_value=True): done = _plantuml_run(jobs, str(jar), "png") assert done == [True, True, True] assert len(calls) == 1 assert sum(1 for a in calls[0] if str(a).endswith(".puml")) == 3 for job in jobs: assert job.out_path.read_bytes().startswith(b"\x89PNG") def test_plantuml_run_keeps_named_diagrams_apart(tmp_path): """`@startuml Name` renames the output file — each source needs its own -o dir.""" jar = _dummy_jar(tmp_path) jobs = [_PumlJob("@startuml Name\nA -> B\n@enduml", tmp_path / f"{i}.png") for i in range(2)] run, calls = _fake_java(out_stem="Name") with patch("md2gost.diagram_renderer.subprocess.run", side_effect=run), \ patch("md2gost.diagram_renderer._java_available", return_value=True): done = _plantuml_run(jobs, str(jar), "png") assert done == [True, True] assert calls[0].count("-o") == 1 src_dirs = {Path(a).parent for a in calls[0] if str(a).endswith(".puml")} assert len(src_dirs) == 2 assert all(job.out_path.is_file() for job in jobs) def test_plantuml_run_failure_reports_every_job(tmp_path): jar = _dummy_jar(tmp_path) jobs = [_PumlJob("@startuml\nA -> B\n@enduml", tmp_path / f"{i}.png") for i in range(2)] run, _calls = _fake_java(fail=True) with patch("md2gost.diagram_renderer.subprocess.run", side_effect=run), \ patch("md2gost.diagram_renderer._java_available", return_value=True): done = _plantuml_run(jobs, str(jar), "png") assert done == [False, False] assert not any(job.out_path.exists() for job in jobs) def test_plantuml_run_without_java_never_spawns(tmp_path): jar = _dummy_jar(tmp_path) run, calls = _fake_java() with patch("md2gost.diagram_renderer.subprocess.run", side_effect=run), \ patch("md2gost.diagram_renderer._java_available", return_value=False): done = _plantuml_run([_PumlJob("@startuml\nA -> B\n@enduml", tmp_path / "a.png")], str(jar), "png") assert done == [False] assert calls == [] def test_batch_prepass_draws_whole_document_in_one_jvm(tmp_path): jar = _dummy_jar(tmp_path) configure_diagrams( fallback="off", cache_dir=str(tmp_path), plantuml_jar=str(jar), diagram_scale=1, ) register_pending_diagram("uml", "A -> B") register_pending_diagram("uml", "C -> D") run, calls = _fake_java() with patch("md2gost.diagram_renderer.subprocess.run", side_effect=run), \ patch("md2gost.diagram_renderer._java_available", return_value=True): first = render_diagram("uml", "A -> B") second = render_diagram("uml", "C -> D") assert len(calls) == 1 assert first.png_path != second.png_path for result in (first, second): assert Path(result.png_path).read_bytes().startswith(b"\x89PNG") def test_batch_prepass_svg_mode_uses_two_jvms_not_four(tmp_path): jar = _dummy_jar(tmp_path) configure_diagrams( fallback="off", cache_dir=str(tmp_path), plantuml_jar=str(jar), diagram_scale=1, diagram_format="svg", ) register_pending_diagram("uml", "A -> B") register_pending_diagram("uml", "C -> D") run, calls = _fake_java() with patch("md2gost.diagram_renderer.subprocess.run", side_effect=run), \ patch("md2gost.diagram_renderer._java_available", return_value=True): first = render_diagram("uml", "A -> B") second = render_diagram("uml", "C -> D") assert sorted(next(a for a in c if a.startswith("-t")) for c in calls) == \ ["-tpng", "-tsvg"] assert first.svg_path and second.svg_path assert Path(first.svg_path).read_bytes().startswith(b" B") cached_png, _ = _cache_paths( cached_src, str(tmp_path), cache_prefix=PLANTUML_CACHE_PREFIX, ) cached_png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"cached") register_pending_diagram("uml", "A -> B") register_pending_diagram("uml", "C -> D") run, calls = _fake_java() with patch("md2gost.diagram_renderer.subprocess.run", side_effect=run), \ patch("md2gost.diagram_renderer._java_available", return_value=True): hit = render_diagram("uml", "A -> B") render_diagram("uml", "C -> D") assert len(calls) == 1 assert sum(1 for a in calls[0] if str(a).endswith(".puml")) == 1 assert Path(hit.png_path).read_bytes().endswith(b"cached") def test_batch_prepass_not_triggered_for_explicit_overrides(tmp_path): configure_diagrams(fallback="remote", cache_dir=str(tmp_path)) register_pending_diagram("uml", "A -> B") def fake_kroki(source, diagram_type, base_url, out_path, fmt="png"): out_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 20) return True with patch("md2gost.diagram_renderer._flush_plantuml_batch") as flush, \ patch("md2gost.diagram_renderer._render_plantuml_jar", return_value=False), \ patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki): render_diagram("uml", "A -> B", cache_dir=str(tmp_path)) flush.assert_not_called() reset_diagram_batch() def test_diagram_figure_registers_for_batch(): from docx import Document from md2gost import package_dir import md2gost.diagram_renderer as dr import os reset_diagram_batch() doc = Document(os.path.join(package_dir(), "Template.docx")) DiagramFigure(doc._body, "uml", "A -> B") DiagramFigure(doc._body, "uml", "A -> B") DiagramFigure(doc._body, "mermaid", "flowchart LR\nA-->B") assert dr._PENDING == [("uml", "A -> B"), ("mermaid", "flowchart LR\nA-->B")] reset_diagram_batch() assert dr._PENDING == [] def test_plantuml_cache_key_carries_jar_version(tmp_path): assert PLANTUML_VERSION in PLANTUML_CACHE_PREFIX prepared, dtype = prepare_source("uml", "A -> B") assert dtype == "plantuml" versioned, _ = _cache_paths( prepared, str(tmp_path), cache_prefix=PLANTUML_CACHE_PREFIX, ) legacy, _ = _cache_paths(prepared, str(tmp_path)) assert versioned != legacy def test_stale_unversioned_cache_is_a_miss_not_a_crash(tmp_path): """Files cached before the jar version entered the key must be re-rendered.""" configure_diagrams(fallback="remote", cache_dir=str(tmp_path), diagram_scale=1) prepared, _ = prepare_source("uml", "A -> B") legacy, _ = _cache_paths(prepared, str(tmp_path)) legacy.write_bytes(b"\x89PNG\r\n\x1a\n" + b"stale") def fake_kroki(source, diagram_type, base_url, out_path, fmt="png"): out_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"fresh") return True 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 Path(result.png_path) != legacy assert Path(result.png_path).read_bytes().endswith(b"fresh") assert legacy.read_bytes().endswith(b"stale") 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")