"""Parse and render IDEF0 fenced diagrams.""" from __future__ import annotations from pathlib import Path from unittest.mock import patch import pytest from PIL import Image from md2gost.diagram_renderer import ( DIAGRAM_LANGS, configure_diagrams, is_diagram_lang, prepare_source, render_diagram, ) from md2gost.extended_markdown import markdown from md2gost.extended_markdown.caption import Caption as CapEl from md2gost.idef0 import ( SAMPLE_CONTEXT, SAMPLE_DECOMPOSITION, Idef0ParseError, parse_idef0, render_idef0, write_idef0_outputs, _Rect, _layout_arrow_label, _load_font, _polyline_backtracks, _seg_hits_open_box, _layout_boxes, _assign_slots, _box_order, _draw_arrow, _make_channels, _draw_kit_form, _Scene, _port_point, ) from md2gost.renderable.caption import CaptionInfo from md2gost.renderable.diagram import DiagramFigure from md2gost.renderable_factory import RenderableFactory def _svg_has_words(svg: str, phrase: str) -> None: missing = [tok for tok in phrase.split() if tok and tok not in svg] assert not missing, f"missing {missing} in SVG for {phrase!r}" def test_is_diagram_lang_idef0(): assert is_diagram_lang("idef0") assert is_diagram_lang("uml-idef0") assert "idef0" in DIAGRAM_LANGS def test_prepare_idef0_stays_native(): src, dtype = prepare_source("idef0", SAMPLE_CONTEXT) assert dtype == "idef0" assert "@startuml" not in src.lower() assert "Распорядиться товаром" in src src2, dtype2 = prepare_source("uml-idef0", "[A0] X\n<- in") assert dtype2 == "idef0" def test_parse_sample_context(): d = parse_idef0(SAMPLE_CONTEXT) assert d.node == "A-0" assert d.title == "Распорядиться товаром" assert d.number == "1" assert d.author == "Кинзябулатов Рамиль" assert "торгового предприятия" in d.project assert d.date == "08.02.2022" assert d.rev == "31.03.2022" assert d.status == "PUBLICATION" assert d.context == "TOP" assert len(d.boxes) == 1 assert d.boxes[0].id == "A0" assert d.boxes[0].name == "Распорядиться товаром" labels = {a.label: a for a in d.arrows} assert set(labels) == {"Спрос", "Нормативная документация", "Товар", "Персонал"} assert labels["Спрос"].targets[0].side == "I" assert labels["Нормативная документация"].targets[0].side == "C" assert labels["Товар"].sources[0].side == "O" assert labels["Персонал"].targets[0].side == "M" def test_parse_sample_decomposition(): d = parse_idef0(SAMPLE_DECOMPOSITION) assert d.node == "A0" assert [b.id for b in d.boxes] == ["A1", "A2", "A3"] by_label = {a.label: a for a in d.arrows} assert by_label["Нормативная документация"].icom == "C1" assert {t.box for t in by_label["Нормативная документация"].targets} == { "A1", "A2", "A3", } assert by_label["график поставок"].sources[0].box == "A1" assert by_label["график поставок"].targets[0].box == "A2" assert by_label["график поставок"].targets[0].side == "C" assert by_label["план закупок"].targets[0].box == "A2" assert by_label["товар к отгрузке"].sources[0].box == "A2" assert by_label["Товар"].icom == "O1" assert {t.box for t in by_label["Персонал"].targets} == {"A1", "A2", "A3"} assert by_label["Склад"].targets[0].box == "A2" assert by_label["Спрос"].icom == "I1" def test_layout_boxes_cluster_is_compact_and_centered(): d = parse_idef0(SAMPLE_DECOMPOSITION) work = _Rect(0, 0, 1400, 720) geoms = _layout_boxes(d, work, 1.0) boxes = [geoms[b.id].rect for b in d.boxes] x0, x1 = min(b.x for b in boxes), max(b.r for b in boxes) y0, y1 = min(b.y for b in boxes), max(b.b for b in boxes) assert x1 - x0 < work.w * 0.55 assert y1 - y0 < work.h * 0.70 assert abs((x0 + x1) / 2 - work.cx) < 80 assert x0 > 80 assert work.r - x1 > 70 assert y0 > 60 assert work.b - y1 > 70 def test_output_icom_with_control_branch_also_hits_boundary(): d = parse_idef0( """ [A1] One [A2] Two A1 -> A2.C : план продаж [O1] """ ) arrow = d.arrows[0] assert arrow.icom == "O1" assert { (t.box, t.side) for t in arrow.targets } == {("A2", "C"), (None, "O")} def test_arrow_label_sits_off_vertical_shaft(): work = _Rect(0, 0, 800, 600) font = _load_font(12, False) placed = _layout_arrow_label( [(200.0, 40.0), (200.0, 320.0)], "Нормативная документация", font, 12.0, 1.0, work, [], ) assert placed is not None halo, attach, _connect, lines, orient = placed assert orient == "V" assert not halo.contains_point(*attach, pad=3) assert abs(halo.cx - attach[0]) >= 10 assert len(lines) >= 2 def test_arrow_label_sits_above_horizontal_shaft(): work = _Rect(0, 0, 800, 600) font = _load_font(12, False) placed = _layout_arrow_label( [(40.0, 200.0), (360.0, 200.0)], "Спрос", font, 12.0, 1.0, work, [], ) assert placed is not None halo, attach, _connect, _lines, orient = placed assert orient == "H" assert halo.b <= attach[1] - 4 or halo.y >= attach[1] + 4 assert not halo.contains_point(*attach, pad=2) def test_decomposition_paths_do_not_backtrack_or_cut_boxes(): d = parse_idef0(SAMPLE_DECOMPOSITION) s = 1.0 w, h = 1400, 920 scene = _Scene(w, h) fonts = { "lab": 9, "val": 11, "small": 9.5, "box": 13, "id": 10, "arrow": 12, "node": 20, "title": 16, "top": 22, } work = _draw_kit_form(scene, d, _Rect(8, 8, w - 16, h - 16), s, fonts) geoms = _layout_boxes(d, work, s) _assign_slots(d, geoms) order = _box_order(d) ch = _make_channels(d, geoms, work, s) for arrow in d.arrows: paths = _draw_arrow(scene, arrow, geoms, work, s, 1.2, order, ch) skip = {e.box for e in arrow.sources + arrow.targets if e.box} for pts in paths: assert not _polyline_backtracks(pts), arrow.label for a, b in zip(pts, pts[1:]): for gid, g in geoms.items(): if gid in skip: continue assert not _seg_hits_open_box(a, b, g.rect), (arrow.label, gid) def test_two_ports_on_same_side_are_spread(): d = parse_idef0( """ [A0] Box <- one <- two -> out ^ ctrl v mech """ ) work = _Rect(0, 0, 800, 600) geoms = _layout_boxes(d, work, 1.0) _assign_slots(d, geoms) g = geoms["A0"] y0, y1 = _port_point(g, "I", 0, 2)[1], _port_point(g, "I", 1, 2)[1] assert abs(y1 - y0) >= 0.5 * g.rect.h def test_parse_keyword_and_explicit_box(): d = parse_idef0( """ [A0] Распорядиться товаром A0 in Спрос A0 control Нормативная документация A0 out Товар A0 mech Персонал """ ) assert [a.label for a in d.arrows] == [ "Спрос", "Нормативная документация", "Товар", "Персонал", ] def test_parse_decomposition_fork_join_icom_tunnel(): d = parse_idef0( """ node A0 title Обработать заказ number 2 [A1] Принять заказ [A2] Закупить [A3] Отгрузить A1 <- Заявка [I1] A1 ^ Правила [C1] A1 -> A2 : потребность A2 -> A3 : товар на складе A3 -> Товар [O1] A1, A2 -> A3 : сводка A1 -> A2, A3 : данные (in Секрет in) Локальный A3 v Склад [M1] """ ) assert d.node == "A0" assert [b.id for b in d.boxes] == ["A1", "A2", "A3"] by_label = {a.label: a for a in d.arrows} assert by_label["потребность"].sources[0].box == "A1" assert by_label["потребность"].targets[0].box == "A2" assert by_label["Заявка"].icom == "I1" assert {t.box for t in by_label["данные"].targets} == {"A2", "A3"} assert {s.box for s in by_label["сводка"].sources} == {"A1", "A2"} secret = by_label["Секрет"] assert secret.sources[0].box is None and secret.sources[0].tunnel local = by_label["Локальный"] assert local.targets[0].tunnel def test_parse_empty_raises(): with pytest.raises(Idef0ParseError, match="пустой"): parse_idef0(" \n # only comment\n") def test_parse_bad_line_raises(): with pytest.raises(Idef0ParseError, match="строку"): parse_idef0("[A0] Box\nthis is not an arrow\n") def test_render_sample_png_and_svg(tmp_path): d = parse_idef0(SAMPLE_CONTEXT) png, svg = render_idef0(d, scale=1.0, want_svg=True) assert png.startswith(b"\x89PNG") assert svg is not None for needle in ( "Распорядиться товаром", "A-0", "Спрос", "Нормативная документация", "Товар", "Персонал", "PUBLICATION", "TOP", "A0", "Кинзябулатов", ): _svg_has_words(svg, needle) img = Image.open(__import__("io").BytesIO(png)) assert img.size[0] >= 1000 assert img.size[1] >= 700 extrema = img.convert("L").getextrema() assert extrema[0] < 250 # has ink, not a blank page out_png = tmp_path / "a.png" out_svg = tmp_path / "a.svg" assert write_idef0_outputs(SAMPLE_CONTEXT, out_png, out_svg, scale=1) assert out_png.read_bytes().startswith(b"\x89PNG") assert "NODE" in out_svg.read_text(encoding="utf-8") def test_render_decomposition_does_not_crash(): src = """ node A0 title Распорядиться товаром [A1] Принять заказ [A2] Отгрузить [A3] Учесть A1 <- Заявка [I1] A1 -> A2 : заказ A2 -> A3 : накладная A3 -> Товар [O1] A1 ^ Правила [C1] A2 v Склад [M1] """ png, svg = render_idef0(parse_idef0(src), scale=1.0, want_svg=True) assert png.startswith(b"\x89PNG") assert "Принять заказ" in (svg or "") assert "заказ" in (svg or "") def test_render_sample_decomposition_png_and_svg(): png, svg = render_idef0(parse_idef0(SAMPLE_DECOMPOSITION), scale=1.0, want_svg=True) assert png.startswith(b"\x89PNG") text = svg or "" for needle in ( "Принять и оценить спрос", "Закупить и хранить", "план закупок", "график поставок", "Персонал", "C1", "A3", ): _svg_has_words(text, needle) assert "Purpose:" not in text assert "Viewpoint:" not in text assert "A4" not in text img = Image.open(__import__("io").BytesIO(png)) assert img.size[0] >= 1400 assert img.size[1] >= 900 extrema = img.convert("L").getextrema() assert extrema[0] < 250 def test_render_diagram_idef0_skips_jar_and_kroki(tmp_path): calls = {"jar": 0, "kroki": 0} def fake_jar(*args, **kwargs): calls["jar"] += 1 return False def fake_kroki(*args, **kwargs): calls["kroki"] += 1 return False configure_diagrams(fallback="off", cache_dir=str(tmp_path), diagram_scale=1) 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( "idef0", SAMPLE_CONTEXT, cache_dir=str(tmp_path), diagram_scale=1, ) assert calls["jar"] == 0 assert calls["kroki"] == 0 assert Path(result.png_path).read_bytes().startswith(b"\x89PNG") assert result.pixel_scale == 1 def test_render_diagram_idef0_scale_and_cache(tmp_path): configure_diagrams(fallback="off", cache_dir=str(tmp_path), diagram_scale=2) r1 = render_diagram("idef0", SAMPLE_CONTEXT, cache_dir=str(tmp_path), diagram_scale=2) r2 = render_diagram("idef0", SAMPLE_CONTEXT, cache_dir=str(tmp_path), diagram_scale=2) assert r1.png_path == r2.png_path assert r1.pixel_scale == 2 img = Image.open(r1.png_path) assert img.size[0] >= 2000 def test_render_diagram_idef0_bad_source(tmp_path): configure_diagrams(fallback="off", cache_dir=str(tmp_path)) with pytest.raises(RuntimeError, match="IDEF0"): render_diagram("idef0", "not a diagram", cache_dir=str(tmp_path)) def test_factory_routes_idef0_to_diagram(): from docx import Document from md2gost import package_dir import os md = "%ctx Контекст A-0\n\n```idef0\n" + SAMPLE_CONTEXT + "```\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]._lang == "idef0"