v1.0 - Version stable: multi-PC, détection UI-DETR-1, 3 modes exécution

- Frontend v4 accessible sur réseau local (192.168.1.40)
- Ports ouverts: 3002 (frontend), 5001 (backend), 5004 (dashboard)
- Ollama GPU fonctionnel
- Self-healing interactif
- Dashboard confiance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Dom
2026-01-29 11:23:51 +01:00
parent 21bfa3b337
commit a27b74cf22
1595 changed files with 412691 additions and 400 deletions

View File

@@ -0,0 +1,3 @@
"""
Tests package for Visual Workflow Builder backend
"""

View File

@@ -0,0 +1,26 @@
"""
Pytest configuration and fixtures for Visual Workflow Builder tests
"""
import pytest
from app import app, db
@pytest.fixture
def client():
"""Create a test client for the Flask app"""
app.config['TESTING'] = True
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
with app.test_client() as client:
with app.app_context():
db.create_all()
yield client
db.drop_all()
@pytest.fixture
def app_context():
"""Create an application context for tests"""
with app.app_context():
db.create_all()
yield app
db.drop_all()

View File

@@ -0,0 +1,393 @@
"""
Tests for COACHING Mode API (REST + WebSocket).
Tests the complete COACHING flow including:
- REST API endpoints for starting/managing COACHING executions
- WebSocket events for real-time suggestions and decisions
- Integration with ExecutionLoop COACHING mode
Exigences: 6.1, 6.2, 6.3, 6.4
"""
import pytest
import json
import time
from unittest.mock import MagicMock, patch
from flask import Flask
from flask_socketio import SocketIOTestClient
@pytest.fixture
def app():
"""Create test Flask app with blueprints."""
from app import create_app
app = create_app()
app.config['TESTING'] = True
return app
@pytest.fixture
def client(app):
"""Create test client."""
return app.test_client()
@pytest.fixture
def mock_executor():
"""Create mock executor for testing."""
with patch('api.executions.get_executor') as mock_get:
executor = MagicMock()
executor.execute_workflow_coaching.return_value = 'coaching_test_001'
executor.execute_workflow.return_value = 'exec_test_001'
executor.is_coaching_execution.return_value = True
executor.get_coaching_stats.return_value = {
'suggestions_made': 5,
'accepted': 3,
'rejected': 1,
'corrected': 1,
'manual_executions': 0,
'acceptance_rate': 0.6
}
executor.submit_coaching_decision.return_value = True
executor.get_execution_status.return_value = MagicMock(
to_dict=lambda: {
'execution_id': 'coaching_test_001',
'status': 'running',
'progress': {'completed_nodes': 2, 'total_nodes': 5}
}
)
executor.list_executions.return_value = [
{'execution_id': 'coaching_test_001', 'status': 'running'},
{'execution_id': 'exec_test_002', 'status': 'completed'}
]
mock_get.return_value = executor
yield executor
class TestCoachingAPIEndpoints:
"""Tests for COACHING REST API endpoints."""
def test_start_coaching_execution(self, client, mock_executor):
"""Test starting a COACHING execution."""
response = client.post(
'/api/executions/coaching',
data=json.dumps({'workflow_id': 'wf_test_001'}),
content_type='application/json'
)
assert response.status_code == 201
data = response.get_json()
assert data['execution_id'] == 'coaching_test_001'
assert data['mode'] == 'coaching'
assert data['status'] == 'started'
def test_start_coaching_no_workflow_id(self, client, mock_executor):
"""Test starting COACHING without workflow_id fails."""
response = client.post(
'/api/executions/coaching',
data=json.dumps({}),
content_type='application/json'
)
assert response.status_code == 400
assert 'error' in response.get_json()
def test_start_execution_with_coaching_mode(self, client, mock_executor):
"""Test starting execution with mode=coaching."""
response = client.post(
'/api/executions/',
data=json.dumps({
'workflow_id': 'wf_test_001',
'mode': 'coaching'
}),
content_type='application/json'
)
assert response.status_code == 201
data = response.get_json()
assert data['mode'] == 'coaching'
def test_get_coaching_stats(self, client, mock_executor):
"""Test getting COACHING statistics."""
response = client.get('/api/executions/coaching_test_001/coaching/stats')
assert response.status_code == 200
data = response.get_json()
assert 'stats' in data
assert data['stats']['suggestions_made'] == 5
assert data['stats']['accepted'] == 3
def test_submit_coaching_decision_accept(self, client, mock_executor):
"""Test submitting accept decision."""
response = client.post(
'/api/executions/coaching_test_001/coaching/decision',
data=json.dumps({'decision': 'accept'}),
content_type='application/json'
)
assert response.status_code == 200
data = response.get_json()
assert data['decision'] == 'accept'
assert data['status'] == 'accepted'
def test_submit_coaching_decision_correct(self, client, mock_executor):
"""Test submitting correct decision with correction."""
response = client.post(
'/api/executions/coaching_test_001/coaching/decision',
data=json.dumps({
'decision': 'correct',
'correction': {
'target': {'id': 'new_button'},
'params': {'timeout': 5}
},
'feedback': 'Le bouton a changé de position'
}),
content_type='application/json'
)
assert response.status_code == 200
data = response.get_json()
assert data['decision'] == 'correct'
def test_submit_coaching_decision_reject(self, client, mock_executor):
"""Test submitting reject decision."""
response = client.post(
'/api/executions/coaching_test_001/coaching/decision',
data=json.dumps({
'decision': 'reject',
'feedback': 'Cette action est incorrecte'
}),
content_type='application/json'
)
assert response.status_code == 200
data = response.get_json()
assert data['decision'] == 'reject'
def test_submit_coaching_decision_manual(self, client, mock_executor):
"""Test submitting manual execution decision."""
response = client.post(
'/api/executions/coaching_test_001/coaching/decision',
data=json.dumps({'decision': 'manual'}),
content_type='application/json'
)
assert response.status_code == 200
data = response.get_json()
assert data['decision'] == 'manual'
def test_submit_coaching_decision_skip(self, client, mock_executor):
"""Test submitting skip decision."""
response = client.post(
'/api/executions/coaching_test_001/coaching/decision',
data=json.dumps({'decision': 'skip'}),
content_type='application/json'
)
assert response.status_code == 200
data = response.get_json()
assert data['decision'] == 'skip'
def test_submit_invalid_decision(self, client, mock_executor):
"""Test submitting invalid decision fails."""
response = client.post(
'/api/executions/coaching_test_001/coaching/decision',
data=json.dumps({'decision': 'invalid'}),
content_type='application/json'
)
assert response.status_code == 400
assert 'error' in response.get_json()
def test_submit_decision_missing(self, client, mock_executor):
"""Test submitting without decision fails."""
response = client.post(
'/api/executions/coaching_test_001/coaching/decision',
data=json.dumps({}),
content_type='application/json'
)
assert response.status_code == 400
def test_get_execution_shows_coaching_flag(self, client, mock_executor):
"""Test that get execution includes is_coaching flag."""
response = client.get('/api/executions/coaching_test_001')
assert response.status_code == 200
data = response.get_json()
assert 'is_coaching' in data
def test_list_executions_filter_coaching(self, client, mock_executor):
"""Test filtering executions by coaching mode."""
response = client.get('/api/executions/?mode=coaching')
assert response.status_code == 200
data = response.get_json()
assert 'executions' in data
class TestCoachingWebSocket:
"""Tests for COACHING WebSocket events."""
@pytest.fixture
def socketio_client(self, app):
"""Create SocketIO test client."""
from app import socketio
return SocketIOTestClient(app, socketio)
def test_subscribe_coaching(self, socketio_client):
"""Test subscribing to COACHING events."""
socketio_client.emit('subscribe_coaching', {
'execution_id': 'coaching_test_001'
})
# Check for response
received = socketio_client.get_received()
# Should receive coaching_subscribed event
events = [r['name'] for r in received]
assert 'coaching_subscribed' in events or 'connected' in events
def test_unsubscribe_coaching(self, socketio_client):
"""Test unsubscribing from COACHING events."""
# First subscribe
socketio_client.emit('subscribe_coaching', {
'execution_id': 'coaching_test_001'
})
socketio_client.get_received() # Clear
# Then unsubscribe
socketio_client.emit('unsubscribe_coaching', {
'execution_id': 'coaching_test_001'
})
received = socketio_client.get_received()
events = [r['name'] for r in received]
assert 'coaching_unsubscribed' in events or len(events) >= 0
def test_get_coaching_stats_websocket(self, socketio_client):
"""Test getting COACHING stats via WebSocket."""
socketio_client.emit('get_coaching_stats', {
'execution_id': 'coaching_test_001'
})
received = socketio_client.get_received()
# Should receive coaching_stats event
events = [r['name'] for r in received]
assert 'coaching_stats' in events or 'error' in events
class TestVisualWorkflowExecutorCoaching:
"""Tests for VisualWorkflowExecutor COACHING methods."""
def test_execute_workflow_coaching_creates_execution(self):
"""Test that execute_workflow_coaching creates execution."""
from services.execution_integration import VisualWorkflowExecutor
executor = VisualWorkflowExecutor()
# Mock the database load
with patch.object(executor.db, 'load') as mock_load:
mock_workflow = MagicMock()
mock_workflow.id = 'test_wf'
mock_workflow.workflow_id = 'test_wf'
mock_load.return_value = mock_workflow
# Mock the conversion
with patch('services.execution_integration.convert_visual_to_graph'):
execution_id = executor.execute_workflow_coaching('test_wf')
assert execution_id.startswith('coaching_')
assert executor.is_coaching_execution(execution_id)
def test_is_coaching_execution_false_for_normal(self):
"""Test is_coaching_execution returns False for normal executions."""
from services.execution_integration import VisualWorkflowExecutor
executor = VisualWorkflowExecutor()
assert executor.is_coaching_execution('normal_exec_001') is False
def test_submit_coaching_decision_non_coaching_fails(self):
"""Test that submit_coaching_decision fails for non-COACHING execution."""
from services.execution_integration import VisualWorkflowExecutor
executor = VisualWorkflowExecutor()
result = executor.submit_coaching_decision(
'non_coaching_exec',
{'decision': 'accept'}
)
assert result is False
def test_get_coaching_stats_returns_none_for_unknown(self):
"""Test get_coaching_stats returns None for unknown execution."""
from services.execution_integration import VisualWorkflowExecutor
executor = VisualWorkflowExecutor()
stats = executor.get_coaching_stats('unknown_exec')
assert stats is None
class TestCoachingDecisionFlow:
"""Tests for the complete COACHING decision flow."""
def test_decision_accept_flow(self):
"""Test the complete accept decision flow."""
from services.execution_integration import VisualWorkflowExecutor
executor = VisualWorkflowExecutor()
# Simulate COACHING execution
execution_id = 'coaching_flow_test'
executor._coaching_mode_executions[execution_id] = True
# Initialize responses dict
executor._coaching_responses = {}
# Submit decision
result = executor.submit_coaching_decision(
execution_id,
{'decision': 'accept', 'feedback': 'Looks good'}
)
assert result is True
assert execution_id in executor._coaching_responses
def test_decision_correct_with_correction(self):
"""Test correct decision with correction data."""
from services.execution_integration import VisualWorkflowExecutor
executor = VisualWorkflowExecutor()
execution_id = 'coaching_correct_test'
executor._coaching_mode_executions[execution_id] = True
executor._coaching_responses = {}
correction = {
'target': {'id': 'corrected_element'},
'params': {'timeout': 10}
}
result = executor.submit_coaching_decision(
execution_id,
{
'decision': 'correct',
'correction': correction,
'feedback': 'Element changed position'
}
)
assert result is True
response = executor._coaching_responses[execution_id]
assert response['correction'] == correction
class TestCoachingIntegrationWithCorrectionPacks:
"""Tests for COACHING integration with Correction Packs."""
def test_coaching_correction_captured(self):
"""Test that COACHING corrections are captured in Correction Packs."""
# This tests the integration between COACHING and Correction Packs
pass # Implemented in test_correction_pack_integration.py
if __name__ == '__main__':
pytest.main([__file__, '-v'])

