feat: Léa apprentissage — mode Shadow amélioré (observation + validation)
Aspect 3/4 Léa : Léa montre ce qu'elle comprend pendant l'enregistrement. ShadowObserver (observation temps réel) : - Segmentation incrémentale en UnderstoodStep (changement app, pause, Ctrl+S) - Détection de variables pendant la saisie (typage : date, email, code, texte) - Notifications 4 niveaux : INFO, DECOUVERTE, QUESTION, VARIABLE - Heartbeat périodique, hook gemma4 optionnel (asynchrone) - Thread-safe (RLock), singleton partagé - Performance : 1000 events en < 500ms ShadowValidator (feedback utilisateur) : - 6 actions : validate, correct, undo, cancel, merge_next, split - Reconstruit un WorkflowIR propre avec variables substituées - Historique complet des feedbacks 5 endpoints REST /api/v1/shadow/* : - start, stop, feedback, understanding, build Hook non-bloquant dans stream_event() (try/except, no-op si inactif). Mode optionnel : pas d'impact tant que shadow/start n'est pas appelé. 54 tests (26 observer + 28 validator), 0 régression. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
430
tests/unit/test_shadow_observer.py
Normal file
430
tests/unit/test_shadow_observer.py
Normal file
@@ -0,0 +1,430 @@
|
||||
"""
|
||||
Tests du ShadowObserver — observation temps réel de Léa.
|
||||
|
||||
Vérifie que :
|
||||
- L'observer démarre et s'arrête correctement
|
||||
- Les événements sont segmentés en étapes logiques
|
||||
- Les variables sont détectées pendant la frappe
|
||||
- Les notifications sont émises avec le bon niveau
|
||||
- La compréhension est accessible en temps réel
|
||||
- Le callback de notification est appelé
|
||||
- Les événements parasites (heartbeat) sont ignorés
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
_ROOT = str(Path(__file__).resolve().parents[2])
|
||||
if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
from core.workflow.shadow_observer import (
|
||||
NiveauNotification,
|
||||
NotificationShadow,
|
||||
ShadowObserver,
|
||||
UnderstoodStep,
|
||||
get_shared_observer,
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Fixtures
|
||||
# =========================================================================
|
||||
|
||||
|
||||
def _evt_click(text="Rechercher", title="Explorateur", ts=100.0):
|
||||
return {
|
||||
"type": "mouse_click",
|
||||
"pos": [400, 580],
|
||||
"window": {"title": title, "app_name": title.split(" - ")[-1] if " - " in title else title},
|
||||
"timestamp": ts,
|
||||
"vision_info": {"text": text},
|
||||
}
|
||||
|
||||
|
||||
def _evt_type(text="bonjour", title="Bloc-notes", ts=101.0):
|
||||
return {
|
||||
"type": "text_input",
|
||||
"text": text,
|
||||
"window": {"title": title, "app_name": title},
|
||||
"timestamp": ts,
|
||||
}
|
||||
|
||||
|
||||
def _evt_key(keys=None, title="Bloc-notes", ts=102.0):
|
||||
return {
|
||||
"type": "key_combo",
|
||||
"keys": keys or ["enter"],
|
||||
"window": {"title": title, "app_name": title},
|
||||
"timestamp": ts,
|
||||
}
|
||||
|
||||
|
||||
def _evt_heartbeat(ts=103.0):
|
||||
return {"type": "heartbeat", "timestamp": ts}
|
||||
|
||||
|
||||
def _make_session_events():
|
||||
"""Scénario typique : ouvrir Bloc-notes, taper du texte, sauvegarder."""
|
||||
return [
|
||||
_evt_click(text="Rechercher", title="Menu Démarrer", ts=100.0),
|
||||
_evt_type(text="blocnote", title="Menu Démarrer", ts=101.0),
|
||||
_evt_key(keys=["enter"], title="Menu Démarrer", ts=102.0),
|
||||
_evt_heartbeat(ts=103.0), # Doit être ignoré
|
||||
# Changement d'application → nouveau segment attendu
|
||||
_evt_click(text="", title="Sans titre - Bloc-notes", ts=105.0),
|
||||
_evt_type(text="Bonjour le monde", title="Sans titre - Bloc-notes", ts=106.0),
|
||||
_evt_key(keys=["ctrl", "s"], title="Sans titre - Bloc-notes", ts=108.0),
|
||||
]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Tests de base
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestShadowObserverBase:
|
||||
|
||||
def test_start_et_stop(self):
|
||||
obs = ShadowObserver()
|
||||
obs.start("sess_test")
|
||||
assert obs.has_session("sess_test")
|
||||
obs.stop("sess_test")
|
||||
assert obs.has_session("sess_test") # stop ne supprime pas
|
||||
obs.reset("sess_test")
|
||||
assert not obs.has_session("sess_test")
|
||||
|
||||
def test_auto_start_sur_premier_event(self):
|
||||
"""observe_event() sans start() doit auto-démarrer la session."""
|
||||
obs = ShadowObserver()
|
||||
obs.observe_event("sess_auto", _evt_click())
|
||||
assert obs.has_session("sess_auto")
|
||||
steps = obs.get_understanding("sess_auto")
|
||||
assert len(steps) >= 1
|
||||
|
||||
def test_heartbeat_ignore(self):
|
||||
obs = ShadowObserver()
|
||||
obs.start("s1")
|
||||
obs.observe_event("s1", _evt_heartbeat())
|
||||
steps = obs.get_understanding("s1")
|
||||
assert len(steps) == 0 # Aucune étape créée par un heartbeat
|
||||
|
||||
def test_focus_change_ignore(self):
|
||||
obs = ShadowObserver()
|
||||
obs.start("s1")
|
||||
obs.observe_event("s1", {"type": "focus_change", "timestamp": 100})
|
||||
assert len(obs.get_understanding("s1")) == 0
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Segmentation
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestShadowObserverSegmentation:
|
||||
|
||||
def test_segmentation_par_changement_app(self):
|
||||
"""Un changement d'application crée un nouveau segment."""
|
||||
obs = ShadowObserver()
|
||||
obs.start("s1")
|
||||
obs.observe_event("s1", _evt_click(title="Firefox"))
|
||||
obs.observe_event("s1", _evt_click(title="Firefox", ts=100.5))
|
||||
obs.observe_event("s1", _evt_click(title="Bloc-notes", ts=101.0))
|
||||
|
||||
steps = obs.get_understanding("s1")
|
||||
assert len(steps) >= 2 # Au moins 2 segments
|
||||
|
||||
def test_segmentation_par_pause_longue(self):
|
||||
"""Une pause > 4s coupe le segment."""
|
||||
obs = ShadowObserver()
|
||||
obs.start("s1")
|
||||
obs.observe_event("s1", _evt_click(title="App1", ts=100.0))
|
||||
obs.observe_event("s1", _evt_click(title="App1", ts=100.5))
|
||||
# Pause de 10 secondes
|
||||
obs.observe_event("s1", _evt_click(title="App1", ts=110.5))
|
||||
|
||||
steps = obs.get_understanding("s1")
|
||||
assert len(steps) >= 2
|
||||
|
||||
def test_segment_complet(self):
|
||||
"""Scénario complet : Bloc-notes + texte + save."""
|
||||
obs = ShadowObserver()
|
||||
obs.start("s1")
|
||||
for evt in _make_session_events():
|
||||
obs.observe_event("s1", evt)
|
||||
obs.stop("s1")
|
||||
|
||||
steps = obs.get_understanding("s1")
|
||||
assert len(steps) >= 2 # Au moins Menu Démarrer + Bloc-notes
|
||||
|
||||
# Au moins une étape doit mentionner le Bloc-notes
|
||||
intents = " ".join(s["intent"].lower() for s in steps)
|
||||
assert "bloc" in intents or "enregistr" in intents or "sauvegard" in intents or \
|
||||
"écrir" in intents or "text" in intents.lower()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Intention et raffinement
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestShadowObserverIntent:
|
||||
|
||||
def test_intent_recherche(self):
|
||||
"""Clic + saisie + entrée → 'Rechercher X'."""
|
||||
obs = ShadowObserver()
|
||||
obs.start("s1")
|
||||
obs.observe_event("s1", _evt_click(text="Champ recherche", title="Explorateur", ts=100.0))
|
||||
obs.observe_event("s1", _evt_type(text="calculatrice", title="Explorateur", ts=100.5))
|
||||
obs.observe_event("s1", _evt_key(keys=["enter"], title="Explorateur", ts=101.0))
|
||||
|
||||
current = obs.get_current_step("s1")
|
||||
assert current is not None
|
||||
assert "recherch" in current["intent"].lower() or "calculatrice" in current["intent"].lower()
|
||||
|
||||
def test_intent_ctrl_s(self):
|
||||
"""Ctrl+S → 'Sauvegarder'."""
|
||||
obs = ShadowObserver()
|
||||
obs.start("s1")
|
||||
obs.observe_event("s1", _evt_click(title="Bloc-notes", ts=100.0))
|
||||
obs.observe_event("s1", _evt_key(keys=["ctrl", "s"], title="Bloc-notes", ts=100.5))
|
||||
|
||||
current = obs.get_current_step("s1")
|
||||
assert current is not None
|
||||
assert "sauvegard" in current["intent"].lower()
|
||||
|
||||
def test_intent_ecrire(self):
|
||||
"""Saisie seule → 'Écrire'."""
|
||||
obs = ShadowObserver()
|
||||
obs.start("s1")
|
||||
obs.observe_event("s1", _evt_type(text="Un texte libre", title="Bloc-notes"))
|
||||
|
||||
current = obs.get_current_step("s1")
|
||||
assert current is not None
|
||||
assert "écri" in current["intent"].lower() or "text" in current["intent"].lower()
|
||||
|
||||
def test_confidence_augmente_avec_contexte(self):
|
||||
"""La confiance augmente quand le contexte devient clair."""
|
||||
obs = ShadowObserver()
|
||||
obs.start("s1")
|
||||
obs.observe_event("s1", _evt_click(title="App"))
|
||||
c1 = obs.get_current_step("s1")["confidence"]
|
||||
|
||||
obs.observe_event("s1", _evt_key(keys=["ctrl", "s"], title="App"))
|
||||
c2 = obs.get_current_step("s1")["confidence"]
|
||||
|
||||
assert c2 >= c1
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Détection de variables
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestShadowObserverVariables:
|
||||
|
||||
def test_variable_detectee_lors_saisie(self):
|
||||
"""Une saisie texte > 3 caractères crée une variable."""
|
||||
obs = ShadowObserver()
|
||||
notifs = []
|
||||
obs._notify_callback = lambda n: notifs.append(n)
|
||||
obs.start("s1")
|
||||
|
||||
obs.observe_event("s1", _evt_type(text="Jean Dupont", title="Formulaire"))
|
||||
|
||||
var_notifs = [n for n in notifs if n.niveau == NiveauNotification.VARIABLE]
|
||||
assert len(var_notifs) == 1
|
||||
assert "Jean Dupont" in var_notifs[0].message
|
||||
assert var_notifs[0].data["variable_name"].startswith("texte_")
|
||||
|
||||
def test_variable_type_date(self):
|
||||
obs = ShadowObserver()
|
||||
obs.start("s1")
|
||||
obs.observe_event("s1", _evt_type(text="15/03/2026", title="Formulaire"))
|
||||
|
||||
current = obs.get_current_step("s1")
|
||||
assert len(current["variables_detectees"]) == 1
|
||||
|
||||
def test_variable_type_email(self):
|
||||
obs = ShadowObserver()
|
||||
notifs = []
|
||||
obs._notify_callback = lambda n: notifs.append(n)
|
||||
obs.start("s1")
|
||||
|
||||
obs.observe_event("s1", _evt_type(text="jean@example.com", title="Formulaire"))
|
||||
|
||||
var_notifs = [n for n in notifs if n.niveau == NiveauNotification.VARIABLE]
|
||||
assert len(var_notifs) == 1
|
||||
assert "e-mail" in var_notifs[0].message or "mail" in var_notifs[0].message
|
||||
|
||||
def test_texte_court_ignore(self):
|
||||
"""Un texte de moins de 3 caractères n'est pas une variable."""
|
||||
obs = ShadowObserver()
|
||||
notifs = []
|
||||
obs._notify_callback = lambda n: notifs.append(n)
|
||||
obs.start("s1")
|
||||
|
||||
obs.observe_event("s1", _evt_type(text="ab", title="App"))
|
||||
|
||||
var_notifs = [n for n in notifs if n.niveau == NiveauNotification.VARIABLE]
|
||||
assert len(var_notifs) == 0
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Notifications et callbacks
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestShadowObserverNotifications:
|
||||
|
||||
def test_notification_au_demarrage(self):
|
||||
notifs = []
|
||||
obs = ShadowObserver(notify_callback=lambda n: notifs.append(n))
|
||||
obs.start("s1")
|
||||
|
||||
assert len(notifs) >= 1
|
||||
assert notifs[0].niveau == NiveauNotification.INFO
|
||||
assert "observe" in notifs[0].message.lower()
|
||||
|
||||
def test_notification_nouvelle_etape(self):
|
||||
"""Un changement d'application émet une notification DECOUVERTE."""
|
||||
notifs = []
|
||||
obs = ShadowObserver(notify_callback=lambda n: notifs.append(n))
|
||||
obs.start("s1")
|
||||
|
||||
obs.observe_event("s1", _evt_click(title="Firefox", ts=100.0))
|
||||
obs.observe_event("s1", _evt_click(title="Bloc-notes", ts=101.0))
|
||||
|
||||
decouverts = [n for n in notifs if n.niveau == NiveauNotification.DECOUVERTE]
|
||||
assert len(decouverts) >= 1
|
||||
|
||||
def test_notification_stop_resume(self):
|
||||
"""Au stop, on émet un résumé du nombre d'étapes."""
|
||||
notifs = []
|
||||
obs = ShadowObserver(notify_callback=lambda n: notifs.append(n))
|
||||
obs.start("s1")
|
||||
for evt in _make_session_events():
|
||||
obs.observe_event("s1", evt)
|
||||
obs.stop("s1")
|
||||
|
||||
messages = [n.message.lower() for n in notifs]
|
||||
assert any("étape" in m or "observ" in m for m in messages)
|
||||
|
||||
def test_notifications_since_ts(self):
|
||||
"""get_notifications(since_ts=...) filtre correctement."""
|
||||
obs = ShadowObserver()
|
||||
obs.start("s1")
|
||||
time.sleep(0.01)
|
||||
mid_ts = time.time()
|
||||
time.sleep(0.01)
|
||||
obs.observe_event("s1", _evt_click(title="Firefox"))
|
||||
obs.observe_event("s1", _evt_click(title="Bloc-notes"))
|
||||
|
||||
recentes = obs.get_notifications("s1", since_ts=mid_ts)
|
||||
toutes = obs.get_notifications("s1", since_ts=0)
|
||||
assert len(recentes) < len(toutes)
|
||||
|
||||
def test_callback_erreur_ne_plante_pas(self):
|
||||
"""Un callback qui lève ne doit pas faire planter l'observer."""
|
||||
def bad_callback(notif):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
obs = ShadowObserver(notify_callback=bad_callback)
|
||||
obs.start("s1") # Devrait émettre une notification (qui plante en callback)
|
||||
# Si on arrive ici, c'est OK
|
||||
obs.observe_event("s1", _evt_click())
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Compréhension et API publique
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestShadowObserverUnderstanding:
|
||||
|
||||
def test_get_understanding_format(self):
|
||||
"""La structure retournée est bien celle attendue."""
|
||||
obs = ShadowObserver()
|
||||
obs.start("s1")
|
||||
obs.observe_event("s1", _evt_click(title="App"))
|
||||
|
||||
steps = obs.get_understanding("s1")
|
||||
assert isinstance(steps, list)
|
||||
assert len(steps) >= 1
|
||||
step = steps[0]
|
||||
assert "step" in step
|
||||
assert "intent" in step
|
||||
assert "confidence" in step
|
||||
assert isinstance(step["step"], int)
|
||||
assert 0.0 <= step["confidence"] <= 1.0
|
||||
|
||||
def test_get_understanding_sans_session(self):
|
||||
obs = ShadowObserver()
|
||||
steps = obs.get_understanding("inexistante")
|
||||
assert steps == []
|
||||
|
||||
def test_get_current_step(self):
|
||||
obs = ShadowObserver()
|
||||
obs.start("s1")
|
||||
assert obs.get_current_step("s1") is None
|
||||
|
||||
obs.observe_event("s1", _evt_click(title="App"))
|
||||
current = obs.get_current_step("s1")
|
||||
assert current is not None
|
||||
assert current["step_index"] == 1
|
||||
|
||||
def test_get_steps_internal(self):
|
||||
"""get_steps_internal retourne des UnderstoodStep copiés."""
|
||||
obs = ShadowObserver()
|
||||
obs.start("s1")
|
||||
obs.observe_event("s1", _evt_click(title="App"))
|
||||
|
||||
internals = obs.get_steps_internal("s1")
|
||||
assert len(internals) >= 1
|
||||
assert isinstance(internals[0], UnderstoodStep)
|
||||
|
||||
# Mutation externe ne doit pas affecter l'observer
|
||||
internals[0].intent = "HACKED"
|
||||
again = obs.get_steps_internal("s1")
|
||||
assert again[0].intent != "HACKED"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Singleton partagé
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestSharedObserver:
|
||||
|
||||
def test_singleton(self):
|
||||
obs1 = get_shared_observer()
|
||||
obs2 = get_shared_observer()
|
||||
assert obs1 is obs2
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Performance (contrainte : observe_event doit être rapide)
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestShadowObserverPerformance:
|
||||
|
||||
def test_observe_event_rapide(self):
|
||||
"""observe_event() doit traiter 1000 events en moins de 500ms."""
|
||||
obs = ShadowObserver()
|
||||
obs.start("s_perf")
|
||||
|
||||
events = []
|
||||
for i in range(1000):
|
||||
events.append(_evt_click(title="App", ts=100.0 + i * 0.01))
|
||||
|
||||
start = time.time()
|
||||
for evt in events:
|
||||
obs.observe_event("s_perf", evt)
|
||||
elapsed = time.time() - start
|
||||
|
||||
assert elapsed < 0.5, f"Trop lent : {elapsed:.2f}s pour 1000 events"
|
||||
531
tests/unit/test_shadow_validator.py
Normal file
531
tests/unit/test_shadow_validator.py
Normal file
@@ -0,0 +1,531 @@
|
||||
"""
|
||||
Tests du ShadowValidator — feedback utilisateur et reconstruction WorkflowIR.
|
||||
|
||||
Vérifie que :
|
||||
- Les feedbacks (validate/correct/undo/merge_next/split/cancel) sont appliqués
|
||||
- Le WorkflowIR final est bien reconstruit à partir des étapes corrigées
|
||||
- Les variables sont détectées dans les actions finales
|
||||
- L'historique des feedbacks est conservé
|
||||
- Les erreurs (index invalide, action inconnue) sont gérées proprement
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
_ROOT = str(Path(__file__).resolve().parents[2])
|
||||
if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
from core.workflow.shadow_observer import ShadowObserver, UnderstoodStep
|
||||
from core.workflow.shadow_validator import FeedbackResult, ShadowValidator
|
||||
from core.workflow.workflow_ir import WorkflowIR
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Fixtures
|
||||
# =========================================================================
|
||||
|
||||
|
||||
def _make_step(step_index=1, intent="Ouvrir Firefox", app="Firefox",
|
||||
events=None, **kwargs):
|
||||
return UnderstoodStep(
|
||||
step_index=step_index,
|
||||
intent=intent,
|
||||
app_name=app,
|
||||
window_title=app,
|
||||
events=events or [
|
||||
{"type": "mouse_click", "window": {"title": app},
|
||||
"vision_info": {"text": "bouton"}, "timestamp": 100.0}
|
||||
],
|
||||
started_at=100.0,
|
||||
ended_at=101.0,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _make_type_step(texte="Bonjour", app="Bloc-notes", step_index=1):
|
||||
return UnderstoodStep(
|
||||
step_index=step_index,
|
||||
intent=f"Écrire « {texte} »",
|
||||
app_name=app,
|
||||
window_title=app,
|
||||
events=[
|
||||
{"type": "text_input", "text": texte,
|
||||
"window": {"title": app}, "timestamp": 100.0}
|
||||
],
|
||||
started_at=100.0,
|
||||
)
|
||||
|
||||
|
||||
def _make_3_steps():
|
||||
return [
|
||||
_make_step(1, "Ouvrir le Bloc-notes", "Bloc-notes"),
|
||||
_make_type_step("Bonjour le monde", step_index=2),
|
||||
_make_step(3, "Sauvegarder", "Bloc-notes", events=[
|
||||
{"type": "key_combo", "keys": ["ctrl", "s"],
|
||||
"window": {"title": "Bloc-notes"}, "timestamp": 103.0}
|
||||
]),
|
||||
]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Initialisation
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestShadowValidatorBase:
|
||||
|
||||
def test_creation(self):
|
||||
v = ShadowValidator()
|
||||
assert v.steps == []
|
||||
assert v.history == []
|
||||
assert not v.is_cancelled
|
||||
|
||||
def test_set_steps(self):
|
||||
v = ShadowValidator()
|
||||
v.set_steps(_make_3_steps())
|
||||
assert len(v.steps) == 3
|
||||
assert v.steps[0].intent == "Ouvrir le Bloc-notes"
|
||||
|
||||
def test_clone_protege_mutation(self):
|
||||
"""set_steps clone les étapes pour éviter les mutations externes."""
|
||||
v = ShadowValidator()
|
||||
steps = _make_3_steps()
|
||||
v.set_steps(steps)
|
||||
steps[0].intent = "HACKED"
|
||||
assert v.steps[0].intent == "Ouvrir le Bloc-notes"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# validate
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestValidatorValidate:
|
||||
|
||||
def test_validate_etape(self):
|
||||
v = ShadowValidator()
|
||||
v.set_steps(_make_3_steps())
|
||||
|
||||
result = v.apply_feedback({"action": "validate", "step_index": 1})
|
||||
|
||||
assert result.ok is True
|
||||
assert v.steps[0].validated is True
|
||||
assert v.steps[0].confidence >= 0.95
|
||||
assert v.steps[0].intent_provisoire is False
|
||||
|
||||
def test_validate_index_invalide(self):
|
||||
v = ShadowValidator()
|
||||
v.set_steps(_make_3_steps())
|
||||
|
||||
result = v.apply_feedback({"action": "validate", "step_index": 99})
|
||||
assert result.ok is False
|
||||
assert "invalide" in result.message.lower()
|
||||
|
||||
def test_validate_toutes_les_etapes(self):
|
||||
v = ShadowValidator()
|
||||
v.set_steps(_make_3_steps())
|
||||
for i in range(1, 4):
|
||||
v.apply_feedback({"action": "validate", "step_index": i})
|
||||
|
||||
assert all(s.validated for s in v.steps)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# correct
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestValidatorCorrect:
|
||||
|
||||
def test_correct_intent(self):
|
||||
v = ShadowValidator()
|
||||
v.set_steps(_make_3_steps())
|
||||
|
||||
result = v.apply_feedback({
|
||||
"action": "correct",
|
||||
"step_index": 1,
|
||||
"new_intent": "Démarrer la rédaction d'un email",
|
||||
})
|
||||
|
||||
assert result.ok is True
|
||||
assert v.steps[0].intent == "Démarrer la rédaction d'un email"
|
||||
assert v.steps[0].corrected is True
|
||||
assert v.steps[0].validated is True # Corriger = valider implicitement
|
||||
assert "old_intent" in result.data
|
||||
|
||||
def test_correct_intent_vide(self):
|
||||
v = ShadowValidator()
|
||||
v.set_steps(_make_3_steps())
|
||||
|
||||
result = v.apply_feedback({
|
||||
"action": "correct",
|
||||
"step_index": 1,
|
||||
"new_intent": "",
|
||||
})
|
||||
|
||||
assert result.ok is False
|
||||
assert v.steps[0].corrected is False
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# undo
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestValidatorUndo:
|
||||
|
||||
def test_undo_etape(self):
|
||||
v = ShadowValidator()
|
||||
v.set_steps(_make_3_steps())
|
||||
|
||||
result = v.apply_feedback({"action": "undo", "step_index": 2})
|
||||
assert result.ok is True
|
||||
assert v.steps[1].cancelled is True
|
||||
|
||||
def test_undo_exclut_etape_du_workflow(self):
|
||||
"""Une étape undo ne doit pas apparaître dans le WorkflowIR final."""
|
||||
v = ShadowValidator()
|
||||
v.set_steps(_make_3_steps())
|
||||
|
||||
v.apply_feedback({"action": "undo", "step_index": 2})
|
||||
ir = v.build_workflow_ir(session_id="s1", name="Test")
|
||||
|
||||
assert ir is not None
|
||||
assert len(ir.steps) == 2 # 3 - 1 = 2
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# merge_next
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestValidatorMergeNext:
|
||||
|
||||
def test_merge_deux_etapes(self):
|
||||
v = ShadowValidator()
|
||||
v.set_steps(_make_3_steps())
|
||||
|
||||
result = v.apply_feedback({"action": "merge_next", "step_index": 1})
|
||||
assert result.ok is True
|
||||
assert len(v.steps) == 2 # 3 - 1 = 2
|
||||
|
||||
def test_merge_conserve_les_events(self):
|
||||
v = ShadowValidator()
|
||||
v.set_steps(_make_3_steps())
|
||||
|
||||
total_events_before = sum(len(s.events) for s in v.steps)
|
||||
v.apply_feedback({"action": "merge_next", "step_index": 2})
|
||||
total_events_after = sum(len(s.events) for s in v.steps)
|
||||
|
||||
assert total_events_before == total_events_after
|
||||
|
||||
def test_merge_derniere_etape_echoue(self):
|
||||
v = ShadowValidator()
|
||||
v.set_steps(_make_3_steps())
|
||||
|
||||
result = v.apply_feedback({"action": "merge_next", "step_index": 3})
|
||||
assert result.ok is False
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# split
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestValidatorSplit:
|
||||
|
||||
def test_split_en_deux(self):
|
||||
v = ShadowValidator()
|
||||
multi_events_step = _make_step(
|
||||
1, "Étape composite",
|
||||
events=[
|
||||
{"type": "mouse_click", "window": {"title": "App"},
|
||||
"timestamp": 100.0, "vision_info": {}},
|
||||
{"type": "text_input", "text": "partie 1",
|
||||
"window": {"title": "App"}, "timestamp": 101.0},
|
||||
{"type": "text_input", "text": "partie 2",
|
||||
"window": {"title": "App"}, "timestamp": 102.0},
|
||||
],
|
||||
)
|
||||
v.set_steps([multi_events_step])
|
||||
|
||||
result = v.apply_feedback({
|
||||
"action": "split",
|
||||
"step_index": 1,
|
||||
"at_event_index": 2,
|
||||
})
|
||||
|
||||
assert result.ok is True
|
||||
assert len(v.steps) == 2
|
||||
assert len(v.steps[0].events) == 2
|
||||
assert len(v.steps[1].events) == 1
|
||||
|
||||
def test_split_index_invalide(self):
|
||||
v = ShadowValidator()
|
||||
v.set_steps(_make_3_steps())
|
||||
result = v.apply_feedback({
|
||||
"action": "split",
|
||||
"step_index": 1,
|
||||
"at_event_index": 99,
|
||||
})
|
||||
assert result.ok is False
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# cancel
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestValidatorCancel:
|
||||
|
||||
def test_cancel_workflow(self):
|
||||
v = ShadowValidator()
|
||||
v.set_steps(_make_3_steps())
|
||||
|
||||
result = v.apply_feedback({"action": "cancel"})
|
||||
assert result.ok is True
|
||||
assert v.is_cancelled
|
||||
|
||||
def test_cancel_build_retourne_none(self):
|
||||
v = ShadowValidator()
|
||||
v.set_steps(_make_3_steps())
|
||||
v.apply_feedback({"action": "cancel"})
|
||||
|
||||
ir = v.build_workflow_ir(session_id="s1", name="Test")
|
||||
assert ir is None
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Action inconnue
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestValidatorUnknownAction:
|
||||
|
||||
def test_action_inconnue(self):
|
||||
v = ShadowValidator()
|
||||
v.set_steps(_make_3_steps())
|
||||
|
||||
result = v.apply_feedback({"action": "do_magic"})
|
||||
assert result.ok is False
|
||||
assert "inconnue" in result.message.lower()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Construction du WorkflowIR
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestValidatorBuild:
|
||||
|
||||
def test_build_workflow_ir(self):
|
||||
v = ShadowValidator()
|
||||
v.set_steps(_make_3_steps())
|
||||
|
||||
ir = v.build_workflow_ir(
|
||||
session_id="sess_test",
|
||||
name="Mon workflow",
|
||||
domain="generic",
|
||||
)
|
||||
|
||||
assert ir is not None
|
||||
assert isinstance(ir, WorkflowIR)
|
||||
assert ir.name == "Mon workflow"
|
||||
assert ir.learned_from == "sess_test"
|
||||
assert len(ir.steps) == 3
|
||||
|
||||
def test_build_with_variables(self):
|
||||
"""Les textes saisis deviennent des variables dans le WorkflowIR."""
|
||||
v = ShadowValidator()
|
||||
v.set_steps([
|
||||
_make_type_step("Jean Dupont", step_index=1),
|
||||
_make_type_step("jean@example.com", app="Email", step_index=2),
|
||||
])
|
||||
|
||||
ir = v.build_workflow_ir(session_id="s1", name="Test")
|
||||
assert len(ir.variables) == 2
|
||||
|
||||
# Les actions de type type doivent référencer les variables
|
||||
for step in ir.steps:
|
||||
for action in step.actions:
|
||||
if action.type == "type":
|
||||
assert action.variable is True
|
||||
assert action.text.startswith("{")
|
||||
|
||||
def test_build_respecte_corrections(self):
|
||||
"""Les intentions corrigées se retrouvent dans le WorkflowIR."""
|
||||
v = ShadowValidator()
|
||||
v.set_steps(_make_3_steps())
|
||||
|
||||
v.apply_feedback({
|
||||
"action": "correct",
|
||||
"step_index": 1,
|
||||
"new_intent": "Lancer l'application de prise de notes",
|
||||
})
|
||||
|
||||
ir = v.build_workflow_ir(session_id="s1", name="Test")
|
||||
assert ir.steps[0].intent == "Lancer l'application de prise de notes"
|
||||
|
||||
def test_build_exclut_etapes_annulees(self):
|
||||
v = ShadowValidator()
|
||||
v.set_steps(_make_3_steps())
|
||||
v.apply_feedback({"action": "undo", "step_index": 2})
|
||||
|
||||
ir = v.build_workflow_ir(session_id="s1", name="Test")
|
||||
assert len(ir.steps) == 2
|
||||
|
||||
def test_build_require_all_validated(self):
|
||||
"""Avec require_all_validated, erreur si une étape n'est pas validée."""
|
||||
v = ShadowValidator()
|
||||
v.set_steps(_make_3_steps())
|
||||
v.apply_feedback({"action": "validate", "step_index": 1})
|
||||
# Étapes 2 et 3 pas validées
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
v.build_workflow_ir(
|
||||
session_id="s1", name="Test", require_all_validated=True
|
||||
)
|
||||
|
||||
def test_build_applications_detectees(self):
|
||||
v = ShadowValidator()
|
||||
v.set_steps([
|
||||
_make_step(1, "Ouvrir Firefox", "Firefox"),
|
||||
_make_step(2, "Écrire", "Bloc-notes"),
|
||||
])
|
||||
ir = v.build_workflow_ir(session_id="s1", name="Test")
|
||||
assert "Firefox" in ir.applications
|
||||
assert "Bloc-notes" in ir.applications
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Historique
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestValidatorHistory:
|
||||
|
||||
def test_historique_feedbacks(self):
|
||||
v = ShadowValidator()
|
||||
v.set_steps(_make_3_steps())
|
||||
|
||||
v.apply_feedback({"action": "validate", "step_index": 1})
|
||||
v.apply_feedback({
|
||||
"action": "correct", "step_index": 2,
|
||||
"new_intent": "Écrire le texte"
|
||||
})
|
||||
v.apply_feedback({"action": "undo", "step_index": 3})
|
||||
|
||||
history = v.history
|
||||
assert len(history) == 3
|
||||
assert history[0].action == "validate"
|
||||
assert history[1].action == "correct"
|
||||
assert history[2].action == "undo"
|
||||
|
||||
def test_apply_feedbacks_batch(self):
|
||||
v = ShadowValidator()
|
||||
v.set_steps(_make_3_steps())
|
||||
|
||||
results = v.apply_feedbacks([
|
||||
{"action": "validate", "step_index": 1},
|
||||
{"action": "validate", "step_index": 2},
|
||||
{"action": "undo", "step_index": 3},
|
||||
])
|
||||
|
||||
assert len(results) == 3
|
||||
assert all(r.ok for r in results)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Intégration ShadowObserver + ShadowValidator
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestShadowObserverValidatorIntegration:
|
||||
|
||||
def test_observer_vers_validator(self):
|
||||
"""Flow complet : Observer → Validator → WorkflowIR."""
|
||||
obs = ShadowObserver()
|
||||
obs.start("sess_flow")
|
||||
|
||||
# Simuler des événements
|
||||
obs.observe_event("sess_flow", {
|
||||
"type": "mouse_click",
|
||||
"window": {"title": "Menu Démarrer", "app_name": "Menu Démarrer"},
|
||||
"vision_info": {"text": "Rechercher"},
|
||||
"timestamp": 100.0,
|
||||
})
|
||||
obs.observe_event("sess_flow", {
|
||||
"type": "text_input",
|
||||
"text": "blocnote",
|
||||
"window": {"title": "Menu Démarrer", "app_name": "Menu Démarrer"},
|
||||
"timestamp": 100.5,
|
||||
})
|
||||
obs.observe_event("sess_flow", {
|
||||
"type": "key_combo",
|
||||
"keys": ["enter"],
|
||||
"window": {"title": "Menu Démarrer", "app_name": "Menu Démarrer"},
|
||||
"timestamp": 101.0,
|
||||
})
|
||||
# Changement d'application
|
||||
obs.observe_event("sess_flow", {
|
||||
"type": "text_input",
|
||||
"text": "Hello world",
|
||||
"window": {"title": "Sans titre - Bloc-notes", "app_name": "Bloc-notes"},
|
||||
"timestamp": 105.0,
|
||||
})
|
||||
obs.stop("sess_flow")
|
||||
|
||||
# Récupérer les étapes
|
||||
internals = obs.get_steps_internal("sess_flow")
|
||||
assert len(internals) >= 2
|
||||
|
||||
# Passer au validator
|
||||
validator = ShadowValidator()
|
||||
validator.set_steps(internals)
|
||||
|
||||
# Valider la première étape, corriger la seconde
|
||||
validator.apply_feedback({"action": "validate", "step_index": 1})
|
||||
validator.apply_feedback({
|
||||
"action": "correct",
|
||||
"step_index": 2,
|
||||
"new_intent": "Écrire un texte de démonstration",
|
||||
})
|
||||
|
||||
# Construire le WorkflowIR
|
||||
ir = validator.build_workflow_ir(
|
||||
session_id="sess_flow",
|
||||
name="Flow de test",
|
||||
domain="generic",
|
||||
)
|
||||
|
||||
assert ir is not None
|
||||
assert len(ir.steps) >= 2
|
||||
assert ir.steps[1].intent == "Écrire un texte de démonstration"
|
||||
assert len(ir.variables) >= 1 # Au moins "blocnote" ou "Hello world"
|
||||
|
||||
def test_undo_puis_build(self):
|
||||
obs = ShadowObserver()
|
||||
obs.start("sess_undo")
|
||||
for i in range(3):
|
||||
obs.observe_event("sess_undo", {
|
||||
"type": "mouse_click",
|
||||
"window": {"title": f"App{i}"},
|
||||
"vision_info": {"text": "bouton"},
|
||||
"timestamp": 100.0 + i * 6.0, # > 4s pour créer des segments
|
||||
})
|
||||
obs.stop("sess_undo")
|
||||
|
||||
validator = ShadowValidator()
|
||||
validator.set_steps(obs.get_steps_internal("sess_undo"))
|
||||
nb_before = len(validator.steps)
|
||||
assert nb_before >= 2
|
||||
|
||||
validator.apply_feedback({"action": "undo", "step_index": 1})
|
||||
|
||||
ir = validator.build_workflow_ir(session_id="sess_undo", name="Test")
|
||||
assert len(ir.steps) == nb_before - 1
|
||||
Reference in New Issue
Block a user