AP1.15c: Join/Branch-Topologie mit parallel_group und optional_branch.
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 2m18s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 13s
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 2m18s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 13s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
364ad44759
commit
d7aefed0de
|
|
@ -1,26 +1,69 @@
|
|||
"""Join- und Branch-Topologie — Reservierung für AP1.15c+.
|
||||
"""Join- und Branch-Topologie — AP1.15c.
|
||||
|
||||
Heute blockiert `roadmap_engine.compute_initiative_graph_state` nur über
|
||||
`requires` und `blocks`. `parallel_group` und `optional_branch` sind im Schema
|
||||
vorhanden, werden aber noch nicht für blocked/ready ausgewertet.
|
||||
|
||||
Erweiterungspunkt: neue Auswertung hier kapseln, Engine ruft dann
|
||||
`apply_topology_to_blocked_map(...)` auf — ohne Router- oder UI-Sonderlogik.
|
||||
|
||||
Siehe ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md § Join/Branch deferred.
|
||||
AND-Join: mehrere `requires` auf dasselbe Gate (weiterhin in roadmap_engine).
|
||||
`optional_branch`: blockiert nicht — wird aus blocked_by entfernt (defensiv).
|
||||
`parallel_group`: Metadaten für parallele Stränge (group_key); keine Blockade.
|
||||
OR-Alternativpfade: bewusst nicht implementiert (PO-Deferral).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
# Im Schema/API vorhanden; Engine-Logik folgt in AP1.15c
|
||||
DEFERRED_TOPOLOGY_EDGE_KINDS = frozenset({"parallel_group", "optional_branch"})
|
||||
|
||||
# Designer AP1.15c; OR-Pfade nur nach validiertem Use Case + PO-Freigabe
|
||||
PLANNED_TOPOLOGY_PACKAGES = ("AP1.15c",)
|
||||
|
||||
|
||||
def _dep_type(dep: dict[str, Any]) -> str:
|
||||
return dep.get("dependency_type") or dep.get("edge_kind") or "requires"
|
||||
|
||||
|
||||
def build_topology_metadata(
|
||||
*,
|
||||
dependencies: list[dict[str, Any]],
|
||||
item_by_id: dict[str, dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
"""Read-only Topologie-Hinweise für UI/Steuerung."""
|
||||
parallel_groups: dict[str, list[str]] = defaultdict(list)
|
||||
optional_edges: list[dict[str, str]] = []
|
||||
|
||||
for dep in dependencies:
|
||||
dep_type = _dep_type(dep)
|
||||
from_id = str(dep["from_item_id"])
|
||||
to_id = str(dep["to_item_id"])
|
||||
if from_id not in item_by_id or to_id not in item_by_id:
|
||||
continue
|
||||
|
||||
if dep_type == "optional_branch":
|
||||
optional_edges.append({"from_item_id": from_id, "to_item_id": to_id})
|
||||
elif dep_type == "parallel_group":
|
||||
group_key = (dep.get("group_key") or "default").strip() or "default"
|
||||
for item_id in (from_id, to_id):
|
||||
if item_id not in parallel_groups[group_key]:
|
||||
parallel_groups[group_key].append(item_id)
|
||||
|
||||
return {
|
||||
"parallel_groups": dict(parallel_groups),
|
||||
"optional_branches": optional_edges,
|
||||
}
|
||||
|
||||
|
||||
def _optional_prerequisite_pairs(
|
||||
dependencies: list[dict[str, Any]],
|
||||
item_by_id: dict[str, dict[str, Any]],
|
||||
) -> set[tuple[str, str]]:
|
||||
pairs: set[tuple[str, str]] = set()
|
||||
for dep in dependencies:
|
||||
if _dep_type(dep) != "optional_branch":
|
||||
continue
|
||||
from_id = str(dep["from_item_id"])
|
||||
to_id = str(dep["to_item_id"])
|
||||
if from_id in item_by_id and to_id in item_by_id:
|
||||
pairs.add((from_id, to_id))
|
||||
return pairs
|
||||
|
||||
|
||||
def apply_deferred_topology(
|
||||
*,
|
||||
dependencies: list[dict[str, Any]],
|
||||
|
|
@ -28,9 +71,37 @@ def apply_deferred_topology(
|
|||
blocked_by_map: dict[str, list[str]],
|
||||
) -> dict[str, list[str]]:
|
||||
"""
|
||||
Erweitert blocked_by_map um parallel_group / optional_branch (AP1.15c).
|
||||
Entfernt optionale Voraussetzungen aus blocked_by.
|
||||
|
||||
Aktuell: No-Op — gibt blocked_by_map unverändert zurück.
|
||||
`parallel_group` ändert blocked_by nicht — Join bleibt über `requires`-Kanten.
|
||||
"""
|
||||
_ = (dependencies, item_by_id)
|
||||
optional_pairs = _optional_prerequisite_pairs(dependencies, item_by_id)
|
||||
if not optional_pairs:
|
||||
return blocked_by_map
|
||||
|
||||
adjusted: dict[str, list[str]] = {}
|
||||
for item_id, blockers in blocked_by_map.items():
|
||||
filtered = [b for b in blockers if (item_id, b) not in optional_pairs]
|
||||
adjusted[item_id] = filtered
|
||||
return adjusted
|
||||
|
||||
|
||||
def topology_hints_for_item(
|
||||
*,
|
||||
item_id: str,
|
||||
topology: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
parallel_keys = [
|
||||
key
|
||||
for key, members in topology.get("parallel_groups", {}).items()
|
||||
if item_id in members
|
||||
]
|
||||
optional_waiting_on = [
|
||||
edge["to_item_id"]
|
||||
for edge in topology.get("optional_branches", [])
|
||||
if edge["from_item_id"] == item_id
|
||||
]
|
||||
return {
|
||||
"parallel_group_keys": parallel_keys,
|
||||
"optional_prerequisites": optional_waiting_on,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ from steering.graph.profiles import (
|
|||
get_graph_profile,
|
||||
graph_profile_as_dict,
|
||||
)
|
||||
from steering.graph.join_branch import apply_deferred_topology
|
||||
from steering.graph.join_branch import (
|
||||
apply_deferred_topology,
|
||||
build_topology_metadata,
|
||||
topology_hints_for_item,
|
||||
)
|
||||
|
||||
TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"})
|
||||
ACTIVE_STATUSES = frozenset({"planned", "active", "at_risk"})
|
||||
|
|
@ -113,6 +117,11 @@ def compute_initiative_graph_state(
|
|||
blocked_by_map=blocked_by_map,
|
||||
)
|
||||
|
||||
topology = build_topology_metadata(
|
||||
dependencies=dependencies,
|
||||
item_by_id=item_by_id,
|
||||
)
|
||||
|
||||
item_states: dict[str, dict[str, Any]] = {}
|
||||
blocked_items: list[str] = []
|
||||
ready_items: list[str] = []
|
||||
|
|
@ -127,12 +136,15 @@ def compute_initiative_graph_state(
|
|||
)
|
||||
|
||||
ratio = _fulfillment_ratio(criteria_progress.get(item_id))
|
||||
hints = topology_hints_for_item(item_id=item_id, topology=topology)
|
||||
item_states[item_id] = {
|
||||
"status": status,
|
||||
"blocked": blocked,
|
||||
"ready": ready,
|
||||
"blocked_by": blocked_by,
|
||||
"fulfillment_ratio": ratio,
|
||||
"parallel_group_keys": hints["parallel_group_keys"],
|
||||
"optional_prerequisites": hints["optional_prerequisites"],
|
||||
}
|
||||
if blocked and status in ACTIVE_STATUSES:
|
||||
blocked_items.append(item_id)
|
||||
|
|
@ -143,6 +155,7 @@ def compute_initiative_graph_state(
|
|||
"items": item_states,
|
||||
"blocked_items": blocked_items,
|
||||
"ready_items": ready_items,
|
||||
"topology": topology,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
70
backend/tests/test_ap15c_join_branch.py
Normal file
70
backend/tests/test_ap15c_join_branch.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""Tests for join/branch topology — AP1.15c."""
|
||||
|
||||
from steering.graph.join_branch import (
|
||||
apply_deferred_topology,
|
||||
build_topology_metadata,
|
||||
)
|
||||
from steering.graph.roadmap_engine import compute_initiative_graph_state
|
||||
|
||||
|
||||
def _item(item_id: str, status: str = "planned", **kwargs):
|
||||
return {
|
||||
"id": item_id,
|
||||
"status": status,
|
||||
"sort_order": kwargs.get("sort_order", 0),
|
||||
"sequencing_mode": kwargs.get("sequencing_mode", "sequential"),
|
||||
"title": kwargs.get("title", item_id),
|
||||
}
|
||||
|
||||
|
||||
def test_optional_branch_does_not_block():
|
||||
items = [_item("join", "planned"), _item("opt", "planned"), _item("req", "planned")]
|
||||
deps = [
|
||||
{
|
||||
"from_item_id": "join",
|
||||
"to_item_id": "req",
|
||||
"dependency_type": "requires",
|
||||
},
|
||||
{
|
||||
"from_item_id": "join",
|
||||
"to_item_id": "opt",
|
||||
"dependency_type": "optional_branch",
|
||||
},
|
||||
]
|
||||
state = compute_initiative_graph_state(items=items, dependencies=deps)
|
||||
assert state["items"]["join"]["blocked"] is True
|
||||
assert state["items"]["join"]["blocked_by"] == ["req"]
|
||||
assert "opt" not in state["items"]["join"]["blocked_by"]
|
||||
assert state["items"]["join"]["optional_prerequisites"] == ["opt"]
|
||||
|
||||
|
||||
def test_parallel_group_metadata():
|
||||
items = [_item("a"), _item("b"), _item("c")]
|
||||
deps = [
|
||||
{
|
||||
"from_item_id": "b",
|
||||
"to_item_id": "c",
|
||||
"dependency_type": "parallel_group",
|
||||
"group_key": "phase-1",
|
||||
}
|
||||
]
|
||||
topology = build_topology_metadata(dependencies=deps, item_by_id={i["id"]: i for i in items})
|
||||
assert set(topology["parallel_groups"]["phase-1"]) == {"b", "c"}
|
||||
|
||||
|
||||
def test_apply_deferred_topology_strips_optional_blockers():
|
||||
item_by_id = {"x": _item("x"), "y": _item("y")}
|
||||
blocked_by_map = {"x": ["y", "z"]}
|
||||
deps = [
|
||||
{
|
||||
"from_item_id": "x",
|
||||
"to_item_id": "y",
|
||||
"dependency_type": "optional_branch",
|
||||
}
|
||||
]
|
||||
result = apply_deferred_topology(
|
||||
dependencies=deps,
|
||||
item_by_id=item_by_id,
|
||||
blocked_by_map=blocked_by_map,
|
||||
)
|
||||
assert result["x"] == ["z"]
|
||||
|
|
@ -224,6 +224,9 @@ Plan-UI: **Detail-Modal/Route** `/initiatives/:id/plan/items/:itemId` — Checkl
|
|||
| **AP1.15a** | Designer MVP: Pan/Zoom, Kanten (Klick), Layout localStorage |
|
||||
| **AP1.15b** | UX: Drag-Kanten, Gate anlegen, Klick-Panel (ohne Navigation) |
|
||||
| **AP1.15c** | Topologie: `parallel_group`, `optional_branch`, Join-Semantik in Engine + Designer |
|
||||
| **AP1.15d** | (optional) OR-Alternativpfade — nur bei validiertem Use Case |
|
||||
|
||||
**Stand AP1.15c (2026-07-11):** AND-Join über `requires`; optional blockiert nicht; parallel als Metadaten; OR ausstehend.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
|||
| Abhängigkeiten | ◐ | API; Graph Engine AP1.4d |
|
||||
| parallel vs. sequenziell | ◐ | `sequencing_mode` (deprecated); Graph AP1.4d |
|
||||
| Plan-Graph vs. Ist-Overlay | ✗ | AP1.13: ein Graph + Status am Knoten — Plan/Ist vermischt |
|
||||
| Zielzustands-Designer (Joint/Parallel) | ◐ | AP1.15a Designer MVP; Join/Branch → AP1.15c (deferred, vorbereitet) |
|
||||
| Zielzustands-Designer (Joint/Parallel) | ◐ | AP1.15a/b Designer; AP1.15c parallel/optional in Engine+Designer; OR deferred |
|
||||
| Plan-Revision / Graph-Historie | ◐ | AP1.14 Plan-Snapshots |
|
||||
| Verify vor `reached` | ◐ | Kriterienplan + gate_override |
|
||||
| Reopen nach `reached` | ◐ | `active`, Kriterien bleiben AP1.4b |
|
||||
|
|
|
|||
|
|
@ -19,11 +19,14 @@ function formatDate(value) {
|
|||
|
||||
export function GateDesignerNodePanel({
|
||||
item,
|
||||
graphState,
|
||||
criteriaProgress,
|
||||
onClose,
|
||||
}) {
|
||||
if (!item) return null
|
||||
|
||||
const graphItem = graphState?.items?.[item.id]
|
||||
|
||||
const progress = criteriaProgress?.[item.id]
|
||||
const closed = progress?.closed ?? 0
|
||||
const total = progress?.total ?? 0
|
||||
|
|
@ -73,6 +76,18 @@ export function GateDesignerNodePanel({
|
|||
<p className="gate-designer-node-panel__goal muted">{item.goal_description}</p>
|
||||
)}
|
||||
|
||||
{graphItem?.parallel_group_keys?.length > 0 && (
|
||||
<p className="gate-designer-node-panel__topology muted">
|
||||
Parallel: {graphItem.parallel_group_keys.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{graphItem?.optional_prerequisites?.length > 0 && (
|
||||
<p className="gate-designer-node-panel__topology muted">
|
||||
Optional: {graphItem.optional_prerequisites.length} Zweig(e) — blockieren nicht
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="gate-designer-node-panel__actions">
|
||||
<Link to={gatePath(item.id)} className="btn btn-secondary btn-sm">
|
||||
Vollständig bearbeiten
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
loadManualGatePositions,
|
||||
storeManualGatePositions,
|
||||
} from '../plan/gateGraphDesigner.js'
|
||||
import { DESIGNER_EDGE_KINDS, DESIGNER_EDGE_LABELS } from '../plan/gateGraphTopology.js'
|
||||
import { DESIGNER_EDGE_KINDS, DESIGNER_EDGE_LABELS, edgeKindNeedsGroupKey, resolveDependencyEndpoints } from '../plan/gateGraphTopology.js'
|
||||
import { LoadingState } from './LoadingState.jsx'
|
||||
import { ErrorState } from './ErrorState.jsx'
|
||||
import { EmptyState } from './EmptyState.jsx'
|
||||
|
|
@ -62,6 +62,7 @@ export function GateGraphDesigner({
|
|||
busy: busyProp = false,
|
||||
onCreate,
|
||||
onGraphChanged,
|
||||
graphState,
|
||||
}) {
|
||||
const viewportRef = useRef(null)
|
||||
const [dependencies, setDependencies] = useState([])
|
||||
|
|
@ -73,6 +74,7 @@ export function GateGraphDesigner({
|
|||
const [error, setError] = useState(null)
|
||||
const [busyLocal, setBusyLocal] = useState(false)
|
||||
const [edgeType, setEdgeType] = useState('requires')
|
||||
const [groupKey, setGroupKey] = useState('phase-1')
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState(null)
|
||||
const [selectedNodeId, setSelectedNodeId] = useState(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
|
|
@ -150,21 +152,28 @@ export function GateGraphDesigner({
|
|||
onGraphChanged?.()
|
||||
}
|
||||
|
||||
async function handleCreateEdge(fromId, toId) {
|
||||
if (!fromId || !toId || fromId === toId) return
|
||||
async function handleCreateEdge(sourceId, targetId) {
|
||||
if (!sourceId || !targetId || sourceId === targetId) return
|
||||
if (edgeKindNeedsGroupKey(edgeType) && !groupKey.trim()) {
|
||||
setError('Parallelgruppe benötigt einen Schlüssel (group_key).')
|
||||
return
|
||||
}
|
||||
setBusyLocal(true)
|
||||
setError(null)
|
||||
try {
|
||||
let fromItemId = fromId
|
||||
let toItemId = toId
|
||||
if (edgeType === 'requires') {
|
||||
fromItemId = toId
|
||||
toItemId = fromId
|
||||
}
|
||||
await addRoadmapItemDependency(fromItemId, {
|
||||
const { from_item_id: fromItemId, to_item_id: toItemId } = resolveDependencyEndpoints(
|
||||
edgeType,
|
||||
sourceId,
|
||||
targetId,
|
||||
)
|
||||
const body = {
|
||||
to_item_id: toItemId,
|
||||
dependency_type: edgeType,
|
||||
})
|
||||
}
|
||||
if (edgeKindNeedsGroupKey(edgeType)) {
|
||||
body.group_key = groupKey.trim()
|
||||
}
|
||||
await addRoadmapItemDependency(fromItemId, body)
|
||||
await notifyGraphChanged()
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Kante konnte nicht angelegt werden.')
|
||||
|
|
@ -448,6 +457,20 @@ export function GateGraphDesigner({
|
|||
</label>
|
||||
)}
|
||||
|
||||
{canManage && edgeKindNeedsGroupKey(edgeType) && (
|
||||
<label className="gate-graph-designer__edge-type">
|
||||
<span className="muted">Gruppenschlüssel</span>
|
||||
<input
|
||||
type="text"
|
||||
value={groupKey}
|
||||
maxLength={128}
|
||||
disabled={busy}
|
||||
placeholder="z. B. phase-1"
|
||||
onChange={(e) => setGroupKey(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="gate-graph-designer__zoom-group" role="group" aria-label="Zoom">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -493,8 +516,9 @@ export function GateGraphDesigner({
|
|||
|
||||
{canManage && (
|
||||
<p className="gate-graph-designer__hint muted">
|
||||
Vom unteren Punkt eines Gates zur Zielkante ziehen. Doppelklick auf leere Fläche legt ein
|
||||
Gate an. Klick auf Gate öffnet das Info-Panel.
|
||||
Vom unteren Punkt ziehen: Voraussetzung/Block/Bezug/Parallel/Optional. Join-Gates
|
||||
verbinden mehrere Pflicht-Voraussetzungen (AND). Doppelklick auf leere Fläche legt ein
|
||||
Gate an.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
@ -627,6 +651,7 @@ export function GateGraphDesigner({
|
|||
{selectedItem && (
|
||||
<GateDesignerNodePanel
|
||||
item={selectedItem}
|
||||
graphState={graphState}
|
||||
criteriaProgress={criteriaProgress}
|
||||
onClose={() => setSelectedNodeId(null)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -105,6 +105,14 @@ export function GateMapView({ initiativeId, items, graphState: graphStateProp })
|
|||
<span className="gate-map-view__legend-line gate-map-view__legend-line--related" />
|
||||
Bezug
|
||||
</span>
|
||||
<span className="gate-map-view__legend-item">
|
||||
<span className="gate-map-view__legend-line gate-map-view__legend-line--parallel" />
|
||||
Parallel
|
||||
</span>
|
||||
<span className="gate-map-view__legend-item">
|
||||
<span className="gate-map-view__legend-line gate-map-view__legend-line--optional" />
|
||||
Optional
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="gate-map-view__canvas-wrap">
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ export function GatesPlanPanel({
|
|||
busy={busy}
|
||||
onCreate={canManage ? onCreate : undefined}
|
||||
onGraphChanged={refreshGraphState}
|
||||
graphState={graphState}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -132,7 +132,11 @@ export function computeGateGraphLayout(items, dependencies) {
|
|||
y: node.y + node.height / 2,
|
||||
})
|
||||
|
||||
if (dep.dependency_type === 'related') {
|
||||
if (
|
||||
dep.dependency_type === 'related' ||
|
||||
dep.dependency_type === 'parallel_group' ||
|
||||
dep.dependency_type === 'optional_branch'
|
||||
) {
|
||||
const from = center(sourceNode)
|
||||
const to = center(targetNode)
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
/**
|
||||
* Kantentypen für Zielzustands-Graph — AP1.15c erweitert um Topologie.
|
||||
* Siehe ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md § Join/Branch deferred.
|
||||
* Kantentypen für Zielzustands-Graph — AP1.15c Topologie.
|
||||
* OR-Alternativpfade bewusst nicht enthalten (PO-Deferral).
|
||||
*/
|
||||
|
||||
/** Im Designer AP1.15a/b nutzbar */
|
||||
export const DESIGNER_EDGE_KINDS = ['requires', 'blocks', 'related']
|
||||
/** Basis-Kanten im Designer */
|
||||
export const DESIGNER_EDGE_KINDS = [
|
||||
'requires',
|
||||
'blocks',
|
||||
'related',
|
||||
'parallel_group',
|
||||
'optional_branch',
|
||||
]
|
||||
|
||||
/** AP1.15c — parallel_group, optional_branch; ggf. OR später */
|
||||
export const DEFERRED_TOPOLOGY_EDGE_KINDS = ['parallel_group', 'optional_branch']
|
||||
/** Erfordert group_key bei Anlage */
|
||||
export const EDGE_KINDS_REQUIRING_GROUP_KEY = ['parallel_group']
|
||||
|
||||
export const DESIGNER_EDGE_LABELS = {
|
||||
requires: 'Voraussetzung',
|
||||
|
|
@ -16,3 +22,21 @@ export const DESIGNER_EDGE_LABELS = {
|
|||
parallel_group: 'Parallelgruppe',
|
||||
optional_branch: 'Optional',
|
||||
}
|
||||
|
||||
/** @deprecated use DESIGNER_EDGE_KINDS */
|
||||
export const DEFERRED_TOPOLOGY_EDGE_KINDS = ['parallel_group', 'optional_branch']
|
||||
|
||||
export function edgeKindNeedsGroupKey(kind) {
|
||||
return EDGE_KINDS_REQUIRING_GROUP_KEY.includes(kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kanten-Richtung beim Anlegen per Drag (Quelle → Ziel).
|
||||
* requires: Ziel hängt von Quelle ab (from=Ziel, to=Quelle).
|
||||
*/
|
||||
export function resolveDependencyEndpoints(edgeKind, sourceId, targetId) {
|
||||
if (edgeKind === 'requires') {
|
||||
return { from_item_id: targetId, to_item_id: sourceId }
|
||||
}
|
||||
return { from_item_id: sourceId, to_item_id: targetId }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,33 @@ import { describe, expect, it } from 'vitest'
|
|||
import {
|
||||
DEFERRED_TOPOLOGY_EDGE_KINDS,
|
||||
DESIGNER_EDGE_KINDS,
|
||||
edgeKindNeedsGroupKey,
|
||||
resolveDependencyEndpoints,
|
||||
} from './gateGraphTopology.js'
|
||||
|
||||
describe('gateGraphTopology', () => {
|
||||
it('keeps designer and deferred edge kinds separate', () => {
|
||||
it('includes topology kinds in designer', () => {
|
||||
expect(DESIGNER_EDGE_KINDS).toContain('requires')
|
||||
expect(DEFERRED_TOPOLOGY_EDGE_KINDS).toContain('parallel_group')
|
||||
expect(DESIGNER_EDGE_KINDS).toContain('parallel_group')
|
||||
expect(DESIGNER_EDGE_KINDS).toContain('optional_branch')
|
||||
for (const kind of DEFERRED_TOPOLOGY_EDGE_KINDS) {
|
||||
expect(DESIGNER_EDGE_KINDS).not.toContain(kind)
|
||||
expect(DESIGNER_EDGE_KINDS).toContain(kind)
|
||||
}
|
||||
})
|
||||
|
||||
it('requires group_key for parallel_group', () => {
|
||||
expect(edgeKindNeedsGroupKey('parallel_group')).toBe(true)
|
||||
expect(edgeKindNeedsGroupKey('requires')).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves requires edge direction for join semantics', () => {
|
||||
expect(resolveDependencyEndpoints('requires', 'a', 'b')).toEqual({
|
||||
from_item_id: 'b',
|
||||
to_item_id: 'a',
|
||||
})
|
||||
expect(resolveDependencyEndpoints('optional_branch', 'a', 'b')).toEqual({
|
||||
from_item_id: 'a',
|
||||
to_item_id: 'b',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1697,6 +1697,16 @@
|
|||
border-top-style: dashed;
|
||||
}
|
||||
|
||||
.gate-map-view__legend-line--parallel {
|
||||
border-top-color: #7c3aed;
|
||||
border-top-style: dashed;
|
||||
}
|
||||
|
||||
.gate-map-view__legend-line--optional {
|
||||
border-top-color: #059669;
|
||||
border-top-style: dashed;
|
||||
}
|
||||
|
||||
.gate-map-view__canvas-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--jk-border, #dde3ea);
|
||||
|
|
@ -1727,6 +1737,16 @@
|
|||
stroke-dasharray: 6 4;
|
||||
}
|
||||
|
||||
.gate-map-view__edge--parallel_group {
|
||||
stroke: #7c3aed;
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
|
||||
.gate-map-view__edge--optional_branch {
|
||||
stroke: #059669;
|
||||
stroke-dasharray: 8 4;
|
||||
}
|
||||
|
||||
.gate-map-view__arrow--requires {
|
||||
fill: #2563eb;
|
||||
}
|
||||
|
|
@ -1963,6 +1983,11 @@
|
|||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.gate-designer-node-panel__topology {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.gate-designer-node-panel__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user