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
|
AND-Join: mehrere `requires` auf dasselbe Gate (weiterhin in roadmap_engine).
|
||||||
`requires` und `blocks`. `parallel_group` und `optional_branch` sind im Schema
|
`optional_branch`: blockiert nicht — wird aus blocked_by entfernt (defensiv).
|
||||||
vorhanden, werden aber noch nicht für blocked/ready ausgewertet.
|
`parallel_group`: Metadaten für parallele Stränge (group_key); keine Blockade.
|
||||||
|
OR-Alternativpfade: bewusst nicht implementiert (PO-Deferral).
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
# Im Schema/API vorhanden; Engine-Logik folgt in AP1.15c
|
|
||||||
DEFERRED_TOPOLOGY_EDGE_KINDS = frozenset({"parallel_group", "optional_branch"})
|
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",)
|
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(
|
def apply_deferred_topology(
|
||||||
*,
|
*,
|
||||||
dependencies: list[dict[str, Any]],
|
dependencies: list[dict[str, Any]],
|
||||||
|
|
@ -28,9 +71,37 @@ def apply_deferred_topology(
|
||||||
blocked_by_map: dict[str, list[str]],
|
blocked_by_map: dict[str, list[str]],
|
||||||
) -> 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)
|
||||||
return blocked_by_map
|
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,
|
get_graph_profile,
|
||||||
graph_profile_as_dict,
|
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"})
|
TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"})
|
||||||
ACTIVE_STATUSES = frozenset({"planned", "active", "at_risk"})
|
ACTIVE_STATUSES = frozenset({"planned", "active", "at_risk"})
|
||||||
|
|
@ -113,6 +117,11 @@ def compute_initiative_graph_state(
|
||||||
blocked_by_map=blocked_by_map,
|
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]] = {}
|
item_states: dict[str, dict[str, Any]] = {}
|
||||||
blocked_items: list[str] = []
|
blocked_items: list[str] = []
|
||||||
ready_items: list[str] = []
|
ready_items: list[str] = []
|
||||||
|
|
@ -127,12 +136,15 @@ def compute_initiative_graph_state(
|
||||||
)
|
)
|
||||||
|
|
||||||
ratio = _fulfillment_ratio(criteria_progress.get(item_id))
|
ratio = _fulfillment_ratio(criteria_progress.get(item_id))
|
||||||
|
hints = topology_hints_for_item(item_id=item_id, topology=topology)
|
||||||
item_states[item_id] = {
|
item_states[item_id] = {
|
||||||
"status": status,
|
"status": status,
|
||||||
"blocked": blocked,
|
"blocked": blocked,
|
||||||
"ready": ready,
|
"ready": ready,
|
||||||
"blocked_by": blocked_by,
|
"blocked_by": blocked_by,
|
||||||
"fulfillment_ratio": ratio,
|
"fulfillment_ratio": ratio,
|
||||||
|
"parallel_group_keys": hints["parallel_group_keys"],
|
||||||
|
"optional_prerequisites": hints["optional_prerequisites"],
|
||||||
}
|
}
|
||||||
if blocked and status in ACTIVE_STATUSES:
|
if blocked and status in ACTIVE_STATUSES:
|
||||||
blocked_items.append(item_id)
|
blocked_items.append(item_id)
|
||||||
|
|
@ -143,6 +155,7 @@ def compute_initiative_graph_state(
|
||||||
"items": item_states,
|
"items": item_states,
|
||||||
"blocked_items": blocked_items,
|
"blocked_items": blocked_items,
|
||||||
"ready_items": ready_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.15a** | Designer MVP: Pan/Zoom, Kanten (Klick), Layout localStorage |
|
||||||
| **AP1.15b** | UX: Drag-Kanten, Gate anlegen, Klick-Panel (ohne Navigation) |
|
| **AP1.15b** | UX: Drag-Kanten, Gate anlegen, Klick-Panel (ohne Navigation) |
|
||||||
| **AP1.15c** | Topologie: `parallel_group`, `optional_branch`, Join-Semantik in Engine + Designer |
|
| **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 |
|
| Abhängigkeiten | ◐ | API; Graph Engine AP1.4d |
|
||||||
| parallel vs. sequenziell | ◐ | `sequencing_mode` (deprecated); Graph 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 |
|
| 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 |
|
| Plan-Revision / Graph-Historie | ◐ | AP1.14 Plan-Snapshots |
|
||||||
| Verify vor `reached` | ◐ | Kriterienplan + gate_override |
|
| Verify vor `reached` | ◐ | Kriterienplan + gate_override |
|
||||||
| Reopen nach `reached` | ◐ | `active`, Kriterien bleiben AP1.4b |
|
| Reopen nach `reached` | ◐ | `active`, Kriterien bleiben AP1.4b |
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,14 @@ function formatDate(value) {
|
||||||
|
|
||||||
export function GateDesignerNodePanel({
|
export function GateDesignerNodePanel({
|
||||||
item,
|
item,
|
||||||
|
graphState,
|
||||||
criteriaProgress,
|
criteriaProgress,
|
||||||
onClose,
|
onClose,
|
||||||
}) {
|
}) {
|
||||||
if (!item) return null
|
if (!item) return null
|
||||||
|
|
||||||
|
const graphItem = graphState?.items?.[item.id]
|
||||||
|
|
||||||
const progress = criteriaProgress?.[item.id]
|
const progress = criteriaProgress?.[item.id]
|
||||||
const closed = progress?.closed ?? 0
|
const closed = progress?.closed ?? 0
|
||||||
const total = progress?.total ?? 0
|
const total = progress?.total ?? 0
|
||||||
|
|
@ -69,8 +72,20 @@ export function GateDesignerNodePanel({
|
||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{item.goal_description && (
|
{item.goal_description && (
|
||||||
<p className="gate-designer-node-panel__goal muted">{item.goal_description}</p>
|
<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">
|
<div className="gate-designer-node-panel__actions">
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
loadManualGatePositions,
|
loadManualGatePositions,
|
||||||
storeManualGatePositions,
|
storeManualGatePositions,
|
||||||
} from '../plan/gateGraphDesigner.js'
|
} 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 { LoadingState } from './LoadingState.jsx'
|
||||||
import { ErrorState } from './ErrorState.jsx'
|
import { ErrorState } from './ErrorState.jsx'
|
||||||
import { EmptyState } from './EmptyState.jsx'
|
import { EmptyState } from './EmptyState.jsx'
|
||||||
|
|
@ -62,6 +62,7 @@ export function GateGraphDesigner({
|
||||||
busy: busyProp = false,
|
busy: busyProp = false,
|
||||||
onCreate,
|
onCreate,
|
||||||
onGraphChanged,
|
onGraphChanged,
|
||||||
|
graphState,
|
||||||
}) {
|
}) {
|
||||||
const viewportRef = useRef(null)
|
const viewportRef = useRef(null)
|
||||||
const [dependencies, setDependencies] = useState([])
|
const [dependencies, setDependencies] = useState([])
|
||||||
|
|
@ -73,6 +74,7 @@ export function GateGraphDesigner({
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [busyLocal, setBusyLocal] = useState(false)
|
const [busyLocal, setBusyLocal] = useState(false)
|
||||||
const [edgeType, setEdgeType] = useState('requires')
|
const [edgeType, setEdgeType] = useState('requires')
|
||||||
|
const [groupKey, setGroupKey] = useState('phase-1')
|
||||||
const [selectedEdgeId, setSelectedEdgeId] = useState(null)
|
const [selectedEdgeId, setSelectedEdgeId] = useState(null)
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState(null)
|
const [selectedNodeId, setSelectedNodeId] = useState(null)
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
|
@ -150,21 +152,28 @@ export function GateGraphDesigner({
|
||||||
onGraphChanged?.()
|
onGraphChanged?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreateEdge(fromId, toId) {
|
async function handleCreateEdge(sourceId, targetId) {
|
||||||
if (!fromId || !toId || fromId === toId) return
|
if (!sourceId || !targetId || sourceId === targetId) return
|
||||||
|
if (edgeKindNeedsGroupKey(edgeType) && !groupKey.trim()) {
|
||||||
|
setError('Parallelgruppe benötigt einen Schlüssel (group_key).')
|
||||||
|
return
|
||||||
|
}
|
||||||
setBusyLocal(true)
|
setBusyLocal(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
let fromItemId = fromId
|
const { from_item_id: fromItemId, to_item_id: toItemId } = resolveDependencyEndpoints(
|
||||||
let toItemId = toId
|
edgeType,
|
||||||
if (edgeType === 'requires') {
|
sourceId,
|
||||||
fromItemId = toId
|
targetId,
|
||||||
toItemId = fromId
|
)
|
||||||
}
|
const body = {
|
||||||
await addRoadmapItemDependency(fromItemId, {
|
|
||||||
to_item_id: toItemId,
|
to_item_id: toItemId,
|
||||||
dependency_type: edgeType,
|
dependency_type: edgeType,
|
||||||
})
|
}
|
||||||
|
if (edgeKindNeedsGroupKey(edgeType)) {
|
||||||
|
body.group_key = groupKey.trim()
|
||||||
|
}
|
||||||
|
await addRoadmapItemDependency(fromItemId, body)
|
||||||
await notifyGraphChanged()
|
await notifyGraphChanged()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err?.message || 'Kante konnte nicht angelegt werden.')
|
setError(err?.message || 'Kante konnte nicht angelegt werden.')
|
||||||
|
|
@ -448,6 +457,20 @@ export function GateGraphDesigner({
|
||||||
</label>
|
</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">
|
<div className="gate-graph-designer__zoom-group" role="group" aria-label="Zoom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -493,8 +516,9 @@ export function GateGraphDesigner({
|
||||||
|
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<p className="gate-graph-designer__hint muted">
|
<p className="gate-graph-designer__hint muted">
|
||||||
Vom unteren Punkt eines Gates zur Zielkante ziehen. Doppelklick auf leere Fläche legt ein
|
Vom unteren Punkt ziehen: Voraussetzung/Block/Bezug/Parallel/Optional. Join-Gates
|
||||||
Gate an. Klick auf Gate öffnet das Info-Panel.
|
verbinden mehrere Pflicht-Voraussetzungen (AND). Doppelklick auf leere Fläche legt ein
|
||||||
|
Gate an.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -627,6 +651,7 @@ export function GateGraphDesigner({
|
||||||
{selectedItem && (
|
{selectedItem && (
|
||||||
<GateDesignerNodePanel
|
<GateDesignerNodePanel
|
||||||
item={selectedItem}
|
item={selectedItem}
|
||||||
|
graphState={graphState}
|
||||||
criteriaProgress={criteriaProgress}
|
criteriaProgress={criteriaProgress}
|
||||||
onClose={() => setSelectedNodeId(null)}
|
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" />
|
<span className="gate-map-view__legend-line gate-map-view__legend-line--related" />
|
||||||
Bezug
|
Bezug
|
||||||
</span>
|
</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>
|
||||||
|
|
||||||
<div className="gate-map-view__canvas-wrap">
|
<div className="gate-map-view__canvas-wrap">
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,7 @@ export function GatesPlanPanel({
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onCreate={canManage ? onCreate : undefined}
|
onCreate={canManage ? onCreate : undefined}
|
||||||
onGraphChanged={refreshGraphState}
|
onGraphChanged={refreshGraphState}
|
||||||
|
graphState={graphState}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,11 @@ export function computeGateGraphLayout(items, dependencies) {
|
||||||
y: node.y + node.height / 2,
|
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 from = center(sourceNode)
|
||||||
const to = center(targetNode)
|
const to = center(targetNode)
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
/**
|
/**
|
||||||
* Kantentypen für Zielzustands-Graph — AP1.15c erweitert um Topologie.
|
* Kantentypen für Zielzustands-Graph — AP1.15c Topologie.
|
||||||
* Siehe ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md § Join/Branch deferred.
|
* OR-Alternativpfade bewusst nicht enthalten (PO-Deferral).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Im Designer AP1.15a/b nutzbar */
|
/** Basis-Kanten im Designer */
|
||||||
export const DESIGNER_EDGE_KINDS = ['requires', 'blocks', 'related']
|
export const DESIGNER_EDGE_KINDS = [
|
||||||
|
'requires',
|
||||||
|
'blocks',
|
||||||
|
'related',
|
||||||
|
'parallel_group',
|
||||||
|
'optional_branch',
|
||||||
|
]
|
||||||
|
|
||||||
/** AP1.15c — parallel_group, optional_branch; ggf. OR später */
|
/** Erfordert group_key bei Anlage */
|
||||||
export const DEFERRED_TOPOLOGY_EDGE_KINDS = ['parallel_group', 'optional_branch']
|
export const EDGE_KINDS_REQUIRING_GROUP_KEY = ['parallel_group']
|
||||||
|
|
||||||
export const DESIGNER_EDGE_LABELS = {
|
export const DESIGNER_EDGE_LABELS = {
|
||||||
requires: 'Voraussetzung',
|
requires: 'Voraussetzung',
|
||||||
|
|
@ -16,3 +22,21 @@ export const DESIGNER_EDGE_LABELS = {
|
||||||
parallel_group: 'Parallelgruppe',
|
parallel_group: 'Parallelgruppe',
|
||||||
optional_branch: 'Optional',
|
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 {
|
import {
|
||||||
DEFERRED_TOPOLOGY_EDGE_KINDS,
|
DEFERRED_TOPOLOGY_EDGE_KINDS,
|
||||||
DESIGNER_EDGE_KINDS,
|
DESIGNER_EDGE_KINDS,
|
||||||
|
edgeKindNeedsGroupKey,
|
||||||
|
resolveDependencyEndpoints,
|
||||||
} from './gateGraphTopology.js'
|
} from './gateGraphTopology.js'
|
||||||
|
|
||||||
describe('gateGraphTopology', () => {
|
describe('gateGraphTopology', () => {
|
||||||
it('keeps designer and deferred edge kinds separate', () => {
|
it('includes topology kinds in designer', () => {
|
||||||
expect(DESIGNER_EDGE_KINDS).toContain('requires')
|
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) {
|
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;
|
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 {
|
.gate-map-view__canvas-wrap {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid var(--jk-border, #dde3ea);
|
border: 1px solid var(--jk-border, #dde3ea);
|
||||||
|
|
@ -1727,6 +1737,16 @@
|
||||||
stroke-dasharray: 6 4;
|
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 {
|
.gate-map-view__arrow--requires {
|
||||||
fill: #2563eb;
|
fill: #2563eb;
|
||||||
}
|
}
|
||||||
|
|
@ -1963,6 +1983,11 @@
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gate-designer-node-panel__topology {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
.gate-designer-node-panel__actions {
|
.gate-designer-node-panel__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user