""" Tests du ShadowValidator — feedback utilisateur et reconstruction WorkflowIR. Vérifie que : - Les feedbacks (validate/correct/undo/merge_next/split/cancel) sont appliqués - Le WorkflowIR final est bien reconstruit à partir des étapes corrigées - Les variables sont détectées dans les actions finales - L'historique des feedbacks est conservé - Les erreurs (index invalide, action inconnue) sont gérées proprement """ import sys import time from pathlib import Path from unittest.mock import MagicMock import pytest _ROOT = str(Path(__file__).resolve().parents[2]) if _ROOT not in sys.path: sys.path.insert(0, _ROOT) from core.workflow.shadow_observer import ShadowObserver, UnderstoodStep from core.workflow.shadow_validator import FeedbackResult, ShadowValidator from core.workflow.workflow_ir import WorkflowIR # ========================================================================= # Fixtures # ========================================================================= def _make_step(step_index=1, intent="Ouvrir Firefox", app="Firefox", events=None, **kwargs): return UnderstoodStep( step_index=step_index, intent=intent, app_name=app, window_title=app, events=events or [ {"type": "mouse_click", "window": {"title": app}, "vision_info": {"text": "bouton"}, "timestamp": 100.0} ], started_at=100.0, ended_at=101.0, **kwargs, ) def _make_type_step(texte="Bonjour", app="Bloc-notes", step_index=1): return UnderstoodStep( step_index=step_index, intent=f"Écrire « {texte} »", app_name=app, window_title=app, events=[ {"type": "text_input", "text": texte, "window": {"title": app}, "timestamp": 100.0} ], started_at=100.0, ) def _make_3_steps(): return [ _make_step(1, "Ouvrir le Bloc-notes", "Bloc-notes"), _make_type_step("Bonjour le monde", step_index=2), _make_step(3, "Sauvegarder", "Bloc-notes", events=[ {"type": "key_combo", "keys": ["ctrl", "s"], "window": {"title": "Bloc-notes"}, "timestamp": 103.0} ]), ] # ========================================================================= # Initialisation # ========================================================================= class TestShadowValidatorBase: def test_creation(self): v = ShadowValidator() assert v.steps == [] assert v.history == [] assert not v.is_cancelled def test_set_steps(self): v = ShadowValidator() v.set_steps(_make_3_steps()) assert len(v.steps) == 3 assert v.steps[0].intent == "Ouvrir le Bloc-notes" def test_clone_protege_mutation(self): """set_steps clone les étapes pour éviter les mutations externes.""" v = ShadowValidator() steps = _make_3_steps() v.set_steps(steps) steps[0].intent = "HACKED" assert v.steps[0].intent == "Ouvrir le Bloc-notes" # ========================================================================= # validate # ========================================================================= class TestValidatorValidate: def test_validate_etape(self): v = ShadowValidator() v.set_steps(_make_3_steps()) result = v.apply_feedback({"action": "validate", "step_index": 1}) assert result.ok is True assert v.steps[0].validated is True assert v.steps[0].confidence >= 0.95 assert v.steps[0].intent_provisoire is False def test_validate_index_invalide(self): v = ShadowValidator() v.set_steps(_make_3_steps()) result = v.apply_feedback({"action": "validate", "step_index": 99}) assert result.ok is False assert "invalide" in result.message.lower() def test_validate_toutes_les_etapes(self): v = ShadowValidator() v.set_steps(_make_3_steps()) for i in range(1, 4): v.apply_feedback({"action": "validate", "step_index": i}) assert all(s.validated for s in v.steps) # ========================================================================= # correct # ========================================================================= class TestValidatorCorrect: def test_correct_intent(self): v = ShadowValidator() v.set_steps(_make_3_steps()) result = v.apply_feedback({ "action": "correct", "step_index": 1, "new_intent": "Démarrer la rédaction d'un email", }) assert result.ok is True assert v.steps[0].intent == "Démarrer la rédaction d'un email" assert v.steps[0].corrected is True assert v.steps[0].validated is True # Corriger = valider implicitement assert "old_intent" in result.data def test_correct_intent_vide(self): v = ShadowValidator() v.set_steps(_make_3_steps()) result = v.apply_feedback({ "action": "correct", "step_index": 1, "new_intent": "", }) assert result.ok is False assert v.steps[0].corrected is False # ========================================================================= # undo # ========================================================================= class TestValidatorUndo: def test_undo_etape(self): v = ShadowValidator() v.set_steps(_make_3_steps()) result = v.apply_feedback({"action": "undo", "step_index": 2}) assert result.ok is True assert v.steps[1].cancelled is True def test_undo_exclut_etape_du_workflow(self): """Une étape undo ne doit pas apparaître dans le WorkflowIR final.""" v = ShadowValidator() v.set_steps(_make_3_steps()) v.apply_feedback({"action": "undo", "step_index": 2}) ir = v.build_workflow_ir(session_id="s1", name="Test") assert ir is not None assert len(ir.steps) == 2 # 3 - 1 = 2 # ========================================================================= # merge_next # ========================================================================= class TestValidatorMergeNext: def test_merge_deux_etapes(self): v = ShadowValidator() v.set_steps(_make_3_steps()) result = v.apply_feedback({"action": "merge_next", "step_index": 1}) assert result.ok is True assert len(v.steps) == 2 # 3 - 1 = 2 def test_merge_conserve_les_events(self): v = ShadowValidator() v.set_steps(_make_3_steps()) total_events_before = sum(len(s.events) for s in v.steps) v.apply_feedback({"action": "merge_next", "step_index": 2}) total_events_after = sum(len(s.events) for s in v.steps) assert total_events_before == total_events_after def test_merge_derniere_etape_echoue(self): v = ShadowValidator() v.set_steps(_make_3_steps()) result = v.apply_feedback({"action": "merge_next", "step_index": 3}) assert result.ok is False # ========================================================================= # split # ========================================================================= class TestValidatorSplit: def test_split_en_deux(self): v = ShadowValidator() multi_events_step = _make_step( 1, "Étape composite", events=[ {"type": "mouse_click", "window": {"title": "App"}, "timestamp": 100.0, "vision_info": {}}, {"type": "text_input", "text": "partie 1", "window": {"title": "App"}, "timestamp": 101.0}, {"type": "text_input", "text": "partie 2", "window": {"title": "App"}, "timestamp": 102.0}, ], ) v.set_steps([multi_events_step]) result = v.apply_feedback({ "action": "split", "step_index": 1, "at_event_index": 2, }) assert result.ok is True assert len(v.steps) == 2 assert len(v.steps[0].events) == 2 assert len(v.steps[1].events) == 1 def test_split_index_invalide(self): v = ShadowValidator() v.set_steps(_make_3_steps()) result = v.apply_feedback({ "action": "split", "step_index": 1, "at_event_index": 99, }) assert result.ok is False # ========================================================================= # cancel # ========================================================================= class TestValidatorCancel: def test_cancel_workflow(self): v = ShadowValidator() v.set_steps(_make_3_steps()) result = v.apply_feedback({"action": "cancel"}) assert result.ok is True assert v.is_cancelled def test_cancel_build_retourne_none(self): v = ShadowValidator() v.set_steps(_make_3_steps()) v.apply_feedback({"action": "cancel"}) ir = v.build_workflow_ir(session_id="s1", name="Test") assert ir is None # ========================================================================= # Action inconnue # ========================================================================= class TestValidatorUnknownAction: def test_action_inconnue(self): v = ShadowValidator() v.set_steps(_make_3_steps()) result = v.apply_feedback({"action": "do_magic"}) assert result.ok is False assert "inconnue" in result.message.lower() # ========================================================================= # Construction du WorkflowIR # ========================================================================= class TestValidatorBuild: def test_build_workflow_ir(self): v = ShadowValidator() v.set_steps(_make_3_steps()) ir = v.build_workflow_ir( session_id="sess_test", name="Mon workflow", domain="generic", ) assert ir is not None assert isinstance(ir, WorkflowIR) assert ir.name == "Mon workflow" assert ir.learned_from == "sess_test" assert len(ir.steps) == 3 def test_build_with_variables(self): """Les textes saisis deviennent des variables dans le WorkflowIR.""" v = ShadowValidator() v.set_steps([ _make_type_step("Jean Dupont", step_index=1), _make_type_step("jean@example.com", app="Email", step_index=2), ]) ir = v.build_workflow_ir(session_id="s1", name="Test") assert len(ir.variables) == 2 # Les actions de type type doivent référencer les variables for step in ir.steps: for action in step.actions: if action.type == "type": assert action.variable is True assert action.text.startswith("{") def test_build_respecte_corrections(self): """Les intentions corrigées se retrouvent dans le WorkflowIR.""" v = ShadowValidator() v.set_steps(_make_3_steps()) v.apply_feedback({ "action": "correct", "step_index": 1, "new_intent": "Lancer l'application de prise de notes", }) ir = v.build_workflow_ir(session_id="s1", name="Test") assert ir.steps[0].intent == "Lancer l'application de prise de notes" def test_build_exclut_etapes_annulees(self): v = ShadowValidator() v.set_steps(_make_3_steps()) v.apply_feedback({"action": "undo", "step_index": 2}) ir = v.build_workflow_ir(session_id="s1", name="Test") assert len(ir.steps) == 2 def test_build_require_all_validated(self): """Avec require_all_validated, erreur si une étape n'est pas validée.""" v = ShadowValidator() v.set_steps(_make_3_steps()) v.apply_feedback({"action": "validate", "step_index": 1}) # Étapes 2 et 3 pas validées with pytest.raises(ValueError): v.build_workflow_ir( session_id="s1", name="Test", require_all_validated=True ) def test_build_applications_detectees(self): v = ShadowValidator() v.set_steps([ _make_step(1, "Ouvrir Firefox", "Firefox"), _make_step(2, "Écrire", "Bloc-notes"), ]) ir = v.build_workflow_ir(session_id="s1", name="Test") assert "Firefox" in ir.applications assert "Bloc-notes" in ir.applications # ========================================================================= # Historique # ========================================================================= class TestValidatorHistory: def test_historique_feedbacks(self): v = ShadowValidator() v.set_steps(_make_3_steps()) v.apply_feedback({"action": "validate", "step_index": 1}) v.apply_feedback({ "action": "correct", "step_index": 2, "new_intent": "Écrire le texte" }) v.apply_feedback({"action": "undo", "step_index": 3}) history = v.history assert len(history) == 3 assert history[0].action == "validate" assert history[1].action == "correct" assert history[2].action == "undo" def test_apply_feedbacks_batch(self): v = ShadowValidator() v.set_steps(_make_3_steps()) results = v.apply_feedbacks([ {"action": "validate", "step_index": 1}, {"action": "validate", "step_index": 2}, {"action": "undo", "step_index": 3}, ]) assert len(results) == 3 assert all(r.ok for r in results) # ========================================================================= # Intégration ShadowObserver + ShadowValidator # ========================================================================= class TestShadowObserverValidatorIntegration: def test_observer_vers_validator(self): """Flow complet : Observer → Validator → WorkflowIR.""" obs = ShadowObserver() obs.start("sess_flow") # Simuler des événements obs.observe_event("sess_flow", { "type": "mouse_click", "window": {"title": "Menu Démarrer", "app_name": "Menu Démarrer"}, "vision_info": {"text": "Rechercher"}, "timestamp": 100.0, }) obs.observe_event("sess_flow", { "type": "text_input", "text": "blocnote", "window": {"title": "Menu Démarrer", "app_name": "Menu Démarrer"}, "timestamp": 100.5, }) obs.observe_event("sess_flow", { "type": "key_combo", "keys": ["enter"], "window": {"title": "Menu Démarrer", "app_name": "Menu Démarrer"}, "timestamp": 101.0, }) # Changement d'application obs.observe_event("sess_flow", { "type": "text_input", "text": "Hello world", "window": {"title": "Sans titre - Bloc-notes", "app_name": "Bloc-notes"}, "timestamp": 105.0, }) obs.stop("sess_flow") # Récupérer les étapes internals = obs.get_steps_internal("sess_flow") assert len(internals) >= 2 # Passer au validator validator = ShadowValidator() validator.set_steps(internals) # Valider la première étape, corriger la seconde validator.apply_feedback({"action": "validate", "step_index": 1}) validator.apply_feedback({ "action": "correct", "step_index": 2, "new_intent": "Écrire un texte de démonstration", }) # Construire le WorkflowIR ir = validator.build_workflow_ir( session_id="sess_flow", name="Flow de test", domain="generic", ) assert ir is not None assert len(ir.steps) >= 2 assert ir.steps[1].intent == "Écrire un texte de démonstration" assert len(ir.variables) >= 1 # Au moins "blocnote" ou "Hello world" def test_undo_puis_build(self): obs = ShadowObserver() obs.start("sess_undo") for i in range(3): obs.observe_event("sess_undo", { "type": "mouse_click", "window": {"title": f"App{i}"}, "vision_info": {"text": "bouton"}, "timestamp": 100.0 + i * 6.0, # > 4s pour créer des segments }) obs.stop("sess_undo") validator = ShadowValidator() validator.set_steps(obs.get_steps_internal("sess_undo")) nb_before = len(validator.steps) assert nb_before >= 2 validator.apply_feedback({"action": "undo", "step_index": 1}) ir = validator.build_workflow_ir(session_id="sess_undo", name="Test") assert len(ir.steps) == nb_before - 1