@@ -0,0 +1,422 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user