From 97413265f22b26410278e039c6322f28c93d03f3 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 25 Sep 2026 13:01:23 +0200 Subject: [PATCH] Strukturtests auf alle Kanten und alle Elemente ausweiten (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_referenzmodelle.py: kreuzungen() prüft alle Sequenz- und Nachrichtenflüsse gegen Aufgaben, Ereignisse (Kreis), Gateways (Raute) und Datenelemente und liefert alle Funde. Neuer Test je Modell unter beispiele/ (auch Syntaxbeispiele): keine unbekannte Kreuzung. - Bekannte Fälle je Kreuzung in BEKANNTE_KREUZUNGEN, jeder mit eigenem xfail(strict=True)-Test: #12 (Rücksprung durch S5 in beispiel_lanes), #13 (Nachrichtenfluss durch S5 in beispiel_lanes und durch S24 im Referenzmodell). - test_keine_kante_fuehrt_durchs_innere_eines_gateways entfällt: Die neue Prüfung deckt Gateways mit derselben Rautengeometrie ab und meldet mehr. Gegenprobe am Stand vor #8: alle sieben Kreuzungen auf einmal gefunden. - Selbsttest der Prüfung und test_ausweichabstand_liegt_in_beiden_layouts _im_freien_gang (bindet AUSWEICHABSTAND an DEFAULT_SIZE und Abstände). - AGENTS.md: xfail-Ausnahme gilt für alle Modelle unter beispiele/, je Fehler einzeln. Anleitung, Fallstricke, STATUS nachgezogen. - Tests: python -m pytest -q -> 97 bestanden, 3 xfail (#12, #13). Co-Authored-By: Claude Opus 5.5 --- .agents/skills/sn-bpmn/Anleitung.md | 3 +- .agents/skills/sn-bpmn/test_md_to_bpmn.py | 13 +++ AGENTS.md | 7 +- docs/status/STATUS.md | 4 + docs/wissen/fallstricke.md | 9 +- tests/test_referenzmodelle.py | 115 ++++++++++++++++++---- 6 files changed, 126 insertions(+), 25 deletions(-) diff --git a/.agents/skills/sn-bpmn/Anleitung.md b/.agents/skills/sn-bpmn/Anleitung.md index 3fd31c0..e9068b8 100644 --- a/.agents/skills/sn-bpmn/Anleitung.md +++ b/.agents/skills/sn-bpmn/Anleitung.md @@ -204,7 +204,8 @@ In `beispiele/` neben dieser Datei, reine Syntaxbeispiele (fachlich nicht geprü Referenzmodell (Test) – ein echter, größerer Prozess, gegen den Änderungen am Konverter geprüft werden (Byte-Vergleich plus Strukturtests in -`tests/test_referenzmodelle.py`): +`tests/test_referenzmodelle.py`; die Prüfung „keine Kante durch ein Element“ +läuft auch über die Syntaxbeispiele): - `beispiele/referenz_gewannenfestlegung.md` / `.bpmn` – 4 Lanes, externer Pool, 14 Gateways, verschachtelte Varianten; fachlich nicht abgenommen diff --git a/.agents/skills/sn-bpmn/test_md_to_bpmn.py b/.agents/skills/sn-bpmn/test_md_to_bpmn.py index b16b388..e951afd 100644 --- a/.agents/skills/sn-bpmn/test_md_to_bpmn.py +++ b/.agents/skills/sn-bpmn/test_md_to_bpmn.py @@ -387,6 +387,19 @@ def test_ausweichweg_zwischen_gateways_meidet_die_spalte_dazwischen(): 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.""" diff --git a/AGENTS.md b/AGENTS.md index 0b9d848..0f341b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,9 +40,10 @@ Sie bleibt bewusst kurz: Details liegen in `docs/` und werden nur bei Bedarf gel - **Jeder neue oder geänderte Test bekommt einen deutschen Docstring**, erste Zeile in der Form `"""Prüft, dass …"""`. - Tests werden nicht gelöscht, nicht mit `skip` stillgelegt und nicht in ihrer Erwartung angepasst, um grün zu werden – außer das Verhalten wurde bewusst geändert und der Pull Request begründet es. -- **Einzige Ausnahme:** ein bekannter, per Issue erfasster Konverterfehler an einem Referenzmodell - (`beispiele/referenz_*.md`) darf als `xfail(strict=True)` mit Issue-Nummer im Grund eingetragen werden - (`tests/test_referenzmodelle.py`). Nie für Fehler, die die eigene Änderung verursacht. +- **Einzige Ausnahme:** ein bekannter, per Issue erfasster Konverterfehler an einem Modell unter + `.agents/skills/sn-bpmn/beispiele/` (Referenzmodell oder Syntaxbeispiel) darf als `xfail(strict=True)` mit + Issue-Nummer im Grund eingetragen werden, und zwar je Fehler einzeln, nie für ein ganzes Modell + (`BEKANNTE_KREUZUNGEN` in `tests/test_referenzmodelle.py`). Nie für Fehler, die die eigene Änderung verursacht. - **Referenzmodelle** unter `.agents/skills/sn-bpmn/beispiele/referenz_*.md` sind echte, größere Prozesse zum Testen von Konverteränderungen. Ändert sich deren `.bpmn`, im Pull Request mit Vorschau begründen. diff --git a/docs/status/STATUS.md b/docs/status/STATUS.md index bc42790..32e21d3 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 **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). + `AUSWEICHABSTAND` per Test an die Layout-Maße gebunden. xfail-Regel in `AGENTS.md` auf alle Beispiele erweitert. + - 25.09.2026 **XOR-Gateways zeigen ihr „X“** (#15): `isMarkerVisible="true"` an der Diagrammform jedes exklusiven Gateways; alle Beispiele neu erzeugt. diff --git a/docs/wissen/fallstricke.md b/docs/wissen/fallstricke.md index ac26726..4d8d2fb 100644 --- a/docs/wissen/fallstricke.md +++ b/docs/wissen/fallstricke.md @@ -16,9 +16,12 @@ Kurz, konkret, mit Datum und Quelle. Neue Einträge oben in der passenden Rubrik Die Achse liegt **im Gang direkt vor der Ziel-Spalte** (Abstand 85 zur Mitte), **nicht in der Mitte zwischen Quelle und Ziel**: Liegen die beiden zwei Spalten auseinander, steht dort eine Aufgabe (im Referenzmodell S43). (#8, 25.09.2026) -- **Die Strukturtests melden nur die erste Kreuzung.** `_kante_kreuzt_gateway()` bricht beim ersten Fund ab. Im - Referenzmodell stand deshalb nur S12→S13 im Issue, tatsächlich liefen fünf Kanten durch Gateways. Nach einem - Layout-Fix alle Kanten gegen alle Elemente prüfen, nicht nur den gemeldeten Fall. (#8, 25.09.2026) +- **Bekannte Layoutfehler je Kreuzung als xfail, nie je Modell.** Stünde ein ganzes Modell auf `xfail`, bliebe + jede neue Kreuzung darin unsichtbar, solange der alte Fehler besteht. Deshalb prüft + `test_keine_unbekannte_kante_fuehrt_durch_ein_element` alle Kanten gegen alle Elemente und meldet alle Funde + außer den Paaren in `BEKANNTE_KREUZUNGEN`. Jedes dieser Paare hat einen eigenen strikten xfail-Test. Anlass: Die + alte Gateway-Prüfung brach beim ersten Fund ab; bei #8 stand nur S12→S13 im Issue, tatsächlich waren es sieben + Kreuzungen. (#16, 25.09.2026) - **Zusammenführende Gateways brauchen eine Beschriftung mit Zuständigkeit**, sonst landen sie in der Lane „Nicht zugeordnet“ (`split_lane()` braucht Text nach dem Doppelpunkt). In BPMN bleiben Zusammenführungen üblicherweise unbeschriftet – ein Fall für eine spätere Konverteränderung. (23.09.2026, Referenzmodell) diff --git a/tests/test_referenzmodelle.py b/tests/test_referenzmodelle.py index f49fd02..2185106 100644 --- a/tests/test_referenzmodelle.py +++ b/tests/test_referenzmodelle.py @@ -1,9 +1,11 @@ -"""Strukturtests für die Referenzmodelle (.agents/skills/sn-bpmn/beispiele/referenz_*.md) – reines Python/pytest. +"""Strukturtests für die Modelle unter .agents/skills/sn-bpmn/beispiele/ – reines Python/pytest. -Die Referenzmodelle sind echte, größere Prozesse. Der Byte-Vergleich in test_pruefe_prozesse.py meldet jede -Änderung an der Ausgabe von md_to_bpmn.py; diese Tests prüfen zusätzlich, ob das Ergebnis danach noch den -Modellierregeln und den Grenzen von PICTURE genügt – auch wenn die .bpmn bewusst neu erzeugt wurde. +Die Referenzmodelle (referenz_*.md) sind echte, größere Prozesse, die Syntaxbeispiele (beispiel_*.md) zeigen gezielt +einzelne Elemente. Der Byte-Vergleich in test_pruefe_prozesse.py meldet jede Änderung an der Ausgabe von +md_to_bpmn.py; diese Tests prüfen zusätzlich, ob das Ergebnis danach noch den Modellierregeln und den Grenzen von +PICTURE genügt – auch wenn die .bpmn bewusst neu erzeugt wurde. """ +import math import sys from pathlib import Path @@ -14,19 +16,27 @@ SKILL = ROOT / ".agents" / "skills" / "sn-bpmn" sys.path.insert(0, str(SKILL)) import md_to_bpmn as m # noqa: E402 -from test_md_to_bpmn import _kante_kreuzt_gateway # noqa: E402 (dieselbe Geometrieprüfung wie dort) +from test_md_to_bpmn import _liegt_in_rautenflaeche # noqa: E402 (dieselbe Rautengeometrie wie dort) -REFERENZEN = sorted((SKILL / "beispiele").glob("referenz_*.md")) +MODELLE = sorted((SKILL / "beispiele").glob("*.md")) +REFERENZEN = [md for md in MODELLE if md.stem.startswith("referenz_")] GATEWAYS = ("exclusiveGateway", "inclusiveGateway", "parallelGateway", "eventBasedGateway") +EREIGNISSE = ("startEvent", "endEvent", "intermediateCatchEvent", "intermediateThrowEvent", "boundaryEvent") +# Formen, durch die keine Kante laufen darf; Pools, Lanes, Gruppen und Hinweise zählen nicht als Hindernis +HINDERNISSE = GATEWAYS + EREIGNISSE + ( + "task", "manualTask", "userTask", "serviceTask", "scriptTask", "businessRuleTask", "sendTask", "receiveTask", + "subProcess", "callActivity", "dataObjectReference", "dataStoreReference", +) +KANTEN = ("sequenceFlow", "messageFlow") -# Bekannte, noch nicht behobene Konverterfehler je Referenzmodell. strict=True: Sobald der Fehler behoben ist, -# schlägt der Test als XPASS fehl – dann den Eintrag hier entfernen. Nie verwenden, um neue Fehler zu verstecken. -BEKANNTE_KANTEN_DURCH_GATEWAY = {} - - -def mit_bekannten_fehlern(bekannt): - return [pytest.param(md, id=md.stem, marks=pytest.mark.xfail(reason=bekannt[md.stem], strict=True)) - if md.stem in bekannt else pytest.param(md, id=md.stem) for md in REFERENZEN] +# Bekannte, per Issue erfasste Konverterfehler: (Modell, Kante, Element) -> Grund mit Issue-Nummer. +# 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). +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", + ("referenz_gewannenfestlegung", "MsgFlow_2", "Task_S24"): "Issue #13: Nachrichtenfluss von S16 läuft durch S24", +} def baue(md): @@ -34,6 +44,45 @@ def baue(md): return m.build_bpmn(meta, steps, order, externals) +def _liegt_innen(px, py, art, x, y, w, h, abstand=1.0): + """True, wenn der Punkt im Inneren der sichtbaren Form liegt (Kontur und Umgebung zählen nicht).""" + if art in GATEWAYS: + return _liegt_in_rautenflaeche(px, py, x, y, w, h) + if art in EREIGNISSE: + r = min(w, h) / 2 + return math.hypot(px - x - w / 2, py - y - h / 2) < r - abstand + return x + abstand < px < x + w - abstand and y + abstand < py < y + h - abstand + + +def kreuzungen(defs): + """Alle Paare (Kante, Element), bei denen ein Sequenz- oder Nachrichtenfluss durchs Innere eines Elements läuft. + + Die Kante wird in Schritten von höchstens 4 Einheiten abgetastet, damit auch kleine Ereignisse nicht zwischen + zwei Stichproben fallen.""" + art = {el.get("id"): el.tag.rpartition("}")[2] for el in defs.iter() if el.get("id")} + formen = {} + for shape in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNShape"): + eid = shape.get("bpmnElement") + if art.get(eid) in HINDERNISSE: + b = shape.find(f"{{{m.NS['dc']}}}Bounds") + formen[eid] = tuple(float(b.get(a)) for a in ("x", "y", "width", "height")) + + funde = set() + for edge in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNEdge"): + kante = edge.get("bpmnElement") + if art.get(kante) not in KANTEN: + continue + punkte = [(float(wp.get("x")), float(wp.get("y"))) for wp in edge.findall(f"{{{m.NS['di']}}}waypoint")] + for eid, (x, y, w, h) in formen.items(): + for (x1, y1), (x2, y2) in zip(punkte, punkte[1:]): + schritte = max(8, int(math.hypot(x2 - x1, y2 - y1) / 4)) + if any(_liegt_innen(x1 + (x2 - x1) * s / schritte, y1 + (y2 - y1) * s / schritte, art[eid], x, y, w, h) + for s in range(1, schritte)): + funde.add((kante, eid)) + break + return sorted(funde) + + def test_es_gibt_mindestens_ein_referenzmodell(): """Prüft, dass die Strukturtests tatsächlich an einem Referenzmodell laufen.""" assert REFERENZEN @@ -57,7 +106,37 @@ def test_kein_gateway_hat_mehr_als_vier_kanten(md): assert len(kanten) <= 4, f"{gateway.get('id')} hat {len(kanten)} Kanten" -@pytest.mark.parametrize("md", mit_bekannten_fehlern(BEKANNTE_KANTEN_DURCH_GATEWAY)) -def test_keine_kante_fuehrt_durchs_innere_eines_gateways(md): - """Prüft, dass im Referenzmodell keine Kante quer durch eine Raute läuft (Fallstrick Gateway-Kantenführung).""" - assert _kante_kreuzt_gateway(baue(md), m.NS) is None +@pytest.mark.parametrize("md", MODELLE, ids=lambda p: p.stem) +def test_keine_unbekannte_kante_fuehrt_durch_ein_element(md): + """Prüft, dass in keinem Modell ein Sequenz- oder Nachrichtenfluss durchs Innere einer Aufgabe, eines Ereignisses, + eines Gateways oder eines Datenelements läuft – außer den per Issue erfassten Fällen. Meldet alle Funde auf einmal.""" + bekannt = {(k, e) for (modell, k, e) in BEKANNTE_KREUZUNGEN if modell == md.stem} + neu = [paar for paar in kreuzungen(baue(md)) if paar not in bekannt] + assert neu == [], "Kante durch Element (Kante, Element): " + ", ".join(f"{k} → {e}" for k, e in neu) + + +@pytest.mark.parametrize("modell, kante, element", [ + pytest.param(*schluessel, id="-".join(schluessel), marks=pytest.mark.xfail(reason=grund, strict=True)) + for schluessel, grund in BEKANNTE_KREUZUNGEN.items() +]) +def test_bekannte_kreuzung_ist_behoben(modell, kante, element): + """Prüft je per Issue erfasster Kreuzung, ob sie noch besteht; als xfail(strict=True) markiert, damit ein Fix + auffällt und der Eintrag in BEKANNTE_KREUZUNGEN entfernt wird.""" + assert (kante, element) not in kreuzungen(baue(SKILL / "beispiele" / f"{modell}.md")) + + +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") + task_shape = next(s for s in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNShape") if s.get("bpmnElement").startswith("Task_")) + b = task_shape.find(f"{{{m.NS['dc']}}}Bounds") + x, y, w, h = (float(b.get(a)) for a in ("x", "y", "width", "height")) + edge = next(e for e in defs.iter(f"{{{m.NS['bpmndi']}}}BPMNEdge") if e.get("bpmnElement").startswith("Flow_")) + wp = edge.findall(f"{{{m.NS['di']}}}waypoint") + wp[0].set("x", str(x - 20)); wp[0].set("y", str(y + h / 2)) + wp[-1].set("x", str(x + w + 20)); wp[-1].set("y", str(y + h / 2)) + for mitte in wp[1:-1]: + edge.remove(mitte) + assert (edge.get("bpmnElement"), task_shape.get("bpmnElement")) in kreuzungen(defs) + wp[0].set("y", str(y - 30)); wp[-1].set("y", str(y - 30)) + assert (edge.get("bpmnElement"), task_shape.get("bpmnElement")) not in kreuzungen(defs)