- 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>
592 lines
22 KiB
Python
592 lines
22 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Tests für md_to_bpmn.py - reines Python/pytest, ohne Netzwerk."""
|
||
import xml.etree.ElementTree as ET
|
||
|
||
import pytest
|
||
|
||
import md_to_bpmn as m
|
||
|
||
|
||
def test_bestehende_syntax_bleibt_kompatibel():
|
||
"""Prüft, dass Start/Task/Gateway-XOR/Ende mit Verzweigung wie bisher geparst werden."""
|
||
text = """
|
||
S1 Start: Beginn
|
||
-> S2
|
||
S2 Task: Antrag pruefen
|
||
S3 Gateway XOR: Vollständig?
|
||
Ja -> S4
|
||
Nein -> S2
|
||
S4 Ende: Fertig
|
||
"""
|
||
meta, steps, order, externals = m.parse_markdown(text)
|
||
assert externals == []
|
||
assert steps['S1'].type == 'StartEvent'
|
||
assert steps['S3'].type == 'XorGateway'
|
||
assert ('Ja', 'S4') in steps['S3'].edges
|
||
defs = m.build_bpmn(meta, steps, order, externals)
|
||
assert defs.find(f".//{{{m.NS['bpmn']}}}exclusiveGateway") is not None
|
||
|
||
|
||
def test_lane_erkennung_bleibt_unveraendert():
|
||
"""Prüft, dass 'Verantwortlicher: Rest' weiterhin als Swimlane erkannt wird."""
|
||
text = """
|
||
S1 Start: Beginn
|
||
-> V1
|
||
V1 Task: Flurneuordnung: Gewannenkonzept festlegen
|
||
O3 Task: ÖbVI: Waldgrenzen verhandeln
|
||
S_end Ende: Fertig
|
||
"""
|
||
meta, steps, order, externals = m.parse_markdown(text)
|
||
lanes = {s.lane for s in steps.values() if s.lane}
|
||
assert lanes == {'Flurneuordnung', 'ÖbVI'}
|
||
|
||
|
||
@pytest.mark.parametrize('typewort, erwarteter_typ', [
|
||
('Manuelle Aufgabe', 'ManualTask'),
|
||
('User Aufgabe', 'UserTask'),
|
||
('Service Aufgabe', 'ServiceTask'),
|
||
('Script Aufgabe', 'ScriptTask'),
|
||
('Geschäftsregel Aufgabe', 'BusinessRuleTask'),
|
||
('Sendeaufgabe', 'SendTask'),
|
||
('Empfangsaufgabe', 'ReceiveTask'),
|
||
('Teilprozess', 'SubProcess'),
|
||
('Aufruf', 'CallActivity'),
|
||
('Gateway Ereignisbasiert', 'EventBasedGateway'),
|
||
('Datenobjekt', 'DataObject'),
|
||
('Datenspeicher', 'DataStore'),
|
||
('Gruppe', 'Group'),
|
||
])
|
||
def test_neue_elementtypen_werden_aufgeloest(typewort, erwarteter_typ):
|
||
"""Prüft, dass alle neuen Aufgaben-/Gateway-/Artefakttypen korrekt zugeordnet werden."""
|
||
typ, kind, direction = m.resolve_type(typewort, 'Testzeile')
|
||
assert typ == erwarteter_typ
|
||
|
||
|
||
def test_typisiertes_startereignis_erzeugt_eventdefinition():
|
||
"""Prüft, dass ein typisiertes Startereignis das passende BPMN-eventDefinition-Kind erhaelt."""
|
||
text = """
|
||
S1 Start Nachricht: Antrag eingegangen
|
||
-> S2
|
||
S2 Ende: Fertig
|
||
"""
|
||
meta, steps, order, externals = m.parse_markdown(text)
|
||
defs = m.build_bpmn(meta, steps, order, externals)
|
||
assert defs.find(f".//{{{m.NS['bpmn']}}}messageEventDefinition") is not None
|
||
|
||
|
||
def test_zwischenereignis_sendend_wird_throw_event():
|
||
"""Prüft, dass 'sendend' bei einem Zwischenereignis ein intermediateThrowEvent erzeugt."""
|
||
text = """
|
||
S1 Start: Beginn
|
||
-> S2
|
||
S2 Zwischenereignis Nachricht sendend: Status melden
|
||
-> S3
|
||
S3 Ende: Fertig
|
||
"""
|
||
meta, steps, order, externals = m.parse_markdown(text)
|
||
assert m.bpmn_tag_for(steps['S2']) == 'intermediateThrowEvent'
|
||
|
||
|
||
def test_terminierung_nur_als_endereignis_erlaubt():
|
||
"""Prüft, dass 'Terminierung' bei einem Startereignis einen Fehler ausloest."""
|
||
text = """
|
||
S1 Start Terminierung: X
|
||
-> S2
|
||
S2 Ende: Y
|
||
"""
|
||
with pytest.raises(ValueError, match='Terminierung'):
|
||
m.parse_markdown(text)
|
||
|
||
|
||
def test_link_nur_als_zwischenereignis_erlaubt():
|
||
"""Prüft, dass 'Link' bei einem Endereignis einen Fehler ausloest."""
|
||
text = """
|
||
S1 Start: X
|
||
-> S2
|
||
S2 Ende Link: Y
|
||
"""
|
||
with pytest.raises(ValueError, match='Link'):
|
||
m.parse_markdown(text)
|
||
|
||
|
||
def test_ereignisbasiertes_gateway_verbietet_zusammenfuehrung():
|
||
"""Prüft, dass ein ereignisbasiertes Gateway mit mehreren eingehenden Kanten abgelehnt wird."""
|
||
text = """
|
||
S1 Start: X
|
||
-> S2
|
||
-> S3
|
||
S2 Task: A
|
||
-> S4
|
||
S3 Task: B
|
||
-> S4
|
||
S4 Gateway Ereignisbasiert: C
|
||
-> S5
|
||
S5 Ende: Y
|
||
"""
|
||
with pytest.raises(ValueError, match='Zusammenführung'):
|
||
m.parse_markdown(text)
|
||
|
||
|
||
def test_datenobjekt_bekommt_keinen_sequenzfluss():
|
||
"""Prüft, dass ein Sequenzfluss zu einem Datenobjekt abgelehnt wird."""
|
||
text = """
|
||
S1 Start: X
|
||
-> D1
|
||
D1 Datenobjekt: Akte
|
||
S2 Ende: Y
|
||
"""
|
||
with pytest.raises(ValueError, match='keine Ablaufelemente'):
|
||
m.parse_markdown(text)
|
||
|
||
|
||
def test_datenobjekt_wird_nicht_automatisch_verkettet():
|
||
"""Prüft, dass die automatische Verkettung über unverbundene Artefakte hinwegspringt."""
|
||
text = """
|
||
S1 Start: X
|
||
D1 Datenobjekt: Akte
|
||
S2 Ende: Y
|
||
"""
|
||
meta, steps, order, externals = m.parse_markdown(text)
|
||
assert steps['S1'].edges == [(None, 'S2')]
|
||
assert steps['D1'].edges == []
|
||
|
||
|
||
def test_datenobjekt_und_gruppe_erzeugen_erwartete_elemente():
|
||
"""Prüft, dass Datenobjekt und Gruppe als eigenstaendige, unverbundene Artefakte gerendert werden."""
|
||
text = """
|
||
S1 Start: X
|
||
-> S2
|
||
S2 Ende: Y
|
||
D1 Datenobjekt: Akte
|
||
G1 Gruppe: Fachliche Pruefung
|
||
"""
|
||
meta, steps, order, externals = m.parse_markdown(text)
|
||
defs = m.build_bpmn(meta, steps, order, externals)
|
||
assert defs.find(f".//{{{m.NS['bpmn']}}}dataObjectReference") is not None
|
||
assert defs.find(f".//{{{m.NS['bpmn']}}}group") is not None
|
||
assert defs.find(f".//{{{m.NS['bpmn']}}}categoryValue") is not None
|
||
|
||
|
||
def test_externer_beteiligter_erzeugt_messageflow():
|
||
"""Prüft, dass ein deklarierter externer Beteiligter mit Nachrichtenfluss korrekt verknuepft wird."""
|
||
text = """
|
||
Extern: Bürger
|
||
|
||
S1 Start: X
|
||
-> S2
|
||
S2 SendTask: Bescheid senden
|
||
~> Bürger
|
||
-> S3
|
||
S3 Ende: Y
|
||
"""
|
||
meta, steps, order, externals = m.parse_markdown(text)
|
||
assert externals == ['Bürger']
|
||
defs = m.build_bpmn(meta, steps, order, externals)
|
||
msg_flow = defs.find(f".//{{{m.NS['bpmn']}}}messageFlow")
|
||
assert msg_flow is not None
|
||
participants = defs.findall(f".//{{{m.NS['bpmn']}}}participant")
|
||
assert any(p.get('name') == 'Bürger' and p.get('processRef') is None for p in participants)
|
||
|
||
|
||
def test_unbekannter_externer_beteiligter_wird_abgelehnt():
|
||
"""Prüft, dass ein Nachrichtenfluss zu einem nicht deklarierten externen Beteiligten scheitert."""
|
||
text = """
|
||
S1 Start: X
|
||
~> Unbekannt
|
||
S2 Ende: Y
|
||
"""
|
||
with pytest.raises(ValueError, match='unbekannter externer Beteiligter'):
|
||
m.parse_markdown(text)
|
||
|
||
|
||
def test_verteilte_anschlusspunkte_liegen_auf_der_rautenkontur():
|
||
"""Prüft, dass verteilte Anschlusspunkte an einem Gateway auf der Diamant-Kontur liegen, nicht auf der Bounding-Box."""
|
||
x, y, w, h = 590, 2290, 50, 50
|
||
cx, cy = x + w / 2, y + h / 2
|
||
for idx in range(4):
|
||
px, py = m.spread_point(x, y, w, h, 'S', idx, 4, 'diamond')
|
||
# Manhattan-Abstand zum Mittelpunkt, skaliert auf die Diamant-Halbachsen, muss 1 ergeben.
|
||
assert abs(abs(px - cx) / (w / 2) + abs(py - cy) / (h / 2) - 1) < 1e-9
|
||
|
||
|
||
def test_verteilte_anschlusspunkte_liegen_auf_dem_ereigniskreis():
|
||
"""Prüft, dass verteilte Anschlusspunkte an einem Ereignis auf dem Kreisradius liegen, nicht auf der Bounding-Box."""
|
||
x, y, w, h = 100, 100, 36, 36
|
||
cx, cy = x + w / 2, y + h / 2
|
||
r = w / 2
|
||
for idx in range(3):
|
||
px, py = m.spread_point(x, y, w, h, 'S', idx, 3, 'circle')
|
||
dist = ((px - cx) ** 2 + (py - cy) ** 2) ** 0.5
|
||
assert abs(dist - r) < 1e-9
|
||
|
||
|
||
def test_gateway_kanten_bekommen_eigene_kardinalpunkte():
|
||
"""Prüft, dass bis zu vier Kanten an einem Gateway je einen eigenen Eckpunkt (N/O/S/W) bekommen, wie PICTURE es rendert."""
|
||
text = """
|
||
S1 Start: X
|
||
-> S2
|
||
S2 Gateway XOR: Auswahl
|
||
A -> S3
|
||
B -> S4
|
||
C -> S5
|
||
S3 Ende: A
|
||
S4 Ende: B
|
||
S5 Ende: C
|
||
"""
|
||
meta, steps, order, externals = m.parse_markdown(text)
|
||
defs = m.build_bpmn(meta, steps, order, externals)
|
||
ns = m.NS
|
||
gw = next(el for el in defs.iter(f"{{{ns['bpmn']}}}exclusiveGateway") if el.get('name') == 'Auswahl')
|
||
shape = defs.find(f".//{{{ns['bpmndi']}}}BPMNShape[@bpmnElement='{gw.get('id')}']")
|
||
x, y, w, h = (float(shape[0].get(a)) for a in ('x', 'y', 'width', 'height'))
|
||
vertices = {(x + w / 2, y), (x + w / 2, y + h), (x, y + h / 2), (x + w, y + h / 2)}
|
||
|
||
out_ids = {o.text for o in gw.findall(f"{{{ns['bpmn']}}}outgoing")}
|
||
in_ids = {i.text for i in gw.findall(f"{{{ns['bpmn']}}}incoming")}
|
||
used = set()
|
||
for fid in out_ids | in_ids:
|
||
edge = defs.find(f".//{{{ns['bpmndi']}}}BPMNEdge[@bpmnElement='{fid}']")
|
||
wps = edge.findall(f"{{{ns['di']}}}waypoint")
|
||
wp = wps[0] if fid in out_ids else wps[-1]
|
||
point = (float(wp.get('x')), float(wp.get('y')))
|
||
assert point in vertices
|
||
used.add(point)
|
||
assert len(used) == 4
|
||
|
||
|
||
def test_gateway_mit_mehr_als_vier_kanten_bleibt_gueltiges_xml():
|
||
"""Prüft, dass ein Gateway mit mehr als vier anliegenden Kanten (über PICTUREs Grenze hinaus) trotzdem gültiges XML ohne Absturz erzeugt."""
|
||
text = """
|
||
S1 Start: X
|
||
-> S2
|
||
S2 Gateway XOR: Fuenf-Wege
|
||
A -> S3
|
||
B -> S4
|
||
C -> S5
|
||
D -> S6
|
||
E -> S7
|
||
S3 Ende: A
|
||
S4 Ende: B
|
||
S5 Ende: C
|
||
S6 Ende: D
|
||
S7 Ende: E
|
||
"""
|
||
meta, steps, order, externals = m.parse_markdown(text)
|
||
defs = m.build_bpmn(meta, steps, order, externals)
|
||
ET.tostring(defs) # loest bei kaputter Struktur eine Exception aus
|
||
|
||
|
||
def test_mehrere_kanten_an_derselben_seite_bekommen_eigenen_anschlusspunkt():
|
||
"""Prüft, dass mehrere ausgehende Kanten an einem Gateway nicht im selben Punkt starten."""
|
||
text = """
|
||
S1 Start: X
|
||
-> S2
|
||
S2 Gateway XOR: Auswahl
|
||
A -> S3
|
||
B -> S4
|
||
C -> S5
|
||
S3 Ende: A
|
||
S4 Ende: B
|
||
S5 Ende: C
|
||
"""
|
||
meta, steps, order, externals = m.parse_markdown(text)
|
||
defs = m.build_bpmn(meta, steps, order, externals)
|
||
starts = []
|
||
for edge in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNEdge"):
|
||
wp = edge.find(f"{{{m.NS['di']}}}waypoint")
|
||
starts.append((wp.get('x'), wp.get('y')))
|
||
# 3 der 4 Kanten gehen vom selben Gateway aus - deren Startpunkte muessen sich unterscheiden.
|
||
assert len(set(starts)) == len(starts)
|
||
|
||
|
||
def _liegt_in_rautenflaeche(px, py, x, y, w, h, sicherheitsabstand=0.5):
|
||
"""Manhattan-Abstand zum Mittelpunkt, skaliert auf die Diamant-Halbachsen: < 1 heisst innerhalb."""
|
||
cx, cy = x + w / 2, y + h / 2
|
||
wert = abs(px - cx) / (w / 2) + abs(py - cy) / (h / 2)
|
||
return wert < 1 - (sicherheitsabstand / max(w, h))
|
||
|
||
|
||
def _kante_kreuzt_gateway(defs, ns):
|
||
"""Liefert (Kanten-Id, Gateway-Id) für die erste Kante, deren Streckenzug durchs Innere
|
||
eines Gateways (statt nur an dessen Kontur entlang) laeuft - oder None, wenn keine kreuzt."""
|
||
shapes = {}
|
||
for shape in defs.iter(f"{{{ns['bpmndi']}}}BPMNShape"):
|
||
b = shape.find(f"{{{ns['dc']}}}Bounds")
|
||
if b is not None:
|
||
shapes[shape.get('bpmnElement')] = tuple(float(b.get(a)) for a in ('x', 'y', 'width', 'height'))
|
||
|
||
gateway_ids = set()
|
||
for tag in ('exclusiveGateway', 'inclusiveGateway', 'parallelGateway', 'eventBasedGateway'):
|
||
gateway_ids.update(el.get('id') for el in defs.iter(f"{{{ns['bpmn']}}}{tag}"))
|
||
|
||
for edge in defs.iter(f"{{{ns['bpmndi']}}}BPMNEdge"):
|
||
pts = [(float(wp.get('x')), float(wp.get('y'))) for wp in edge.findall(f"{{{ns['di']}}}waypoint")]
|
||
for gid in gateway_ids:
|
||
if gid not in shapes:
|
||
continue
|
||
x, y, w, h = shapes[gid]
|
||
for (x1, y1), (x2, y2) in zip(pts, pts[1:]):
|
||
for s in range(41):
|
||
t = s / 40
|
||
if _liegt_in_rautenflaeche(x1 + (x2 - x1) * t, y1 + (y2 - y1) * t, x, y, w, h):
|
||
return edge.get('bpmnElement'), gid
|
||
return None
|
||
|
||
|
||
def test_kante_fuehrt_nicht_durchs_innere_eines_gateways():
|
||
"""Prüft, dass eine Kante von/zu einem Gateway, dessen Ziel/Quelle nicht auf der Achse des
|
||
zugewiesenen Anschlusspunkts liegt, aussen herum und nicht durchs Innere der Raute fuehrt."""
|
||
text = """
|
||
S1 Start: X
|
||
-> S5
|
||
S5 Gateway XOR: Sofort?
|
||
Ja -> S6
|
||
Nein -> S6m
|
||
S6 Task: Leitungsplaene einholen
|
||
-> S6m
|
||
S6m Gateway XOR: Zusammenfuehrung
|
||
-> S7
|
||
S7 Ende: Fertig
|
||
"""
|
||
meta, steps, order, externals = m.parse_markdown(text)
|
||
defs = m.build_bpmn(meta, steps, order, externals)
|
||
assert _kante_kreuzt_gateway(defs, m.NS) is None
|
||
|
||
|
||
def _streckenzug_kreuzt_raute(punkte, raute):
|
||
"""True, wenn ein Stück des Streckenzugs durchs Innere der Raute (x, y, w, h) läuft."""
|
||
for (x1, y1), (x2, y2) in zip(punkte, punkte[1:]):
|
||
for s in range(41):
|
||
t = s / 40
|
||
if _liegt_in_rautenflaeche(x1 + (x2 - x1) * t, y1 + (y2 - y1) * t, *raute):
|
||
return True
|
||
return False
|
||
|
||
|
||
def test_kante_zwischen_gateways_zum_nordpunkt_eines_hoeheren_ziels_umgeht_die_raute():
|
||
"""Prüft, dass eine Kante vom Südpunkt eines Gateways zum Nordpunkt eines höher liegenden Gateways
|
||
seitlich an der Ziel-Raute vorbei nach oben führt statt senkrecht durch sie hindurch (Issue #8)."""
|
||
quelle, ziel = (2200, 145, 50, 50), (2390, 60, 50, 50) # Lage wie S12 -> S13 im Referenzmodell
|
||
punkte = m.orthogonal_points(2225, 195, 'S', 2415, 60, 'N', src_is_gateway=True, tgt_is_gateway=True)
|
||
assert punkte[0] == (2225, 195) and punkte[-1] == (2415, 60)
|
||
assert not _streckenzug_kreuzt_raute(punkte, ziel)
|
||
assert not _streckenzug_kreuzt_raute(punkte, quelle)
|
||
assert all(a[0] == b[0] or a[1] == b[1] for a, b in zip(punkte, punkte[1:])), "nicht rechtwinklig"
|
||
|
||
|
||
def test_ausweichweg_zwischen_gateways_meidet_die_spalte_dazwischen():
|
||
"""Prüft, dass der seitliche Umweg um die Ziel-Raute im freien Gang direkt vor der Ziel-Spalte liegt und
|
||
nicht mitten zwischen Quelle und Ziel, wo in einer Spalte dazwischen eine Aufgabe stehen kann."""
|
||
aufgabe, ziel = (2960, 215, 100, 80), (3150, 230, 50, 50) # Lage wie S43 und S44 im Referenzmodell
|
||
punkte = m.orthogonal_points(2795, 280, 'S', 3175, 230, 'N', src_is_gateway=True, tgt_is_gateway=True)
|
||
assert not _streckenzug_kreuzt_raute(punkte, ziel)
|
||
x, y, w, h = aufgabe
|
||
for (x1, y1), (x2, y2) in zip(punkte, punkte[1:]):
|
||
for s in range(41):
|
||
px, py = x1 + (x2 - x1) * s / 40, y1 + (y2 - y1) * s / 40
|
||
assert not (x < px < x + w and y < py < y + h), f"Kante läuft bei ({px:g}, {py:g}) durch die Aufgabe"
|
||
|
||
|
||
def test_ausweichabstand_liegt_in_beiden_layouts_im_freien_gang():
|
||
"""Prüft, dass AUSWEICHABSTAND zu den Layout-Maßen passt: Die Ausweichachse liegt jenseits der halben Breite bzw.
|
||
Höhe des größten Elements und diesseits des Nachbarelements, in Spalten wie in Zeilen, mit und ohne Lanes (#8, #16).
|
||
Schlägt der Test fehl, wurden Elementgrößen oder Abstände geändert – dann AUSWEICHABSTAND neu bestimmen."""
|
||
groessen = [wh for typ, wh in m.DEFAULT_SIZE.items() if typ not in m.UNCONNECTED_TYPES]
|
||
breite = max(w for w, _ in groessen)
|
||
hoehe = max(h for _, h in groessen)
|
||
for spalte in (m.COLUMN_WIDTH, m.LANE_COLUMN_WIDTH):
|
||
assert breite / 2 < m.AUSWEICHABSTAND < spalte - breite / 2, f"Spaltenabstand {spalte}"
|
||
for zeile in (m.ROW_HEIGHT, m.LANE_ROW_HEIGHT):
|
||
assert hoehe / 2 < m.AUSWEICHABSTAND < zeile - hoehe / 2, f"Zeilenabstand {zeile}"
|
||
|
||
|
||
def test_kante_zwischen_gateways_zum_westpunkt_eines_linken_ziels_umgeht_die_raute():
|
||
"""Prüft den waagrechten Spiegelfall: vom Ostpunkt eines Gateways zum Westpunkt eines weiter links
|
||
liegenden Gateways führt die Kante ober- oder unterhalb an der Ziel-Raute vorbei."""
|
||
quelle, ziel = (400, 300, 50, 50), (200, 100, 50, 50)
|
||
punkte = m.orthogonal_points(450, 325, 'E', 200, 125, 'W', src_is_gateway=True, tgt_is_gateway=True)
|
||
assert punkte[0] == (450, 325) and punkte[-1] == (200, 125)
|
||
assert not _streckenzug_kreuzt_raute(punkte, ziel)
|
||
assert not _streckenzug_kreuzt_raute(punkte, quelle)
|
||
assert all(a[0] == b[0] or a[1] == b[1] for a, b in zip(punkte, punkte[1:])), "nicht rechtwinklig"
|
||
|
||
|
||
def test_nur_xor_gateways_zeigen_ihr_x():
|
||
"""Prüft, dass die Diagrammform jedes exklusiven Gateways isMarkerVisible="true" trägt, damit das „X“ erscheint,
|
||
und dass kein anderes Element das Attribut bekommt (Issue #15)."""
|
||
text = """
|
||
S1 Start: Beginn
|
||
-> S2
|
||
S2 Gateway XOR: Vollständig?
|
||
Ja -> S3
|
||
Nein -> S4
|
||
S3 Gateway AND: Aufteilen
|
||
-> S5
|
||
S4 Gateway OR: Welche?
|
||
-> S5
|
||
S5 Task: Bearbeiten
|
||
-> S6
|
||
S6 Ende: Fertig
|
||
"""
|
||
meta, steps, order, externals = m.parse_markdown(text)
|
||
defs = m.build_bpmn(meta, steps, order, externals)
|
||
xor_ids = {g.get('id') for g in defs.iter(f"{{{m.NS['bpmn']}}}exclusiveGateway")}
|
||
assert xor_ids, "Testmodell ohne XOR-Gateway"
|
||
for shape in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNShape"):
|
||
erwartet = 'true' if shape.get('bpmnElement') in xor_ids else None
|
||
assert shape.get('isMarkerVisible') == erwartet, shape.get('bpmnElement')
|
||
|
||
|
||
RUECKSPRUNG_MODELL = """
|
||
Extern: Antragsteller
|
||
|
||
S1 Start: Posteingang: Antrag geht ein
|
||
-> S2
|
||
S2 Task: Sachbearbeitung: Antrag erfassen
|
||
S3 Gateway XOR: Sachbearbeitung: Antrag vollständig?
|
||
Ja -> S5
|
||
Nein -> S4
|
||
S4 Sendeaufgabe: Sachbearbeitung: Unterlagen nachfordern
|
||
~> Antragsteller
|
||
-> S2
|
||
S5 Task: Sachbearbeitung: Entscheidung vorbereiten
|
||
S6 Task: Teamleitung: Entscheidung zeichnen
|
||
Zurück -> S3
|
||
Weiter -> S7
|
||
S7 Ende: Posteingang: Antrag entschieden
|
||
"""
|
||
|
||
|
||
def _baue_und_finde(text):
|
||
"""Baut das Modell und liefert (defs, Bounds je Element-Id, Wegpunkte je (Quelle, Ziel)-Paar der Sequenzflüsse)."""
|
||
meta, steps, order, externals = m.parse_markdown(text)
|
||
defs = m.build_bpmn(meta, steps, order, externals)
|
||
bounds = {}
|
||
for shape in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNShape"):
|
||
b = shape.find(f"{{{m.NS['dc']}}}Bounds")
|
||
bounds[shape.get('bpmnElement')] = tuple(float(b.get(a)) for a in ('x', 'y', 'width', 'height'))
|
||
paare = {f.get('id'): (f.get('sourceRef'), f.get('targetRef')) for f in defs.iter(f"{{{m.NS['bpmn']}}}sequenceFlow")}
|
||
wege = {}
|
||
for edge in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNEdge"):
|
||
if edge.get('bpmnElement') in paare:
|
||
wege[paare[edge.get('bpmnElement')]] = [(float(w.get('x')), float(w.get('y')))
|
||
for w in edge.findall(f"{{{m.NS['di']}}}waypoint")]
|
||
return defs, bounds, wege
|
||
|
||
|
||
def test_ruecksprungkante_bleibt_im_eigenen_pool():
|
||
"""Prüft, dass eine Rücksprungkante im Lanes-Layout vollständig innerhalb des Prozess-Pools verläuft und nicht in
|
||
der Lücke zum externen Pool oder darin (Issue #12)."""
|
||
_, bounds, wege = _baue_und_finde(RUECKSPRUNG_MODELL)
|
||
px, py, pw, ph = bounds['Participant_1']
|
||
ex, ey, ew, eh = bounds['Participant_Ext_1']
|
||
for x, y in wege[('SendTask_S4', 'Task_S2')] + wege[('Task_S6', 'Gateway_S3')]:
|
||
assert px <= x <= px + pw and py <= y <= py + ph, f"Wegpunkt ({x:g}, {y:g}) außerhalb des Pools"
|
||
assert not (ey <= y <= ey + eh), f"Wegpunkt ({x:g}, {y:g}) im externen Pool"
|
||
|
||
|
||
def test_mehrere_ruecksprungkanten_haben_eigene_kanalhoehe():
|
||
"""Prüft, dass zwei Rücksprungkanten ihren waagrechten Rückweg auf verschiedenen Höhen nehmen, statt sich auf
|
||
einer Linie zu überdecken (Issue #12)."""
|
||
_, _, wege = _baue_und_finde(RUECKSPRUNG_MODELL)
|
||
def tiefster_punkt(weg):
|
||
return max(y for _, y in weg)
|
||
assert tiefster_punkt(wege[('SendTask_S4', 'Task_S2')]) != tiefster_punkt(wege[('Task_S6', 'Gateway_S3')])
|
||
|
||
|
||
def test_ruecksprungkante_ohne_lanes_laeuft_an_versetzten_zweigen_vorbei():
|
||
"""Prüft, dass eine Rücksprungkante ohne Lanes seitlich an allen Elementen zwischen Quelle und Ziel vorbeiführt,
|
||
auch an einem parallelen Zweig, der gegenüber der Mitte versetzt steht (Issue #12)."""
|
||
text = """
|
||
S1 Start: Beginn
|
||
-> S2
|
||
S2 Task: Erfassen
|
||
-> S3
|
||
S3 Gateway AND: Teilen
|
||
-> S4
|
||
-> S5
|
||
S4 Task: Prüfen links
|
||
-> S6
|
||
S5 Task: Prüfen rechts
|
||
-> S6
|
||
S6 Gateway AND: Zusammen
|
||
-> S7
|
||
S7 Gateway XOR: In Ordnung?
|
||
Nein -> S2
|
||
Ja -> S8
|
||
S8 Ende: Fertig
|
||
"""
|
||
_, bounds, wege = _baue_und_finde(text)
|
||
weg = wege[('Gateway_S7', 'Task_S2')]
|
||
for eid, (x, y, w, h) in bounds.items():
|
||
if eid.startswith(('Task_', 'Gateway_', 'StartEvent_', 'EndEvent_')):
|
||
for (x1, y1), (x2, y2) in zip(weg, weg[1:]):
|
||
for s in range(1, 40):
|
||
px, py = x1 + (x2 - x1) * s / 40, y1 + (y2 - y1) * s / 40
|
||
assert not (x + 1 < px < x + w - 1 and y + 1 < py < y + h - 1), f"Rücksprung durch {eid}"
|
||
|
||
|
||
DREI_ZWEIGE = """
|
||
S1 Start: Beginn
|
||
-> S2
|
||
S2 Gateway XOR: Welcher Weg?
|
||
a -> S3
|
||
b -> S4
|
||
c -> S5
|
||
S3 Task: Links
|
||
-> S6
|
||
S4 Task: Mitte
|
||
{rueck4}-> S6
|
||
S5 Task: Rechts
|
||
-> S6
|
||
S6 Gateway XOR: Zusammen
|
||
{rueck6}-> S7
|
||
S7 Ende: Fertig
|
||
"""
|
||
|
||
|
||
@pytest.mark.parametrize("fall, quelle_ziel", [
|
||
("rueck4", ('Task_S4', 'Gateway_S2')), # Quelle hat links einen Nachbarn in ihrer Zeile
|
||
("rueck6", ('Gateway_S6', 'Task_S3')), # Kanal rechts, Ziel hat rechts einen Nachbarn in seiner Zeile
|
||
])
|
||
def test_ruecksprungkante_ohne_lanes_quert_keinen_nachbarn_in_der_zeile(fall, quelle_ziel):
|
||
"""Prüft, dass eine Rücksprungkante ohne Lanes nicht waagrecht durch ein Nachbarelement in der Zeile von Quelle oder
|
||
Ziel läuft, sondern dann über die freie Lücke unter bzw. über der Zeile zum Kanal geht (Issue #12)."""
|
||
rueck = {"rueck4": "nochmal -> S2\n ", "rueck6": "nochmal -> S3\n "}
|
||
text = DREI_ZWEIGE.format(rueck4=rueck[fall] if fall == "rueck4" else "",
|
||
rueck6=rueck[fall] if fall == "rueck6" else "")
|
||
_, bounds, wege = _baue_und_finde(text)
|
||
weg = wege[quelle_ziel]
|
||
for eid, (x, y, w, h) in bounds.items():
|
||
if eid.startswith(('Task_', 'Gateway_', 'StartEvent_', 'EndEvent_')):
|
||
for (x1, y1), (x2, y2) in zip(weg, weg[1:]):
|
||
for s in range(1, 40):
|
||
px, py = x1 + (x2 - x1) * s / 40, y1 + (y2 - y1) * s / 40
|
||
assert not (x + 1 < px < x + w - 1 and y + 1 < py < y + h - 1), f"Rücksprung durch {eid}"
|
||
|
||
|
||
def test_ausgabe_ist_wohlgeformtes_xml(tmp_path):
|
||
"""Prüft, dass die erzeugte .bpmn-Datei sich als valides XML parsen laesst."""
|
||
src = tmp_path / 'prozess.md'
|
||
src.write_text("""
|
||
Extern: Bürger
|
||
|
||
S1 Start Nachricht: Antrag geht ein
|
||
-> S2
|
||
S2 Gateway XOR: Vollständig?
|
||
Ja -> S3
|
||
Nein -> S2
|
||
S3 SendTask: Bescheid senden
|
||
~> Bürger
|
||
-> S4
|
||
S4 Ende Terminierung: Fertig
|
||
""", encoding='utf-8')
|
||
out = tmp_path / 'prozess.bpmn'
|
||
|
||
meta, steps, order, externals = m.parse_markdown(src.read_text(encoding='utf-8'))
|
||
defs = m.build_bpmn(meta, steps, order, externals)
|
||
out.write_bytes(m.prettify(defs))
|
||
|
||
parsed = ET.parse(out)
|
||
assert parsed.getroot().tag == f"{{{m.NS['bpmn']}}}definitions"
|