chore: sauvegarde complète avant factorisation executor
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

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>
This commit is contained in:
Dom
2026-04-20 17:03:44 +02:00
parent 623be15bfe
commit 447fbb2c6e
1869 changed files with 791438 additions and 324 deletions

View File

@@ -46,7 +46,6 @@ class TestDashboardRoutes:
data = resp.get_json()
assert 'sessions_count' in data
assert 'workflows_count' in data
assert 'tests' in data
def test_system_performance(self, client):
"""L'API system/performance retourne les metriques."""
@@ -54,7 +53,6 @@ class TestDashboardRoutes:
assert resp.status_code == 200
data = resp.get_json()
assert 'faiss' in data
assert 'metrics' in data
def test_version(self, client):
"""L'API version retourne la version actuelle."""
@@ -126,13 +124,10 @@ class TestDashboardRoutes:
data = resp.get_json()
assert 'sessions' in data
def test_tests_list(self, client):
"""L'API tests retourne la liste des tests."""
def test_tests_list_removed(self, client):
"""L'API /api/tests a été retirée (RCE via subprocess)."""
resp = client.get('/api/tests')
assert resp.status_code == 200
data = resp.get_json()
assert 'tests' in data
assert 'total' in data
assert resp.status_code == 404
def test_logs(self, client):
"""L'API logs retourne les logs."""
@@ -155,10 +150,10 @@ class TestDashboardRoutes:
data = resp.get_json()
assert 'triggers' in data
def test_automation_status(self, client):
"""L'API automation/status retourne le statut."""
def test_automation_status_removed(self, client):
"""L'API /api/automation/status a été retirée."""
resp = client.get('/api/automation/status')
assert resp.status_code == 200
assert resp.status_code == 404
def test_metrics_endpoint(self, client):
"""L'endpoint Prometheus /metrics fonctionne."""
@@ -171,151 +166,47 @@ class TestDashboardRoutes:
assert resp.status_code == 404 or resp.status_code == 405
class TestGesturesRoutes:
"""Tests des routes du catalogue de gestes."""
class TestRemovedRoutes:
"""Vérifie que les routes supprimées retournent 404."""
def test_gestures_page_renders(self, client):
"""La page /gestures se rend correctement."""
def test_gestures_page_removed(self, client):
"""La page /gestures a été retirée."""
resp = client.get('/gestures')
assert resp.status_code == 200
assert b'Gestes Primitifs' in resp.data
assert resp.status_code == 404
def test_gestures_page_has_categories(self, client):
"""La page /gestures affiche les catégories de gestes."""
resp = client.get('/gestures')
assert resp.status_code == 200
# Vérifier qu'au moins une catégorie est présente
assert b'windows' in resp.data or b'chrome' in resp.data
def test_gestures_page_has_shortcuts(self, client):
"""La page /gestures affiche les raccourcis clavier."""
resp = client.get('/gestures')
assert resp.status_code == 200
assert b'Ctrl' in resp.data or b'Alt' in resp.data
def test_api_gestures(self, client):
"""L'API /api/gestures retourne les gestes en JSON."""
def test_api_gestures_removed(self, client):
"""L'API /api/gestures a été retirée."""
resp = client.get('/api/gestures')
assert resp.status_code == 200
data = resp.get_json()
assert 'gestures' in data
assert 'total' in data
assert 'categories' in data
assert data['total'] > 0
assert isinstance(data['gestures'], list)
assert len(data['gestures']) == data['total']
assert resp.status_code == 404
def test_api_gestures_structure(self, client):
"""Chaque geste a les champs requis."""
resp = client.get('/api/gestures')
data = resp.get_json()
for gesture in data['gestures']:
assert 'name' in gesture
assert 'category' in gesture
assert 'description' in gesture
def test_api_gestures_categories(self, client):
"""Les catégories sont bien structurées."""
resp = client.get('/api/gestures')
data = resp.get_json()
categories = data['categories']
assert len(categories) >= 4 # windows, chrome, edition, system au minimum
for cat in categories:
assert 'id' in cat
assert 'name' in cat
assert 'count' in cat
assert cat['count'] > 0
class TestStreamingRoutes:
"""Tests des routes streaming."""
def test_streaming_page_renders(self, client):
"""La page /streaming se rend correctement."""
def test_streaming_page_removed(self, client):
"""La page /streaming a été retirée."""
resp = client.get('/streaming')
assert resp.status_code == 200
assert b'Streaming' in resp.data
assert resp.status_code == 404
def test_streaming_page_has_stats_section(self, client):
"""La page /streaming contient les sections de stats."""
resp = client.get('/streaming')
assert resp.status_code == 200
assert b'Sessions actives' in resp.data
assert b'Serveur streaming' in resp.data
def test_extractions_page_removed(self, client):
"""La page /extractions a été retirée."""
resp = client.get('/extractions')
assert resp.status_code == 404
def test_api_streaming_status(self, client):
"""L'API /api/streaming/status retourne un résultat (même si serveur offline)."""
resp = client.get('/api/streaming/status')
# Le serveur streaming peut ne pas être lancé (502) ou répondre (200)
assert resp.status_code in (200, 502)
def test_api_extractions_removed(self, client):
"""L'API /api/extractions a été retirée."""
resp = client.get('/api/extractions')
assert resp.status_code == 404
def test_chat_page_removed(self, client):
"""La page /chat a été retirée."""
resp = client.get('/chat')
assert resp.status_code == 404
class TestFleetProxy:
"""Tests du proxy fleet (requiert serveur streaming, donc 502 attendu)."""
def test_fleet_list_proxy(self, client):
"""Le proxy /api/fleet/fleet retourne 200, 401 ou 502 (serveur offline/auth)."""
resp = client.get('/api/fleet/fleet')
# 200 = ok, 401 = streaming server rejette le token, 502 = serveur offline
assert resp.status_code in (200, 401, 502)
data = resp.get_json()
assert isinstance(data, dict)
class TestExtractionsRoutes:
"""Tests des routes extractions."""
def test_extractions_page_renders(self, client):
"""La page /extractions se rend correctement."""
resp = client.get('/extractions')
assert resp.status_code == 200
assert b'Extractions' in resp.data
def test_extractions_page_module_unavailable(self, client):
"""La page /extractions affiche un message si le module n'est pas disponible."""
resp = client.get('/extractions')
assert resp.status_code == 200
# Le module core.extraction n'existe pas, on doit voir le message
assert b'non disponible' in resp.data or b'Module' in resp.data
def test_api_extractions(self, client):
"""L'API /api/extractions retourne un résultat valide."""
resp = client.get('/api/extractions')
assert resp.status_code == 200
data = resp.get_json()
assert 'available' in data
assert 'extractions' in data
assert isinstance(data['extractions'], list)
def test_api_extractions_module_status(self, client):
"""L'API /api/extractions indique si le module est disponible."""
resp = client.get('/api/extractions')
data = resp.get_json()
# Le module n'existe pas dans ce contexte
assert data['available'] is False
assert 'message' in data
def test_api_extraction_export_no_module(self, client):
"""L'export CSV retourne 501 si le module n'est pas disponible."""
resp = client.get('/api/extractions/test-id/export?format=csv')
assert resp.status_code == 501
data = resp.get_json()
assert 'error' in data
class TestNavigationLinks:
"""Tests de la navigation entre pages."""
def test_index_has_gestures_link(self, client):
"""La page d'accueil contient un lien vers /gestures."""
resp = client.get('/')
assert resp.status_code == 200
assert b'/gestures' in resp.data
def test_index_has_streaming_link(self, client):
"""La page d'accueil contient un lien vers /streaming."""
resp = client.get('/')
assert resp.status_code == 200
assert b'/streaming' in resp.data
def test_index_has_extractions_link(self, client):
"""La page d'accueil contient un lien vers /extractions."""
resp = client.get('/')
assert resp.status_code == 200
assert b'/extractions' in resp.data
def test_gestures_has_back_link(self, client):
"""La page gestures contient un lien retour vers le dashboard."""
resp = client.get('/gestures')
assert resp.status_code == 200
assert b'href="/"' in resp.data or b"href='/'" in resp.data

