Files
rpa_vision_v3/tests/unit/test_dag_executor.py
Dom 5e3865d328 feat: DAG executor async + intégration IA/LLM dans le VWB
- 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>
2026-03-16 22:58:44 +01:00

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")