Files
md_to_gost/tests/test_idef0.py
T
Igor20264 510f7e7adf
Python application / build (push) Waiting to run
v0.5.2
Что то сделал
2026-09-08 19:37:54 +03:00

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"