View File

@@ -0,0 +1,610 @@
# 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 == []

View File

@@ -606,3 +606,79 @@ async def test_concurrent_operations_processed_sequentially(gpu_manager, mock_ol
# Assert - operations should complete without interleaving
assert "load_start" in operation_order
assert "load_end" in operation_order
# =============================================================================
# Tests pour acquire_inference (tâche 1 — sérialisation GPU concurrente)
# =============================================================================
class TestAcquireInference:
"""Sérialisation des appels GPU via acquire_inference()."""
def test_acquire_release_basic(self, config):
"""Le lock s'acquiert et se relâche sans erreur."""
GPUResourceManager.reset_instance()
manager = GPUResourceManager(config)
with manager.acquire_inference() as acquired:
assert acquired is True
# Après sortie du contexte, on peut reprendre le lock immédiatement
with manager.acquire_inference(timeout=0.5) as acquired2:
assert acquired2 is True
def test_acquire_inference_timeout(self, config):
"""Si un autre thread tient le lock, le timeout retourne False."""
import threading
GPUResourceManager.reset_instance()
manager = GPUResourceManager(config)
held = threading.Event()
release = threading.Event()
def holder():
with manager.acquire_inference():
held.set()
release.wait(timeout=5.0)
thread = threading.Thread(target=holder, daemon=True)
thread.start()
assert held.wait(timeout=2.0)
with manager.acquire_inference(timeout=0.1) as acquired:
assert acquired is False
release.set()
thread.join(timeout=2.0)
def test_acquire_inference_serializes_concurrent_calls(self, config):
"""Deux threads ne peuvent pas être dans la section critique en même temps."""
import threading
import time as _time
GPUResourceManager.reset_instance()
manager = GPUResourceManager(config)
inside = [] # compteur des threads actuellement dans la section
max_concurrent = [0]
lock = threading.Lock()
def worker():
with manager.acquire_inference():
with lock:
inside.append(1)
max_concurrent[0] = max(max_concurrent[0], len(inside))
_time.sleep(0.05)
with lock:
inside.pop()
threads = [threading.Thread(target=worker) for _ in range(5)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=5.0)
assert max_concurrent[0] == 1, (
f"Attendu max 1 thread simultané, observé {max_concurrent[0]}"
)

View File

@@ -0,0 +1,384 @@
#!/usr/bin/env python3
"""
Tests unitaires pour l'intégration du Properties Panel VWB avec les actions catalogue
Auteur : Dom, Alice, Kiro - 10 janvier 2026
Tests de validation de la Tâche 2.3 : Properties Panel Adapté VWB
- Intégration VWBActionProperties dans PropertiesPanel
- Éditeurs spécialisés pour paramètres VisionOnly
- Validation en temps réel des configurations
- Sélection visuelle fonctionnelle
"""
import pytest
import json
import os
import sys
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock
# Ajouter le répertoire racine au path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
class TestVWBPropertiesPanelIntegration:
"""Tests d'intégration du Properties Panel VWB avec le catalogue d'actions"""
def setup_method(self):
"""Configuration des tests"""
self.frontend_path = Path("visual_workflow_builder/frontend/src")
self.components_path = self.frontend_path / "components"
self.properties_panel_path = self.components_path / "PropertiesPanel"
def test_properties_panel_structure(self):
"""Test 1: Vérifier la structure du Properties Panel"""
# Vérifier que le fichier principal existe
main_file = self.properties_panel_path / "index.tsx"
assert main_file.exists(), "Le fichier PropertiesPanel/index.tsx doit exister"
# Vérifier que le composant VWBActionProperties existe
vwb_file = self.properties_panel_path / "VWBActionProperties.tsx"
assert vwb_file.exists(), "Le fichier VWBActionProperties.tsx doit exister"
print("✅ Structure du Properties Panel validée")
@pytest.mark.skip(reason="API obsolète : PropertiesPanel refactoré, imports catalogService supprimés")
def test_properties_panel_imports(self):
"""Test 2: Vérifier les imports du Properties Panel"""
main_file = self.properties_panel_path / "index.tsx"
content = main_file.read_text(encoding='utf-8')
# Vérifier les imports essentiels
required_imports = [
"import VWBActionProperties from './VWBActionProperties'",
"import { catalogService } from '../../services/catalogService'",
"import { VWBCatalogAction, VWBActionValidationResult } from '../../types/catalog'",
"import VisualSelector from '../VisualSelector'",
"import VariableAutocomplete from '../VariableAutocomplete'"
]
for import_stmt in required_imports:
assert import_stmt in content, f"Import manquant: {import_stmt}"
print("✅ Imports du Properties Panel validés")
@pytest.mark.skip(reason="API obsolète : PropertiesPanel refactoré, pattern détection VWB changé")
def test_vwb_action_detection_logic(self):
"""Test 3: Vérifier la logique de détection des actions VWB"""
main_file = self.properties_panel_path / "index.tsx"
content = main_file.read_text(encoding='utf-8')
# Vérifier la logique de détection des actions VWB
detection_patterns = [
"const isVWBCatalogAction = useMemo",
"selectedStep?.type?.startsWith('vwb_catalog_')",
"selectedStep?.data?.isVWBCatalogAction === true"
]
for pattern in detection_patterns:
assert pattern in content, f"Pattern de détection manquant: {pattern}"
print("✅ Logique de détection des actions VWB validée")
@pytest.mark.skip(reason="API obsolète : PropertiesPanel refactoré, pattern chargement VWB changé")
def test_vwb_action_loading_logic(self):
"""Test 4: Vérifier la logique de chargement des actions VWB"""
main_file = self.properties_panel_path / "index.tsx"
content = main_file.read_text(encoding='utf-8')
# Vérifier la logique de chargement
loading_patterns = [
"const loadVWBAction = async",
"await catalogService.getActionDetails",
"setVwbAction(action)"
]
for pattern in loading_patterns:
assert pattern in content, f"Pattern de chargement manquant: {pattern}"
print("✅ Logique de chargement des actions VWB validée")
def test_vwb_parameter_handlers(self):
"""Test 5: Vérifier les gestionnaires de paramètres VWB"""
main_file = self.properties_panel_path / "index.tsx"
content = main_file.read_text(encoding='utf-8')
# Vérifier les gestionnaires spécialisés
handler_patterns = [
"const handleVWBParameterChange",
"const handleVWBValidationChange",
"onParameterChange={handleVWBParameterChange}",
"onValidationChange={handleVWBValidationChange}"
]
for pattern in handler_patterns:
assert pattern in content, f"Gestionnaire manquant: {pattern}"
print("✅ Gestionnaires de paramètres VWB validés")
@pytest.mark.skip(reason="API obsolète : PropertiesPanel refactoré, pattern rendu conditionnel changé")
def test_conditional_rendering_logic(self):
"""Test 6: Vérifier la logique de rendu conditionnel"""
main_file = self.properties_panel_path / "index.tsx"
content = main_file.read_text(encoding='utf-8')
# Vérifier le rendu conditionnel
rendering_patterns = [
"{isVWBCatalogAction && vwbAction ? (",
"<VWBActionProperties",
"action={vwbAction!}",
"parameters={localParameters}",
"variables={variables as Variable[]}"
]
for pattern in rendering_patterns:
assert pattern in content, f"Pattern de rendu manquant: {pattern}"
print("✅ Logique de rendu conditionnel validée")
def test_vwb_action_properties_structure(self):
"""Test 7: Vérifier la structure du composant VWBActionProperties"""
vwb_file = self.properties_panel_path / "VWBActionProperties.tsx"
content = vwb_file.read_text(encoding='utf-8')
# Vérifier les éléments essentiels
essential_elements = [
"interface VWBActionPropertiesProps",
"interface VisualAnchorEditorProps",
"const VisualAnchorEditor: React.FC",
"const VWBActionProperties: React.FC",
"export default VWBActionProperties"
]
for element in essential_elements:
assert element in content, f"Élément manquant: {element}"
print("✅ Structure VWBActionProperties validée")
def test_visual_anchor_editor(self):
"""Test 8: Vérifier l'éditeur d'ancres visuelles"""
vwb_file = self.properties_panel_path / "VWBActionProperties.tsx"
content = vwb_file.read_text(encoding='utf-8')
# Vérifier les fonctionnalités de l'éditeur d'ancres
anchor_features = [
"const handleVisualSelection",
"const handleConfidenceChange",
"const handleRemoveAnchor",
"anchor_type: 'generic'",
"confidence_threshold:",
"<VisualSelector"
]
for feature in anchor_features:
assert feature in content, f"Fonctionnalité d'ancre manquante: {feature}"
print("✅ Éditeur d'ancres visuelles validé")
def test_parameter_type_editors(self):
"""Test 9: Vérifier les éditeurs de types de paramètres"""
vwb_file = self.properties_panel_path / "VWBActionProperties.tsx"
content = vwb_file.read_text(encoding='utf-8')
# Vérifier les éditeurs pour chaque type
type_editors = [
"case 'string':",
"case 'number':",
"case 'boolean':",
"case 'VWBVisualAnchor':",
"<VariableAutocomplete",
"<TextField",
"<Switch",
"<VisualAnchorEditor"
]
for editor in type_editors:
assert editor in content, f"Éditeur de type manquant: {editor}"
print("✅ Éditeurs de types de paramètres validés")
def test_validation_integration(self):
"""Test 10: Vérifier l'intégration de la validation"""
vwb_file = self.properties_panel_path / "VWBActionProperties.tsx"
content = vwb_file.read_text(encoding='utf-8')
# Vérifier la validation en temps réel
validation_features = [
"const validateParameters",
"await catalogService.validateAction",
"const vwbValidation: VWBActionValidationResult",
"setValidation(vwbValidation)",
"onValidationChange?.(vwbValidation)"
]
for feature in validation_features:
assert feature in content, f"Fonctionnalité de validation manquante: {feature}"
print("✅ Intégration de la validation validée")
def test_ui_components_integration(self):
"""Test 11: Vérifier l'intégration des composants UI"""
vwb_file = self.properties_panel_path / "VWBActionProperties.tsx"
content = vwb_file.read_text(encoding='utf-8')
# Vérifier les composants Material-UI utilisés
ui_components = [
"Alert severity=\"error\"",
"Alert severity=\"success\"",
"Accordion",
"AccordionSummary",
"AccordionDetails",
"Card variant=\"outlined\"",
"CardContent",
"CardMedia",
"Slider",
"Tooltip"
]
for component in ui_components:
assert component in content, f"Composant UI manquant: {component}"
print("✅ Intégration des composants UI validée")
def test_accessibility_features(self):
"""Test 12: Vérifier les fonctionnalités d'accessibilité"""
main_file = self.properties_panel_path / "index.tsx"
content = main_file.read_text(encoding='utf-8')
# Vérifier les attributs d'accessibilité
accessibility_features = [
"role=\"complementary\"",
"aria-label=",
"tabIndex={0}",
"onKeyDown={handleKeyDown}"
]
for feature in accessibility_features:
assert feature in content, f"Fonctionnalité d'accessibilité manquante: {feature}"
print("✅ Fonctionnalités d'accessibilité validées")
def test_error_handling(self):
"""Test 13: Vérifier la gestion d'erreurs"""
files_to_check = [
self.properties_panel_path / "index.tsx",
self.properties_panel_path / "VWBActionProperties.tsx"
]
for file_path in files_to_check:
content = file_path.read_text(encoding='utf-8')
# Vérifier la gestion d'erreurs (au moins un pattern doit être présent)
error_handling = [
"try {",
"} catch (error) {",
"console.error(",
]
# Au moins un pattern de gestion d'erreur doit être présent
has_error_handling = any(pattern in content for pattern in error_handling)
assert has_error_handling, f"Aucune gestion d'erreur trouvée dans {file_path.name}"
# Vérifier spécifiquement pour VWBActionProperties
if file_path.name == "VWBActionProperties.tsx":
assert "error instanceof Error" in content, f"Gestion d'erreur spécifique manquante dans {file_path.name}"
print("✅ Gestion d'erreurs validée")
def test_french_localization(self):
"""Test 14: Vérifier la localisation française"""
files_to_check = [
self.properties_panel_path / "index.tsx",
self.properties_panel_path / "VWBActionProperties.tsx"
]
# Messages français requis
french_messages = [
"Propriétés de l'étape",
"Paramètres requis",
"Paramètres optionnels",
"Sélectionner un élément",
"Configuration avancée",
"Seuil de confiance",
"Variables disponibles",
"Exemples d'utilisation"
]
for file_path in files_to_check:
content = file_path.read_text(encoding='utf-8')
# Compter les messages français trouvés
found_messages = sum(1 for msg in french_messages if msg in content)
# Au moins quelques messages doivent être présents dans chaque fichier
assert found_messages > 0, f"Aucun message français trouvé dans {file_path.name}"
print("✅ Localisation française validée")
def test_performance_optimizations(self):
"""Test 15: Vérifier les optimisations de performance"""
main_file = self.properties_panel_path / "index.tsx"
content = main_file.read_text(encoding='utf-8')
# Vérifier les optimisations
optimizations = [
"useMemo(",
"useCallback(",
"memo(PropertiesPanel",
"React.useEffect("
]
for optimization in optimizations:
assert optimization in content, f"Optimisation manquante: {optimization}"
print("✅ Optimisations de performance validées")
def run_tests():
"""Exécuter tous les tests"""
test_instance = TestVWBPropertiesPanelIntegration()
test_instance.setup_method()
tests = [
test_instance.test_properties_panel_structure,
test_instance.test_properties_panel_imports,
test_instance.test_vwb_action_detection_logic,
test_instance.test_vwb_action_loading_logic,
test_instance.test_vwb_parameter_handlers,
test_instance.test_conditional_rendering_logic,
test_instance.test_vwb_action_properties_structure,
test_instance.test_visual_anchor_editor,
test_instance.test_parameter_type_editors,
test_instance.test_validation_integration,
test_instance.test_ui_components_integration,
test_instance.test_accessibility_features,
test_instance.test_error_handling,
test_instance.test_french_localization,
test_instance.test_performance_optimizations,
]
passed = 0
failed = 0
print("🧪 TESTS UNITAIRES - PROPERTIES PANEL VWB INTÉGRATION")
print("=" * 60)
for test in tests:
try:
test()
passed += 1
except Exception as e:
print(f"{test.__name__}: {str(e)}")
failed += 1
print("\n" + "=" * 60)
print(f"📊 RÉSULTATS: {passed}/{len(tests)} tests réussis")
if failed == 0:
print("🎉 TOUS LES TESTS SONT PASSÉS!")
return True
else:
print(f"⚠️ {failed} test(s) échoué(s)")
return False
if __name__ == "__main__":
success = run_tests()
sys.exit(0 if success else 1)