""" Tests de execution_plan_runner — adaptateur ExecutionPlan → queue de replay. Vérifie que : - Un ExecutionNode est correctement converti en action replay - Les stratégies de résolution (OCR / template / VLM) produisent le bon target_spec - Les variables {var} et ${var} sont substituées dans les textes - L'injection dans la queue _replay_queues est correcte (avec et sans lock) - La conversion d'un plan complet respecte l'ordre et les limites - Les types d'actions non exécutables sont ignorés Ces tests sont isolés et ne dépendent pas du serveur FastAPI (on importe uniquement execution_plan_runner et les dataclasses du core). """ import sys import threading from collections import defaultdict from pathlib import Path import pytest _ROOT = str(Path(__file__).resolve().parents[2]) if _ROOT not in sys.path: sys.path.insert(0, _ROOT) from core.workflow.execution_plan import ( ExecutionNode, ExecutionPlan, ResolutionStrategy, SuccessCondition, ) from core.workflow.execution_compiler import ExecutionCompiler from core.workflow.workflow_ir import WorkflowIR from agent_v0.server_v1.execution_plan_runner import ( execution_node_to_action, execution_plan_to_actions, inject_plan_into_queue, substitute_variables, ) # ========================================================================= # Substitution de variables # ========================================================================= class TestSubstituteVariables: def test_substitution_curly(self): assert substitute_variables("{nom}", {"nom": "Dupont"}) == "Dupont" def test_substitution_dollar(self): assert substitute_variables("${nom}", {"nom": "Dupont"}) == "Dupont" def test_substitution_dans_phrase(self): assert ( substitute_variables("Bonjour {nom}, votre code est ${code}", {"nom": "Alice", "code": "A42"}) == "Bonjour Alice, votre code est A42" ) def test_variable_inconnue_inchangee(self): # Une variable inconnue reste dans le texte (pas de KeyError) assert substitute_variables("{inconnu}", {"autre": "val"}) == "{inconnu}" def test_texte_sans_variable(self): assert substitute_variables("texte simple", {"x": "1"}) == "texte simple" def test_texte_vide(self): assert substitute_variables("", {"x": "1"}) == "" def test_variables_vides(self): assert substitute_variables("{x}", {}) == "{x}" # ========================================================================= # Conversion ExecutionNode → action replay # ========================================================================= class TestExecutionNodeToAction: def test_click_avec_strategie_ocr(self): """Un clic avec stratégie OCR produit une action click visuelle avec by_text.""" node = ExecutionNode( node_id="n1", action_type="click", intent="Cliquer sur Enregistrer", strategy_primary=ResolutionStrategy( method="ocr", target_text="Enregistrer", threshold=0.8, ), ) action = execution_node_to_action(node) assert action is not None assert action["type"] == "click" assert action["action_id"].startswith("act_plan_") assert action["plan_node_id"] == "n1" assert action["intention"] == "Cliquer sur Enregistrer" assert action["visual_mode"] is True assert "x_pct" in action and "y_pct" in action assert action["target_spec"]["by_text"] == "Enregistrer" def test_click_avec_strategie_template(self): """Un clic avec stratégie template expose l'anchor_image_base64.""" node = ExecutionNode( node_id="n2", action_type="click", strategy_primary=ResolutionStrategy( method="template", anchor_b64="AAABBBCCCDDD", target_text="Ouvrir", ), ) action = execution_node_to_action(node) assert action is not None assert action["type"] == "click" assert action["target_spec"]["anchor_image_base64"] == "AAABBBCCCDDD" assert action["target_spec"]["by_text"] == "Ouvrir" assert action["visual_mode"] is True def test_click_avec_strategie_vlm(self): """Un clic avec stratégie VLM expose vlm_description.""" node = ExecutionNode( node_id="n3", action_type="click", strategy_primary=ResolutionStrategy( method="vlm", vlm_description="bouton rouge en haut à droite", ), ) action = execution_node_to_action(node) assert action is not None assert action["target_spec"]["vlm_description"] == "bouton rouge en haut à droite" assert action["visual_mode"] is True def test_click_avec_fallbacks_ajoute_hints(self): """Les fallbacks enrichissent le target_spec avec toutes les ancres disponibles.""" node = ExecutionNode( node_id="n4", action_type="click", intent="Ouvrir le menu", strategy_primary=ResolutionStrategy(method="ocr", target_text="Menu"), strategy_fallbacks=[ ResolutionStrategy( method="template", anchor_b64="XYZ", target_text="Menu", ), ResolutionStrategy( method="vlm", vlm_description="menu déroulant", ), ], ) action = execution_node_to_action(node) spec = action["target_spec"] assert spec["by_text"] == "Menu" assert spec["anchor_image_base64"] == "XYZ" assert spec["vlm_description"] == "menu déroulant" def test_click_avec_success_condition_expected_title(self): """La success_condition avec expected_title passe dans expected_window_title.""" node = ExecutionNode( node_id="n5", action_type="click", strategy_primary=ResolutionStrategy(method="ocr", target_text="OK"), success_condition=SuccessCondition( method="title_match", expected_title="Document sauvegardé", ), ) action = execution_node_to_action(node) assert action["expected_window_title"] == "Document sauvegardé" assert action["target_spec"]["window_title"] == "Document sauvegardé" def test_type_avec_variable_substitution(self): """Un node type avec variable {patient} est substitué.""" node = ExecutionNode( node_id="n6", action_type="type", text="{patient}", variable_name="patient", ) action = execution_node_to_action(node, variables={"patient": "DUPONT"}) assert action["type"] == "type" assert action["text"] == "DUPONT" assert action["variable_name"] == "patient" def test_type_sans_variable(self): """Un texte sans variable est inchangé.""" node = ExecutionNode( node_id="n7", action_type="type", text="Bonjour", ) action = execution_node_to_action(node) assert action["text"] == "Bonjour" def test_key_combo(self): """Un key_combo expose les touches.""" node = ExecutionNode( node_id="n8", action_type="key_combo", keys=["ctrl", "s"], ) action = execution_node_to_action(node) assert action["type"] == "key_combo" assert action["keys"] == ["ctrl", "s"] def test_key_combo_vide_retourne_none(self): """Un key_combo sans touches est ignoré.""" node = ExecutionNode( node_id="n9", action_type="key_combo", keys=[], ) assert execution_node_to_action(node) is None def test_wait(self): """Un wait expose duration_ms.""" node = ExecutionNode( node_id="n10", action_type="wait", duration_ms=2500, ) action = execution_node_to_action(node) assert action["type"] == "wait" assert action["duration_ms"] == 2500 def test_wait_sans_duration_default(self): """Un wait sans duration a un défaut de 1000ms.""" node = ExecutionNode(node_id="n11", action_type="wait") action = execution_node_to_action(node) assert action["duration_ms"] == 1000 def test_scroll(self): """Un scroll produit une action scroll.""" node = ExecutionNode(node_id="n12", action_type="scroll") action = execution_node_to_action(node) assert action["type"] == "scroll" assert "delta" in action def test_type_inconnu_retourne_none(self): """Un type d'action inconnu est ignoré (retourne None).""" node = ExecutionNode(node_id="n13", action_type="unknown_thing") assert execution_node_to_action(node) is None def test_metadonnees_execution_propagees(self): """timeout_ms, max_retries, recovery_action passent dans l'action.""" node = ExecutionNode( node_id="n14", action_type="click", strategy_primary=ResolutionStrategy(method="ocr", target_text="X"), timeout_ms=15000, max_retries=3, recovery_action="undo", ) action = execution_node_to_action(node) assert action["timeout_ms"] == 15000 assert action["max_retries"] == 3 assert action["recovery_action"] == "undo" def test_node_optionnel(self): """is_optional est propagé.""" node = ExecutionNode( node_id="n15", action_type="click", strategy_primary=ResolutionStrategy(method="ocr", target_text="X"), is_optional=True, ) action = execution_node_to_action(node) assert action["is_optional"] is True def test_id_prefix_custom(self): """Le préfixe d'id peut être personnalisé.""" node = ExecutionNode( node_id="n16", action_type="click", strategy_primary=ResolutionStrategy(method="ocr", target_text="X"), ) action = execution_node_to_action(node, id_prefix="act_custom") assert action["action_id"].startswith("act_custom_") # ========================================================================= # Conversion ExecutionPlan → liste d'actions # ========================================================================= class TestExecutionPlanToActions: def _make_plan(self) -> ExecutionPlan: plan = ExecutionPlan( plan_id="plan_test", workflow_id="wf_test", version=1, variables={"nom_fichier": "rapport.pdf"}, ) plan.nodes = [ ExecutionNode( node_id="n1", action_type="click", strategy_primary=ResolutionStrategy(method="ocr", target_text="Ouvrir"), ), ExecutionNode( node_id="n2", action_type="type", text="{nom_fichier}", variable_name="nom_fichier", ), ExecutionNode( node_id="n3", action_type="key_combo", keys=["enter"], ), ExecutionNode( node_id="n4", action_type="wait", duration_ms=1500, ), ] plan.total_nodes = 4 return plan def test_conversion_ordre_respecte(self): plan = self._make_plan() actions = execution_plan_to_actions(plan) assert len(actions) == 4 assert actions[0]["type"] == "click" assert actions[1]["type"] == "type" assert actions[2]["type"] == "key_combo" assert actions[3]["type"] == "wait" def test_variables_du_plan_appliquees(self): plan = self._make_plan() actions = execution_plan_to_actions(plan) type_action = next(a for a in actions if a["type"] == "type") assert type_action["text"] == "rapport.pdf" def test_variables_override(self): """Les variables passées en argument écrasent celles du plan.""" plan = self._make_plan() actions = execution_plan_to_actions( plan, variables={"nom_fichier": "facture.pdf"}, ) type_action = next(a for a in actions if a["type"] == "type") assert type_action["text"] == "facture.pdf" def test_plan_vide(self): plan = ExecutionPlan(plan_id="empty", workflow_id="wf_empty") actions = execution_plan_to_actions(plan) assert actions == [] def test_noeud_non_convertible_ignore(self): """Un nœud inconnu ne bloque pas la conversion.""" plan = ExecutionPlan(plan_id="p", workflow_id="wf") plan.nodes = [ ExecutionNode( node_id="n1", action_type="click", strategy_primary=ResolutionStrategy(method="ocr", target_text="OK"), ), ExecutionNode(node_id="n2", action_type="unknown_type"), ExecutionNode( node_id="n3", action_type="type", text="hello", ), ] actions = execution_plan_to_actions(plan) assert len(actions) == 2 assert actions[0]["type"] == "click" assert actions[1]["type"] == "type" # ========================================================================= # Injection dans la queue de replay # ========================================================================= class TestInjectPlanIntoQueue: def _make_simple_plan(self) -> ExecutionPlan: plan = ExecutionPlan(plan_id="p_inj", workflow_id="wf_inj") plan.nodes = [ ExecutionNode( node_id="n1", action_type="click", strategy_primary=ResolutionStrategy(method="ocr", target_text="Go"), ), ExecutionNode(node_id="n2", action_type="wait", duration_ms=500), ] return plan def test_injection_replace(self): """Par défaut, la queue est remplacée.""" plan = self._make_simple_plan() queues: dict = defaultdict(list) queues["sess_abc"] = [{"type": "click", "action_id": "old"}] actions = inject_plan_into_queue( plan=plan, session_id="sess_abc", replay_queues=queues, ) assert len(actions) == 2 assert len(queues["sess_abc"]) == 2 # L'ancienne action a été remplacée assert all(a["action_id"] != "old" for a in queues["sess_abc"]) def test_injection_append(self): """Avec replace=False, on ajoute aux actions existantes.""" plan = self._make_simple_plan() queues: dict = defaultdict(list) queues["sess_abc"] = [{"type": "click", "action_id": "existing"}] inject_plan_into_queue( plan=plan, session_id="sess_abc", replay_queues=queues, replace=False, ) assert len(queues["sess_abc"]) == 3 assert queues["sess_abc"][0]["action_id"] == "existing" def test_injection_avec_lock(self): """Le lock est respecté pendant l'injection.""" plan = self._make_simple_plan() queues: dict = defaultdict(list) lock = threading.Lock() actions = inject_plan_into_queue( plan=plan, session_id="sess_x", replay_queues=queues, lock=lock, ) assert len(actions) == 2 assert len(queues["sess_x"]) == 2 # Le lock est bien libéré après l'injection assert lock.acquire(blocking=False) is True lock.release() def test_injection_avec_variables(self): """Les variables sont substituées lors de l'injection.""" plan = ExecutionPlan(plan_id="p_var", workflow_id="wf_var") plan.nodes = [ ExecutionNode( node_id="n1", action_type="type", text="{patient}", variable_name="patient", ), ] queues: dict = defaultdict(list) actions = inject_plan_into_queue( plan=plan, session_id="sess_v", replay_queues=queues, variables={"patient": "MARTIN"}, ) assert actions[0]["text"] == "MARTIN" assert queues["sess_v"][0]["text"] == "MARTIN" # ========================================================================= # Intégration : pipeline complet IR → Plan → Actions # ========================================================================= class TestFullPipelineV4: """Teste le pipeline complet : WorkflowIR → ExecutionPlan → actions replay.""" def test_pipeline_complet_ir_vers_actions(self): # 1. Construire un WorkflowIR ir = WorkflowIR.new("Test pipeline V4", domain="generic") ir.add_step( "Ouvrir le fichier", actions=[ {"type": "click", "target": "bouton Ouvrir", "anchor_hint": "Ouvrir"}, {"type": "wait", "duration_ms": 1000}, ], postcondition="La fenêtre Ouvrir est visible", ) ir.add_step( "Saisir le nom", actions=[ {"type": "type", "text": "{nom_fichier}", "variable": True}, {"type": "key_combo", "keys": ["enter"]}, ], ) ir.add_variable("nom_fichier", description="Fichier", default="doc.pdf") # 2. Compiler → ExecutionPlan compiler = ExecutionCompiler() plan = compiler.compile(ir) assert plan.total_nodes == 4 # 3. Convertir → actions replay actions = execution_plan_to_actions(plan) assert len(actions) == 4 types = [a["type"] for a in actions] assert types == ["click", "wait", "type", "key_combo"] # Le clic a une stratégie OCR → by_text click = actions[0] assert click["visual_mode"] is True assert click["target_spec"].get("by_text") == "Ouvrir" # Le type a substitué la variable depuis le plan type_action = actions[2] assert type_action["text"] == "doc.pdf" # Le key_combo a les touches assert actions[3]["keys"] == ["enter"] def test_pipeline_avec_params_override(self): """Les params passés à l'injection prévalent sur le plan.""" ir = WorkflowIR.new("Variables override") ir.add_step("Saisie", actions=[ {"type": "type", "text": "{code}", "variable": True}, ]) ir.add_variable("code", default="DEFAULT") compiler = ExecutionCompiler() plan = compiler.compile(ir) actions = execution_plan_to_actions( plan, variables={"code": "RUNTIME"}, ) assert actions[0]["text"] == "RUNTIME" def test_pipeline_plan_serialise_et_recharge(self): """Le plan peut être sérialisé/rechargé puis converti en actions.""" ir = WorkflowIR.new("Roundtrip") ir.add_step("X", actions=[ {"type": "click", "target": "btn", "anchor_hint": "Valider"}, ]) compiler = ExecutionCompiler() plan = compiler.compile(ir) json_str = plan.to_json() plan2 = ExecutionPlan.from_json(json_str) actions = execution_plan_to_actions(plan2) assert len(actions) == 1 assert actions[0]["type"] == "click"