View File

@@ -0,0 +1,393 @@
"""
Unit tests for visual workflow models
"""
import pytest
from datetime import datetime
from backend.models import (
VisualWorkflow,
VisualNode,
VisualEdge,
Variable,
WorkflowSettings,
Position,
Size,
Port,
NodeStatus,
NodeCategory,
ParameterType,
generate_id
)
def test_position_serialization():
"""Test Position to_dict and from_dict"""
pos = Position(x=100.5, y=200.3)
data = pos.to_dict()
assert data == {'x': 100.5, 'y': 200.3}
pos2 = Position.from_dict(data)
assert pos2.x == pos.x
assert pos2.y == pos.y
def test_size_serialization():
"""Test Size to_dict and from_dict"""
size = Size(width=150, height=80)
data = size.to_dict()
assert data == {'width': 150, 'height': 80}
size2 = Size.from_dict(data)
assert size2.width == size.width
assert size2.height == size.height
def test_visual_node_creation():
"""Test creating a VisualNode"""
node = VisualNode(
id='node-1',
type='click',
position=Position(x=100, y=200),
size=Size(width=200, height=100),
parameters={'target': 'button'},
input_ports=[Port(id='in', name='Input', type='input')],
output_ports=[Port(id='out', name='Output', type='output')]
)
assert node.id == 'node-1'
assert node.type == 'click'
assert node.position.x == 100
assert node.parameters['target'] == 'button'
assert len(node.input_ports) == 1
assert len(node.output_ports) == 1
def test_visual_node_serialization():
"""Test VisualNode to_dict and from_dict"""
node = VisualNode(
id='node-1',
type='click',
position=Position(x=100, y=200),
size=Size(width=200, height=100),
parameters={'target': 'button', 'timeout': 5000},
input_ports=[Port(id='in', name='Input', type='input')],
output_ports=[Port(id='out', name='Output', type='output')],
label='Click Button',
status=NodeStatus.IDLE
)
data = node.to_dict()
assert data['id'] == 'node-1'
assert data['type'] == 'click'
assert data['position'] == {'x': 100, 'y': 200}
assert data['parameters']['target'] == 'button'
assert data['status'] == 'idle'
assert data['label'] == 'Click Button'
node2 = VisualNode.from_dict(data)
assert node2.id == node.id
assert node2.type == node.type
assert node2.position.x == node.position.x
assert node2.status == node.status
def test_visual_edge_creation():
"""Test creating a VisualEdge"""
edge = VisualEdge(
id='edge-1',
source='node-1',
target='node-2',
source_port='out',
target_port='in'
)
assert edge.id == 'edge-1'
assert edge.source == 'node-1'
assert edge.target == 'node-2'
assert edge.selected == False
assert edge.animated == False
def test_visual_edge_serialization():
"""Test VisualEdge to_dict and from_dict"""
edge = VisualEdge(
id='edge-1',
source='node-1',
target='node-2',
source_port='out',
target_port='in',
selected=True
)
data = edge.to_dict()
assert data['id'] == 'edge-1'
assert data['source'] == 'node-1'
assert data['target'] == 'node-2'
assert data['selected'] == True
edge2 = VisualEdge.from_dict(data)
assert edge2.id == edge.id
assert edge2.source == edge.source
assert edge2.selected == edge.selected
def test_variable_creation():
"""Test creating a Variable"""
var = Variable(
name='username',
type='string',
value='john_doe',
description='User login name'
)
assert var.name == 'username'
assert var.type == 'string'
assert var.value == 'john_doe'
assert var.description == 'User login name'
def test_workflow_settings_defaults():
"""Test WorkflowSettings default values"""
settings = WorkflowSettings()
assert settings.timeout == 300000
assert settings.retry_on_failure == True
assert settings.max_retries == 3
assert settings.enable_self_healing == True
assert settings.enable_analytics == True
def test_visual_workflow_creation():
"""Test creating a complete VisualWorkflow"""
now = datetime.now()
workflow = VisualWorkflow(
id='wf-1',
name='Test Workflow',
description='A test workflow',
version='1.0.0',
created_at=now,
updated_at=now,
created_by='test_user',
nodes=[],
edges=[],
variables=[],
settings=WorkflowSettings(),
tags=['test', 'demo'],
category='automation'
)
assert workflow.id == 'wf-1'
assert workflow.name == 'Test Workflow'
assert workflow.version == '1.0.0'
assert len(workflow.tags) == 2
assert workflow.is_template == False
def test_visual_workflow_serialization():
"""Test VisualWorkflow to_dict and from_dict"""
now = datetime.now()
node = VisualNode(
id='node-1',
type='click',
position=Position(x=100, y=200),
size=Size(width=200, height=100),
parameters={},
input_ports=[],
output_ports=[]
)
edge = VisualEdge(
id='edge-1',
source='node-1',
target='node-2',
source_port='out',
target_port='in'
)
var = Variable(name='test', type='string', value='value')
workflow = VisualWorkflow(
id='wf-1',
name='Test Workflow',
description='Test',
version='1.0.0',
created_at=now,
updated_at=now,
created_by='user',
nodes=[node],
edges=[edge],
variables=[var],
settings=WorkflowSettings()
)
data = workflow.to_dict()
assert data['id'] == 'wf-1'
assert data['name'] == 'Test Workflow'
assert len(data['nodes']) == 1
assert len(data['edges']) == 1
assert len(data['variables']) == 1
workflow2 = VisualWorkflow.from_dict(data)
assert workflow2.id == workflow.id
assert workflow2.name == workflow.name
assert len(workflow2.nodes) == 1
assert len(workflow2.edges) == 1
def test_workflow_validation_success():
"""Test workflow validation with valid workflow"""
now = datetime.now()
node1 = VisualNode(
id='node-1',
type='click',
position=Position(x=100, y=200),
size=Size(width=200, height=100),
parameters={},
input_ports=[],
output_ports=[]
)
node2 = VisualNode(
id='node-2',
type='type',
position=Position(x=300, y=200),
size=Size(width=200, height=100),
parameters={},
input_ports=[],
output_ports=[]
)
edge = VisualEdge(
id='edge-1',
source='node-1',
target='node-2',
source_port='out',
target_port='in'
)
workflow = VisualWorkflow(
id='wf-1',
name='Test Workflow',
description='Test',
version='1.0.0',
created_at=now,
updated_at=now,
created_by='user',
nodes=[node1, node2],
edges=[edge],
variables=[],
settings=WorkflowSettings()
)
errors = workflow.validate()
assert len(errors) == 0
def test_workflow_validation_missing_fields():
"""Test workflow validation with missing required fields"""
now = datetime.now()
workflow = VisualWorkflow(
id='', # Missing ID
name='', # Missing name
description='Test',
version='', # Missing version
created_at=now,
updated_at=now,
created_by='user',
nodes=[],
edges=[],
variables=[],
settings=WorkflowSettings()
)
errors = workflow.validate()
assert len(errors) == 3
assert any('ID is required' in e for e in errors)
assert any('name is required' in e for e in errors)
assert any('version is required' in e for e in errors)
def test_workflow_validation_invalid_edge():
"""Test workflow validation with edge referencing non-existent node"""
now = datetime.now()
node1 = VisualNode(
id='node-1',
type='click',
position=Position(x=100, y=200),
size=Size(width=200, height=100),
parameters={},
input_ports=[],
output_ports=[]
)
edge = VisualEdge(
id='edge-1',
source='node-1',
target='node-999', # Non-existent node
source_port='out',
target_port='in'
)
workflow = VisualWorkflow(
id='wf-1',
name='Test Workflow',
description='Test',
version='1.0.0',
created_at=now,
updated_at=now,
created_by='user',
nodes=[node1],
edges=[edge],
variables=[],
settings=WorkflowSettings()
)
errors = workflow.validate()
assert len(errors) == 1
assert 'non-existent target node' in errors[0]
def test_workflow_validation_duplicate_variables():
"""Test workflow validation with duplicate variable names"""
now = datetime.now()
var1 = Variable(name='test', type='string', value='value1')
var2 = Variable(name='test', type='string', value='value2') # Duplicate name
workflow = VisualWorkflow(
id='wf-1',
name='Test Workflow',
description='Test',
version='1.0.0',
created_at=now,
updated_at=now,
created_by='user',
nodes=[],
edges=[],
variables=[var1, var2],
settings=WorkflowSettings()
)
errors = workflow.validate()
assert len(errors) == 1
assert 'Duplicate variable names' in errors[0]
def test_generate_id():
"""Test ID generation"""
id1 = generate_id()
id2 = generate_id()
assert id1 != id2
assert len(id1) == 36 # UUID format
assert '-' in id1