# tests/unit/test_env_setup.py """ Tests unitaires pour la phase de setup environnement (pré-replay). Vérifie que les fonctions d'extraction d'apps et de génération d'actions de setup 100% visuelles fonctionnent correctement. """ import pytest import sys from pathlib import Path # Ajouter le répertoire racine au path pour l'import ROOT = Path(__file__).parent.parent.parent sys.path.insert(0, str(ROOT)) from agent_v0.server_v1.api_stream import ( _extract_required_apps_from_events, _extract_required_apps_from_workflow, _resolve_launch_command, _infer_app_from_window_titles, _generate_setup_actions, _get_visual_search_info, _APP_LAUNCH_COMMANDS, _APP_VISUAL_SEARCH, _SETUP_IGNORE_APPS, ) # ========================================================================= # Tests pour _resolve_launch_command # ========================================================================= class TestResolveLaunchCommand: """Tests pour la résolution des commandes de lancement.""" def test_known_app(self): """Les apps connues retournent la bonne commande.""" assert _resolve_launch_command("Notepad.exe") == "notepad" assert _resolve_launch_command("notepad.exe") == "notepad" def test_known_app_case_insensitive(self): """Le mapping est insensible à la casse.""" assert _resolve_launch_command("NOTEPAD.EXE") == "notepad" assert _resolve_launch_command("Chrome.exe") == "chrome" def test_unknown_app_strips_exe(self): """Les apps inconnues utilisent le nom sans .exe.""" assert _resolve_launch_command("MonApp.exe") == "MonApp" assert _resolve_launch_command("customtool.exe") == "customtool" def test_no_exe_extension(self): """Les noms sans .exe sont retournés tels quels.""" assert _resolve_launch_command("notepad") == "notepad" def test_all_mapped_apps(self): """Toutes les apps du mapping sont résolvables.""" for app_name, expected_cmd in _APP_LAUNCH_COMMANDS.items(): assert _resolve_launch_command(app_name) == expected_cmd # ========================================================================= # Tests pour _get_visual_search_info # ========================================================================= class TestGetVisualSearchInfo: """Tests pour la résolution des infos de recherche visuelle.""" def test_known_app_notepad(self): """Notepad retourne les infos visuelles françaises.""" info = _get_visual_search_info("Notepad.exe") assert info["search_text"] == "Bloc-notes" assert info["display_name"] == "Bloc-notes" assert "Bloc-notes" in info["vlm_description"] def test_known_app_calc(self): """Calculatrice retourne les infos visuelles françaises.""" info = _get_visual_search_info("calc.exe") assert info["search_text"] == "Calculatrice" def test_known_app_word(self): """Word retourne les infos visuelles.""" info = _get_visual_search_info("winword.exe") assert info["search_text"] == "Word" assert info["display_name"] == "Microsoft Word" def test_case_insensitive(self): """Le mapping est insensible à la casse.""" info = _get_visual_search_info("NOTEPAD.EXE") assert info["search_text"] == "Bloc-notes" def test_unknown_app_fallback(self): """Une app inconnue utilise le nom sans .exe comme fallback.""" info = _get_visual_search_info("MonApp.exe") assert info["search_text"] == "MonApp" assert info["display_name"] == "MonApp" assert "MonApp" in info["vlm_description"] def test_unknown_app_no_exe(self): """Une app sans .exe utilise le nom tel quel.""" info = _get_visual_search_info("myapp") assert info["search_text"] == "myapp" def test_all_visual_apps_have_required_keys(self): """Toutes les apps du mapping visuel ont les clés requises.""" for app_name, info in _APP_VISUAL_SEARCH.items(): assert "search_text" in info, f"{app_name} manque search_text" assert "display_name" in info, f"{app_name} manque display_name" assert "vlm_description" in info, f"{app_name} manque vlm_description" # ========================================================================= # Tests pour _infer_app_from_window_titles # ========================================================================= class TestInferAppFromWindowTitles: """Tests pour l'inférence d'app depuis les titres de fenêtres.""" def test_notepad_french(self): """Détecte Notepad depuis un titre français.""" app, cmd, title = _infer_app_from_window_titles(["Sans titre – Bloc-notes"]) assert app == "Notepad.exe" assert cmd == "notepad" assert title == "Sans titre – Bloc-notes" def test_notepad_english(self): """Détecte Notepad depuis un titre anglais.""" app, cmd, title = _infer_app_from_window_titles(["Untitled - Notepad"]) assert app == "Notepad.exe" assert cmd == "notepad" def test_word(self): """Détecte Word.""" app, cmd, _ = _infer_app_from_window_titles(["Document1 - Word"]) assert app == "winword.exe" assert cmd == "winword" def test_excel(self): """Détecte Excel.""" app, cmd, _ = _infer_app_from_window_titles(["Classeur1 - Excel"]) assert app == "excel.exe" assert cmd == "excel" def test_chrome(self): """Détecte Chrome.""" app, cmd, _ = _infer_app_from_window_titles(["Google - Chrome"]) assert app == "chrome.exe" assert cmd == "chrome" def test_explorer_ignored(self): """Explorer est ignoré (app système).""" app, cmd, _ = _infer_app_from_window_titles(["Explorateur de fichiers"]) assert app == "" assert cmd == "" def test_unknown_title(self): """Un titre inconnu retourne des chaînes vides.""" app, cmd, _ = _infer_app_from_window_titles(["Ma Super App Custom"]) assert app == "" assert cmd == "" def test_empty_list(self): """Une liste vide retourne des chaînes vides.""" app, cmd, _ = _infer_app_from_window_titles([]) assert app == "" assert cmd == "" def test_first_match_wins(self): """Le premier titre reconnu est utilisé.""" app, cmd, title = _infer_app_from_window_titles([ "Rechercher", # Pas reconnu "*test – Bloc-notes", # Notepad "Document1 - Excel", # Excel (pas utilisé car Notepad est trouvé avant) ]) assert app == "Notepad.exe" assert title == "*test – Bloc-notes" def test_returns_matched_title(self): """Le titre matché est celui de l'app, pas le premier de la liste.""" app, cmd, title = _infer_app_from_window_titles([ "Rechercher", # Pas reconnu "Sans titre – Bloc-notes", # Notepad → ce titre ]) assert title == "Sans titre – Bloc-notes" # ========================================================================= # Tests pour _extract_required_apps_from_events # ========================================================================= class TestExtractRequiredAppsFromEvents: """Tests pour l'extraction d'apps depuis les événements bruts.""" def _make_events(self, focus_changes): """Helper : créer des événements bruts à partir de changements de focus.""" events = [] for from_info, to_info in focus_changes: events.append({ "session_id": "test_sess", "event": { "type": "window_focus_change", "from": from_info, "to": to_info, }, }) return events def test_notepad_session(self): """Détecte Notepad comme app principale.""" events = self._make_events([ (None, {"app_name": "explorer.exe", "title": "Explorateur"}), ({"app_name": "explorer.exe"}, {"app_name": "SearchHost.exe", "title": "Rechercher"}), ({"app_name": "SearchHost.exe"}, {"app_name": "Notepad.exe", "title": "Bloc-notes"}), ({"app_name": "Notepad.exe"}, {"app_name": "Notepad.exe", "title": "Sans titre – Bloc-notes"}), ({"app_name": "Notepad.exe"}, {"app_name": "Notepad.exe", "title": "*test – Bloc-notes"}), ]) result = _extract_required_apps_from_events(events) assert result["primary_app"] == "Notepad.exe" assert result["primary_launch_cmd"] == "notepad" # Le premier app hors ignorées est Notepad assert result["first_window_title"] == "Bloc-notes" def test_empty_events(self): """Pas d'événements → dict vide.""" assert _extract_required_apps_from_events([]) == {} def test_only_system_apps(self): """Que des apps système → dict vide.""" events = self._make_events([ (None, {"app_name": "explorer.exe", "title": "Bureau"}), (None, {"app_name": "SearchHost.exe", "title": "Rechercher"}), ]) assert _extract_required_apps_from_events(events) == {} def test_multiple_apps_picks_most_frequent(self): """L'app la plus fréquente (hors système) est choisie.""" events = self._make_events([ (None, {"app_name": "Notepad.exe", "title": "Bloc-notes"}), (None, {"app_name": "calc.exe", "title": "Calculatrice"}), (None, {"app_name": "calc.exe", "title": "Calculatrice"}), (None, {"app_name": "calc.exe", "title": "Calculatrice"}), ]) result = _extract_required_apps_from_events(events) assert result["primary_app"] == "calc.exe" assert result["primary_launch_cmd"] == "calc" # ========================================================================= # Tests pour _extract_required_apps_from_workflow # ========================================================================= class TestExtractRequiredAppsFromWorkflow: """Tests pour l'extraction d'apps depuis un workflow structuré.""" def test_workflow_dict_with_notepad(self): """Détecte Notepad depuis un workflow dict.""" workflow = { "nodes": [ { "node_id": "node_000", "template": { "window": { "title_pattern": "Sans titre – Bloc-notes", "title_contains": "Bloc-notes", }, }, }, ], "edges": [], "metadata": { "source_session_id": "sess_test", "machine_id": "DESKTOP-TEST", }, } result = _extract_required_apps_from_workflow(workflow) assert result["primary_app"] == "Notepad.exe" assert result["primary_launch_cmd"] == "notepad" def test_workflow_no_recognizable_titles(self): """Workflow sans titres reconnaissables → dict vide.""" workflow = { "nodes": [ { "node_id": "node_000", "template": { "window": {"title_pattern": "Rechercher"}, }, }, ], "edges": [], "metadata": {}, } result = _extract_required_apps_from_workflow(workflow) assert result == {} def test_empty_workflow(self): """Workflow vide → dict vide.""" assert _extract_required_apps_from_workflow({"nodes": [], "edges": []}) == {} assert _extract_required_apps_from_workflow({}) == {} # ========================================================================= # Tests pour _generate_setup_actions — 100% visuel # ========================================================================= class TestGenerateSetupActions: """Tests pour la génération des actions de setup 100% visuelles.""" def test_notepad_setup_visual(self): """Génère les bonnes actions visuelles pour lancer Notepad.""" app_info = { "primary_app": "Notepad.exe", "primary_launch_cmd": "notepad", "first_window_title": "Sans titre – Bloc-notes", } actions = _generate_setup_actions(app_info) # 9 actions : click_start, wait, click_search, wait, type, wait, click_result, wait, verify assert len(actions) == 9 # Étape 1 : clic visuel sur le bouton Démarrer assert actions[0]["type"] == "click" assert actions[0]["visual_mode"] is True assert actions[0]["target_spec"]["by_role"] == "start_button" assert actions[0]["target_spec"]["by_text"] == "Démarrer" # Étape 2 : attente menu Démarrer assert actions[1]["type"] == "wait" assert actions[1]["duration_ms"] == 1000 # Étape 3 : clic visuel sur la barre de recherche assert actions[2]["type"] == "click" assert actions[2]["visual_mode"] is True assert actions[2]["target_spec"]["by_role"] == "search_box" # Étape 4 : attente barre de recherche active assert actions[3]["type"] == "wait" assert actions[3]["duration_ms"] == 500 # Étape 5 : taper le nom visuel français assert actions[4]["type"] == "type" assert actions[4]["text"] == "Bloc-notes" # Étape 6 : attente résultats assert actions[5]["type"] == "wait" assert actions[5]["duration_ms"] == 1200 # Étape 7 : clic visuel sur le résultat assert actions[6]["type"] == "click" assert actions[6]["visual_mode"] is True assert actions[6]["target_spec"]["by_text"] == "Bloc-notes" assert actions[6]["target_spec"]["by_role"] == "app_icon" # Étape 8 : attente lancement (app légère = 2000ms) assert actions[7]["type"] == "wait" assert actions[7]["duration_ms"] == 2000 # Étape 9 : vérification visuelle assert actions[8]["type"] == "verify_screen" assert actions[8]["_expected_title"] == "Sans titre – Bloc-notes" # Toutes les actions sont marquées comme phase setup for action in actions: assert action.get("_setup_phase") is True def test_no_key_combo_in_setup(self): """AUCUNE action key_combo ne doit être générée dans le setup.""" app_info = { "primary_app": "Notepad.exe", "primary_launch_cmd": "notepad", "first_window_title": "Bloc-notes", } actions = _generate_setup_actions(app_info) key_combos = [a for a in actions if a["type"] == "key_combo"] assert key_combos == [], ( "Le setup 100% visuel ne doit JAMAIS contenir de key_combo. " f"Trouvé : {key_combos}" ) def test_all_clicks_are_visual(self): """Tous les clics du setup doivent avoir visual_mode=True et un target_spec.""" app_info = { "primary_app": "Notepad.exe", "primary_launch_cmd": "notepad", "first_window_title": "Bloc-notes", } actions = _generate_setup_actions(app_info) clicks = [a for a in actions if a["type"] == "click"] assert len(clicks) >= 3 # Démarrer, recherche, résultat for click in clicks: assert click.get("visual_mode") is True, ( f"Clic {click.get('action_id')} n'a pas visual_mode=True" ) assert click.get("target_spec"), ( f"Clic {click.get('action_id')} n'a pas de target_spec" ) # Chaque target_spec doit avoir by_text et by_role spec = click["target_spec"] assert "by_text" in spec, f"target_spec sans by_text : {spec}" assert "by_role" in spec, f"target_spec sans by_role : {spec}" assert "vlm_description" in spec, f"target_spec sans vlm_description : {spec}" def test_clicks_have_fallback_coordinates(self): """Tous les clics visuels ont des coordonnées de fallback (x_pct, y_pct).""" app_info = { "primary_app": "Notepad.exe", "primary_launch_cmd": "notepad", "first_window_title": "Bloc-notes", } actions = _generate_setup_actions(app_info) clicks = [a for a in actions if a["type"] == "click"] for click in clicks: assert "x_pct" in click, f"Clic {click['action_id']} sans x_pct" assert "y_pct" in click, f"Clic {click['action_id']} sans y_pct" assert isinstance(click["x_pct"], (int, float)) assert isinstance(click["y_pct"], (int, float)) def test_heavy_app_longer_wait(self): """Les apps lourdes ont un temps d'attente plus long.""" app_info = { "primary_app": "winword.exe", "primary_launch_cmd": "winword", "first_window_title": "Document1 - Word", } actions = _generate_setup_actions(app_info) wait_action = [a for a in actions if a.get("_setup_step") == "wait_app_launch"][0] assert wait_action["duration_ms"] == 3000 def test_light_app_shorter_wait(self): """Les apps légères ont un temps d'attente standard.""" app_info = { "primary_app": "Notepad.exe", "primary_launch_cmd": "notepad", "first_window_title": "Bloc-notes", } actions = _generate_setup_actions(app_info) wait_action = [a for a in actions if a.get("_setup_step") == "wait_app_launch"][0] assert wait_action["duration_ms"] == 2000 def test_word_uses_visual_search_text(self): """Word utilise 'Word' comme texte de recherche visuelle, pas 'winword'.""" app_info = { "primary_app": "winword.exe", "primary_launch_cmd": "winword", "first_window_title": "Document1 - Word", } actions = _generate_setup_actions(app_info) # Le type doit utiliser le texte visuel, pas la commande shell type_action = [a for a in actions if a["type"] == "type"][0] assert type_action["text"] == "Word" # Le clic sur le résultat doit utiliser le display_name click_result = [a for a in actions if a.get("_setup_step") == "click_app_result"][0] assert click_result["target_spec"]["by_text"] == "Microsoft Word" def test_verify_screen_present_with_title(self): """Un verify_screen est ajouté quand un titre de fenêtre est connu.""" app_info = { "primary_app": "Notepad.exe", "primary_launch_cmd": "notepad", "first_window_title": "Sans titre – Bloc-notes", } actions = _generate_setup_actions(app_info) verify = [a for a in actions if a.get("type") == "verify_screen"] assert len(verify) == 1 assert verify[0]["_expected_title"] == "Sans titre – Bloc-notes" def test_no_verify_without_title(self): """Pas de verify_screen si aucun titre de fenêtre n'est connu.""" app_info = { "primary_app": "Notepad.exe", "primary_launch_cmd": "notepad", "first_window_title": "", } actions = _generate_setup_actions(app_info) verify = [a for a in actions if a.get("type") == "verify_screen"] assert len(verify) == 0 def test_empty_app_info(self): """Dict vide → pas d'actions.""" assert _generate_setup_actions({}) == [] def test_system_app_ignored(self): """Les apps système sont ignorées.""" app_info = { "primary_app": "explorer.exe", "primary_launch_cmd": "explorer", "first_window_title": "Explorateur", } assert _generate_setup_actions(app_info) == [] def test_no_launch_cmd(self): """Pas de commande de lancement → pas d'actions.""" app_info = { "primary_app": "Notepad.exe", "primary_launch_cmd": "", "first_window_title": "Bloc-notes", } assert _generate_setup_actions(app_info) == [] def test_action_ids_unique(self): """Tous les action_id sont uniques.""" app_info = { "primary_app": "Notepad.exe", "primary_launch_cmd": "notepad", "first_window_title": "Bloc-notes", } actions = _generate_setup_actions(app_info) ids = [a["action_id"] for a in actions] assert len(ids) == len(set(ids)) def test_custom_prefix(self): """Le préfixe personnalisé est utilisé dans les action_id.""" app_info = { "primary_app": "Notepad.exe", "primary_launch_cmd": "notepad", "first_window_title": "Bloc-notes", } actions = _generate_setup_actions(app_info, setup_id_prefix="mysetup") for action in actions: assert "mysetup" in action["action_id"] def test_unknown_app_uses_fallback_visual_info(self): """Une app inconnue utilise le nom de l'exécutable comme texte de recherche.""" app_info = { "primary_app": "MonAppMedical.exe", "primary_launch_cmd": "MonAppMedical", "first_window_title": "Mon App", } actions = _generate_setup_actions(app_info) # Le type doit utiliser le nom sans .exe type_action = [a for a in actions if a["type"] == "type"][0] assert type_action["text"] == "MonAppMedical" # ========================================================================= # Tests d'intégration : pipeline complet events → setup visuel # ========================================================================= class TestSetupPipeline: """Tests du pipeline complet : extraction + génération visuelle.""" def test_full_pipeline_from_events(self): """Pipeline complet depuis des événements bruts de type Notepad.""" events = [ {"event": {"type": "window_focus_change", "from": None, "to": {"app_name": "explorer.exe", "title": "Bureau"}}}, {"event": {"type": "window_focus_change", "from": {"app_name": "explorer.exe"}, "to": {"app_name": "Notepad.exe", "title": "Sans titre – Bloc-notes"}}}, {"event": {"type": "window_focus_change", "from": {"app_name": "Notepad.exe"}, "to": {"app_name": "Notepad.exe", "title": "*test – Bloc-notes"}}}, ] app_info = _extract_required_apps_from_events(events) assert app_info["primary_app"] == "Notepad.exe" actions = _generate_setup_actions(app_info) assert len(actions) >= 8 # Au minimum 8 actions visuelles (sans verify si pas de titre) # Vérifier l'ordre logique 100% visuel types = [a["type"] for a in actions] assert types[0] == "click" # Clic Démarrer assert types[1] == "wait" # Attente menu assert types[2] == "click" # Clic barre de recherche assert types[3] == "wait" # Attente barre active assert types[4] == "type" # Taper le nom assert types[5] == "wait" # Attente résultats assert types[6] == "click" # Clic sur le résultat assert types[7] == "wait" # Attente lancement # AUCUN key_combo dans le pipeline assert "key_combo" not in types, "Le pipeline ne doit contenir aucun key_combo" # Le texte tapé est le nom visuel français assert actions[4]["text"] == "Bloc-notes" def test_full_pipeline_from_workflow(self): """Pipeline complet depuis un workflow structuré.""" workflow = { "nodes": [ {"node_id": "n0", "template": { "window": {"title_pattern": "Sans titre – Bloc-notes"}, }}, {"node_id": "n1", "template": { "window": {"title_pattern": "*test – Bloc-notes"}, }}, ], "edges": [], "metadata": {}, } app_info = _extract_required_apps_from_workflow(workflow) assert app_info["primary_app"] == "Notepad.exe" actions = _generate_setup_actions(app_info) assert len(actions) >= 8 # Le texte tapé doit être le nom visuel, pas la commande shell type_action = [a for a in actions if a["type"] == "type"][0] assert type_action["text"] == "Bloc-notes" # Aucun key_combo key_combos = [a for a in actions if a["type"] == "key_combo"] assert key_combos == []