diff --git a/.agents/skills/sn-bpmn/Anleitung.md b/.agents/skills/sn-bpmn/Anleitung.md
index 9ea21e7..a63f97c 100644
--- a/.agents/skills/sn-bpmn/Anleitung.md
+++ b/.agents/skills/sn-bpmn/Anleitung.md
@@ -108,6 +108,11 @@ Jeder Schritt ist eine Zeile der Form:
Vollständige Liste inkl. Bedeutung: `Elementkatalog.md`.
+ Hinweise stehen im Diagramm rechts neben dem Pool untereinander, in einem
+ Kästchen passend zur Textlänge. Gateway-Beschriftungen stehen oben rechts an
+ der Raute (wie im PICTURE-Editor), bei belegtem Platz an der nächsten freien
+ Stelle.
+
### Typisierte Ereignisse
Start-, Zwischen- und Endereignisse können ein zweites Wort für die Art des
diff --git a/.agents/skills/sn-bpmn/beispiele/beispiel_lanes.bpmn b/.agents/skills/sn-bpmn/beispiele/beispiel_lanes.bpmn
index dd3566a..3ad5bbe 100644
--- a/.agents/skills/sn-bpmn/beispiele/beispiel_lanes.bpmn
+++ b/.agents/skills/sn-bpmn/beispiele/beispiel_lanes.bpmn
@@ -91,6 +91,9 @@
+
+
+
@@ -134,7 +137,7 @@
-
+
diff --git a/.agents/skills/sn-bpmn/beispiele/beispiel_prozess.bpmn b/.agents/skills/sn-bpmn/beispiele/beispiel_prozess.bpmn
index 178d4f3..15d12a1 100644
--- a/.agents/skills/sn-bpmn/beispiele/beispiel_prozess.bpmn
+++ b/.agents/skills/sn-bpmn/beispiele/beispiel_prozess.bpmn
@@ -66,6 +66,9 @@
+
+
+
@@ -75,6 +78,9 @@
+
+
+
@@ -115,7 +121,7 @@
-
+
@@ -145,7 +151,7 @@
-
+
diff --git a/.agents/skills/sn-bpmn/beispiele/referenz_gewannenfestlegung.bpmn b/.agents/skills/sn-bpmn/beispiele/referenz_gewannenfestlegung.bpmn
index 84d6fbf..396037a 100644
--- a/.agents/skills/sn-bpmn/beispiele/referenz_gewannenfestlegung.bpmn
+++ b/.agents/skills/sn-bpmn/beispiele/referenz_gewannenfestlegung.bpmn
@@ -273,22 +273,22 @@
-
+
-
+
-
+
-
+
-
+
-
+
@@ -307,6 +307,9 @@
+
+
+
@@ -319,30 +322,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -355,9 +376,15 @@
+
+
+
+
+
+
@@ -367,9 +394,15 @@
+
+
+
+
+
+
@@ -379,15 +412,24 @@
+
+
+
+
+
+
+
+
+
@@ -402,16 +444,16 @@
-
+
-
+
-
+
-
+
@@ -451,7 +493,7 @@
-
+
@@ -500,7 +542,7 @@
-
+
@@ -519,7 +561,7 @@
-
+
@@ -557,7 +599,7 @@
-
+
@@ -575,7 +617,7 @@
-
+
@@ -619,7 +661,7 @@
-
+
@@ -675,7 +717,7 @@
-
+
diff --git a/.agents/skills/sn-bpmn/md_to_bpmn.py b/.agents/skills/sn-bpmn/md_to_bpmn.py
index 76cf9f7..77c5658 100644
--- a/.agents/skills/sn-bpmn/md_to_bpmn.py
+++ b/.agents/skills/sn-bpmn/md_to_bpmn.py
@@ -412,6 +412,55 @@ RUECKSPRUNG_SEITE = 30
# im Gang gegenüber den Rücksprungkanten.
NACHRICHT_STUB = 30
NACHRICHT_VERSATZ = 20
+# Beschriftungen (#14): grobe Schätzung der Textgröße, damit Kästchen und
+# Beschriftungen groß genug sind - PICTURE vergrößert sonst beim Import und
+# überlappt Nachbarn. Anmerkungen stehen rechts neben dem Pool.
+ZEICHENBREITE = 7
+ZEILENHOEHE = 16
+ANMERKUNG_BREITE = 250
+ANMERKUNG_ABSTAND = 40
+GATEWAY_LABEL_BREITE = 90
+GATEWAY_LABEL_ZEILE = 14
+
+
+def text_zeilen(text, breite):
+ """Geschätzte Zeilenzahl von text bei Umbruch an Wortgrenzen in einem Feld der
+ angegebenen Breite (abzüglich 10 Innenrand, ZEICHENBREITE je Zeichen)."""
+ je_zeile = max(1, int((breite - 10) // ZEICHENBREITE))
+ zeilen, laenge = 1, 0
+ for wort in text.split():
+ if laenge == 0:
+ laenge = len(wort)
+ elif laenge + 1 + len(wort) <= je_zeile:
+ laenge += 1 + len(wort)
+ else:
+ zeilen, laenge = zeilen + 1, len(wort)
+ while laenge > je_zeile: # überlange Wörter brechen hart um
+ zeilen, laenge = zeilen + 1, laenge - je_zeile
+ return zeilen
+
+
+def _rechtecke_ueberlappen(a, b):
+ """True, wenn sich die Rechtecke a und b (x, y, w, h) echt überschneiden."""
+ return a[0] < b[0] + b[2] and b[0] < a[0] + a[2] and a[1] < b[1] + b[3] and b[1] < a[1] + a[3]
+
+
+def _strecke_schneidet(p, q, box):
+ """True, wenn die Strecke p-q durchs Innere des Rechtecks box (x, y, w, h) läuft."""
+ x, y, w, h = box
+ (x1, y1), (x2, y2) = p, q
+ if abs(y1 - y2) < 1e-6: # waagrecht
+ return y < y1 < y + h and min(x1, x2) < x + w and max(x1, x2) > x
+ if abs(x1 - x2) < 1e-6: # senkrecht
+ return x < x1 < x + w and min(y1, y2) < y + h and max(y1, y2) > y
+ return any(x < x1 + (x2 - x1) * s / 40 < x + w and y < y1 + (y2 - y1) * s / 40 < y + h
+ for s in range(1, 40))
+
+
+def anmerkung_groesse(text):
+ """Breite und Höhe eines Anmerkungs-Kästchens, passend zur Textlänge."""
+ return ANMERKUNG_BREITE, max(DEFAULT_SIZE['TextAnnotation'][1],
+ text_zeilen(text, ANMERKUNG_BREITE) * ZEILENHOEHE + 10)
GROESSTE_BREITE = max(w for typ, (w, _) in DEFAULT_SIZE.items() if typ not in UNCONNECTED_TYPES)
@@ -884,10 +933,14 @@ def build_bpmn(meta_md, steps, order, externals):
def bounds_of(sid):
st = steps[sid]
- w, h = DEFAULT_SIZE.get(st.type, (100, 80))
+ if st.type == 'TextAnnotation':
+ w, h = anmerkung_groesse(st.label)
+ else:
+ w, h = DEFAULT_SIZE.get(st.type, (100, 80))
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
+ im_diagramm = [sid for sid in order if steps[sid].type != 'TextAnnotation']
+ pool_width = max((x_pos[sid] + bounds_of(sid)[2] for sid in im_diagramm), default=400) + MARGIN
back_flow_ids = [flow_id for flow_id, _, _, _, is_back in flows if is_back]
msg_kanal_x = {}
if not has_lanes and msg_flow_records:
@@ -910,7 +963,16 @@ def build_bpmn(meta_md, steps, order, externals):
pool_height += band
lane_geo = (lane_top, lane_height, pool_height)
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 im_diagramm), default=300) + MARGIN
+
+ # Anmerkungen rechts neben dem Pool untereinander, in der Größe ihres Texts:
+ # so kreuzen sie keine Lane-Grenzen und überlappen sich nicht, auch wenn
+ # PICTURE die Position streng aus der Datei übernimmt (#14).
+ anm_y = 0
+ for sid in order:
+ if steps[sid].type == 'TextAnnotation':
+ x_pos[sid], y_pos[sid] = pool_width + ANMERKUNG_ABSTAND, anm_y
+ anm_y += bounds_of(sid)[3] + ANMERKUNG_ABSTAND / 2
ext_pos = {}
if has_collaboration:
@@ -931,6 +993,9 @@ def build_bpmn(meta_md, steps, order, externals):
'id': f'{eid}_di', 'bpmnElement': eid, 'isHorizontal': 'true',
}).append(_bounds_el(0, ex_y, pool_width, 100))
+ gateway_shapes = {} # Gateways mit Beschriftung, Position wird zuletzt gewählt
+ hindernisse = [] # Rechtecke von Elementen und Kantenbeschriftungen
+ strecken = [] # alle Kantenstücke als ((x1, y1), (x2, y2))
for sid in order:
attrs = {'id': f'{node_ids[sid]}_di', 'bpmnElement': node_ids[sid]}
if steps[sid].type == 'XorGateway':
@@ -940,6 +1005,9 @@ def build_bpmn(meta_md, steps, order, externals):
shape = ET.SubElement(plane, q('bpmndi', 'BPMNShape'), attrs)
x, y, w, h = bounds_of(sid)
shape.append(_bounds_el(x, y, w, h))
+ if SHAPE_KIND.get(steps[sid].type) == 'diamond' and steps[sid].label:
+ gateway_shapes[sid] = shape # Beschriftung erst nach den Kanten, siehe unten
+ hindernisse.append((x, y, w, h))
# Anschlusspunkte je (Element, Seite) zählen, damit mehrere Kanten an derselben
# Seite nicht alle im selben Punkt zusammenlaufen (Modellierregel 4).
@@ -1048,18 +1116,24 @@ def build_bpmn(meta_md, steps, order, externals):
for px, py in points:
ET.SubElement(edge, q('di', 'waypoint'), {'x': f'{px:g}', 'y': f'{py:g}'})
+ strecken.extend(zip(points, points[1:]))
if label:
# explizite Label-Position setzen, um Überlappungen mit Pfeilen/Nachbarlabels
# zu reduzieren: am ersten Segment, leicht seitlich versetzt
(p0x, p0y), (p1x, p1y) = points[0], points[1] if len(points) > 1 else points[0]
mx, my = (p0x + p1x) / 2, (p0y + p1y) / 2
- if abs(p0y - p1y) < 1e-6: # horizontales Segment -> Label darüber
+ if abs(p0y - p1y) < 1e-6 and SHAPE_KIND.get(steps[src].type) == 'diamond':
+ # waagrecht aus einem Gateway -> Label darunter; darüber steht
+ # die Beschriftung des Gateways (#14)
+ lx, ly = mx - 15, my + 6
+ elif abs(p0y - p1y) < 1e-6: # horizontales Segment -> Label darüber
lx, ly = mx - 15, my - 20
else: # vertikales Segment -> Label seitlich
lx, ly = mx + 8, my - 8
bpmn_label = ET.SubElement(edge, q('bpmndi', 'BPMNLabel'))
bpmn_label.append(_bounds_el(lx, ly, max(30, len(label) * 6), 14))
+ hindernisse.append((lx, ly, max(30, len(label) * 6), 14))
for mfid, sid, direction, target in msg_flow_records:
edge = ET.SubElement(plane, q('bpmndi', 'BPMNEdge'), {'id': f'{mfid}_di', 'bpmnElement': mfid})
@@ -1084,6 +1158,28 @@ def build_bpmn(meta_md, steps, order, externals):
points = points[::-1]
for px, py in points:
ET.SubElement(edge, q('di', 'waypoint'), {'x': f'{px:g}', 'y': f'{py:g}'})
+ strecken.extend(zip(points, points[1:]))
+
+ # Gateway-Beschriftungen zuletzt, wenn alle Kanten und Kantenbeschriftungen
+ # feststehen: oben rechts (45°) wie im PICTURE-Editor, sonst die nächste freie
+ # Stelle. Ohne Angabe stünde sie unter der Raute (180°), wo der südliche Pfad
+ # und dessen Beschriftung sie schneiden; PICTURE übernimmt die Position (#14).
+ for sid, shape in gateway_shapes.items():
+ x, y, w, h = bounds_of(sid)
+ lw = GATEWAY_LABEL_BREITE
+ lh = text_zeilen(steps[sid].label, lw) * GATEWAY_LABEL_ZEILE
+ # 45°; 45° rechts vom Ost-Stub (dort steigt oft die eigene Kante auf);
+ # 315°; 135°; 225°
+ ecken = [(x + w * 3 / 4, y + h / 4 - lh), (x + w + GATEWAY_STUB + 5, y + h / 4 - lh),
+ (x + w / 4 - lw, y + h / 4 - lh), (x + w * 3 / 4, y + h * 3 / 4),
+ (x + w / 4 - lw, y + h * 3 / 4)]
+ eigene = (x, y, w, h)
+ frei = [(lx, ly) for lx, ly in ecken
+ if not any(_strecke_schneidet(a, b, (lx, ly, lw, lh)) for a, b in strecken)
+ and not any(_rechtecke_ueberlappen((lx, ly, lw, lh), r) for r in hindernisse if r != eigene)]
+ lx, ly = frei[0] if frei else ecken[0]
+ ET.SubElement(shape, q('bpmndi', 'BPMNLabel')).append(_bounds_el(lx, ly, lw, lh))
+ hindernisse.append((lx, ly, lw, lh))
return definitions
diff --git a/.agents/skills/sn-bpmn/test_md_to_bpmn.py b/.agents/skills/sn-bpmn/test_md_to_bpmn.py
index ec4e793..a742e23 100644
--- a/.agents/skills/sn-bpmn/test_md_to_bpmn.py
+++ b/.agents/skills/sn-bpmn/test_md_to_bpmn.py
@@ -621,6 +621,40 @@ def test_nachrichtenfluss_quert_kein_element(text):
assert not (x + 1 < px < x + w - 1 and y + 1 < py < y + h - 1), f"{mfid} durch {eid}"
+def test_textschaetzung_bricht_an_wortgrenzen_und_bei_ueberlangen_woertern_um():
+ """Prüft die Zeilenschätzung für Beschriftungen: kurzer Text eine Zeile, Umbruch an Wortgrenzen, ein Wort länger
+ als die Zeile wird hart umbrochen (Issue #14)."""
+ je_zeile = int((100 - 10) // m.ZEICHENBREITE)
+ assert m.text_zeilen("Ja", 100) == 1
+ assert m.text_zeilen("a" * je_zeile + " b", 100) == 2
+ assert m.text_zeilen("x" * (2 * je_zeile + 1), 100) == 3
+
+
+def test_gateway_beschriftung_steht_ohne_hindernis_oben_rechts():
+ """Prüft, dass ein Gateway ohne Hindernis seine Beschriftung auf 45° (oben rechts) bekommt, wie im PICTURE-Editor,
+ und nicht darunter, wo der südliche Pfad sie schneiden würde (Issue #14)."""
+ text = """
+ S1 Start: Beginn
+ -> S2
+ S2 Gateway XOR: Antrag vollständig?
+ Ja -> S3
+ S3 Ende: Fertig
+ """
+ defs, bounds, _ = _baue_und_finde(text)
+ shape = next(s for s in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNShape") if s.get('bpmnElement') == 'Gateway_S2')
+ b = shape.find(f"{{{m.NS['bpmndi']}}}BPMNLabel").find(f"{{{m.NS['dc']}}}Bounds")
+ lx, ly, lh = float(b.get('x')), float(b.get('y')), float(b.get('height'))
+ x, y, w, h = bounds['Gateway_S2']
+ assert lx == x + w * 3 / 4 and ly + lh == y + h / 4
+
+
+def test_anmerkung_waechst_mit_ihrem_text():
+ """Prüft, dass eine lange Anmerkung ein höheres Kästchen bekommt als eine kurze (Issue #14)."""
+ kurz_w, kurz_h = m.anmerkung_groesse("Kurz")
+ lang_w, lang_h = m.anmerkung_groesse("Ein langer Hinweis " * 20)
+ assert kurz_w == lang_w and lang_h > kurz_h
+
+
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'
diff --git a/docs/status/STATUS.md b/docs/status/STATUS.md
index 67ce486..ff6f835 100644
--- a/docs/status/STATUS.md
+++ b/docs/status/STATUS.md
@@ -31,6 +31,10 @@ Skills sind übernommen, alles QGIS- und Plugin-Spezifische ist entfernt. Kern i
## Erledigt
+- 25.09.2026 **Beschriftungen überlappen nicht mehr** (#14): Gateway-Beschriftungen mit fester Position (45°,
+ sonst nächste freie Stelle), Anmerkungen rechts neben dem Pool in passender Größe, Kantenbeschriftungen
+ waagrecht aus Gateways unter der Linie.
+
- 25.09.2026 **Nachrichtenflüsse senkrecht und an allen Elementen vorbei** (#13): Ende senkrecht unter dem
Start, bei Hindernis über einen freien Gang. Damit gibt es in den Beispielen keine bekannte Kreuzung mehr
(`BEKANNTE_KREUZUNGEN` leer).
diff --git a/docs/wissen/fallstricke.md b/docs/wissen/fallstricke.md
index 23def6c..5481d53 100644
--- a/docs/wissen/fallstricke.md
+++ b/docs/wissen/fallstricke.md
@@ -5,6 +5,16 @@ Kurz, konkret, mit Datum und Quelle. Neue Einträge oben in der passenden Rubrik
## BPMN und PICTURE (Skill sn-bpmn, md_to_bpmn.py)
+- **PICTURE übernimmt Beschriftungspositionen und Kästchengrößen aus der Datei.** Ohne `BPMNLabel` setzt es die
+ Gateway-Beschriftung auf 180° (unter die Raute), wo der südliche Pfad und dessen Beschriftung sie schneiden. Im
+ PICTURE-Editor steht sie auf 45°. Der Konverter setzt die Position deshalb selbst: 45°, sonst 45° rechts vom
+ Ost-Stub, 315°, 135°, 225° – die erste Stelle, die keine Kante, Kantenbeschriftung oder kein Element schneidet.
+ Dafür werden die Gateway-Beschriftungen **nach** allen Kanten gesetzt. Zu kleine Anmerkungs-Kästchen vergrößert
+ PICTURE beim Import, dann überlappen sie Nachbarn und Lane-Grenzen. Deshalb richtet sich die Höhe nach dem Text
+ (`text_zeilen()`, grobe Schätzung 7 je Zeichen), und die Anmerkungen stehen rechts neben dem Pool.
+ `GATEWAY_LABEL_BREITE = 90` hängt an `AUSWEICHABSTAND` (#8): Bei 100 ragte die Beschriftung über die
+ Ausweichachse bei x + 130. Pool-Name über dem Namen der ersten Lane: nur in der Vorschau beobachtet, in PICTURE
+ kein Problem. (PICTURE 2026.4.1, #14, 25.09.2026)
- **Nachrichtenflüsse enden senkrecht unter ihrem Start, nicht an einem verteilten Punkt.** Der externe Pool ist
ein breites Rechteck, jeder Punkt an seiner Oberkante ist ein gültiger Anschluss. Die frühere Verteilung der
Anschlusspunkte über die Pool-Oberkante erzeugte schräge Linien quer durch das Diagramm. Steht unter dem Schritt
diff --git a/tests/test_referenzmodelle.py b/tests/test_referenzmodelle.py
index 4593602..9091f6b 100644
--- a/tests/test_referenzmodelle.py
+++ b/tests/test_referenzmodelle.py
@@ -121,6 +121,121 @@ def test_bekannte_kreuzung_ist_behoben(modell, kante, element):
assert (kante, element) not in kreuzungen(baue(SKILL / "beispiele" / f"{modell}.md"))
+def _box(el):
+ b = el.find(f"{{{m.NS['dc']}}}Bounds")
+ return tuple(float(b.get(a)) for a in ("x", "y", "width", "height"))
+
+
+def _ueberlappen(a, b, rand=0.5):
+ """True, wenn sich die Rechtecke a und b (x, y, w, h) echt überschneiden (Berührung zählt nicht)."""
+ return (a[0] + rand < b[0] + b[2] and b[0] + rand < a[0] + a[2]
+ and a[1] + rand < b[1] + b[3] and b[1] + rand < a[1] + a[3])
+
+
+def beschriftungskonflikte(defs):
+ """Alle Konflikte einer Gateway-Beschriftung mit Kanten, Kantenbeschriftungen oder anderen Elementen."""
+ art = {el.get("id"): el.tag.rpartition("}")[2] for el in defs.iter() if el.get("id")}
+ gw_labels, formen = {}, {}
+ for shape in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNShape"):
+ eid = shape.get("bpmnElement")
+ if art.get(eid) in HINDERNISSE or art.get(eid) == "textAnnotation":
+ formen[eid] = _box(shape)
+ label = shape.find(f"{{{m.NS['bpmndi']}}}BPMNLabel")
+ if art.get(eid) in GATEWAYS and label is not None:
+ gw_labels[eid] = _box(label)
+ konflikte = []
+ for edge in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNEdge"):
+ kante = edge.get("bpmnElement")
+ punkte = [(float(wp.get("x")), float(wp.get("y"))) for wp in edge.findall(f"{{{m.NS['di']}}}waypoint")]
+ label = edge.find(f"{{{m.NS['bpmndi']}}}BPMNLabel")
+ for gid, (x, y, w, h) in gw_labels.items():
+ if label is not None and _ueberlappen(_box(label), (x, y, w, h)):
+ konflikte.append((gid, f"Beschriftung von {kante}"))
+ for (x1, y1), (x2, y2) in zip(punkte, punkte[1:]):
+ schritte = max(8, int(math.hypot(x2 - x1, y2 - y1) / 4))
+ if any(x < x1 + (x2 - x1) * s / schritte < x + w and y < y1 + (y2 - y1) * s / schritte < y + h
+ for s in range(1, schritte)):
+ konflikte.append((gid, kante))
+ break
+ for gid, box in gw_labels.items():
+ konflikte += [(gid, eid) for eid, form in formen.items() if eid != gid and _ueberlappen(box, form)]
+ return sorted(konflikte)
+
+
+@pytest.mark.parametrize("md", MODELLE, ids=lambda p: p.stem)
+def test_gateway_beschriftung_wird_von_nichts_geschnitten(md):
+ """Prüft, dass jede Gateway-Beschriftung eine feste Position hat und weder von einer Kante noch von einer
+ Kantenbeschriftung oder einem anderen Element geschnitten wird (Issue #14; PICTURE übernimmt die Position)."""
+ defs = baue(md)
+ art = {el.get("id"): el.tag.rpartition("}")[2] for el in defs.iter() if el.get("id")}
+ ohne = [s.get("bpmnElement") for s in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNShape")
+ if art.get(s.get("bpmnElement")) in GATEWAYS and s.find(f"{{{m.NS['bpmndi']}}}BPMNLabel") is None
+ and next(el for el in defs.iter() if el.get("id") == s.get("bpmnElement")).get("name")]
+ assert ohne == [], "Gateway mit Namen, aber ohne Beschriftungsposition: " + ", ".join(ohne)
+ konflikte = beschriftungskonflikte(defs)
+ assert konflikte == [], "Gateway-Beschriftung geschnitten: " + ", ".join(f"{g} ← {k}" for g, k in konflikte)
+
+
+@pytest.mark.parametrize("md", [md for md in MODELLE if md.stem != "beispiel_prozess"], ids=lambda p: p.stem)
+def test_gateway_beschriftung_bleibt_in_der_eigenen_lane(md):
+ """Prüft, dass jede Gateway-Beschriftung vollständig in der Lane ihres Gateways liegt und nicht in den Lane-Kopf
+ mit den Lane-Namen oder in eine Nachbar-Lane ragt (Issue #14)."""
+ defs = baue(md)
+ lanes = [_box(s) for s in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNShape") if s.get("bpmnElement").startswith("Lane_")]
+ assert lanes, "Modell ohne Lanes"
+ for shape in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNShape"):
+ label = shape.find(f"{{{m.NS['bpmndi']}}}BPMNLabel")
+ if label is None:
+ continue
+ x, y, w, h = _box(shape)
+ lx, ly, lw, lh = _box(label)
+ lane = next(b for b in lanes if b[1] <= y + h / 2 <= b[1] + b[3])
+ assert lane[1] <= ly and ly + lh <= lane[1] + lane[3], f"{shape.get('bpmnElement')}: ragt aus der Lane"
+ assert lx >= lane[0] + m.LANE_HEADER_WIDTH, f"{shape.get('bpmnElement')}: ragt in den Lane-Kopf"
+
+
+MIT_ANMERKUNGEN = [md for md in MODELLE if " Hinweis:" in md.read_text(encoding="utf-8")]
+
+
+def test_es_gibt_ein_modell_mit_anmerkungen():
+ """Prüft, dass die Anmerkungs-Tests tatsächlich an einem Modell laufen."""
+ assert MIT_ANMERKUNGEN
+
+
+@pytest.mark.parametrize("md", MIT_ANMERKUNGEN, ids=lambda p: p.stem)
+def test_anmerkungen_ueberlappen_nichts_und_stehen_neben_dem_pool(md):
+ """Prüft, dass Anmerkungen (Hinweise) sich nicht gegenseitig überlappen, kein anderes Element überdecken und –
+ wenn es einen Prozess-Pool gibt – rechts daneben stehen, also keine Lane-Grenze kreuzen (Issue #14)."""
+ defs = baue(md)
+ art = {el.get("id"): el.tag.rpartition("}")[2] for el in defs.iter() if el.get("id")}
+ formen = {s.get("bpmnElement"): _box(s) for s in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNShape")}
+ anmerkungen = {eid: box for eid, box in formen.items() if art.get(eid) == "textAnnotation"}
+ ids = sorted(anmerkungen)
+ for i, a in enumerate(ids):
+ for b in ids[i + 1:]:
+ assert not _ueberlappen(anmerkungen[a], anmerkungen[b]), f"{a} überlappt {b}"
+ for eid, form in formen.items():
+ if art.get(eid) in HINDERNISSE:
+ assert not _ueberlappen(anmerkungen[a], form), f"{a} überdeckt {eid}"
+ if "Participant_1" in formen:
+ px, _, pw, _ = formen["Participant_1"]
+ for eid, (x, _, _, _) in anmerkungen.items():
+ assert x >= px + pw, f"{eid} steht im Pool (x {x:g} < {px + pw:g})"
+
+
+@pytest.mark.parametrize("md", MIT_ANMERKUNGEN, ids=lambda p: p.stem)
+def test_anmerkung_ist_gross_genug_fuer_ihren_text(md):
+ """Prüft, dass jedes Anmerkungs-Kästchen seinen Text fassen kann (grob: 7 Einheiten je Zeichen, 16 je Zeile).
+ Sonst vergrößert PICTURE das Kästchen beim Import, und es überlappt Nachbarn und Lane-Grenzen (Issue #14)."""
+ defs = baue(md)
+ formen = {s.get("bpmnElement"): _box(s) for s in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNShape")}
+ for anm in defs.iter(f"{{{m.NS['bpmn']}}}textAnnotation"):
+ text = anm.find(f"{{{m.NS['bpmn']}}}text").text or ""
+ _, _, w, h = formen[anm.get("id")]
+ zeilen = math.ceil(len(text) * 7 / (w - 10))
+ assert h >= zeilen * 16, f"{anm.get('id')}: {len(text)} Zeichen brauchen etwa {zeilen} Zeilen, Höhe nur {h:g}"
+
+
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")