- Mit Lanes: ruecksprung_points() fährt senkrecht nur in den Gängen zwischen den Spalten (rechts der Quelle, links des Ziels) und waagrecht knapp unter Quelle bzw. Ziel und in einem Kanal am Poolboden. Das Kanalband wird nach der Platzierung an die letzte Lane angehängt: Die Elemente bleiben, wo sie sind, die Kante bleibt im eigenen Pool, jede Rücksprungkante hat eine eigene Kanalhöhe. - Ohne Lanes: Kanal seitlich an allen Elementen zwischen Quelle und Ziel vorbei (parallele Zweige stehen versetzt). Steht ein Nachbar in der Zeile im Weg, unten hinaus bzw. oben hinein über die Lücke zwischen den Zeilen (zeile(), zeile_frei()). - beispiel_lanes: Kante S4->S2 läuft nicht mehr durch S5, Pool um das Kanalband höher, externer Pool entsprechend tiefer. beispiel_prozess: Kanal bei x 50 statt 20. Referenzmodell unverändert. - xfail für #12 entfernt; fünf neue Tests in test_md_to_bpmn.py. - Belastungstest (126 Rücksprung-Varianten, mit/ohne Lanes): Kreuzungen durch Rücksprungkanten vorher in 31 Fällen, jetzt in keinem. - Tests: python -m pytest -q -> 102 bestanden, 2 xfail (#13). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
7.4 KiB
Python
142 lines
7.4 KiB
Python
"""Strukturtests für die Modelle unter .agents/skills/sn-bpmn/beispiele/ – reines Python/pytest.
|
||
|
||
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.
|
||
"""
|
||
import math
|
||
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
|
||
from test_md_to_bpmn import _liegt_in_rautenflaeche # noqa: E402 (dieselbe Rautengeometrie wie dort)
|
||
|
||
MODELLE = sorted((SKILL / "beispiele").glob("*.md"))
|
||
REFERENZEN = [md for md in MODELLE if md.stem.startswith("referenz_")]
|
||
GATEWAYS = ("exclusiveGateway", "inclusiveGateway", "parallelGateway", "eventBasedGateway")
|
||
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")
|
||
|
||
# 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", "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",
|
||
}
|
||
|
||
|
||
def baue(md):
|
||
meta, steps, order, externals = m.parse_markdown(md.read_text(encoding="utf-8"))
|
||
return m.build_bpmn(meta, steps, order, externals)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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"
|
||
|
||
|
||
@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)
|