diff --git a/tests/unit/test_ora_loop.py b/tests/unit/test_ora_loop.py new file mode 100644 index 000000000..26ee1c58a --- /dev/null +++ b/tests/unit/test_ora_loop.py @@ -0,0 +1,152 @@ +"""Tests unitaires pour la boucle ORA (observe→raisonne→agit).""" + +import pytest +from unittest.mock import MagicMock, patch +from core.execution.observe_reason_act import ( + ORALoop, Observation, Decision, VerificationResult, LoopResult +) + + +class TestORALoopInit: + def test_default_params(self): + loop = ORALoop() + assert loop.max_retries == 2 + assert loop.max_steps == 50 + assert loop.verify_level == 'auto' + + def test_custom_params(self): + loop = ORALoop(max_retries=5, max_steps=10, verify_level='phash') + assert loop.max_retries == 5 + assert loop.max_steps == 10 + assert loop.verify_level == 'phash' + + +class TestDecision: + def test_click_decision(self): + d = Decision( + action='click', target='Enregistrer', value='', + reasoning='Bouton visible', expected_after='Fichier sauvegardé', + confidence=0.95 + ) + assert d.action == 'click' + assert d.done == False + + def test_done_decision(self): + d = Decision( + action='done', target='', value='', + reasoning='Objectif atteint', expected_after='', + confidence=1.0, done=True + ) + assert d.done == True + + +class TestReasonWorkflowStep: + def test_click_anchor_step(self): + loop = ORALoop() + obs = MagicMock() + step = { + 'action_type': 'click_anchor', + 'label': 'Clic sur Demo', + 'visual_anchor': { + 'target_text': 'Demo', + 'screenshot': 'base64data', + 'bounding_box': {'x': 100, 'y': 200, 'width': 50, 'height': 30} + } + } + decision = loop.reason_workflow_step(step, obs) + assert decision.action == 'click' + assert decision.target == 'Demo' + + def test_type_text_step(self): + loop = ORALoop() + obs = MagicMock() + step = { + 'action_type': 'type_text', + 'label': 'Saisir URL', + 'parameters': {'text': 'https://youtube.com'} + } + decision = loop.reason_workflow_step(step, obs) + assert decision.action == 'type' + assert decision.value == 'https://youtube.com' + + def test_keyboard_shortcut_step(self): + loop = ORALoop() + obs = MagicMock() + step = { + 'action_type': 'keyboard_shortcut', + 'label': 'Ctrl+S', + 'parameters': {'keys': ['ctrl', 's']} + } + decision = loop.reason_workflow_step(step, obs) + assert decision.action == 'hotkey' + + def test_wait_step(self): + loop = ORALoop() + obs = MagicMock() + step = { + 'action_type': 'wait_for_anchor', + 'label': 'Attente', + 'parameters': {'timeout_ms': 3000} + } + decision = loop.reason_workflow_step(step, obs) + assert decision.action == 'wait' + + def test_unknown_step_passthrough(self): + loop = ORALoop() + obs = MagicMock() + step = {'action_type': 'custom_action', 'label': 'Action custom'} + decision = loop.reason_workflow_step(step, obs) + assert decision.action == 'passthrough' + + +class TestVerify: + def test_verify_none_mode(self): + loop = ORALoop(verify_level='none') + pre = MagicMock() + post = MagicMock() + decision = Decision('click', 'btn', '', '', '', 0.9) + result = loop.verify(pre, post, decision) + assert result.success == True + + def test_verify_wait_action(self): + loop = ORALoop(verify_level='phash') + pre = MagicMock() + post = MagicMock() + decision = Decision('wait', '', '', '', '', 0.9) + result = loop.verify(pre, post, decision) + assert result.success == True + + def test_verify_done_action(self): + loop = ORALoop() + pre = MagicMock() + post = MagicMock() + decision = Decision('done', '', '', '', '', 1.0, done=True) + result = loop.verify(pre, post, decision) + assert result.success == True + + +class TestRunWorkflow: + def test_empty_workflow(self): + loop = ORALoop() + result = loop.run_workflow([]) + assert result.success == True + assert result.steps_completed == 0 + + def test_too_many_steps(self): + loop = ORALoop(max_steps=5) + steps = [{'action_type': 'wait', 'parameters': {}} for _ in range(10)] + result = loop.run_workflow(steps) + assert result.success == False + assert 'max_steps' in result.reason.lower() or result.steps_completed <= 5 + + +class TestRunInstruction: + def test_has_method(self): + loop = ORALoop() + assert hasattr(loop, 'run_instruction') + assert callable(loop.run_instruction) + + def test_has_reason_instruction(self): + loop = ORALoop() + assert hasattr(loop, 'reason_instruction') + assert callable(loop.reason_instruction)