Rücksprungkanten an allen Elementen vorbeiführen (#12) #20
@@ -170,7 +170,8 @@ dort explizite Pfeile setzen, wo der Ablauf von der reinen Lesereihenfolge
|
|||||||
abweicht (Verzweigung, Sprung zurück, Zusammenführung).
|
abweicht (Verzweigung, Sprung zurück, Zusammenführung).
|
||||||
|
|
||||||
Rücksprünge (Schleifen, z. B. „Nachforderung" zurück zur Prüfung) werden
|
Rücksprünge (Schleifen, z. B. „Nachforderung" zurück zur Prüfung) werden
|
||||||
automatisch erkannt und am Rand des Diagramms herumgeführt.
|
automatisch erkannt und an allen Elementen vorbei zurückgeführt: mit Lanes
|
||||||
|
über einen Kanal am Boden des eigenen Pools, ohne Lanes seitlich am Diagramm.
|
||||||
|
|
||||||
### Verantwortliche / Swimlanes
|
### Verantwortliche / Swimlanes
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
<bpmndi:BPMNDiagram id="Diagram_1">
|
<bpmndi:BPMNDiagram id="Diagram_1">
|
||||||
<bpmndi:BPMNPlane id="Plane_1" bpmnElement="Collaboration_1">
|
<bpmndi:BPMNPlane id="Plane_1" bpmnElement="Collaboration_1">
|
||||||
<bpmndi:BPMNShape id="Participant_1_di" bpmnElement="Participant_1" isHorizontal="true">
|
<bpmndi:BPMNShape id="Participant_1_di" bpmnElement="Participant_1" isHorizontal="true">
|
||||||
<dc:Bounds x="0" y="0" width="1366" height="720"/>
|
<dc:Bounds x="0" y="0" width="1366" height="750"/>
|
||||||
</bpmndi:BPMNShape>
|
</bpmndi:BPMNShape>
|
||||||
<bpmndi:BPMNShape id="Lane_1_di" bpmnElement="Lane_1" isHorizontal="true">
|
<bpmndi:BPMNShape id="Lane_1_di" bpmnElement="Lane_1" isHorizontal="true">
|
||||||
<dc:Bounds x="0" y="0" width="1366" height="190"/>
|
<dc:Bounds x="0" y="0" width="1366" height="190"/>
|
||||||
@@ -78,10 +78,10 @@
|
|||||||
<dc:Bounds x="0" y="190" width="1366" height="340"/>
|
<dc:Bounds x="0" y="190" width="1366" height="340"/>
|
||||||
</bpmndi:BPMNShape>
|
</bpmndi:BPMNShape>
|
||||||
<bpmndi:BPMNShape id="Lane_3_di" bpmnElement="Lane_3" isHorizontal="true">
|
<bpmndi:BPMNShape id="Lane_3_di" bpmnElement="Lane_3" isHorizontal="true">
|
||||||
<dc:Bounds x="0" y="530" width="1366" height="190"/>
|
<dc:Bounds x="0" y="530" width="1366" height="220"/>
|
||||||
</bpmndi:BPMNShape>
|
</bpmndi:BPMNShape>
|
||||||
<bpmndi:BPMNShape id="Participant_Ext_1_di" bpmnElement="Participant_Ext_1" isHorizontal="true">
|
<bpmndi:BPMNShape id="Participant_Ext_1_di" bpmnElement="Participant_Ext_1" isHorizontal="true">
|
||||||
<dc:Bounds x="0" y="780" width="1366" height="100"/>
|
<dc:Bounds x="0" y="810" width="1366" height="100"/>
|
||||||
</bpmndi:BPMNShape>
|
</bpmndi:BPMNShape>
|
||||||
<bpmndi:BPMNShape id="StartEvent_S1_di" bpmnElement="StartEvent_S1">
|
<bpmndi:BPMNShape id="StartEvent_S1_di" bpmnElement="StartEvent_S1">
|
||||||
<dc:Bounds x="110" y="77" width="36" height="36"/>
|
<dc:Bounds x="110" y="77" width="36" height="36"/>
|
||||||
@@ -139,8 +139,12 @@
|
|||||||
</bpmndi:BPMNEdge>
|
</bpmndi:BPMNEdge>
|
||||||
<bpmndi:BPMNEdge id="Flow_5_di" bpmnElement="Flow_5">
|
<bpmndi:BPMNEdge id="Flow_5_di" bpmnElement="Flow_5">
|
||||||
<di:waypoint x="713.333" y="315"/>
|
<di:waypoint x="713.333" y="315"/>
|
||||||
<di:waypoint x="713.333" y="770"/>
|
<di:waypoint x="713.333" y="335"/>
|
||||||
<di:waypoint x="350" y="770"/>
|
<di:waypoint x="825" y="335"/>
|
||||||
|
<di:waypoint x="825" y="735"/>
|
||||||
|
<di:waypoint x="255" y="735"/>
|
||||||
|
<di:waypoint x="255" y="420"/>
|
||||||
|
<di:waypoint x="350" y="420"/>
|
||||||
<di:waypoint x="350" y="400"/>
|
<di:waypoint x="350" y="400"/>
|
||||||
</bpmndi:BPMNEdge>
|
</bpmndi:BPMNEdge>
|
||||||
<bpmndi:BPMNEdge id="Flow_6_di" bpmnElement="Flow_6">
|
<bpmndi:BPMNEdge id="Flow_6_di" bpmnElement="Flow_6">
|
||||||
@@ -160,16 +164,16 @@
|
|||||||
<di:waypoint x="1250" y="95"/>
|
<di:waypoint x="1250" y="95"/>
|
||||||
</bpmndi:BPMNEdge>
|
</bpmndi:BPMNEdge>
|
||||||
<bpmndi:BPMNEdge id="MsgFlow_1_di" bpmnElement="MsgFlow_1">
|
<bpmndi:BPMNEdge id="MsgFlow_1_di" bpmnElement="MsgFlow_1">
|
||||||
<di:waypoint x="341.5" y="780"/>
|
<di:waypoint x="341.5" y="810"/>
|
||||||
<di:waypoint x="128" y="113"/>
|
<di:waypoint x="128" y="113"/>
|
||||||
</bpmndi:BPMNEdge>
|
</bpmndi:BPMNEdge>
|
||||||
<bpmndi:BPMNEdge id="MsgFlow_2_di" bpmnElement="MsgFlow_2">
|
<bpmndi:BPMNEdge id="MsgFlow_2_di" bpmnElement="MsgFlow_2">
|
||||||
<di:waypoint x="746.667" y="315"/>
|
<di:waypoint x="746.667" y="315"/>
|
||||||
<di:waypoint x="683" y="780"/>
|
<di:waypoint x="683" y="810"/>
|
||||||
</bpmndi:BPMNEdge>
|
</bpmndi:BPMNEdge>
|
||||||
<bpmndi:BPMNEdge id="MsgFlow_3_di" bpmnElement="MsgFlow_3">
|
<bpmndi:BPMNEdge id="MsgFlow_3_di" bpmnElement="MsgFlow_3">
|
||||||
<di:waypoint x="1110" y="135"/>
|
<di:waypoint x="1110" y="135"/>
|
||||||
<di:waypoint x="1024.5" y="780"/>
|
<di:waypoint x="1024.5" y="810"/>
|
||||||
</bpmndi:BPMNEdge>
|
</bpmndi:BPMNEdge>
|
||||||
</bpmndi:BPMNPlane>
|
</bpmndi:BPMNPlane>
|
||||||
</bpmndi:BPMNDiagram>
|
</bpmndi:BPMNDiagram>
|
||||||
|
|||||||
@@ -120,8 +120,8 @@
|
|||||||
</bpmndi:BPMNEdge>
|
</bpmndi:BPMNEdge>
|
||||||
<bpmndi:BPMNEdge id="Flow_5_di" bpmnElement="Flow_5">
|
<bpmndi:BPMNEdge id="Flow_5_di" bpmnElement="Flow_5">
|
||||||
<di:waypoint x="80" y="630"/>
|
<di:waypoint x="80" y="630"/>
|
||||||
<di:waypoint x="20" y="630"/>
|
<di:waypoint x="50" y="630"/>
|
||||||
<di:waypoint x="20" y="290"/>
|
<di:waypoint x="50" y="290"/>
|
||||||
<di:waypoint x="165" y="290"/>
|
<di:waypoint x="165" y="290"/>
|
||||||
</bpmndi:BPMNEdge>
|
</bpmndi:BPMNEdge>
|
||||||
<bpmndi:BPMNEdge id="Flow_6_di" bpmnElement="Flow_6">
|
<bpmndi:BPMNEdge id="Flow_6_di" bpmnElement="Flow_6">
|
||||||
|
|||||||
@@ -399,6 +399,15 @@ LANE_HEADER_WIDTH = 30
|
|||||||
LANE_PADDING = 40
|
LANE_PADDING = 40
|
||||||
LANE_ROW_HEIGHT = 150
|
LANE_ROW_HEIGHT = 150
|
||||||
LANE_MIN_HEIGHT = 150
|
LANE_MIN_HEIGHT = 150
|
||||||
|
# Rücksprungkanten im Lanes-Layout (#12): Abstand der Kanalhöhen am Poolboden und
|
||||||
|
# Länge des senkrechten Stücks unter Quelle bzw. Ziel. Unter jedem Element sind im
|
||||||
|
# eigenen Zeilenslot mindestens (LANE_ROW_HEIGHT - 80) / 2 = 35 frei.
|
||||||
|
RUECKSPRUNG_ABSTAND = 15
|
||||||
|
RUECKSPRUNG_STUB = 20
|
||||||
|
# Ohne Lanes: Abstand des seitlichen Kanals vom äußersten Element (MARGIN 80 lässt
|
||||||
|
# links Platz für mehrere Kanäle).
|
||||||
|
RUECKSPRUNG_SEITE = 30
|
||||||
|
GROESSTE_BREITE = max(w for typ, (w, _) in DEFAULT_SIZE.items() if typ not in UNCONNECTED_TYPES)
|
||||||
|
|
||||||
|
|
||||||
def layout_lanes(steps, order, lanes):
|
def layout_lanes(steps, order, lanes):
|
||||||
@@ -595,6 +604,21 @@ def _ausweichachse(quelle, ziel):
|
|||||||
return ziel + AUSWEICHABSTAND if quelle > ziel else ziel - AUSWEICHABSTAND
|
return ziel + AUSWEICHABSTAND if quelle > ziel else ziel - AUSWEICHABSTAND
|
||||||
|
|
||||||
|
|
||||||
|
def ruecksprung_points(ox, oy, tx, ty, src_spalte, tgt_spalte, kanal_y):
|
||||||
|
"""Weg einer Rücksprungkante im Lanes-Layout vom Südpunkt der Quelle zum
|
||||||
|
Südpunkt des Ziels. Senkrecht wird nur in den freien Gängen zwischen den
|
||||||
|
Spalten gefahren (rechts der Quell-, links der Ziel-Spalte), waagrecht nur
|
||||||
|
knapp unter Quelle bzw. Ziel und im Kanal am Poolboden - so schneidet die
|
||||||
|
Kante keine Elemente, die in denselben Spalten weiter unten stehen (#12).
|
||||||
|
src_spalte/tgt_spalte sind die linken Kanten der Spalten (x_pos)."""
|
||||||
|
rechts = src_spalte + (GROESSTE_BREITE + LANE_COLUMN_WIDTH) / 2
|
||||||
|
links = tgt_spalte - (LANE_COLUMN_WIDTH - GROESSTE_BREITE) / 2
|
||||||
|
unter_quelle = oy + RUECKSPRUNG_STUB
|
||||||
|
unter_ziel = ty + RUECKSPRUNG_STUB
|
||||||
|
return [(ox, oy), (ox, unter_quelle), (rechts, unter_quelle), (rechts, kanal_y),
|
||||||
|
(links, kanal_y), (links, unter_ziel), (tx, unter_ziel), (tx, ty)]
|
||||||
|
|
||||||
|
|
||||||
def orthogonal_points(ox, oy, src_side, tx, ty, tgt_side,
|
def orthogonal_points(ox, oy, src_side, tx, ty, tgt_side,
|
||||||
src_is_gateway=False, tgt_is_gateway=False):
|
src_is_gateway=False, tgt_is_gateway=False):
|
||||||
"""Baut einen rechtwinkligen Kantenverlauf zwischen zwei Anschlusspunkten, der
|
"""Baut einen rechtwinkligen Kantenverlauf zwischen zwei Anschlusspunkten, der
|
||||||
@@ -859,8 +883,17 @@ def build_bpmn(meta_md, steps, order, externals):
|
|||||||
return x_pos[sid], y_pos[sid], w, h
|
return x_pos[sid], y_pos[sid], w, h
|
||||||
|
|
||||||
pool_width = max((x_pos[sid] + bounds_of(sid)[2] for sid in order), default=400) + MARGIN
|
pool_width = max((x_pos[sid] + bounds_of(sid)[2] for sid in order), default=400) + MARGIN
|
||||||
|
back_flow_ids = [flow_id for flow_id, _, _, _, is_back in flows if is_back]
|
||||||
if has_lanes:
|
if has_lanes:
|
||||||
lane_top, lane_height, pool_height = lane_geo
|
lane_top, lane_height, pool_height = lane_geo
|
||||||
|
if back_flow_ids:
|
||||||
|
# Kanal für Rücksprungkanten als Band am Boden der letzten Lane, erst
|
||||||
|
# nach der Platzierung angehängt - die Elemente bleiben, wo sie sind,
|
||||||
|
# und der Rückweg verläuft im eigenen Pool (#12).
|
||||||
|
band = RUECKSPRUNG_ABSTAND * (len(back_flow_ids) + 1)
|
||||||
|
lane_height[lanes[-1]] += band
|
||||||
|
pool_height += band
|
||||||
|
lane_geo = (lane_top, lane_height, pool_height)
|
||||||
else:
|
else:
|
||||||
pool_height = max((y_pos[sid] + bounds_of(sid)[3] for sid in order), default=300) + MARGIN
|
pool_height = max((y_pos[sid] + bounds_of(sid)[3] for sid in order), default=300) + MARGIN
|
||||||
|
|
||||||
@@ -910,14 +943,31 @@ def build_bpmn(meta_md, steps, order, externals):
|
|||||||
|
|
||||||
gateway_sides = assign_gateway_sides(steps, flows)
|
gateway_sides = assign_gateway_sides(steps, flows)
|
||||||
|
|
||||||
|
def zeile(sid):
|
||||||
|
"""Die übrigen verbundenen Elemente, die mit sid auf einer Höhe stehen."""
|
||||||
|
y, h = y_pos[sid], bounds_of(sid)[3]
|
||||||
|
return [o for o in order if o != sid and steps[o].type not in UNCONNECTED_TYPES
|
||||||
|
and y_pos[o] < y + h and y_pos[o] + bounds_of(o)[3] > y]
|
||||||
|
|
||||||
|
def zeile_frei(sid, seite):
|
||||||
|
"""True, wenn in der Zeile von sid zur Seite 'W' bzw. 'E' hin kein Element steht."""
|
||||||
|
if seite == 'W':
|
||||||
|
return all(x_pos[o] >= x_pos[sid] for o in zeile(sid))
|
||||||
|
rechts = x_pos[sid] + bounds_of(sid)[2]
|
||||||
|
return all(x_pos[o] + bounds_of(o)[2] <= rechts for o in zeile(sid))
|
||||||
|
|
||||||
for flow_id, src, tgt, label, is_back in flows:
|
for flow_id, src, tgt, label, is_back in flows:
|
||||||
if has_lanes:
|
if has_lanes:
|
||||||
src_side, tgt_side = ('E', 'W') if not is_back else ('S', 'S')
|
src_side, tgt_side = ('E', 'W') if not is_back else ('S', 'S')
|
||||||
elif not is_back:
|
elif not is_back:
|
||||||
src_side, tgt_side = 'S', 'N'
|
src_side, tgt_side = 'S', 'N'
|
||||||
else:
|
else:
|
||||||
|
# Rücksprung ohne Lanes: seitlich hinaus zum Kanal - außer ein
|
||||||
|
# Nachbar in derselben Zeile steht im Weg, dann unten hinaus bzw.
|
||||||
|
# von oben hinein über die freie Lücke zwischen den Zeilen (#12).
|
||||||
same_side = 'W' if x_pos[src] <= x_pos[tgt] else 'E'
|
same_side = 'W' if x_pos[src] <= x_pos[tgt] else 'E'
|
||||||
src_side, tgt_side = same_side, same_side
|
src_side = same_side if zeile_frei(src, same_side) else 'S'
|
||||||
|
tgt_side = same_side if zeile_frei(tgt, same_side) else 'N'
|
||||||
if not is_back:
|
if not is_back:
|
||||||
# PICTURE-Gateways haben nur 4 feste Anschlusspunkte (N/O/S/W) - eigene
|
# PICTURE-Gateways haben nur 4 feste Anschlusspunkte (N/O/S/W) - eigene
|
||||||
# Zuteilung statt der obigen Layout-Konvention, siehe assign_gateway_sides().
|
# Zuteilung statt der obigen Layout-Konvention, siehe assign_gateway_sides().
|
||||||
@@ -957,12 +1007,30 @@ def build_bpmn(meta_md, steps, order, externals):
|
|||||||
points = orthogonal_points(ox, oy, src_side, tx, ty, tgt_side,
|
points = orthogonal_points(ox, oy, src_side, tx, ty, tgt_side,
|
||||||
src_is_gateway, tgt_is_gateway)
|
src_is_gateway, tgt_is_gateway)
|
||||||
elif has_lanes:
|
elif has_lanes:
|
||||||
channel_y = pool_bottom + 50
|
band = RUECKSPRUNG_ABSTAND * (len(back_flow_ids) + 1)
|
||||||
points = [(ox, oy), (ox, channel_y), (tx, channel_y), (tx, ty)]
|
channel_y = pool_bottom - band + RUECKSPRUNG_ABSTAND * (back_flow_ids.index(flow_id) + 1)
|
||||||
|
points = ruecksprung_points(ox, oy, tx, ty, x_pos[src], x_pos[tgt], channel_y)
|
||||||
else:
|
else:
|
||||||
offset = -60 if src_side == 'W' else 60
|
# Ohne Lanes stehen die Elemente nicht in festen Spalten (parallele
|
||||||
channel_x = (min(ox, tx) if src_side == 'W' else max(ox, tx)) + offset
|
# Zweige sind zur Mitte versetzt): Kanal seitlich an allen Elementen
|
||||||
points = [(ox, oy), (channel_x, oy), (channel_x, ty), (tx, ty)]
|
# vorbei, die zwischen den Zeilen von Ziel und Quelle stehen (#12).
|
||||||
|
start, y_start = [(ox, oy)], oy
|
||||||
|
if src_side == 'S':
|
||||||
|
y_start = max(y_pos[o] + bounds_of(o)[3] for o in zeile(src) + [src]) + RUECKSPRUNG_STUB
|
||||||
|
start.append((ox, y_start))
|
||||||
|
ende, y_ende = [(tx, ty)], ty
|
||||||
|
if tgt_side == 'N':
|
||||||
|
y_ende = min(y_pos[o] for o in zeile(tgt) + [tgt]) - RUECKSPRUNG_STUB
|
||||||
|
ende.insert(0, (tx, y_ende))
|
||||||
|
oben, unten = min(y_start, y_ende), max(y_start, y_ende)
|
||||||
|
zwischen = [sid for sid in order if steps[sid].type not in UNCONNECTED_TYPES
|
||||||
|
and y_pos[sid] <= unten and y_pos[sid] + bounds_of(sid)[3] >= oben]
|
||||||
|
versatz = RUECKSPRUNG_SEITE + RUECKSPRUNG_ABSTAND * back_flow_ids.index(flow_id)
|
||||||
|
if x_pos[src] <= x_pos[tgt]:
|
||||||
|
channel_x = min(x_pos[sid] for sid in zwischen) - versatz
|
||||||
|
else:
|
||||||
|
channel_x = max(x_pos[sid] + bounds_of(sid)[2] for sid in zwischen) + versatz
|
||||||
|
points = _dedupe_consecutive(start + [(channel_x, y_start), (channel_x, y_ende)] + ende)
|
||||||
|
|
||||||
for px, py in points:
|
for px, py in points:
|
||||||
ET.SubElement(edge, q('di', 'waypoint'), {'x': f'{px:g}', 'y': f'{py:g}'})
|
ET.SubElement(edge, q('di', 'waypoint'), {'x': f'{px:g}', 'y': f'{py:g}'})
|
||||||
|
|||||||
@@ -437,6 +437,134 @@ def test_nur_xor_gateways_zeigen_ihr_x():
|
|||||||
assert shape.get('isMarkerVisible') == erwartet, shape.get('bpmnElement')
|
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):
|
def test_ausgabe_ist_wohlgeformtes_xml(tmp_path):
|
||||||
"""Prüft, dass die erzeugte .bpmn-Datei sich als valides XML parsen laesst."""
|
"""Prüft, dass die erzeugte .bpmn-Datei sich als valides XML parsen laesst."""
|
||||||
src = tmp_path / 'prozess.md'
|
src = tmp_path / 'prozess.md'
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ Skills sind übernommen, alles QGIS- und Plugin-Spezifische ist entfernt. Kern i
|
|||||||
|
|
||||||
## Erledigt
|
## Erledigt
|
||||||
|
|
||||||
|
- 25.09.2026 **Rücksprungkanten laufen an allen Elementen vorbei** (#12): mit Lanes über Gänge zwischen den
|
||||||
|
Spalten und einen Kanal im eigenen Pool, ohne Lanes seitlich an allen Elementen vorbei. Vorbestehend und offen:
|
||||||
|
Vorwärtskanten bei Querverbindungen zwischen Zweigen (eigenes Issue).
|
||||||
|
|
||||||
- 25.09.2026 **Strukturtests für alle Kanten und Elemente** (#16): alle Sequenz- und Nachrichtenflüsse aller
|
- 25.09.2026 **Strukturtests für alle Kanten und Elemente** (#16): alle Sequenz- und Nachrichtenflüsse aller
|
||||||
Beispiele gegen alle Elemente, alle Funde auf einmal; bekannte Fälle je Kreuzung als `xfail` (#12, #13).
|
Beispiele gegen alle Elemente, alle Funde auf einmal; bekannte Fälle je Kreuzung als `xfail` (#12, #13).
|
||||||
`AUSWEICHABSTAND` per Test an die Layout-Maße gebunden. xfail-Regel in `AGENTS.md` auf alle Beispiele erweitert.
|
`AUSWEICHABSTAND` per Test an die Layout-Maße gebunden. xfail-Regel in `AGENTS.md` auf alle Beispiele erweitert.
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ Kurz, konkret, mit Datum und Quelle. Neue Einträge oben in der passenden Rubrik
|
|||||||
|
|
||||||
## BPMN und PICTURE (Skill sn-bpmn, md_to_bpmn.py)
|
## BPMN und PICTURE (Skill sn-bpmn, md_to_bpmn.py)
|
||||||
|
|
||||||
|
- **Rücksprungkanten brauchen je Layout eine eigene Führung.** Mit Lanes stehen die Elemente in festen Spalten
|
||||||
|
(linksbündig, 190 auseinander). Der Rückweg läuft senkrecht nur in den Gängen zwischen den Spalten und waagrecht
|
||||||
|
in einem Kanalband, das am Boden der letzten Lane **nach** der Platzierung angehängt wird. So bleiben die
|
||||||
|
Elemente, wo sie sind, und die Kante bleibt im eigenen Pool. Ohne Lanes gibt es keine festen Spalten, parallele
|
||||||
|
Zweige stehen versetzt zur Mitte. Der Kanal liegt deshalb seitlich an **allen** Elementen zwischen Quelle und
|
||||||
|
Ziel vorbei. Steht ein Nachbar in derselben Zeile im Weg, geht die Kante unten hinaus bzw. oben hinein.
|
||||||
|
Geprüft mit allen 126 Rücksprung-Varianten zweier Testmodelle, mit und ohne Lanes. (#12, 25.09.2026)
|
||||||
- **Vorschau und PICTURE können sich unterscheiden, auch ohne Fehler im Modell.** Beispiel XOR-Gateway: PICTURE
|
- **Vorschau und PICTURE können sich unterscheiden, auch ohne Fehler im Modell.** Beispiel XOR-Gateway: PICTURE
|
||||||
zeichnet das „X“ nach eigenen Regeln, auch ohne `isMarkerVisible`. Die Vorschau (bpmn-js) hält sich an das
|
zeichnet das „X“ nach eigenen Regeln, auch ohne `isMarkerVisible`. Die Vorschau (bpmn-js) hält sich an das
|
||||||
Attribut und zeigte ohne es leere Rauten. Seit #15 setzt der Konverter das Attribut, beide zeigen das X. Wirkt
|
Attribut und zeigte ohne es leere Rauten. Seit #15 setzt der Konverter das Attribut, beide zeigen das X. Wirkt
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ KANTEN = ("sequenceFlow", "messageFlow")
|
|||||||
# Jedes Paar bekommt einen eigenen xfail(strict=True)-Test; sobald der Fehler behoben ist, schlägt dieser als XPASS
|
# 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).
|
# fehl – dann den Eintrag hier entfernen. Nie verwenden, um neue Fehler zu verstecken (AGENTS.md).
|
||||||
BEKANNTE_KREUZUNGEN = {
|
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",
|
("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",
|
("referenz_gewannenfestlegung", "MsgFlow_2", "Task_S24"): "Issue #13: Nachrichtenfluss von S16 läuft durch S24",
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user