# 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) )