2026-09-25 13:01:23 +02:00
|
|
|
|
"""Strukturtests für die Modelle unter .agents/skills/sn-bpmn/beispiele/ – reines Python/pytest.
|
2026-09-23 15:18:07 +02:00
|
|
|
|
|
2026-09-25 13:01:23 +02:00
|
|
|
|
Die Referenzmodelle (referenz_*.md) sind echte, größere Prozesse, die Syntaxbeispiele (beispiel_*.md) zeigen gezielt
|
|
|
|
|
|
einzelne Elemente. Der Byte-Vergleich in test_pruefe_prozesse.py meldet jede Änderung an der Ausgabe von
|
|
|
|
|
|
md_to_bpmn.py; diese Tests prüfen zusätzlich, ob das Ergebnis danach noch den Modellierregeln und den Grenzen von
|
|
|
|
|
|
PICTURE genügt – auch wenn die .bpmn bewusst neu erzeugt wurde.
|
2026-09-23 15:18:07 +02:00
|
|
|
|
"""
|
2026-09-25 13:01:23 +02:00
|
|
|
|
import math
|
2026-09-23 15:18:07 +02:00
|
|
|
|
import sys
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
SKILL = ROOT / ".agents" / "skills" / "sn-bpmn"
|
|
|
|
|
|
sys.path.insert(0, str(SKILL))
|
|
|
|
|
|
|
|
|
|
|
|
import md_to_bpmn as m # noqa: E402
|
2026-09-25 13:01:23 +02:00
|
|
|
|
from test_md_to_bpmn import _liegt_in_rautenflaeche # noqa: E402 (dieselbe Rautengeometrie wie dort)
|
2026-09-23 15:18:07 +02:00
|
|
|
|
|
2026-09-25 13:01:23 +02:00
|
|
|
|
MODELLE = sorted((SKILL / "beispiele").glob("*.md"))
|
|
|
|
|
|
REFERENZEN = [md for md in MODELLE if md.stem.startswith("referenz_")]
|
2026-09-23 15:18:07 +02:00
|
|
|
|
GATEWAYS = ("exclusiveGateway", "inclusiveGateway", "parallelGateway", "eventBasedGateway")
|
2026-09-25 13:01:23 +02:00
|
|
|
|
EREIGNISSE = ("startEvent", "endEvent", "intermediateCatchEvent", "intermediateThrowEvent", "boundaryEvent")
|
|
|
|
|
|
# Formen, durch die keine Kante laufen darf; Pools, Lanes, Gruppen und Hinweise zählen nicht als Hindernis
|
|
|
|
|
|
HINDERNISSE = GATEWAYS + EREIGNISSE + (
|
|
|
|
|
|
"task", "manualTask", "userTask", "serviceTask", "scriptTask", "businessRuleTask", "sendTask", "receiveTask",
|
|
|
|
|
|
"subProcess", "callActivity", "dataObjectReference", "dataStoreReference",
|
|
|
|
|
|
)
|
|
|
|
|
|
KANTEN = ("sequenceFlow", "messageFlow")
|
2026-09-23 15:18:07 +02:00
|
|
|
|
|
2026-09-25 13:01:23 +02:00
|
|
|
|
# Bekannte, per Issue erfasste Konverterfehler: (Modell, Kante, Element) -> Grund mit Issue-Nummer.
|
|
|
|
|
|
# Jedes Paar bekommt einen eigenen xfail(strict=True)-Test; sobald der Fehler behoben ist, schlägt dieser als XPASS
|
|
|
|
|
|
# fehl – dann den Eintrag hier entfernen. Nie verwenden, um neue Fehler zu verstecken (AGENTS.md).
|
|
|
|
|
|
BEKANNTE_KREUZUNGEN = {
|
|
|
|
|
|
("beispiel_lanes", "Flow_5", "Task_S5"): "Issue #12: Rücksprungkante S4→S2 läuft senkrecht durch S5",
|
|
|
|
|
|
("beispiel_lanes", "MsgFlow_2", "Task_S5"): "Issue #13: Nachrichtenfluss zum externen Pool läuft durch S5",
|
|
|
|
|
|
("referenz_gewannenfestlegung", "MsgFlow_2", "Task_S24"): "Issue #13: Nachrichtenfluss von S16 läuft durch S24",
|
|
|
|
|
|
}
|
2026-09-23 15:18:07 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def baue(md):
|
|
|
|
|
|
meta, steps, order, externals = m.parse_markdown(md.read_text(encoding="utf-8"))
|
|
|
|
|
|
return m.build_bpmn(meta, steps, order, externals)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-25 13:01:23 +02:00
|
|
|
|
def _liegt_innen(px, py, art, x, y, w, h, abstand=1.0):
|
|
|
|
|
|
"""True, wenn der Punkt im Inneren der sichtbaren Form liegt (Kontur und Umgebung zählen nicht)."""
|
|
|
|
|
|
if art in GATEWAYS:
|
|
|
|
|
|
return _liegt_in_rautenflaeche(px, py, x, y, w, h)
|
|
|
|
|
|
if art in EREIGNISSE:
|
|
|
|
|
|
r = min(w, h) / 2
|
|
|
|
|
|
return math.hypot(px - x - w / 2, py - y - h / 2) < r - abstand
|
|
|
|
|
|
return x + abstand < px < x + w - abstand and y + abstand < py < y + h - abstand
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def kreuzungen(defs):
|
|
|
|
|
|
"""Alle Paare (Kante, Element), bei denen ein Sequenz- oder Nachrichtenfluss durchs Innere eines Elements läuft.
|
|
|
|
|
|
|
|
|
|
|
|
Die Kante wird in Schritten von höchstens 4 Einheiten abgetastet, damit auch kleine Ereignisse nicht zwischen
|
|
|
|
|
|
zwei Stichproben fallen."""
|
|
|
|
|
|
art = {el.get("id"): el.tag.rpartition("}")[2] for el in defs.iter() if el.get("id")}
|
|
|
|
|
|
formen = {}
|
|
|
|
|
|
for shape in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNShape"):
|
|
|
|
|
|
eid = shape.get("bpmnElement")
|
|
|
|
|
|
if art.get(eid) in HINDERNISSE:
|
|
|
|
|
|
b = shape.find(f"{{{m.NS['dc']}}}Bounds")
|
|
|
|
|
|
formen[eid] = tuple(float(b.get(a)) for a in ("x", "y", "width", "height"))
|
|
|
|
|
|
|
|
|
|
|
|
funde = set()
|
|
|
|
|
|
for edge in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNEdge"):
|
|
|
|
|
|
kante = edge.get("bpmnElement")
|
|
|
|
|
|
if art.get(kante) not in KANTEN:
|
|
|
|
|
|
continue
|
|
|
|
|
|
punkte = [(float(wp.get("x")), float(wp.get("y"))) for wp in edge.findall(f"{{{m.NS['di']}}}waypoint")]
|
|
|
|
|
|
for eid, (x, y, w, h) in formen.items():
|
|
|
|
|
|
for (x1, y1), (x2, y2) in zip(punkte, punkte[1:]):
|
|
|
|
|
|
schritte = max(8, int(math.hypot(x2 - x1, y2 - y1) / 4))
|
|
|
|
|
|
if any(_liegt_innen(x1 + (x2 - x1) * s / schritte, y1 + (y2 - y1) * s / schritte, art[eid], x, y, w, h)
|
|
|
|
|
|
for s in range(1, schritte)):
|
|
|
|
|
|
funde.add((kante, eid))
|
|
|
|
|
|
break
|
|
|
|
|
|
return sorted(funde)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-23 15:18:07 +02:00
|
|
|
|
def test_es_gibt_mindestens_ein_referenzmodell():
|
|
|
|
|
|
"""Prüft, dass die Strukturtests tatsächlich an einem Referenzmodell laufen."""
|
|
|
|
|
|
assert REFERENZEN
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("md", REFERENZEN, ids=lambda p: p.stem)
|
|
|
|
|
|
def test_jedes_element_hat_eine_zustaendigkeit(md):
|
|
|
|
|
|
"""Prüft, dass im Referenzmodell kein Element in der Lane 'Nicht zugeordnet' landet."""
|
|
|
|
|
|
lanes = [lane.get("name") for lane in baue(md).iter(f"{{{m.NS['bpmn']}}}lane")]
|
|
|
|
|
|
assert lanes, "Referenzmodell ohne Lanes"
|
|
|
|
|
|
assert m.UNASSIGNED_LANE not in lanes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("md", REFERENZEN, ids=lambda p: p.stem)
|
|
|
|
|
|
def test_kein_gateway_hat_mehr_als_vier_kanten(md):
|
|
|
|
|
|
"""Prüft Modellierregel 4: kein Gateway im Referenzmodell braucht mehr als die vier Anschlusspunkte von PICTURE."""
|
|
|
|
|
|
defs = baue(md)
|
|
|
|
|
|
for tag in GATEWAYS:
|
|
|
|
|
|
for gateway in defs.iter(f"{{{m.NS['bpmn']}}}{tag}"):
|
|
|
|
|
|
kanten = gateway.findall(f"{{{m.NS['bpmn']}}}incoming") + gateway.findall(f"{{{m.NS['bpmn']}}}outgoing")
|
|
|
|
|
|
assert len(kanten) <= 4, f"{gateway.get('id')} hat {len(kanten)} Kanten"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-25 13:01:23 +02:00
|
|
|
|
@pytest.mark.parametrize("md", MODELLE, ids=lambda p: p.stem)
|
|
|
|
|
|
def test_keine_unbekannte_kante_fuehrt_durch_ein_element(md):
|
|
|
|
|
|
"""Prüft, dass in keinem Modell ein Sequenz- oder Nachrichtenfluss durchs Innere einer Aufgabe, eines Ereignisses,
|
|
|
|
|
|
eines Gateways oder eines Datenelements läuft – außer den per Issue erfassten Fällen. Meldet alle Funde auf einmal."""
|
|
|
|
|
|
bekannt = {(k, e) for (modell, k, e) in BEKANNTE_KREUZUNGEN if modell == md.stem}
|
|
|
|
|
|
neu = [paar for paar in kreuzungen(baue(md)) if paar not in bekannt]
|
|
|
|
|
|
assert neu == [], "Kante durch Element (Kante, Element): " + ", ".join(f"{k} → {e}" for k, e in neu)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("modell, kante, element", [
|
|
|
|
|
|
pytest.param(*schluessel, id="-".join(schluessel), marks=pytest.mark.xfail(reason=grund, strict=True))
|
|
|
|
|
|
for schluessel, grund in BEKANNTE_KREUZUNGEN.items()
|
|
|
|
|
|
])
|
|
|
|
|
|
def test_bekannte_kreuzung_ist_behoben(modell, kante, element):
|
|
|
|
|
|
"""Prüft je per Issue erfasster Kreuzung, ob sie noch besteht; als xfail(strict=True) markiert, damit ein Fix
|
|
|
|
|
|
auffällt und der Eintrag in BEKANNTE_KREUZUNGEN entfernt wird."""
|
|
|
|
|
|
assert (kante, element) not in kreuzungen(baue(SKILL / "beispiele" / f"{modell}.md"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_kreuzungspruefung_findet_eine_kante_durch_eine_aufgabe():
|
|
|
|
|
|
"""Prüft die Prüfung selbst: eine absichtlich durch eine Aufgabe gelegte Kante wird gefunden, eine daneben nicht."""
|
|
|
|
|
|
defs = baue(SKILL / "beispiele" / "beispiel_prozess.md")
|
|
|
|
|
|
task_shape = next(s for s in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNShape") if s.get("bpmnElement").startswith("Task_"))
|
|
|
|
|
|
b = task_shape.find(f"{{{m.NS['dc']}}}Bounds")
|
|
|
|
|
|
x, y, w, h = (float(b.get(a)) for a in ("x", "y", "width", "height"))
|
|
|
|
|
|
edge = next(e for e in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNEdge") if e.get("bpmnElement").startswith("Flow_"))
|
|
|
|
|
|
wp = edge.findall(f"{{{m.NS['di']}}}waypoint")
|
|
|
|
|
|
wp[0].set("x", str(x - 20)); wp[0].set("y", str(y + h / 2))
|
|
|
|
|
|
wp[-1].set("x", str(x + w + 20)); wp[-1].set("y", str(y + h / 2))
|
|
|
|
|
|
for mitte in wp[1:-1]:
|
|
|
|
|
|
edge.remove(mitte)
|
|
|
|
|
|
assert (edge.get("bpmnElement"), task_shape.get("bpmnElement")) in kreuzungen(defs)
|
|
|
|
|
|
wp[0].set("y", str(y - 30)); wp[-1].set("y", str(y - 30))
|
|
|
|
|
|
assert (edge.get("bpmnElement"), task_shape.get("bpmnElement")) not in kreuzungen(defs)
|