Rücksprungkanten an allen Elementen vorbeiführen (#12) #20

Merged
Daniel merged 1 commits from bugfix/12-ruecksprungkanten into main 2026-09-25 13:24:42 +00:00
8 changed files with 229 additions and 18 deletions
+2 -1
View File
@@ -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">
+74 -6
View File
@@ -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}'})
+128
View File
@@ -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'
+4
View File
@@ -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.
+7
View File
@@ -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
-1
View File
@@ -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",
} }