423 lines
14 KiB
Python
423 lines
14 KiB
Python
"""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"
|