352 lines
13 KiB
Python
352 lines
13 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
Tests for the Schicht-3 Adapter Validator (Phase 3).
|
|
|
|
Validates the 5 drift rules between Editor-Node Adapters and the
|
|
Schicht-2 Actions they bind to:
|
|
|
|
Rule 1: every userParams.actionArg exists in the Action
|
|
Rule 2: every required Action arg is covered (userParams or contextParams)
|
|
Rule 3: every Action parameter type exists in PORT_TYPE_CATALOG
|
|
Rule 4: Action outputType exists in PORT_TYPE_CATALOG
|
|
Rule 5: every Action with dynamicMode=False has an Editor adapter
|
|
|
|
Plus a healthy-state test that runs the validator against the live
|
|
STATIC_NODE_TYPES + every shipping Method instance, and asserts no drift.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import sys
|
|
import types
|
|
|
|
import pytest
|
|
|
|
from modules.datamodels.datamodelWorkflowActions import (
|
|
WorkflowActionDefinition,
|
|
WorkflowActionParameter,
|
|
)
|
|
from modules.features.graphicalEditor.adapterValidator import (
|
|
AdapterValidationReport,
|
|
_buildActionsRegistryFromMethods,
|
|
_formatAdapterReport,
|
|
_validateAdapterAgainstAction,
|
|
_validateAllAdapters,
|
|
)
|
|
from modules.features.graphicalEditor.nodeAdapter import (
|
|
NodeAdapter,
|
|
UserParamMapping,
|
|
)
|
|
from modules.shared.frontendTypes import FrontendType
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test factories
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _makeParam(typeStr: str, *, required: bool = False, **kwargs) -> WorkflowActionParameter:
|
|
defaults = {
|
|
"name": "p",
|
|
"type": typeStr,
|
|
"frontendType": FrontendType.TEXT,
|
|
"required": required,
|
|
"description": "",
|
|
}
|
|
defaults.update(kwargs)
|
|
return WorkflowActionParameter(**defaults)
|
|
|
|
|
|
def _makeAction(
|
|
actionId: str = "trustee.processDocuments",
|
|
parameters: dict | None = None,
|
|
outputType: str = "TrusteeProcessResult",
|
|
dynamicMode: bool = False,
|
|
) -> WorkflowActionDefinition:
|
|
return WorkflowActionDefinition(
|
|
actionId=actionId,
|
|
description="t",
|
|
parameters=parameters or {},
|
|
outputType=outputType,
|
|
dynamicMode=dynamicMode,
|
|
execute=lambda *a, **k: None,
|
|
)
|
|
|
|
|
|
def _makeAdapter(
|
|
*,
|
|
userArgs: list[str] | None = None,
|
|
contextArgs: list[str] | None = None,
|
|
) -> NodeAdapter:
|
|
return NodeAdapter(
|
|
nodeId="trustee.processDocuments",
|
|
bindsAction="trustee.processDocuments",
|
|
category="trustee",
|
|
label="Verarbeiten",
|
|
description="...",
|
|
userParams=[UserParamMapping(actionArg=a) for a in (userArgs or [])],
|
|
contextParams={k: f"$session.{k}" for k in (contextArgs or [])},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Per-rule unit tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRule1_UserParamArgExistsInAction:
|
|
def test_okWhenAllArgsExist(self):
|
|
action = _makeAction(parameters={
|
|
"documentList": _makeParam("DocumentList", required=True),
|
|
"featureInstanceId": _makeParam("FeatureInstanceRef", required=True),
|
|
})
|
|
adapter = _makeAdapter(userArgs=["documentList", "featureInstanceId"])
|
|
report = _validateAdapterAgainstAction(adapter, action)
|
|
assert report.isHealthy, report.errors
|
|
|
|
def test_failsWhenAdapterReferencesUnknownArg(self):
|
|
action = _makeAction(parameters={"documentList": _makeParam("DocumentList", required=True),
|
|
"featureInstanceId": _makeParam("FeatureInstanceRef", required=True)})
|
|
adapter = _makeAdapter(userArgs=["documentList", "featureInstanceId", "ghostArg"])
|
|
report = _validateAdapterAgainstAction(adapter, action)
|
|
assert any("ghostArg" in e for e in report.errors)
|
|
|
|
|
|
class TestRule2_RequiredArgsCovered:
|
|
def test_failsWhenRequiredArgMissing(self):
|
|
action = _makeAction(parameters={
|
|
"documentList": _makeParam("DocumentList", required=True),
|
|
"featureInstanceId": _makeParam("FeatureInstanceRef", required=True),
|
|
})
|
|
adapter = _makeAdapter(userArgs=["documentList"]) # missing featureInstanceId
|
|
report = _validateAdapterAgainstAction(adapter, action)
|
|
assert any("featureInstanceId" in e for e in report.errors)
|
|
|
|
def test_okWhenRequiredArgInContext(self):
|
|
action = _makeAction(parameters={
|
|
"documentList": _makeParam("DocumentList", required=True),
|
|
"mandateId": _makeParam("str", required=True),
|
|
})
|
|
adapter = _makeAdapter(userArgs=["documentList"], contextArgs=["mandateId"])
|
|
report = _validateAdapterAgainstAction(adapter, action)
|
|
assert report.isHealthy, report.errors
|
|
|
|
def test_optionalArgMayBeUnset(self):
|
|
action = _makeAction(parameters={
|
|
"documentList": _makeParam("DocumentList", required=True),
|
|
"prompt": _makeParam("str", required=False),
|
|
})
|
|
adapter = _makeAdapter(userArgs=["documentList"])
|
|
report = _validateAdapterAgainstAction(adapter, action)
|
|
assert report.isHealthy, report.errors
|
|
|
|
|
|
class TestRule3_ActionParamTypesInCatalog:
|
|
def test_failsForUnknownType(self):
|
|
action = _makeAction(parameters={"documentList": _makeParam("Foobar", required=True)})
|
|
adapter = _makeAdapter(userArgs=["documentList"])
|
|
report = _validateAdapterAgainstAction(adapter, action)
|
|
assert any("Foobar" in e for e in report.errors)
|
|
|
|
|
|
class TestRule4_OutputTypeInCatalog:
|
|
def test_failsForUnknownOutputType(self):
|
|
action = _makeAction(outputType="Nonsense")
|
|
adapter = _makeAdapter()
|
|
report = _validateAdapterAgainstAction(adapter, action)
|
|
assert any("Nonsense" in e for e in report.errors)
|
|
|
|
def test_okForActionResult(self):
|
|
action = _makeAction(outputType="ActionResult")
|
|
adapter = _makeAdapter()
|
|
report = _validateAdapterAgainstAction(adapter, action)
|
|
assert report.isHealthy, report.errors
|
|
|
|
|
|
class TestRule5_OrphanActionsAcrossRegistry:
|
|
def test_warnsForActionWithoutAdapter(self):
|
|
action = _makeAction(actionId="trustee.queryData")
|
|
registry = {"trustee": {"queryData": action}}
|
|
report = _validateAllAdapters([], registry)
|
|
assert any("trustee.queryData" in w for w in report.warnings)
|
|
|
|
def test_dynamicModeActionDoesNotWarn(self):
|
|
action = _makeAction(actionId="trustee.queryData", dynamicMode=True)
|
|
registry = {"trustee": {"queryData": action}}
|
|
report = _validateAllAdapters([], registry)
|
|
assert report.warnings == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Aggregator + report formatter
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestValidateAllAdapters:
|
|
def test_passesWithFullCoverage(self):
|
|
node = {
|
|
"id": "trustee.processDocuments",
|
|
"category": "trustee",
|
|
"label": "X", "description": "Y",
|
|
"parameters": [
|
|
{"name": "documentList", "type": "DocumentList"},
|
|
{"name": "featureInstanceId", "type": "FeatureInstanceRef"},
|
|
],
|
|
"inputs": 1, "outputs": 1,
|
|
"inputPorts": {0: {"accepts": ["DocumentList"]}},
|
|
"_method": "trustee", "_action": "processDocuments",
|
|
}
|
|
action = _makeAction(parameters={
|
|
"documentList": _makeParam("DocumentList", required=True),
|
|
"featureInstanceId": _makeParam("FeatureInstanceRef", required=True),
|
|
})
|
|
registry = {"trustee": {"processDocuments": action}}
|
|
report = _validateAllAdapters([node], registry)
|
|
assert report.isHealthy, report.errors
|
|
|
|
def test_reportsMissingAction(self):
|
|
node = {
|
|
"id": "trustee.processDocuments",
|
|
"_method": "trustee", "_action": "ghostAction",
|
|
"parameters": [], "inputs": 0,
|
|
}
|
|
report = _validateAllAdapters([node], {"trustee": {}})
|
|
assert any("ghostAction" in e for e in report.errors)
|
|
|
|
|
|
class TestFormatReport:
|
|
def test_healthy(self):
|
|
out = _formatAdapterReport(AdapterValidationReport())
|
|
assert "healthy" in out.lower()
|
|
|
|
def test_withErrorsAndWarnings(self):
|
|
rep = AdapterValidationReport(errors=["e1"], warnings=["w1"])
|
|
out = _formatAdapterReport(rep)
|
|
assert "ERROR" in out and "WARN" in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Healthy-state: live methods + STATIC_NODE_TYPES
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _NullRbac:
|
|
def getUserPermissions(self, **kwargs):
|
|
class _P:
|
|
view = read = create = update = delete = True
|
|
return _P()
|
|
|
|
|
|
class _StubServices:
|
|
def __init__(self):
|
|
self.rbac = _NullRbac()
|
|
self.user = type("U", (), {"id": "test-user", "roleLabels": []})()
|
|
self.mandateId = None
|
|
self.featureInstanceId = None
|
|
|
|
|
|
def _ensureOptionalDeps():
|
|
class _AnyAttrModule(types.ModuleType):
|
|
def __getattr__(self, name):
|
|
return type(name, (), {})
|
|
|
|
for name in ("aiohttp",):
|
|
if name not in sys.modules:
|
|
sys.modules[name] = _AnyAttrModule(name)
|
|
|
|
|
|
_LIVE_METHODS = [
|
|
("modules.workflows.methods.methodTrustee.methodTrustee", "MethodTrustee", "trustee"),
|
|
("modules.workflows.methods.methodRedmine.methodRedmine", "MethodRedmine", "redmine"),
|
|
("modules.workflows.methods.methodSharepoint.methodSharepoint", "MethodSharepoint", "sharepoint"),
|
|
("modules.workflows.methods.methodOutlook.methodOutlook", "MethodOutlook", "outlook"),
|
|
("modules.workflows.methods.methodAi.methodAi", "MethodAi", "ai"),
|
|
("modules.workflows.methods.methodClickup.methodClickup", "MethodClickup", "clickup"),
|
|
("modules.workflows.methods.methodFile.methodFile", "MethodFile", "file"),
|
|
("modules.workflows.methods.methodContext.methodContext", "MethodContext", "context"),
|
|
]
|
|
|
|
|
|
def _instantiateLiveMethods() -> dict:
|
|
"""Best-effort instantiation of every shipping Method with stub services.
|
|
|
|
Returns {shortName: instance}. Methods that can't be instantiated in the
|
|
test env (missing dependencies) are skipped silently — Phase 2 has its
|
|
own healthy-state test that catches per-method drift.
|
|
"""
|
|
_ensureOptionalDeps()
|
|
out: dict = {}
|
|
for modulePath, className, shortName in _LIVE_METHODS:
|
|
try:
|
|
module = importlib.import_module(modulePath)
|
|
cls = getattr(module, className, None)
|
|
if cls is None:
|
|
continue
|
|
instance = cls(_StubServices())
|
|
out[shortName] = instance
|
|
except Exception:
|
|
continue
|
|
return out
|
|
|
|
|
|
# Snapshot of pre-Phase-3 drift discovered when the validator was first run
|
|
# against the live STATIC_NODE_TYPES + live Method registry.
|
|
#
|
|
# After Phase-4 Adapter-Drift-Cleanup (Plan #4) this set is intentionally
|
|
# empty: every Editor adapter must align cleanly with its Schicht-2 Action,
|
|
# and the regression net below now uses `assert report.errors == []`.
|
|
#
|
|
# History of removed drifts:
|
|
# wiki/c-work/4-done/2026-04-adapter-drift-cleanup.md
|
|
#
|
|
# Rule: this set MUST stay empty. New drift => fix the adapter or the action,
|
|
# not the snapshot.
|
|
_KNOWN_ADAPTER_DRIFTS: frozenset[tuple[str, str]] = frozenset()
|
|
|
|
|
|
def _extractDriftKey(errorMessage: str) -> tuple[str, str] | None:
|
|
"""Parse a validator error message into a (nodeId, fieldName) drift key.
|
|
|
|
Recognises both rule-1 ("userParams.actionArg 'X' does not exist…") and
|
|
rule-2 ("required action arg 'X' is neither in userParams…") patterns.
|
|
"""
|
|
import re
|
|
m = re.search(
|
|
r"adapter '([^']+)' bindsAction '[^']+': userParams\.actionArg '([^']+)'",
|
|
errorMessage,
|
|
)
|
|
if m:
|
|
return (m.group(1), m.group(2))
|
|
m = re.search(
|
|
r"adapter '([^']+)' bindsAction '[^']+': required action arg '([^']+)'",
|
|
errorMessage,
|
|
)
|
|
if m:
|
|
return (m.group(1), m.group(2))
|
|
return None
|
|
|
|
|
|
def test_staticNodesHaveNoDriftAgainstLiveMethods():
|
|
"""Strict regression: every Editor adapter in STATIC_NODE_TYPES must align
|
|
with its Schicht-2 Action signature.
|
|
|
|
Phase 3 shipped the validator with a tracked drift snapshot
|
|
(`_KNOWN_ADAPTER_DRIFTS`); Phase 4 cleaned the backlog so the snapshot is
|
|
empty and we now demand zero errors. Any new drift fails immediately —
|
|
fix the adapter or the action, never the assertion.
|
|
|
|
History: wiki/c-work/4-done/2026-04-adapter-drift-cleanup.md
|
|
"""
|
|
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
|
|
|
instances = _instantiateLiveMethods()
|
|
if not instances:
|
|
pytest.skip("no methods could be instantiated in this test env")
|
|
|
|
registry = _buildActionsRegistryFromMethods(instances)
|
|
report = _validateAllAdapters(list(STATIC_NODE_TYPES), registry)
|
|
|
|
assert _KNOWN_ADAPTER_DRIFTS == frozenset(), (
|
|
"_KNOWN_ADAPTER_DRIFTS must stay empty after Phase-4 cleanup. "
|
|
"Do not add new entries — fix the drift instead."
|
|
)
|
|
assert report.errors == [], (
|
|
"Adapter↔Action drift detected:\n" + "\n".join(report.errors)
|
|
)
|