""" Tests du WorkflowIR et de l'IRBuilder. Vérifie que : - Le format WorkflowIR est correct (sérialisation, désérialisation, versioning) - L'IRBuilder segmente et comprend les traces brutes - Les variables sont détectées et substituables - Le tout fonctionne sans gemma4 (fallback gracieux) """ import json import shutil import sys import tempfile from pathlib import Path from unittest.mock import MagicMock, patch import pytest _ROOT = str(Path(__file__).resolve().parents[2]) if _ROOT not in sys.path: sys.path.insert(0, _ROOT) from core.workflow.workflow_ir import WorkflowIR, Step, Action, Variable from core.workflow.ir_builder import IRBuilder # ========================================================================= # WorkflowIR — format et sérialisation # ========================================================================= class TestWorkflowIR: def test_creation_vide(self): ir = WorkflowIR.new("Test workflow") assert ir.workflow_id.startswith("wf_") assert ir.version == 1 assert ir.name == "Test workflow" assert ir.steps == [] assert ir.variables == [] def test_ajout_etapes(self): ir = WorkflowIR.new("Test") ir.add_step("Ouvrir l'application", actions=[ {"type": "click", "target": "icône app"}, {"type": "wait", "duration_ms": 2000}, ]) ir.add_step("Saisir les données", actions=[ {"type": "type", "text": "bonjour"}, ]) assert len(ir.steps) == 2 assert ir.steps[0].intent == "Ouvrir l'application" assert len(ir.steps[0].actions) == 2 assert ir.steps[0].actions[0].type == "click" def test_ajout_variables(self): ir = WorkflowIR.new("Test") ir.add_variable("patient", description="Nom du patient", source="screen") ir.add_variable("code", description="Code à saisir", default="A00.0") assert len(ir.variables) == 2 assert ir.variables[0].name == "patient" assert ir.variables[1].default == "A00.0" def test_serialisation_json(self): ir = WorkflowIR.new("Mon workflow", domain="tim_codage") ir.add_step("Étape 1") ir.add_variable("var1", description="Une variable") json_str = ir.to_json() data = json.loads(json_str) assert data["name"] == "Mon workflow" assert data["domain"] == "tim_codage" assert len(data["steps"]) == 1 assert len(data["variables"]) == 1 def test_deserialisation_json(self): ir = WorkflowIR.new("Test roundtrip") ir.add_step("Ouvrir", actions=[{"type": "click", "target": "bouton"}]) ir.add_variable("v1", description="test") json_str = ir.to_json() ir2 = WorkflowIR.from_json(json_str) assert ir2.name == "Test roundtrip" assert len(ir2.steps) == 1 assert ir2.steps[0].intent == "Ouvrir" assert ir2.steps[0].actions[0].type == "click" assert len(ir2.variables) == 1 def test_save_et_load(self): tmpdir = tempfile.mkdtemp() try: ir = WorkflowIR.new("Save test") ir.add_step("Étape 1") path = ir.save(tmpdir) assert path.is_file() ir2 = WorkflowIR.load(str(path)) assert ir2.name == "Save test" assert len(ir2.steps) == 1 finally: shutil.rmtree(tmpdir) def test_increment_version(self): ir = WorkflowIR.new("Versionning") assert ir.version == 1 ir2 = ir.increment_version() assert ir2.version == 2 assert ir.version == 1 # Original inchangé assert ir2.name == "Versionning" def test_domaine_generique(self): """Le WorkflowIR est générique — pas lié à un métier.""" for domain in ["tim_codage", "comptabilite", "rh_paie", "stocks", "generic"]: ir = WorkflowIR.new("Test", domain=domain) assert ir.domain == domain def test_etape_optionnelle(self): ir = WorkflowIR.new("Test") ir.add_step("Vérification facultative", is_optional=True) assert ir.steps[0].is_optional is True def test_etape_boucle(self): ir = WorkflowIR.new("Test") ir.add_step("Traiter chaque dossier", is_loop=True, loop_variable="dossier") assert ir.steps[0].is_loop is True assert ir.steps[0].loop_variable == "dossier" # ========================================================================= # IRBuilder — construction depuis RawTrace # ========================================================================= class TestIRBuilder: def _make_events(self): """Créer des événements bruts simulés (comme live_events.jsonl).""" return [ {"event": {"type": "mouse_click", "pos": [400, 580], "window": {"title": "Lea : Explorateur"}, "timestamp": 100.0, "vision_info": {"text": "Rechercher"}}}, {"event": {"type": "text_input", "text": "blocnote", "window": {"title": "Rechercher"}, "timestamp": 102.0}}, {"event": {"type": "key_combo", "keys": ["enter"], "window": {"title": "Rechercher"}, "timestamp": 103.0}}, {"event": {"type": "heartbeat", "timestamp": 104.0}}, # Parasite — doit être filtré {"event": {"type": "mouse_click", "pos": [300, 200], "window": {"title": "Rechercher"}, "timestamp": 105.0, "vision_info": {"text": "Bloc-notes"}}}, {"event": {"type": "mouse_click", "pos": [500, 300], "window": {"title": "Sans titre – Bloc-notes"}, "timestamp": 112.0, "vision_info": {"text": ""}}}, {"event": {"type": "text_input", "text": "Bonjour le monde", "window": {"title": "*Sans titre – Bloc-notes"}, "timestamp": 113.0}}, {"event": {"type": "key_combo", "keys": ["ctrl", "s"], "window": {"title": "*Sans titre – Bloc-notes"}, "timestamp": 115.0}}, ] def test_builder_sans_gemma4(self): """Le builder fonctionne même sans gemma4 (fallback gracieux).""" builder = IRBuilder(gemma4_port="99999") # Port invalide events = self._make_events() ir = builder.build(events, session_id="test_sess", domain="generic", name="Test") assert ir.name == "Test" assert ir.learned_from == "test_sess" assert len(ir.steps) >= 1 assert len(ir.applications) >= 1 def test_filtre_heartbeat(self): """Les heartbeat sont filtrés.""" builder = IRBuilder(gemma4_port="99999") events = self._make_events() ir = builder.build(events, name="Test") # Vérifier qu'aucune action n'est de type heartbeat for step in ir.steps: for action in step.actions: assert action.type != "heartbeat" def test_detection_applications(self): """Les applications utilisées sont détectées.""" builder = IRBuilder(gemma4_port="99999") events = self._make_events() ir = builder.build(events, name="Test") assert "Bloc-notes" in ir.applications or "Explorateur" in ir.applications def test_detection_variables(self): """Le texte saisi est détecté comme variable.""" builder = IRBuilder(gemma4_port="99999") events = self._make_events() ir = builder.build(events, name="Test") # Le texte "blocnote" et "Bonjour le monde" doivent être des variables assert len(ir.variables) >= 1 var_defaults = [v.default for v in ir.variables] assert any("blocnote" in d or "Bonjour" in d for d in var_defaults) def test_segmentation_par_application(self): """Les événements sont segmentés par changement d'application.""" builder = IRBuilder(gemma4_port="99999") events = self._make_events() ir = builder.build(events, name="Test") # Au moins 2 étapes (Explorateur → Bloc-notes) assert len(ir.steps) >= 2 def test_actions_dans_les_etapes(self): """Chaque étape contient les bonnes actions.""" builder = IRBuilder(gemma4_port="99999") events = self._make_events() ir = builder.build(events, name="Test") all_actions = [] for step in ir.steps: all_actions.extend(step.actions) types = [a.type for a in all_actions] assert "click" in types assert "type" in types assert "key_combo" in types def test_workflow_ir_complet_roundtrip(self): """Build → JSON → reload → même contenu.""" builder = IRBuilder(gemma4_port="99999") events = self._make_events() ir = builder.build(events, name="Roundtrip test", domain="compta") json_str = ir.to_json() ir2 = WorkflowIR.from_json(json_str) assert ir2.name == "Roundtrip test" assert ir2.domain == "compta" assert len(ir2.steps) == len(ir.steps) assert len(ir2.variables) == len(ir.variables) @patch("requests.post") def test_builder_avec_gemma4_mock(self, mock_post): """Avec gemma4, le builder enrichit les intentions.""" mock_resp = MagicMock() mock_resp.ok = True mock_resp.json.return_value = { "message": {"content": ( "INTENTION: Rechercher et ouvrir le Bloc-notes\n" "AVANT: L'explorateur de fichiers est ouvert\n" "APRÈS: Le Bloc-notes est ouvert et actif" )} } mock_post.return_value = mock_resp builder = IRBuilder() events = self._make_events() ir = builder.build(events, name="Test gemma4") # Au moins une étape doit avoir une intention enrichie intents = [s.intent for s in ir.steps] has_enriched = any("Bloc-notes" in i or "Rechercher" in i for i in intents) assert has_enriched or len(ir.steps) >= 1 # Fallback acceptable