Adding unit tests to WorkflowPlayer.py
This commit is contained in:
@@ -4,10 +4,12 @@
|
|||||||
|
|
||||||
using `_id={WORKFLOW_DESIGNER_INSTANCE_ID}{session['user_id']}{get_unique_id()}`
|
using `_id={WORKFLOW_DESIGNER_INSTANCE_ID}{session['user_id']}{get_unique_id()}`
|
||||||
|
|
||||||
| Name | value |
|
| Name | value |
|
||||||
|---------------|------------------|
|
|-----------------|--------------------|
|
||||||
| Canvas | `c_{self._id}` |
|
| Canvas | `c_{self._id}` |
|
||||||
| Designer | `d_{self._id}` |
|
| Designer | `d_{self._id}` |
|
||||||
| Error Message | `err_{self._id}` |
|
| Error Message | `err_{self._id}` |
|
||||||
| Properties | `p_{self._id}` |
|
| Properties | `p_{self._id}` |
|
||||||
| Spliter | `s_{self._id}` |
|
| Spliter | `s_{self._id}` |
|
||||||
|
| Top element | `t_{self._id}` |
|
||||||
|
|
||||||
|
|||||||
@@ -135,8 +135,8 @@ def post(session, _id: str, tab_boundaries: str):
|
|||||||
return instance.play_workflow(json.loads(tab_boundaries))
|
return instance.play_workflow(json.loads(tab_boundaries))
|
||||||
|
|
||||||
@rt(Routes.StopWorkflow)
|
@rt(Routes.StopWorkflow)
|
||||||
def post(session, _id: str, tab_boundaries: str):
|
def post(session, _id: str):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Entering {Routes.StopWorkflow} with args {debug_session(session)}, {_id=}")
|
f"Entering {Routes.StopWorkflow} with args {debug_session(session)}, {_id=}")
|
||||||
instance = InstanceManager.get(session, _id)
|
instance = InstanceManager.get(session, _id)
|
||||||
return instance.stop_workflow(json.loads(tab_boundaries))
|
return instance.stop_workflow()
|
||||||
@@ -81,7 +81,7 @@ class WorkflowDesignerCommandManager(BaseCommandManager):
|
|||||||
"hx_post": f"{ROUTE_ROOT}{Routes.PauseWorkflow}",
|
"hx_post": f"{ROUTE_ROOT}{Routes.PauseWorkflow}",
|
||||||
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
|
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
|
||||||
"hx-swap": "outerHTML",
|
"hx-swap": "outerHTML",
|
||||||
"hx-vals": f'js:{{"_id": "{self._id}", "tab_boundaries": getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
|
"hx-vals": f'js:{{"_id": "{self._id}")}}',
|
||||||
}
|
}
|
||||||
|
|
||||||
def stop_workflow(self):
|
def stop_workflow(self):
|
||||||
@@ -89,7 +89,7 @@ class WorkflowDesignerCommandManager(BaseCommandManager):
|
|||||||
"hx_post": f"{ROUTE_ROOT}{Routes.StopWorkflow}",
|
"hx_post": f"{ROUTE_ROOT}{Routes.StopWorkflow}",
|
||||||
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
|
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
|
||||||
"hx-swap": "outerHTML",
|
"hx-swap": "outerHTML",
|
||||||
"hx-vals": f'js:{{"_id": "{self._id}", "tab_boundaries": getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
|
"hx-vals": f'js:{{"_id": "{self._id}")}}',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from components.workflows.commands import WorkflowDesignerCommandManager
|
|||||||
from components.workflows.components.WorkflowPlayer import WorkflowPlayer
|
from components.workflows.components.WorkflowPlayer import WorkflowPlayer
|
||||||
from components.workflows.constants import WORKFLOW_DESIGNER_INSTANCE_ID, ProcessorTypes
|
from components.workflows.constants import WORKFLOW_DESIGNER_INSTANCE_ID, ProcessorTypes
|
||||||
from components.workflows.db_management import WorkflowsDesignerSettings, WorkflowComponent, \
|
from components.workflows.db_management import WorkflowsDesignerSettings, WorkflowComponent, \
|
||||||
Connection, WorkflowsDesignerDbManager, WorkflowsPlayerSettings, WorkflowComponentRuntimeState, ComponentState
|
Connection, WorkflowsDesignerDbManager, ComponentState
|
||||||
from components_helpers import apply_boundaries, mk_tooltip, mk_dialog_buttons, mk_icon
|
from components_helpers import apply_boundaries, mk_tooltip, mk_dialog_buttons, mk_icon
|
||||||
from core.instance_manager import InstanceManager
|
from core.instance_manager import InstanceManager
|
||||||
from core.utils import get_unique_id, make_safe_id
|
from core.utils import get_unique_id, make_safe_id
|
||||||
@@ -71,9 +71,7 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
WorkflowPlayer,
|
WorkflowPlayer,
|
||||||
settings_manager=self._settings_manager,
|
settings_manager=self._settings_manager,
|
||||||
tabs_manager=self.tabs_manager,
|
tabs_manager=self.tabs_manager,
|
||||||
player_settings=WorkflowsPlayerSettings(workflow_name,
|
designer=self,
|
||||||
list(self._state.components.values()),
|
|
||||||
self._state.connections),
|
|
||||||
boundaries=boundaries)
|
boundaries=boundaries)
|
||||||
|
|
||||||
self._error_message = None
|
self._error_message = None
|
||||||
@@ -202,10 +200,10 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
# change the tab and display the results
|
# change the tab and display the results
|
||||||
self._player.set_boundaries(boundaries)
|
self._player.set_boundaries(boundaries)
|
||||||
self.tabs_manager.add_tab(f"Workflow {self._designer_settings.workflow_name}", self._player, self._player.key)
|
self.tabs_manager.add_tab(f"Workflow {self._designer_settings.workflow_name}", self._player, self._player.key)
|
||||||
|
|
||||||
return self.tabs_manager.refresh()
|
return self.tabs_manager.refresh()
|
||||||
|
|
||||||
def stop_workflow(self, boundaries):
|
def stop_workflow(self):
|
||||||
self._error_message = None
|
self._error_message = None
|
||||||
self._player.run()
|
self._player.run()
|
||||||
return self.tabs_manager.refresh()
|
return self.tabs_manager.refresh()
|
||||||
@@ -220,6 +218,15 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
|
|
||||||
return self.refresh_properties()
|
return self.refresh_properties()
|
||||||
|
|
||||||
|
def get_workflow_name(self):
|
||||||
|
return self._designer_settings.workflow_name
|
||||||
|
|
||||||
|
def get_workflow_components(self):
|
||||||
|
return self._state.components.values()
|
||||||
|
|
||||||
|
def get_workflow_connections(self):
|
||||||
|
return self._state.connections
|
||||||
|
|
||||||
def __ft__(self):
|
def __ft__(self):
|
||||||
return Div(
|
return Div(
|
||||||
H1(f"{self._designer_settings.workflow_name}", cls="text-xl font-bold"),
|
H1(f"{self._designer_settings.workflow_name}", cls="text-xl font-bold"),
|
||||||
@@ -227,7 +234,8 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
Div(
|
Div(
|
||||||
self._mk_media(),
|
self._mk_media(),
|
||||||
self._mk_error_message(),
|
self._mk_error_message(),
|
||||||
cls="flex mb-2"
|
cls="flex mb-2",
|
||||||
|
id=f"t_{self._id}"
|
||||||
),
|
),
|
||||||
self._mk_designer(),
|
self._mk_designer(),
|
||||||
Div(cls="wkf-splitter", id=f"s_{self._id}"),
|
Div(cls="wkf-splitter", id=f"s_{self._id}"),
|
||||||
@@ -268,21 +276,23 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
</svg>
|
</svg>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _mk_component(self, component: WorkflowComponent, runtime_state: WorkflowComponentRuntimeState):
|
def _mk_component(self, component: WorkflowComponent):
|
||||||
|
|
||||||
|
runtime_state = self._player.get_component_runtime_state(component.id)
|
||||||
|
|
||||||
info = COMPONENT_TYPES[component.type]
|
info = COMPONENT_TYPES[component.type]
|
||||||
is_selected = self._state.selected_component_id == component.id
|
is_selected = self._state.selected_component_id == component.id
|
||||||
|
tooltip_content = None
|
||||||
|
tooltip_class = ""
|
||||||
|
|
||||||
if runtime_state.state == ComponentState.FAILURE:
|
if runtime_state.state == ComponentState.FAILURE:
|
||||||
state_class = 'error' # To be styled with a red highlight
|
state_class = 'error' # To be styled with a red highlight
|
||||||
|
tooltip_content = runtime_state.error_message
|
||||||
|
tooltip_class = "mmt-tooltip"
|
||||||
elif runtime_state.state == ComponentState.NOT_RUN:
|
elif runtime_state.state == ComponentState.NOT_RUN:
|
||||||
state_class = 'not-run' # To be styled as greyed-out
|
state_class = 'not-run' # To be styled as greyed-out
|
||||||
else:
|
else:
|
||||||
state_class = ''
|
state_class = ''
|
||||||
if runtime_state.state == ComponentState.FAILURE:
|
|
||||||
tooltip_content = runtime_state.error_message
|
|
||||||
tooltip_class = "mmt-tooltip"
|
|
||||||
else:
|
|
||||||
tooltip_content = None
|
|
||||||
tooltip_class = ""
|
|
||||||
|
|
||||||
return Div(
|
return Div(
|
||||||
# Input connection point
|
# Input connection point
|
||||||
@@ -315,8 +325,7 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
*[NotStr(self._mk_connection_svg(conn)) for conn in self._state.connections],
|
*[NotStr(self._mk_connection_svg(conn)) for conn in self._state.connections],
|
||||||
|
|
||||||
# Render components
|
# Render components
|
||||||
*[self._mk_component(comp, state) for comp, state in zip(self._state.components.values(),
|
*[self._mk_component(comp) for comp in self._state.components.values()],
|
||||||
self._player.runtime_states.values())],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _mk_canvas(self, oob=False):
|
def _mk_canvas(self, oob=False):
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from components.datagrid_new.components.DataGrid import DataGrid
|
|||||||
from components.datagrid_new.settings import DataGridSettings
|
from components.datagrid_new.settings import DataGridSettings
|
||||||
from components.workflows.commands import WorkflowPlayerCommandManager
|
from components.workflows.commands import WorkflowPlayerCommandManager
|
||||||
from components.workflows.constants import WORKFLOW_PLAYER_INSTANCE_ID, ProcessorTypes
|
from components.workflows.constants import WORKFLOW_PLAYER_INSTANCE_ID, ProcessorTypes
|
||||||
from components.workflows.db_management import WorkflowsPlayerSettings, WorkflowComponentRuntimeState, \
|
from components.workflows.db_management import WorkflowComponentRuntimeState, \
|
||||||
WorkflowComponent, ComponentState
|
WorkflowComponent, ComponentState
|
||||||
from core.instance_manager import InstanceManager
|
from core.instance_manager import InstanceManager
|
||||||
from core.utils import get_unique_id, make_safe_id
|
from core.utils import get_unique_id, make_safe_id
|
||||||
@@ -34,13 +34,13 @@ class WorkflowPlayer(BaseComponent):
|
|||||||
_id=None,
|
_id=None,
|
||||||
settings_manager=None,
|
settings_manager=None,
|
||||||
tabs_manager=None,
|
tabs_manager=None,
|
||||||
player_settings: WorkflowsPlayerSettings = None,
|
designer=None,
|
||||||
boundaries: dict = None):
|
boundaries: dict = None):
|
||||||
super().__init__(session, _id)
|
super().__init__(session, _id)
|
||||||
self._settings_manager = settings_manager
|
self._settings_manager = settings_manager
|
||||||
self.tabs_manager = tabs_manager
|
self.tabs_manager = tabs_manager
|
||||||
self.key = f"__WorkflowPlayer_{player_settings.workflow_name}"
|
self._designer = designer
|
||||||
self._player_settings: WorkflowsPlayerSettings = player_settings
|
self.key = f"__WorkflowPlayer_{designer.get_workflow_name()}"
|
||||||
self._boundaries = boundaries
|
self._boundaries = boundaries
|
||||||
self.commands = WorkflowPlayerCommandManager(self)
|
self.commands = WorkflowPlayerCommandManager(self)
|
||||||
self._datagrid = InstanceManager.get(self._session,
|
self._datagrid = InstanceManager.get(self._session,
|
||||||
@@ -49,18 +49,26 @@ class WorkflowPlayer(BaseComponent):
|
|||||||
key=self.key,
|
key=self.key,
|
||||||
grid_settings=grid_settings,
|
grid_settings=grid_settings,
|
||||||
boundaries=boundaries)
|
boundaries=boundaries)
|
||||||
self.runtime_states = {component.id: WorkflowComponentRuntimeState(component.id)
|
self.runtime_states = {}
|
||||||
for component in player_settings.components}
|
|
||||||
self.global_error = None
|
self.global_error = None
|
||||||
self.has_error = False
|
self.has_error = False
|
||||||
|
|
||||||
def set_boundaries(self, boundaries: dict):
|
def set_boundaries(self, boundaries: dict):
|
||||||
self._datagrid.set_boundaries(boundaries)
|
self._datagrid.set_boundaries(boundaries)
|
||||||
|
|
||||||
|
def get_component_runtime_state(self, component_id: str):
|
||||||
|
# return a default value if the player hasn't been played yet
|
||||||
|
return self.runtime_states.get(component_id, WorkflowComponentRuntimeState(component_id))
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
self._reset_state()
|
# at least one connection is required to play
|
||||||
|
if len(self._designer.get_workflow_connections()) == 0:
|
||||||
|
self.global_error = "No connections defined."
|
||||||
|
return
|
||||||
|
|
||||||
components_by_id = {c.id: c for c in self._player_settings.components}
|
self._init_state()
|
||||||
|
|
||||||
|
components_by_id = {c.id: c for c in self._designer.get_workflow_components()}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sorted_components = self._get_sorted_components()
|
sorted_components = self._get_sorted_components()
|
||||||
@@ -122,8 +130,8 @@ class WorkflowPlayer(BaseComponent):
|
|||||||
self._datagrid.init_from_dataframe(df)
|
self._datagrid.init_from_dataframe(df)
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._reset_state()
|
self._init_state()
|
||||||
|
|
||||||
def get_dataframe(self):
|
def get_dataframe(self):
|
||||||
return self._datagrid.get_dataframe()
|
return self._datagrid.get_dataframe()
|
||||||
|
|
||||||
@@ -144,11 +152,11 @@ class WorkflowPlayer(BaseComponent):
|
|||||||
|
|
||||||
:return: A list of sorted WorkflowComponent objects.
|
:return: A list of sorted WorkflowComponent objects.
|
||||||
"""
|
"""
|
||||||
components_by_id = {c.id: c for c in self._player_settings.components}
|
components_by_id = {c.id: c for c in self._designer.get_workflow_components()}
|
||||||
|
|
||||||
# Get all component IDs involved in connections
|
# Get all component IDs involved in connections
|
||||||
involved_ids = set()
|
involved_ids = set()
|
||||||
for conn in self._player_settings.connections:
|
for conn in self._designer.get_workflow_connections():
|
||||||
involved_ids.add(conn.from_id)
|
involved_ids.add(conn.from_id)
|
||||||
involved_ids.add(conn.to_id)
|
involved_ids.add(conn.to_id)
|
||||||
|
|
||||||
@@ -161,7 +169,7 @@ class WorkflowPlayer(BaseComponent):
|
|||||||
adj = {cid: [] for cid in involved_ids}
|
adj = {cid: [] for cid in involved_ids}
|
||||||
in_degree = {cid: 0 for cid in involved_ids}
|
in_degree = {cid: 0 for cid in involved_ids}
|
||||||
|
|
||||||
for conn in self._player_settings.connections:
|
for conn in self._designer.get_workflow_connections():
|
||||||
# from_id -> to_id
|
# from_id -> to_id
|
||||||
adj[conn.from_id].append(conn.to_id)
|
adj[conn.from_id].append(conn.to_id)
|
||||||
in_degree[conn.to_id] += 1
|
in_degree[conn.to_id] += 1
|
||||||
@@ -210,12 +218,11 @@ class WorkflowPlayer(BaseComponent):
|
|||||||
|
|
||||||
return engine
|
return engine
|
||||||
|
|
||||||
def _reset_state(self, state: ComponentState = ComponentState.SUCCESS):
|
def _init_state(self):
|
||||||
self.global_error = None
|
self.global_error = None
|
||||||
self.has_error = False
|
self.has_error = False
|
||||||
for runtime_state in self.runtime_states.values():
|
self.runtime_states = {component.id: WorkflowComponentRuntimeState(component.id)
|
||||||
runtime_state.state = state
|
for component in self._designer.get_workflow_components()}
|
||||||
runtime_state.error_message = None
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_component_id(session, suffix=None):
|
def create_component_id(session, suffix=None):
|
||||||
|
|||||||
@@ -61,13 +61,6 @@ class WorkflowsDesignerState:
|
|||||||
selected_component_id = None
|
selected_component_id = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class WorkflowsPlayerSettings:
|
|
||||||
workflow_name: str
|
|
||||||
components: list[WorkflowComponent]
|
|
||||||
connections: list[Connection]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WorkflowsSettings:
|
class WorkflowsSettings:
|
||||||
workflows: list[str] = field(default_factory=list)
|
workflows: list[str] = field(default_factory=list)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ def sample_structure():
|
|||||||
"""
|
"""
|
||||||
A pytest fixture to provide a sample tree structure for testing.
|
A pytest fixture to provide a sample tree structure for testing.
|
||||||
"""
|
"""
|
||||||
return Html(
|
return Div(
|
||||||
Header(cls="first-class"),
|
Header(cls="first-class"),
|
||||||
Body(
|
Body(
|
||||||
"hello world",
|
"hello world",
|
||||||
@@ -26,13 +26,13 @@ def sample_structure():
|
|||||||
|
|
||||||
@pytest.mark.parametrize("value, expected, expected_error", [
|
@pytest.mark.parametrize("value, expected, expected_error", [
|
||||||
(Div(), "value",
|
(Div(), "value",
|
||||||
"The types are different: <class 'fastcore.xml.FT'> != <class 'str'>\nactual=div((),{})\nexpected=value."),
|
"The types are different: <class 'fastcore.xml.FT'> != <class 'str'>\nactual=<div></div>\nexpected=value."),
|
||||||
(Div(), A(),
|
(Div(), A(),
|
||||||
"The elements are different: 'div' != 'a'."),
|
"The elements are different: 'div' != 'a'."),
|
||||||
(Div(Div()), Div(A()),
|
(Div(Div()), Div(A()),
|
||||||
"Path 'div':\n\tThe elements are different: 'div' != 'a'."),
|
"Path 'div':\n\tThe elements are different: 'div' != 'a'."),
|
||||||
(Div(A(Span())), Div(A("element")),
|
(Div(A(Span())), Div(A("element")),
|
||||||
"Path 'div.a':\n\tThe types are different: <class 'fastcore.xml.FT'> != <class 'str'>\nactual=span((),{})\nexpected=element."),
|
"Path 'div.a':\n\tThe types are different: <class 'fastcore.xml.FT'> != <class 'str'>\nactual=<span></span>\nexpected=element."),
|
||||||
(Div(attr="one"), Div(attr="two"),
|
(Div(attr="one"), Div(attr="two"),
|
||||||
"Path 'div':\n\tThe values are different for 'attr' : 'one' != 'two'."),
|
"Path 'div':\n\tThe values are different for 'attr' : 'one' != 'two'."),
|
||||||
(Div(A(attr="alpha")), Div(A(attr="beta")),
|
(Div(A(attr="alpha")), Div(A(attr="beta")),
|
||||||
|
|||||||
@@ -5,18 +5,19 @@ from fasthtml.xtend import Script
|
|||||||
|
|
||||||
from components.workflows.components.WorkflowDesigner import WorkflowDesigner, COMPONENT_TYPES
|
from components.workflows.components.WorkflowDesigner import WorkflowDesigner, COMPONENT_TYPES
|
||||||
from components.workflows.constants import ProcessorTypes
|
from components.workflows.constants import ProcessorTypes
|
||||||
from components.workflows.db_management import WorkflowsDesignerSettings, WorkflowComponent, Connection, \
|
from components.workflows.db_management import WorkflowsDesignerSettings, WorkflowComponent, Connection
|
||||||
WorkflowComponentRuntimeState
|
|
||||||
from core.settings_management import SettingsManager, MemoryDbEngine
|
from core.settings_management import SettingsManager, MemoryDbEngine
|
||||||
from helpers import matches, Contains
|
from helpers import matches, Contains
|
||||||
|
from my_mocks import tabs_manager
|
||||||
|
|
||||||
TEST_WORKFLOW_DESIGNER_ID = "workflow_designer_id"
|
TEST_WORKFLOW_DESIGNER_ID = "workflow_designer_id"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def designer(session):
|
def designer(session, tabs_manager):
|
||||||
return WorkflowDesigner(session=session, _id=TEST_WORKFLOW_DESIGNER_ID,
|
return WorkflowDesigner(session=session, _id=TEST_WORKFLOW_DESIGNER_ID,
|
||||||
settings_manager=SettingsManager(engine=MemoryDbEngine()),
|
settings_manager=SettingsManager(engine=MemoryDbEngine()),
|
||||||
|
tabs_manager=tabs_manager,
|
||||||
key=TEST_WORKFLOW_DESIGNER_ID,
|
key=TEST_WORKFLOW_DESIGNER_ID,
|
||||||
designer_settings=WorkflowsDesignerSettings("Workflow Name"),
|
designer_settings=WorkflowsDesignerSettings("Workflow Name"),
|
||||||
boundaries={"height": 500, "width": 800}
|
boundaries={"height": 500, "width": 800}
|
||||||
@@ -72,6 +73,7 @@ def test_i_can_render_no_component(designer):
|
|||||||
expected = Div(
|
expected = Div(
|
||||||
H1("Workflow Name"),
|
H1("Workflow Name"),
|
||||||
P("Drag components from the toolbox to the canvas to create your workflow."),
|
P("Drag components from the toolbox to the canvas to create your workflow."),
|
||||||
|
Div(id=f"t_{designer.get_id()}"), # media + error message
|
||||||
Div(id=f"d_{designer.get_id()}"), # designer container
|
Div(id=f"d_{designer.get_id()}"), # designer container
|
||||||
Div(cls="wkf-splitter"),
|
Div(cls="wkf-splitter"),
|
||||||
Div(id=f"p_{designer.get_id()}"), # properties panel
|
Div(id=f"p_{designer.get_id()}"), # properties panel
|
||||||
@@ -84,8 +86,7 @@ def test_i_can_render_no_component(designer):
|
|||||||
|
|
||||||
def test_i_can_render_a_producer(designer, producer_component):
|
def test_i_can_render_a_producer(designer, producer_component):
|
||||||
component = producer_component
|
component = producer_component
|
||||||
component_state = WorkflowComponentRuntimeState(component.id)
|
actual = designer._mk_component(component)
|
||||||
actual = designer._mk_component(component, component_state)
|
|
||||||
expected = Div(
|
expected = Div(
|
||||||
# input connection point
|
# input connection point
|
||||||
Div(cls="wkf-connection-point wkf-input-point",
|
Div(cls="wkf-connection-point wkf-input-point",
|
||||||
|
|||||||
@@ -4,189 +4,212 @@ import pandas as pd
|
|||||||
import pytest
|
import pytest
|
||||||
from pandas.testing import assert_frame_equal
|
from pandas.testing import assert_frame_equal
|
||||||
|
|
||||||
from components.workflows.components.WorkflowDesigner import COMPONENT_TYPES
|
from components.workflows.components.WorkflowDesigner import COMPONENT_TYPES, WorkflowDesigner
|
||||||
from components.workflows.components.WorkflowPlayer import WorkflowPlayer, WorkflowsPlayerError
|
from components.workflows.components.WorkflowPlayer import WorkflowPlayer, WorkflowsPlayerError
|
||||||
from components.workflows.constants import ProcessorTypes
|
from components.workflows.constants import ProcessorTypes
|
||||||
from components.workflows.db_management import WorkflowsPlayerSettings, WorkflowComponent, Connection, ComponentState
|
from components.workflows.db_management import WorkflowComponent, Connection, ComponentState, WorkflowsDesignerSettings
|
||||||
from core.settings_management import SettingsManager, MemoryDbEngine
|
from core.settings_management import SettingsManager, MemoryDbEngine
|
||||||
from my_mocks import tabs_manager
|
from my_mocks import tabs_manager
|
||||||
|
|
||||||
|
TEST_WORKFLOW_DESIGNER_ID = "workflow_designer_id"
|
||||||
TEST_WORKFLOW_PLAYER_ID = "workflow_player_id"
|
TEST_WORKFLOW_PLAYER_ID = "workflow_player_id"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(session, tabs_manager):
|
def settings_manager():
|
||||||
"""
|
return SettingsManager(MemoryDbEngine())
|
||||||
Sets up a standard WorkflowPlayer instance with a 3-component linear workflow.
|
|
||||||
A helper method 'get_dataframe' is attached for easier testing.
|
|
||||||
"""
|
@pytest.fixture
|
||||||
components = [
|
def designer(session, settings_manager, tabs_manager):
|
||||||
WorkflowComponent(
|
components = [
|
||||||
"comp_producer",
|
WorkflowComponent(
|
||||||
ProcessorTypes.Producer,
|
"comp_producer",
|
||||||
10, 100,
|
ProcessorTypes.Producer,
|
||||||
COMPONENT_TYPES[ProcessorTypes.Producer]["title"],
|
10, 100,
|
||||||
COMPONENT_TYPES[ProcessorTypes.Producer]["description"],
|
COMPONENT_TYPES[ProcessorTypes.Producer]["title"],
|
||||||
{"processor_name": "Repository"}
|
COMPONENT_TYPES[ProcessorTypes.Producer]["description"],
|
||||||
),
|
{"processor_name": "Repository"}
|
||||||
WorkflowComponent(
|
),
|
||||||
"comp_filter",
|
WorkflowComponent(
|
||||||
ProcessorTypes.Filter,
|
"comp_filter",
|
||||||
40, 100,
|
ProcessorTypes.Filter,
|
||||||
COMPONENT_TYPES[ProcessorTypes.Filter]["title"],
|
40, 100,
|
||||||
COMPONENT_TYPES[ProcessorTypes.Filter]["description"],
|
COMPONENT_TYPES[ProcessorTypes.Filter]["title"],
|
||||||
{"processor_name": "Default"}
|
COMPONENT_TYPES[ProcessorTypes.Filter]["description"],
|
||||||
),
|
{"processor_name": "Default"}
|
||||||
WorkflowComponent(
|
),
|
||||||
"comp_presenter",
|
WorkflowComponent(
|
||||||
ProcessorTypes.Presenter,
|
"comp_presenter",
|
||||||
70, 100,
|
ProcessorTypes.Presenter,
|
||||||
COMPONENT_TYPES[ProcessorTypes.Presenter]["title"],
|
70, 100,
|
||||||
COMPONENT_TYPES[ProcessorTypes.Presenter]["description"],
|
COMPONENT_TYPES[ProcessorTypes.Presenter]["title"],
|
||||||
{"processor_name": "Default"}
|
COMPONENT_TYPES[ProcessorTypes.Presenter]["description"],
|
||||||
)
|
{"processor_name": "Default"}
|
||||||
]
|
|
||||||
connections = [
|
|
||||||
Connection("conn_1", "comp_producer", "comp_filter"),
|
|
||||||
Connection("conn_2", "comp_filter", "comp_presenter"),
|
|
||||||
]
|
|
||||||
return WorkflowPlayer(session=session,
|
|
||||||
_id=TEST_WORKFLOW_PLAYER_ID,
|
|
||||||
settings_manager=SettingsManager(engine=MemoryDbEngine()),
|
|
||||||
tabs_manager=tabs_manager,
|
|
||||||
player_settings=WorkflowsPlayerSettings("Workflow Name", components, connections),
|
|
||||||
boundaries={"height": 500, "width": 800}
|
|
||||||
)
|
)
|
||||||
|
]
|
||||||
|
connections = [
|
||||||
|
Connection("conn_1", "comp_producer", "comp_filter"),
|
||||||
|
Connection("conn_2", "comp_filter", "comp_presenter"),
|
||||||
|
]
|
||||||
|
|
||||||
|
designer = WorkflowDesigner(
|
||||||
|
session,
|
||||||
|
TEST_WORKFLOW_DESIGNER_ID,
|
||||||
|
settings_manager,
|
||||||
|
tabs_manager,
|
||||||
|
"Workflow Designer",
|
||||||
|
WorkflowsDesignerSettings(workflow_name="Test Workflow"),
|
||||||
|
{"height": 500, "width": 800}
|
||||||
|
)
|
||||||
|
|
||||||
|
return designer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def player(session, settings_manager, tabs_manager, designer):
|
||||||
|
"""
|
||||||
|
Sets up a standard WorkflowPlayer instance with a 3-component linear workflow.
|
||||||
|
A helper method 'get_dataframe' is attached for easier testing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return WorkflowPlayer(session=session,
|
||||||
|
_id=TEST_WORKFLOW_PLAYER_ID,
|
||||||
|
settings_manager=settings_manager,
|
||||||
|
tabs_manager=tabs_manager,
|
||||||
|
designer=designer,
|
||||||
|
boundaries={"height": 500, "width": 800}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_run_successful_workflow(player, mocker):
|
def test_run_successful_workflow(player, mocker):
|
||||||
"""
|
"""
|
||||||
Tests the "happy path" where the workflow runs successfully from start to finish.
|
Tests the "happy path" where the workflow runs successfully from start to finish.
|
||||||
"""
|
"""
|
||||||
# 1. Arrange: Mock a successful engine run
|
# 1. Arrange: Mock a successful engine run
|
||||||
mock_engine = MagicMock()
|
mock_engine = MagicMock()
|
||||||
mock_engine.has_error = False
|
mock_engine.has_error = False
|
||||||
mock_result_data = [
|
mock_result_data = [
|
||||||
MagicMock(as_dict=lambda: {'col_a': 1, 'col_b': 'x'}),
|
MagicMock(as_dict=lambda: {'col_a': 1, 'col_b': 'x'}),
|
||||||
MagicMock(as_dict=lambda: {'col_a': 2, 'col_b': 'y'})
|
MagicMock(as_dict=lambda: {'col_a': 2, 'col_b': 'y'})
|
||||||
]
|
]
|
||||||
mock_engine.run_to_list.return_value = mock_result_data
|
mock_engine.run_to_list.return_value = mock_result_data
|
||||||
mocker.patch.object(player, '_get_engine', return_value=mock_engine)
|
mocker.patch.object(player, '_get_engine', return_value=mock_engine)
|
||||||
|
|
||||||
# 2. Act
|
# 2. Act
|
||||||
player.run()
|
player.run()
|
||||||
|
|
||||||
# 3. Assert: Check for success state and correct data
|
# 3. Assert: Check for success state and correct data
|
||||||
assert not player.has_error
|
assert not player.has_error
|
||||||
assert player.global_error is None
|
assert player.global_error is None
|
||||||
for component_id, state in player.runtime_states.items():
|
for component_id, state in player.runtime_states.items():
|
||||||
assert state.state == ComponentState.SUCCESS
|
assert state.state == ComponentState.SUCCESS
|
||||||
|
|
||||||
player._get_engine.assert_called_once()
|
player._get_engine.assert_called_once()
|
||||||
mock_engine.run_to_list.assert_called_once()
|
mock_engine.run_to_list.assert_called_once()
|
||||||
|
|
||||||
expected_df = pd.DataFrame([row.as_dict() for row in mock_result_data])
|
expected_df = pd.DataFrame([row.as_dict() for row in mock_result_data])
|
||||||
assert_frame_equal(player.get_dataframe(), expected_df)
|
assert_frame_equal(player.get_dataframe(), expected_df)
|
||||||
|
|
||||||
|
|
||||||
def test_run_with_cyclical_dependency(player, mocker):
|
def test_run_with_cyclical_dependency(player, mocker):
|
||||||
"""
|
"""
|
||||||
Tests that a workflow with a cycle is detected and handled before execution.
|
Tests that a workflow with a cycle is detected and handled before execution.
|
||||||
"""
|
"""
|
||||||
# 1. Arrange: Introduce a cycle and spy on engine creation
|
# 1. Arrange: Introduce a cycle and spy on engine creation
|
||||||
player._player_settings.connections.append(Connection("conn_3", "comp_presenter", "comp_producer"))
|
player._player_settings.connections.append(Connection("conn_3", "comp_presenter", "comp_producer"))
|
||||||
spy_get_engine = mocker.spy(player, '_get_engine')
|
spy_get_engine = mocker.spy(player, '_get_engine')
|
||||||
|
|
||||||
# 2. Act
|
# 2. Act
|
||||||
player.run()
|
player.run()
|
||||||
|
|
||||||
# 3. Assert: Check for the specific cycle error
|
# 3. Assert: Check for the specific cycle error
|
||||||
assert player.has_error
|
assert player.has_error
|
||||||
assert "Workflow configuration error: A cycle was detected" in player.global_error
|
assert "Workflow configuration error: A cycle was detected" in player.global_error
|
||||||
assert player.get_dataframe().empty
|
assert player.get_dataframe().empty
|
||||||
spy_get_engine.assert_not_called()
|
spy_get_engine.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_run_with_component_initialization_failure(player, mocker):
|
def test_run_with_component_initialization_failure(player, mocker):
|
||||||
"""
|
"""
|
||||||
Tests that an error during a component's initialization is handled correctly.
|
Tests that an error during a component's initialization is handled correctly.
|
||||||
"""
|
"""
|
||||||
# 1. Arrange: Make the engine creation fail for a specific component
|
# 1. Arrange: Make the engine creation fail for a specific component
|
||||||
failing_component_id = "comp_filter"
|
failing_component_id = "comp_filter"
|
||||||
error = ValueError("Missing a required property")
|
error = ValueError("Missing a required property")
|
||||||
mocker.patch.object(player, '_get_engine', side_effect=WorkflowsPlayerError(failing_component_id, error))
|
mocker.patch.object(player, '_get_engine', side_effect=WorkflowsPlayerError(failing_component_id, error))
|
||||||
|
|
||||||
# 2. Act
|
# 2. Act
|
||||||
player.run()
|
player.run()
|
||||||
|
|
||||||
# 3. Assert: Check that the specific component is marked as failed
|
# 3. Assert: Check that the specific component is marked as failed
|
||||||
assert player.has_error
|
assert player.has_error
|
||||||
assert f"Failed to init component '{failing_component_id}'" in player.global_error
|
assert f"Failed to init component '{failing_component_id}'" in player.global_error
|
||||||
assert player.runtime_states[failing_component_id].state == ComponentState.FAILURE
|
assert player.runtime_states[failing_component_id].state == ComponentState.FAILURE
|
||||||
assert str(error) in player.runtime_states[failing_component_id].error_message
|
assert str(error) in player.runtime_states[failing_component_id].error_message
|
||||||
assert player.runtime_states["comp_producer"].state == ComponentState.NOT_RUN
|
assert player.runtime_states["comp_producer"].state == ComponentState.NOT_RUN
|
||||||
|
|
||||||
|
|
||||||
def test_run_with_failure_in_middle_component(player, mocker):
|
def test_run_with_failure_in_middle_component(player, mocker):
|
||||||
"""
|
"""
|
||||||
Tests failure in a middle component updates all component states correctly.
|
Tests failure in a middle component updates all component states correctly.
|
||||||
"""
|
"""
|
||||||
# 1. Arrange: Mock an engine that fails at the filter component
|
# 1. Arrange: Mock an engine that fails at the filter component
|
||||||
mock_engine = MagicMock()
|
mock_engine = MagicMock()
|
||||||
mock_engine.has_error = True
|
mock_engine.has_error = True
|
||||||
failing_component_id = "comp_filter"
|
failing_component_id = "comp_filter"
|
||||||
error = RuntimeError("Data processing failed unexpectedly")
|
error = RuntimeError("Data processing failed unexpectedly")
|
||||||
mock_engine.errors = {failing_component_id: error}
|
mock_engine.errors = {failing_component_id: error}
|
||||||
mock_engine.run_to_list.return_value = []
|
mock_engine.run_to_list.return_value = []
|
||||||
mocker.patch.object(player, '_get_engine', return_value=mock_engine)
|
mocker.patch.object(player, '_get_engine', return_value=mock_engine)
|
||||||
|
|
||||||
# 2. Act
|
# 2. Act
|
||||||
player.run()
|
player.run()
|
||||||
|
|
||||||
# 3. Assert: Check the state of each component in the chain
|
# 3. Assert: Check the state of each component in the chain
|
||||||
assert player.has_error
|
assert player.has_error
|
||||||
assert f"Error in component 'Default': {error}" in player.global_error
|
assert f"Error in component 'Default': {error}" in player.global_error
|
||||||
assert player.runtime_states["comp_producer"].state == ComponentState.SUCCESS
|
assert player.runtime_states["comp_producer"].state == ComponentState.SUCCESS
|
||||||
assert player.runtime_states[failing_component_id].state == ComponentState.FAILURE
|
assert player.runtime_states[failing_component_id].state == ComponentState.FAILURE
|
||||||
assert str(error) in player.runtime_states[failing_component_id].error_message
|
assert str(error) in player.runtime_states[failing_component_id].error_message
|
||||||
assert player.runtime_states["comp_presenter"].state == ComponentState.NOT_RUN
|
assert player.runtime_states["comp_presenter"].state == ComponentState.NOT_RUN
|
||||||
|
|
||||||
|
|
||||||
def test_run_with_empty_workflow(player, mocker):
|
def test_run_with_empty_workflow(player, mocker):
|
||||||
"""
|
"""
|
||||||
Tests that running a workflow with no components completes without errors.
|
Tests that running a workflow with no components completes without errors.
|
||||||
"""
|
"""
|
||||||
# 1. Arrange: Clear components and connections
|
# 1. Arrange: Clear components and connections
|
||||||
player._player_settings.components = []
|
player._player_settings.components = []
|
||||||
player._player_settings.connections = []
|
player._player_settings.connections = []
|
||||||
player.runtime_states = {}
|
player.runtime_states = {}
|
||||||
spy_get_engine = mocker.spy(player, '_get_engine')
|
spy_get_engine = mocker.spy(player, '_get_engine')
|
||||||
|
|
||||||
# 2. Act
|
# 2. Act
|
||||||
player.run()
|
player.run()
|
||||||
|
|
||||||
# 3. Assert: Ensure it finishes cleanly with no data
|
# 3. Assert: Ensure it finishes cleanly with no data
|
||||||
assert not player.has_error
|
assert not player.has_error
|
||||||
assert player.global_error is None
|
assert player.global_error is None
|
||||||
assert player.get_dataframe().empty
|
assert player.get_dataframe().empty
|
||||||
spy_get_engine.assert_called_once()
|
spy_get_engine.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
def test_run_with_global_engine_error(player, mocker):
|
def test_run_with_global_engine_error(player, mocker):
|
||||||
"""
|
"""
|
||||||
Tests a scenario where the engine reports a global error not tied to a specific component.
|
Tests a scenario where the engine reports a global error not tied to a specific component.
|
||||||
"""
|
"""
|
||||||
# 1. Arrange: Mock a global engine failure
|
# 1. Arrange: Mock a global engine failure
|
||||||
mock_engine = MagicMock()
|
mock_engine = MagicMock()
|
||||||
mock_engine.has_error = True
|
mock_engine.has_error = True
|
||||||
mock_engine.errors = {} # No specific component error
|
mock_engine.errors = {} # No specific component error
|
||||||
mock_engine.global_error = "A simulated global engine failure"
|
mock_engine.global_error = "A simulated global engine failure"
|
||||||
mock_engine.run_to_list.return_value = []
|
mock_engine.run_to_list.return_value = []
|
||||||
mocker.patch.object(player, '_get_engine', return_value=mock_engine)
|
mocker.patch.object(player, '_get_engine', return_value=mock_engine)
|
||||||
|
|
||||||
# 2. Act
|
# 2. Act
|
||||||
player.run()
|
player.run()
|
||||||
|
|
||||||
# 3. Assert: The player should report the global error from the engine
|
# 3. Assert: The player should report the global error from the engine
|
||||||
assert player.has_error
|
assert player.has_error
|
||||||
assert player.global_error == mock_engine.global_error
|
assert player.global_error == mock_engine.global_error
|
||||||
|
|||||||
Reference in New Issue
Block a user