- DAGExecutor : exécution workflow par graphe de dépendances,
étapes LLM parallèles, UI séquentielles, injection ${step.result}
- LLMActionHandler : analyze_text, translate, extract_data, generate_text
via Ollama /api/chat (qwen3-vl:8b, temperature 0.1)
- VWB palette : catégorie "IA / LLM" avec 4 actions draggables
- VWB propriétés : éditeurs pour chaque action LLM (modèle, prompt, langue)
- VWB endpoint : POST /api/v3/workflow/<id>/execute-dag
- 37 tests unitaires DAG executor (tous passent)
- Fix log spam cache workflows (info → debug)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1061 lines
36 KiB
Python
1061 lines
36 KiB
Python
"""
|
|
Tests unitaires pour le DAGExecutor — Exécuteur de workflow DAG
|
|
|
|
Teste :
|
|
- Exécution linéaire (A → B → C)
|
|
- Exécution parallèle de tâches LLM
|
|
- Injection de résultats (${step_id.result})
|
|
- Propagation d'échecs aux dépendants
|
|
- Annulation de workflow
|
|
- Détection de cycles
|
|
- Étapes conditionnelles et wait
|
|
|
|
Utilise des mocks LLM (time.sleep court) au lieu d'Ollama réel.
|
|
|
|
Auteur : Dom, Claude
|
|
Date : 16 mars 2026
|
|
"""
|
|
|
|
import threading
|
|
import time
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
from core.execution.dag_executor import (
|
|
DAGExecutionResult,
|
|
DAGExecutor,
|
|
StepStatus,
|
|
StepType,
|
|
WorkflowStep,
|
|
)
|
|
from core.execution.llm_actions import LLMActionHandler
|
|
|
|
|
|
# =============================================================================
|
|
# Fixtures
|
|
# =============================================================================
|
|
|
|
|
|
class MockLLMHandler:
|
|
"""Handler LLM simulé pour les tests.
|
|
|
|
Simule un appel LLM avec un court délai et retourne
|
|
un résultat prévisible basé sur l'action.
|
|
"""
|
|
|
|
def __init__(self, delay: float = 0.05):
|
|
self.delay = delay
|
|
self.call_count = 0
|
|
self._lock = threading.Lock()
|
|
|
|
def execute(self, action: dict, context: dict) -> str:
|
|
"""Simule un appel LLM."""
|
|
time.sleep(self.delay)
|
|
with self._lock:
|
|
self.call_count += 1
|
|
|
|
llm_action = action.get("llm_action", "unknown")
|
|
|
|
if llm_action == "translate":
|
|
target = action.get("target_lang", "?")
|
|
text = action.get("text", "")
|
|
return f"[Traduit en {target}] {text}"
|
|
|
|
if llm_action == "analyze_text":
|
|
return f"[Analyse] {action.get('text', '')[:50]}"
|
|
|
|
if llm_action == "generate_text":
|
|
return f"[Généré] {action.get('prompt', '')}"
|
|
|
|
if llm_action == "extract_data":
|
|
return {"extracted": True, "source": action.get("text", "")}
|
|
|
|
return f"[LLM:{llm_action}]"
|
|
|
|
|
|
class MockUIHandler:
|
|
"""Handler UI simulé pour les tests."""
|
|
|
|
def __init__(self, delay: float = 0.01):
|
|
self.delay = delay
|
|
self.executed_actions: list = []
|
|
self._lock = threading.Lock()
|
|
|
|
def __call__(self, action: dict) -> dict:
|
|
"""Simule une action UI."""
|
|
time.sleep(self.delay)
|
|
with self._lock:
|
|
self.executed_actions.append(action)
|
|
return {"success": True, "action_type": action.get("type", "unknown")}
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_llm():
|
|
"""Handler LLM mock."""
|
|
return MockLLMHandler(delay=0.05)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_ui():
|
|
"""Handler UI mock."""
|
|
return MockUIHandler(delay=0.01)
|
|
|
|
|
|
@pytest.fixture
|
|
def executor(mock_llm, mock_ui):
|
|
"""DAGExecutor configuré avec des handlers mock."""
|
|
return DAGExecutor(
|
|
max_llm_workers=2,
|
|
max_ui_workers=1,
|
|
llm_handler=mock_llm,
|
|
ui_handler=mock_ui,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Tests de workflow linéaire
|
|
# =============================================================================
|
|
|
|
|
|
class TestLinearWorkflow:
|
|
"""Teste l'exécution séquentielle A → B → C."""
|
|
|
|
def test_linear_three_steps(self, executor, mock_ui):
|
|
"""Trois étapes UI exécutées en séquence."""
|
|
steps = [
|
|
WorkflowStep(
|
|
step_id="step_1",
|
|
step_type=StepType.UI_ACTION,
|
|
action={"type": "click", "target": "bouton_ouvrir"},
|
|
),
|
|
WorkflowStep(
|
|
step_id="step_2",
|
|
step_type=StepType.UI_ACTION,
|
|
action={"type": "click", "target": "champ_texte"},
|
|
depends_on=["step_1"],
|
|
),
|
|
WorkflowStep(
|
|
step_id="step_3",
|
|
step_type=StepType.UI_ACTION,
|
|
action={"type": "type_text", "text": "Bonjour"},
|
|
depends_on=["step_2"],
|
|
),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
result = executor.execute(timeout=10)
|
|
|
|
assert result.success is True
|
|
assert len(result.results) == 3
|
|
assert len(result.errors) == 0
|
|
|
|
# Les 3 actions UI ont été exécutées
|
|
assert len(mock_ui.executed_actions) == 3
|
|
|
|
def test_linear_preserves_order(self, executor):
|
|
"""Les étapes linéaires s'exécutent dans l'ordre des dépendances."""
|
|
completion_order = []
|
|
lock = threading.Lock()
|
|
|
|
def on_change(step):
|
|
if step.status == StepStatus.COMPLETED:
|
|
with lock:
|
|
completion_order.append(step.step_id)
|
|
|
|
executor.on_step_change(on_change)
|
|
|
|
steps = [
|
|
WorkflowStep(step_id="A", step_type=StepType.UI_ACTION, action={"type": "a"}),
|
|
WorkflowStep(step_id="B", step_type=StepType.UI_ACTION, action={"type": "b"}, depends_on=["A"]),
|
|
WorkflowStep(step_id="C", step_type=StepType.UI_ACTION, action={"type": "c"}, depends_on=["B"]),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
executor.execute(timeout=10)
|
|
|
|
assert completion_order == ["A", "B", "C"]
|
|
|
|
def test_single_step_workflow(self, executor):
|
|
"""Un workflow avec une seule étape fonctionne."""
|
|
steps = [
|
|
WorkflowStep(
|
|
step_id="only",
|
|
step_type=StepType.UI_ACTION,
|
|
action={"type": "click"},
|
|
),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
result = executor.execute(timeout=5)
|
|
|
|
assert result.success is True
|
|
assert "only" in result.results
|
|
|
|
|
|
# =============================================================================
|
|
# Tests de parallélisme LLM
|
|
# =============================================================================
|
|
|
|
|
|
class TestParallelLLMSteps:
|
|
"""Teste l'exécution parallèle des étapes LLM."""
|
|
|
|
def test_parallel_translations(self, executor, mock_llm):
|
|
"""Deux traductions en parallèle après une analyse.
|
|
|
|
Graphe : A → B (analyse), B → C (trad FR), B → D (trad CN), C+D → E
|
|
"""
|
|
steps = [
|
|
WorkflowStep(
|
|
step_id="select",
|
|
step_type=StepType.UI_ACTION,
|
|
action={"type": "select_text"},
|
|
),
|
|
WorkflowStep(
|
|
step_id="analyze",
|
|
step_type=StepType.LLM_CALL,
|
|
action={"llm_action": "analyze_text", "text": "Hello world"},
|
|
depends_on=["select"],
|
|
),
|
|
WorkflowStep(
|
|
step_id="trad_fr",
|
|
step_type=StepType.LLM_CALL,
|
|
action={
|
|
"llm_action": "translate",
|
|
"text": "${analyze.result}",
|
|
"target_lang": "français",
|
|
},
|
|
depends_on=["analyze"],
|
|
),
|
|
WorkflowStep(
|
|
step_id="trad_cn",
|
|
step_type=StepType.LLM_CALL,
|
|
action={
|
|
"llm_action": "translate",
|
|
"text": "${analyze.result}",
|
|
"target_lang": "chinois",
|
|
},
|
|
depends_on=["analyze"],
|
|
),
|
|
WorkflowStep(
|
|
step_id="done",
|
|
step_type=StepType.UI_ACTION,
|
|
action={"type": "notify", "message": "Terminé"},
|
|
depends_on=["trad_fr", "trad_cn"],
|
|
),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
result = executor.execute(timeout=10)
|
|
|
|
assert result.success is True
|
|
assert mock_llm.call_count == 3 # analyze + 2 traductions
|
|
|
|
# Les traductions FR et CN contiennent le résultat de l'analyse
|
|
assert "français" in result.results["trad_fr"]
|
|
assert "chinois" in result.results["trad_cn"]
|
|
|
|
def test_parallel_steps_faster_than_sequential(self, executor):
|
|
"""Les étapes parallèles sont plus rapides que séquentielles.
|
|
|
|
Deux LLM de 0.1s chacun en parallèle devraient prendre ~0.1s,
|
|
pas ~0.2s.
|
|
"""
|
|
slow_llm = MockLLMHandler(delay=0.1)
|
|
executor.set_llm_handler(slow_llm)
|
|
|
|
steps = [
|
|
WorkflowStep(
|
|
step_id="llm_a",
|
|
step_type=StepType.LLM_CALL,
|
|
action={"llm_action": "analyze_text", "text": "A"},
|
|
),
|
|
WorkflowStep(
|
|
step_id="llm_b",
|
|
step_type=StepType.LLM_CALL,
|
|
action={"llm_action": "analyze_text", "text": "B"},
|
|
),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
start = time.monotonic()
|
|
result = executor.execute(timeout=5)
|
|
elapsed = time.monotonic() - start
|
|
|
|
assert result.success is True
|
|
# En parallèle : ~0.1s. En séquentiel ça serait ~0.2s.
|
|
# On laisse une marge pour la latence de threading.
|
|
assert elapsed < 0.18, f"Trop lent ({elapsed:.3f}s), pas de parallélisme ?"
|
|
|
|
def test_ui_action_during_llm(self, executor):
|
|
"""Les actions UI peuvent s'exécuter pendant que les LLM tournent.
|
|
|
|
Graphe :
|
|
- step_1 (UI) → step_2 (LLM, lent)
|
|
- step_3 (UI, indépendant) peut démarrer immédiatement
|
|
"""
|
|
slow_llm = MockLLMHandler(delay=0.15)
|
|
executor.set_llm_handler(slow_llm)
|
|
|
|
completion_order = []
|
|
lock = threading.Lock()
|
|
|
|
def on_change(step):
|
|
if step.status == StepStatus.COMPLETED:
|
|
with lock:
|
|
completion_order.append(step.step_id)
|
|
|
|
executor.on_step_change(on_change)
|
|
|
|
steps = [
|
|
WorkflowStep(
|
|
step_id="open_app",
|
|
step_type=StepType.UI_ACTION,
|
|
action={"type": "open", "app": "onlyoffice"},
|
|
),
|
|
WorkflowStep(
|
|
step_id="slow_analysis",
|
|
step_type=StepType.LLM_CALL,
|
|
action={"llm_action": "analyze_text", "text": "Long text..."},
|
|
depends_on=["open_app"],
|
|
),
|
|
WorkflowStep(
|
|
step_id="open_gedit",
|
|
step_type=StepType.UI_ACTION,
|
|
action={"type": "open", "app": "gedit"},
|
|
# Pas de dépendance → peut démarrer en parallèle
|
|
),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
result = executor.execute(timeout=10)
|
|
|
|
assert result.success is True
|
|
# open_gedit devrait terminer avant slow_analysis
|
|
gedit_idx = completion_order.index("open_gedit")
|
|
analysis_idx = completion_order.index("slow_analysis")
|
|
assert gedit_idx < analysis_idx, (
|
|
f"open_gedit ({gedit_idx}) aurait dû terminer avant "
|
|
f"slow_analysis ({analysis_idx})"
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Tests d'injection de résultats
|
|
# =============================================================================
|
|
|
|
|
|
class TestDependencyInjection:
|
|
"""Teste la substitution ${step_id.result} dans les paramètres."""
|
|
|
|
def test_simple_result_injection(self, executor, mock_llm):
|
|
"""${step_id.result} est remplacé par le résultat de l'étape."""
|
|
steps = [
|
|
WorkflowStep(
|
|
step_id="analyze",
|
|
step_type=StepType.LLM_CALL,
|
|
action={"llm_action": "analyze_text", "text": "Hello"},
|
|
),
|
|
WorkflowStep(
|
|
step_id="translate",
|
|
step_type=StepType.LLM_CALL,
|
|
action={
|
|
"llm_action": "translate",
|
|
"text": "${analyze.result}",
|
|
"target_lang": "français",
|
|
},
|
|
depends_on=["analyze"],
|
|
),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
result = executor.execute(timeout=10)
|
|
|
|
assert result.success is True
|
|
# Le texte de la traduction contient le résultat de l'analyse
|
|
translate_result = result.results["translate"]
|
|
assert "[Analyse]" in translate_result
|
|
|
|
def test_nested_dict_injection(self, executor):
|
|
"""L'injection fonctionne dans les dicts imbriqués."""
|
|
# Simuler un résultat de type dict
|
|
mock_llm = MagicMock()
|
|
mock_llm.execute.return_value = {"summary": "Résumé du texte", "lang": "en"}
|
|
executor.set_llm_handler(mock_llm)
|
|
|
|
steps = [
|
|
WorkflowStep(
|
|
step_id="extract",
|
|
step_type=StepType.LLM_CALL,
|
|
action={"llm_action": "extract_data", "text": "data"},
|
|
),
|
|
WorkflowStep(
|
|
step_id="use_result",
|
|
step_type=StepType.LLM_CALL,
|
|
action={
|
|
"llm_action": "generate_text",
|
|
"prompt": "Utilise : ${extract.result.summary}",
|
|
},
|
|
depends_on=["extract"],
|
|
),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
result = executor.execute(timeout=10)
|
|
|
|
assert result.success is True
|
|
# Vérifier que le second appel a reçu la valeur injectée
|
|
second_call_action = mock_llm.execute.call_args_list[1][0][0]
|
|
assert second_call_action["prompt"] == "Utilise : Résumé du texte"
|
|
|
|
def test_multiple_references_in_string(self, executor):
|
|
"""Plusieurs références ${...} dans une même chaîne."""
|
|
mock_llm = MagicMock()
|
|
mock_llm.execute.side_effect = ["Bonjour", "世界"]
|
|
executor.set_llm_handler(mock_llm)
|
|
|
|
steps = [
|
|
WorkflowStep(
|
|
step_id="trad_fr",
|
|
step_type=StepType.LLM_CALL,
|
|
action={"llm_action": "translate", "text": "Hello"},
|
|
),
|
|
WorkflowStep(
|
|
step_id="trad_cn",
|
|
step_type=StepType.LLM_CALL,
|
|
action={"llm_action": "translate", "text": "World"},
|
|
),
|
|
WorkflowStep(
|
|
step_id="combine",
|
|
step_type=StepType.UI_ACTION,
|
|
action={
|
|
"type": "type_text",
|
|
"text": "FR: ${trad_fr.result}, CN: ${trad_cn.result}",
|
|
},
|
|
depends_on=["trad_fr", "trad_cn"],
|
|
),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
result = executor.execute(timeout=10)
|
|
|
|
assert result.success is True
|
|
# Vérifier via le handler UI que le texte a été injecté
|
|
combine_action = result.steps["combine"]["action"]
|
|
# Le résultat de combine contient les deux traductions
|
|
# (vérifié via le mock_ui qui reçoit l'action résolue)
|
|
|
|
def test_missing_reference_preserved(self, executor, mock_llm):
|
|
"""Une référence à un résultat inexistant est conservée telle quelle."""
|
|
steps = [
|
|
WorkflowStep(
|
|
step_id="use_missing",
|
|
step_type=StepType.LLM_CALL,
|
|
action={
|
|
"llm_action": "generate_text",
|
|
"prompt": "Valeur : ${nonexistent.result}",
|
|
},
|
|
),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
result = executor.execute(timeout=5)
|
|
|
|
assert result.success is True
|
|
|
|
|
|
# =============================================================================
|
|
# Tests de gestion des échecs
|
|
# =============================================================================
|
|
|
|
|
|
class TestFailureHandling:
|
|
"""Teste la propagation des échecs dans le DAG."""
|
|
|
|
def test_failed_step_blocks_dependents(self, executor):
|
|
"""Une étape échouée cause le SKIP de ses dépendants."""
|
|
failing_llm = MagicMock()
|
|
failing_llm.execute.side_effect = RuntimeError("Ollama down")
|
|
executor.set_llm_handler(failing_llm)
|
|
|
|
steps = [
|
|
WorkflowStep(
|
|
step_id="will_fail",
|
|
step_type=StepType.LLM_CALL,
|
|
action={"llm_action": "analyze_text", "text": "test"},
|
|
),
|
|
WorkflowStep(
|
|
step_id="dependent_1",
|
|
step_type=StepType.LLM_CALL,
|
|
action={"llm_action": "translate", "text": "${will_fail.result}"},
|
|
depends_on=["will_fail"],
|
|
),
|
|
WorkflowStep(
|
|
step_id="dependent_2",
|
|
step_type=StepType.UI_ACTION,
|
|
action={"type": "click"},
|
|
depends_on=["dependent_1"],
|
|
),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
result = executor.execute(timeout=10)
|
|
|
|
assert result.success is False
|
|
assert result.steps["will_fail"]["status"] == "failed"
|
|
assert result.steps["dependent_1"]["status"] == "skipped"
|
|
assert result.steps["dependent_2"]["status"] == "skipped"
|
|
assert len(result.errors) >= 1
|
|
|
|
def test_independent_steps_continue_after_failure(self, executor, mock_llm):
|
|
"""Les étapes indépendantes continuent même si une autre échoue."""
|
|
# Configurer un handler qui échoue seulement pour step_fail
|
|
original_execute = mock_llm.execute
|
|
|
|
def selective_fail(action, context):
|
|
if action.get("_will_fail"):
|
|
raise RuntimeError("Échec intentionnel")
|
|
return original_execute(action, context)
|
|
|
|
mock_llm.execute = selective_fail
|
|
|
|
steps = [
|
|
WorkflowStep(
|
|
step_id="step_fail",
|
|
step_type=StepType.LLM_CALL,
|
|
action={"llm_action": "analyze_text", "text": "fail", "_will_fail": True},
|
|
),
|
|
WorkflowStep(
|
|
step_id="step_ok",
|
|
step_type=StepType.LLM_CALL,
|
|
action={"llm_action": "analyze_text", "text": "ok"},
|
|
),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
result = executor.execute(timeout=10)
|
|
|
|
assert result.success is False # Global = échec car step_fail a échoué
|
|
assert result.steps["step_fail"]["status"] == "failed"
|
|
assert result.steps["step_ok"]["status"] == "completed"
|
|
|
|
def test_partial_dependency_failure(self, executor, mock_llm):
|
|
"""Si une branche échoue mais l'autre réussit, le dépendant commun est SKIPPED."""
|
|
original_execute = mock_llm.execute
|
|
|
|
def selective_fail(action, context):
|
|
if action.get("_will_fail"):
|
|
raise RuntimeError("Branche échouée")
|
|
return original_execute(action, context)
|
|
|
|
mock_llm.execute = selective_fail
|
|
|
|
steps = [
|
|
WorkflowStep(
|
|
step_id="branch_ok",
|
|
step_type=StepType.LLM_CALL,
|
|
action={"llm_action": "analyze_text", "text": "ok"},
|
|
),
|
|
WorkflowStep(
|
|
step_id="branch_fail",
|
|
step_type=StepType.LLM_CALL,
|
|
action={"llm_action": "analyze_text", "_will_fail": True},
|
|
),
|
|
WorkflowStep(
|
|
step_id="join",
|
|
step_type=StepType.UI_ACTION,
|
|
action={"type": "click"},
|
|
depends_on=["branch_ok", "branch_fail"],
|
|
),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
result = executor.execute(timeout=10)
|
|
|
|
assert result.steps["branch_ok"]["status"] == "completed"
|
|
assert result.steps["branch_fail"]["status"] == "failed"
|
|
assert result.steps["join"]["status"] == "skipped"
|
|
|
|
|
|
# =============================================================================
|
|
# Tests d'annulation
|
|
# =============================================================================
|
|
|
|
|
|
class TestCancelWorkflow:
|
|
"""Teste l'annulation de workflow en cours."""
|
|
|
|
def test_cancel_stops_new_steps(self, executor):
|
|
"""L'annulation empêche le démarrage de nouvelles étapes."""
|
|
# LLM lent pour avoir le temps d'annuler
|
|
slow_llm = MockLLMHandler(delay=0.3)
|
|
executor.set_llm_handler(slow_llm)
|
|
|
|
steps = [
|
|
WorkflowStep(
|
|
step_id="slow_step",
|
|
step_type=StepType.LLM_CALL,
|
|
action={"llm_action": "analyze_text", "text": "lent"},
|
|
),
|
|
WorkflowStep(
|
|
step_id="after_slow",
|
|
step_type=StepType.UI_ACTION,
|
|
action={"type": "click"},
|
|
depends_on=["slow_step"],
|
|
),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
|
|
# Annuler après un court délai
|
|
def cancel_soon():
|
|
time.sleep(0.05)
|
|
executor.cancel()
|
|
|
|
cancel_thread = threading.Thread(target=cancel_soon)
|
|
cancel_thread.start()
|
|
|
|
result = executor.execute(timeout=10)
|
|
cancel_thread.join()
|
|
|
|
# Le workflow ne devrait pas être un succès complet
|
|
status = executor.get_status()
|
|
assert status["cancelled"] is True
|
|
|
|
def test_cancel_idempotent(self, executor, mock_llm):
|
|
"""Appeler cancel() plusieurs fois ne pose pas de problème."""
|
|
steps = [
|
|
WorkflowStep(
|
|
step_id="s1",
|
|
step_type=StepType.UI_ACTION,
|
|
action={"type": "click"},
|
|
),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
executor.cancel()
|
|
executor.cancel() # Deuxième appel — ne doit pas lever d'exception
|
|
|
|
status = executor.get_status()
|
|
assert status["cancelled"] is True
|
|
|
|
|
|
# =============================================================================
|
|
# Tests de validation du DAG
|
|
# =============================================================================
|
|
|
|
|
|
class TestDAGValidation:
|
|
"""Teste la validation de la structure du graphe."""
|
|
|
|
def test_cycle_detection(self, executor):
|
|
"""Un cycle dans le graphe lève une ValueError."""
|
|
steps = [
|
|
WorkflowStep(step_id="A", step_type=StepType.UI_ACTION, depends_on=["C"]),
|
|
WorkflowStep(step_id="B", step_type=StepType.UI_ACTION, depends_on=["A"]),
|
|
WorkflowStep(step_id="C", step_type=StepType.UI_ACTION, depends_on=["B"]),
|
|
]
|
|
|
|
with pytest.raises(ValueError, match="[Cc]ycle"):
|
|
executor.load_workflow(steps)
|
|
|
|
def test_self_dependency_detected(self, executor):
|
|
"""Une étape qui dépend d'elle-même est détectée comme cycle."""
|
|
steps = [
|
|
WorkflowStep(step_id="self_ref", step_type=StepType.UI_ACTION, depends_on=["self_ref"]),
|
|
]
|
|
|
|
with pytest.raises(ValueError, match="[Cc]ycle"):
|
|
executor.load_workflow(steps)
|
|
|
|
def test_missing_dependency_raises(self, executor):
|
|
"""Référencer une dépendance inexistante lève une ValueError."""
|
|
steps = [
|
|
WorkflowStep(
|
|
step_id="orphan",
|
|
step_type=StepType.UI_ACTION,
|
|
depends_on=["nonexistent"],
|
|
),
|
|
]
|
|
|
|
with pytest.raises(ValueError, match="nonexistent"):
|
|
executor.load_workflow(steps)
|
|
|
|
def test_duplicate_step_id_raises(self, executor):
|
|
"""Deux étapes avec le même ID lèvent une ValueError."""
|
|
steps = [
|
|
WorkflowStep(step_id="dup", step_type=StepType.UI_ACTION),
|
|
WorkflowStep(step_id="dup", step_type=StepType.LLM_CALL),
|
|
]
|
|
|
|
with pytest.raises(ValueError, match="dupliqué"):
|
|
executor.load_workflow(steps)
|
|
|
|
def test_empty_workflow(self, executor):
|
|
"""Un workflow vide est valide et s'exécute immédiatement."""
|
|
executor.load_workflow([])
|
|
result = executor.execute(timeout=5)
|
|
assert result.success is True
|
|
|
|
|
|
# =============================================================================
|
|
# Tests des étapes spéciales (WAIT, CONDITION)
|
|
# =============================================================================
|
|
|
|
|
|
class TestSpecialSteps:
|
|
"""Teste les étapes WAIT et CONDITION."""
|
|
|
|
def test_wait_step(self, executor):
|
|
"""L'étape WAIT attend la durée spécifiée."""
|
|
steps = [
|
|
WorkflowStep(
|
|
step_id="wait_short",
|
|
step_type=StepType.WAIT,
|
|
action={"duration": 0.05},
|
|
),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
start = time.monotonic()
|
|
result = executor.execute(timeout=5)
|
|
elapsed = time.monotonic() - start
|
|
|
|
assert result.success is True
|
|
assert elapsed >= 0.04 # Au moins 40ms (marge)
|
|
assert result.results["wait_short"]["waited"] == 0.05
|
|
|
|
def test_condition_true(self, executor, mock_llm):
|
|
"""Une condition vraie laisse passer les dépendants."""
|
|
steps = [
|
|
WorkflowStep(
|
|
step_id="check",
|
|
step_type=StepType.CONDITION,
|
|
action={"condition": "True"},
|
|
),
|
|
WorkflowStep(
|
|
step_id="after_check",
|
|
step_type=StepType.UI_ACTION,
|
|
action={"type": "click"},
|
|
depends_on=["check"],
|
|
),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
result = executor.execute(timeout=5)
|
|
|
|
assert result.success is True
|
|
assert result.steps["check"]["status"] == "completed"
|
|
assert result.steps["after_check"]["status"] == "completed"
|
|
|
|
def test_condition_false_with_skip(self, executor):
|
|
"""Une condition fausse avec skip_on_false ignore les dépendants."""
|
|
steps = [
|
|
WorkflowStep(
|
|
step_id="check_false",
|
|
step_type=StepType.CONDITION,
|
|
action={"condition": "False", "skip_on_false": True},
|
|
),
|
|
WorkflowStep(
|
|
step_id="should_skip",
|
|
step_type=StepType.UI_ACTION,
|
|
action={"type": "click"},
|
|
depends_on=["check_false"],
|
|
),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
result = executor.execute(timeout=5)
|
|
|
|
assert result.steps["check_false"]["status"] == "completed"
|
|
assert result.steps["should_skip"]["status"] == "skipped"
|
|
|
|
|
|
# =============================================================================
|
|
# Tests du statut et des callbacks
|
|
# =============================================================================
|
|
|
|
|
|
class TestStatusAndCallbacks:
|
|
"""Teste le reporting de statut et les callbacks."""
|
|
|
|
def test_get_status_returns_all_steps(self, executor):
|
|
"""get_status() retourne l'état de toutes les étapes."""
|
|
steps = [
|
|
WorkflowStep(step_id="s1", step_type=StepType.UI_ACTION, action={"type": "a"}),
|
|
WorkflowStep(step_id="s2", step_type=StepType.UI_ACTION, action={"type": "b"}, depends_on=["s1"]),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
status = executor.get_status()
|
|
|
|
assert "s1" in status["steps"]
|
|
assert "s2" in status["steps"]
|
|
assert status["steps"]["s1"]["status"] == "pending"
|
|
|
|
def test_callbacks_called_on_status_change(self, executor, mock_llm):
|
|
"""Les callbacks sont appelés à chaque changement de statut."""
|
|
changes = []
|
|
|
|
def track_changes(step):
|
|
changes.append((step.step_id, step.status.value))
|
|
|
|
executor.on_step_change(track_changes)
|
|
|
|
steps = [
|
|
WorkflowStep(
|
|
step_id="only",
|
|
step_type=StepType.UI_ACTION,
|
|
action={"type": "click"},
|
|
),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
executor.execute(timeout=5)
|
|
|
|
# Au minimum : RUNNING → COMPLETED
|
|
statuses = [s for sid, s in changes if sid == "only"]
|
|
assert "running" in statuses
|
|
assert "completed" in statuses
|
|
|
|
def test_callback_error_does_not_crash(self, executor, mock_llm):
|
|
"""Un callback qui lève une exception ne fait pas planter l'exécution."""
|
|
|
|
def bad_callback(step):
|
|
raise RuntimeError("Callback cassé !")
|
|
|
|
executor.on_step_change(bad_callback)
|
|
|
|
steps = [
|
|
WorkflowStep(
|
|
step_id="robust",
|
|
step_type=StepType.UI_ACTION,
|
|
action={"type": "click"},
|
|
),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
result = executor.execute(timeout=5)
|
|
|
|
# L'exécution réussit malgré le callback cassé
|
|
assert result.success is True
|
|
|
|
def test_step_duration_tracked(self, executor):
|
|
"""La durée de chaque étape est enregistrée."""
|
|
slow_llm = MockLLMHandler(delay=0.05)
|
|
executor.set_llm_handler(slow_llm)
|
|
|
|
steps = [
|
|
WorkflowStep(
|
|
step_id="timed",
|
|
step_type=StepType.LLM_CALL,
|
|
action={"llm_action": "analyze_text", "text": "test"},
|
|
),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
result = executor.execute(timeout=5)
|
|
|
|
step_data = result.steps["timed"]
|
|
assert step_data["duration"] is not None
|
|
assert step_data["duration"] >= 0.04 # Au moins ~50ms
|
|
|
|
|
|
# =============================================================================
|
|
# Tests du workflow complet (scénario utilisateur)
|
|
# =============================================================================
|
|
|
|
|
|
class TestFullWorkflowScenario:
|
|
"""Teste le scénario complet décrit dans la spécification."""
|
|
|
|
def test_onlyoffice_translate_gedit_scenario(self, executor, mock_llm, mock_ui):
|
|
"""Scénario complet : OnlyOffice → analyse → 2 traductions → Gedit.
|
|
|
|
1. Ouvrir OnlyOffice (UI)
|
|
2. Sélectionner texte (UI, dépend de 1)
|
|
3. Analyser texte (LLM, dépend de 2)
|
|
4. Traduire FR (LLM, dépend de 3)
|
|
5. Traduire CN (LLM, dépend de 3, parallèle avec 4)
|
|
6. Ouvrir Gedit (UI, indépendant)
|
|
7. Écrire FR (UI, dépend de 4 ET 6)
|
|
8. Écrire CN (UI, dépend de 5 ET 7)
|
|
"""
|
|
steps = [
|
|
WorkflowStep(
|
|
step_id="open_office",
|
|
step_type=StepType.UI_ACTION,
|
|
action={"type": "open", "app": "onlyoffice"},
|
|
),
|
|
WorkflowStep(
|
|
step_id="select_text",
|
|
step_type=StepType.UI_ACTION,
|
|
action={"type": "select_all"},
|
|
depends_on=["open_office"],
|
|
),
|
|
WorkflowStep(
|
|
step_id="analyze",
|
|
step_type=StepType.LLM_CALL,
|
|
action={"llm_action": "analyze_text", "text": "Sample document text"},
|
|
depends_on=["select_text"],
|
|
),
|
|
WorkflowStep(
|
|
step_id="trad_fr",
|
|
step_type=StepType.LLM_CALL,
|
|
action={
|
|
"llm_action": "translate",
|
|
"text": "${analyze.result}",
|
|
"target_lang": "français",
|
|
},
|
|
depends_on=["analyze"],
|
|
),
|
|
WorkflowStep(
|
|
step_id="trad_cn",
|
|
step_type=StepType.LLM_CALL,
|
|
action={
|
|
"llm_action": "translate",
|
|
"text": "${analyze.result}",
|
|
"target_lang": "chinois",
|
|
},
|
|
depends_on=["analyze"],
|
|
),
|
|
WorkflowStep(
|
|
step_id="open_gedit",
|
|
step_type=StepType.UI_ACTION,
|
|
action={"type": "open", "app": "gedit"},
|
|
# Pas de dépendance — peut démarrer dès le début
|
|
),
|
|
WorkflowStep(
|
|
step_id="write_fr",
|
|
step_type=StepType.UI_ACTION,
|
|
action={
|
|
"type": "type_text",
|
|
"text": "${trad_fr.result}",
|
|
},
|
|
depends_on=["trad_fr", "open_gedit"],
|
|
),
|
|
WorkflowStep(
|
|
step_id="write_cn",
|
|
step_type=StepType.UI_ACTION,
|
|
action={
|
|
"type": "type_text",
|
|
"text": "${trad_cn.result}",
|
|
},
|
|
depends_on=["trad_cn", "write_fr"],
|
|
),
|
|
]
|
|
|
|
executor.load_workflow(steps)
|
|
result = executor.execute(timeout=30)
|
|
|
|
assert result.success is True
|
|
assert len(result.results) == 8
|
|
assert len(result.errors) == 0
|
|
|
|
# Vérifier que les traductions ont été produites
|
|
assert "français" in result.results["trad_fr"]
|
|
assert "chinois" in result.results["trad_cn"]
|
|
|
|
# Vérifier que toutes les étapes sont COMPLETED
|
|
for sid, step_data in result.steps.items():
|
|
assert step_data["status"] == "completed", (
|
|
f"Étape '{sid}' devrait être completed, "
|
|
f"pas {step_data['status']}"
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Tests du LLMActionHandler (mock Ollama)
|
|
# =============================================================================
|
|
|
|
|
|
class TestLLMActionHandler:
|
|
"""Teste le LLMActionHandler avec un serveur Ollama mocké."""
|
|
|
|
def test_dispatch_analyze(self):
|
|
"""Le dispatcher route correctement vers analyze_text."""
|
|
handler = LLMActionHandler()
|
|
with patch.object(handler, "_chat", return_value="Résumé du texte") as mock_chat:
|
|
result = handler.execute(
|
|
{"llm_action": "analyze_text", "text": "Hello", "instruction": "Résume"},
|
|
{},
|
|
)
|
|
assert result == "Résumé du texte"
|
|
mock_chat.assert_called_once()
|
|
|
|
def test_dispatch_translate(self):
|
|
"""Le dispatcher route correctement vers translate."""
|
|
handler = LLMActionHandler()
|
|
with patch.object(handler, "_chat", return_value="Bonjour") as mock_chat:
|
|
result = handler.execute(
|
|
{"llm_action": "translate", "text": "Hello", "target_lang": "français"},
|
|
{},
|
|
)
|
|
assert result == "Bonjour"
|
|
|
|
def test_dispatch_unknown_action_raises(self):
|
|
"""Une action LLM inconnue lève une ValueError."""
|
|
handler = LLMActionHandler()
|
|
with pytest.raises(ValueError, match="inconnue"):
|
|
handler.execute({"llm_action": "unknown_action"}, {})
|
|
|
|
def test_extract_data_parses_json(self):
|
|
"""extract_data parse la réponse JSON du LLM."""
|
|
handler = LLMActionHandler()
|
|
json_response = '{"name": "Jean", "age": 42}'
|
|
with patch.object(handler, "_chat", return_value=json_response):
|
|
result = handler.extract_data("texte", {"name": "str", "age": "int"})
|
|
assert result == {"name": "Jean", "age": 42}
|
|
|
|
def test_extract_data_handles_bad_json(self):
|
|
"""extract_data gère gracieusement une réponse non-JSON."""
|
|
handler = LLMActionHandler()
|
|
with patch.object(handler, "_chat", return_value="Voici le résultat: {\"a\": 1}"):
|
|
result = handler.extract_data("texte", {"a": "int"})
|
|
assert result["a"] == 1
|
|
|
|
def test_chat_uses_api_chat_endpoint(self):
|
|
"""_chat appelle /api/chat (pas /api/generate)."""
|
|
handler = LLMActionHandler(ollama_endpoint="http://localhost:11434")
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"message": {"content": "Réponse test"}
|
|
}
|
|
|
|
with patch("core.execution.llm_actions.requests.post", return_value=mock_response) as mock_post:
|
|
result = handler._chat("system", "user message")
|
|
assert result == "Réponse test"
|
|
# Vérifier que c'est bien /api/chat
|
|
call_url = mock_post.call_args[0][0]
|
|
assert call_url == "http://localhost:11434/api/chat"
|
|
|
|
def test_chat_includes_nothink_for_qwen(self):
|
|
"""Pour les modèles Qwen, /nothink est ajouté au message utilisateur."""
|
|
handler = LLMActionHandler(model="qwen3-vl:8b")
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {"message": {"content": "ok"}}
|
|
|
|
with patch("core.execution.llm_actions.requests.post", return_value=mock_response) as mock_post:
|
|
handler._chat("system", "test message")
|
|
payload = mock_post.call_args[1]["json"]
|
|
user_msg = payload["messages"][1]["content"]
|
|
assert user_msg.startswith("/nothink")
|
|
|
|
def test_connection_error_raises_runtime(self):
|
|
"""Une erreur de connexion lève RuntimeError."""
|
|
handler = LLMActionHandler(ollama_endpoint="http://localhost:99999")
|
|
with patch("core.execution.llm_actions.requests.post", side_effect=requests.exceptions.ConnectionError):
|
|
with pytest.raises(RuntimeError, match="connecter"):
|
|
handler._chat("system", "test")
|
|
|
|
def test_timeout_error_raises_runtime(self):
|
|
"""Un timeout lève RuntimeError."""
|
|
handler = LLMActionHandler(timeout=1)
|
|
with patch("core.execution.llm_actions.requests.post", side_effect=requests.exceptions.Timeout):
|
|
with pytest.raises(RuntimeError, match="[Tt]imeout"):
|
|
handler._chat("system", "test")
|