From 993b6de50c7782c9d37980e8dc220980b9871e46 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 25 Sep 2026 16:10:19 +0200 Subject: [PATCH] =?UTF-8?q?Beschriftungen=20und=20Anmerkungen=20ohne=20?= =?UTF-8?q?=C3=9Cberlappung=20setzen=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gateway-Beschriftungen bekommen eine feste Position (BPMNLabel), weil PICTURE sie aus der Datei übernimmt: 45° wie im PICTURE-Editor, sonst 45° rechts vom Ost-Stub, 315°, 135°, 225° – die erste Stelle ohne Kante, Kantenbeschriftung oder Element. Gesetzt nach allen Kanten. - Kantenbeschriftungen waagrecht aus einem Gateway stehen unter der Linie (darüber steht die Gateway-Beschriftung). - Anmerkungen rechts neben dem Pool untereinander, Höhe passend zum Text (text_zeilen(), anmerkung_groesse()); Pool ohne Anmerkungen bemessen. - Beispiele neu erzeugt. Alle 17 Gateway-Beschriftungen auf 45° bzw. 45° rechts vom Stub, keine im Lane-Kopf oder in einer Nachbar-Lane. - Strukturtests für alle Beispiele (Beschriftung ungeschnitten, in der eigenen Lane; Anmerkungen ohne Überlappung, neben dem Pool, groß genug) und drei Einzeltests. - Tests: python -m pytest -q -> 117 bestanden, 1 übersprungen (leere Parameterliste der bekannten Kreuzungen). Co-Authored-By: Claude Opus 5.5 --- .agents/skills/sn-bpmn/Anleitung.md | 5 + .../sn-bpmn/beispiele/beispiel_lanes.bpmn | 5 +- .../sn-bpmn/beispiele/beispiel_prozess.bpmn | 10 +- .../referenz_gewannenfestlegung.bpmn | 76 +++++++++--- .agents/skills/sn-bpmn/md_to_bpmn.py | 104 +++++++++++++++- .agents/skills/sn-bpmn/test_md_to_bpmn.py | 34 ++++++ docs/status/STATUS.md | 4 + docs/wissen/fallstricke.md | 10 ++ tests/test_referenzmodelle.py | 115 ++++++++++++++++++ 9 files changed, 339 insertions(+), 24 deletions(-) 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")