Files
rpa_vision_v3/tests/unit/test_env_setup.py
Dom 447fbb2c6e
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 12s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
chore: sauvegarde complète avant factorisation executor
Point de sauvegarde incluant les fichiers non committés des sessions
précédentes (systemd, docs, agents, GPU manager).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 17:03:44 +02:00

611 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 == []