From c5202f7871352375c9e1a2525fcd7c85fdeb0296 Mon Sep 17 00:00:00 2001 From: Petar Pejovic <108530920+petar-qb@users.noreply.github.com> Date: Tue, 10 Oct 2023 12:53:49 +0200 Subject: [PATCH] Action loop and callback mapping tests (#93) --- ..._action_loop_and_callback_mapping_tests.md | 41 +++ vizro-core/pyproject.toml | 2 +- .../_callback_mapping_utils.py | 7 + .../src/vizro/actions/export_data_action.py | 2 +- .../src/vizro/models/_action/_action.py | 1 - .../test_get_action_loop_components.py | 168 ++++++++++ .../test_get_action_callback_mapping.py | 312 ++++++++++++++++++ .../vizro/actions/test_export_data_action.py | 22 ++ .../unit/vizro/models/_action/test_action.py | 5 - 9 files changed, 552 insertions(+), 8 deletions(-) create mode 100644 vizro-core/changelog.d/20231004_152109_petar_pejovic_action_loop_and_callback_mapping_tests.md create mode 100644 vizro-core/tests/unit/vizro/actions/_action_loop/test_get_action_loop_components.py create mode 100644 vizro-core/tests/unit/vizro/actions/_callback_mapping/test_get_action_callback_mapping.py diff --git a/vizro-core/changelog.d/20231004_152109_petar_pejovic_action_loop_and_callback_mapping_tests.md b/vizro-core/changelog.d/20231004_152109_petar_pejovic_action_loop_and_callback_mapping_tests.md new file mode 100644 index 000000000..5f27f4f02 --- /dev/null +++ b/vizro-core/changelog.d/20231004_152109_petar_pejovic_action_loop_and_callback_mapping_tests.md @@ -0,0 +1,41 @@ + + + + + + + +### Fixed + +- If the `targets` argument in the `export_data` action function is specified as `"falsy"` value (`None`, `[]`), triggering the action will result in the same outcome as if the argument were not set, exporting data from all charts on the current page. ([#93](https://github.com/mckinsey/vizro/pull/93)) + + diff --git a/vizro-core/pyproject.toml b/vizro-core/pyproject.toml index c310a0f98..19ac1376e 100644 --- a/vizro-core/pyproject.toml +++ b/vizro-core/pyproject.toml @@ -56,7 +56,7 @@ exclude_lines = [ "if __name__ == .__main__.:", "if TYPE_CHECKING:" ] -fail_under = 86 +fail_under = 92 show_missing = true skip_covered = true diff --git a/vizro-core/src/vizro/actions/_callback_mapping/_callback_mapping_utils.py b/vizro-core/src/vizro/actions/_callback_mapping/_callback_mapping_utils.py index 004b6a8ff..2954e1676 100644 --- a/vizro-core/src/vizro/actions/_callback_mapping/_callback_mapping_utils.py +++ b/vizro-core/src/vizro/actions/_callback_mapping/_callback_mapping_utils.py @@ -170,9 +170,13 @@ def _get_action_callback_outputs(action_id: ModelID) -> Dict[str, Output]: def _get_export_data_callback_outputs(action_id: ModelID) -> Dict[str, List[State]]: """Gets mapping of relevant output target name and Outputs for `export_data` action.""" action = model_manager[action_id] + try: targets = action.function["targets"] # type: ignore[attr-defined] except KeyError: + targets = None + + if not targets: targets = _get_components_with_data(action_id=action_id) return { @@ -196,6 +200,9 @@ def _get_export_data_callback_components(action_id: ModelID) -> List[dcc.Downloa try: targets = action.function["targets"] # type: ignore[attr-defined] except KeyError: + targets = None + + if not targets: targets = _get_components_with_data(action_id=action_id) return [ diff --git a/vizro-core/src/vizro/actions/export_data_action.py b/vizro-core/src/vizro/actions/export_data_action.py index 3c75df63a..09b73423e 100644 --- a/vizro-core/src/vizro/actions/export_data_action.py +++ b/vizro-core/src/vizro/actions/export_data_action.py @@ -33,7 +33,7 @@ def export_data( Returns: Dict mapping target component id to modified charts/components e.g. {'my_scatter': Figure({})} """ - if targets is None: + if not targets: targets = [ output["id"]["target_id"] for output in ctx.outputs_list diff --git a/vizro-core/src/vizro/models/_action/_action.py b/vizro-core/src/vizro/models/_action/_action.py index 23ed1d900..6f9800a1e 100644 --- a/vizro-core/src/vizro/models/_action/_action.py +++ b/vizro-core/src/vizro/models/_action/_action.py @@ -158,7 +158,6 @@ def build(self): def callback_wrapper(trigger: None, **inputs: Dict[str, Any]) -> Dict[str, Any]: return self._action_callback_function(**inputs) - # return action_components return html.Div( children=action_components, id=f"{self.id}_action_model_components_div", diff --git a/vizro-core/tests/unit/vizro/actions/_action_loop/test_get_action_loop_components.py b/vizro-core/tests/unit/vizro/actions/_action_loop/test_get_action_loop_components.py new file mode 100644 index 000000000..8539b4992 --- /dev/null +++ b/vizro-core/tests/unit/vizro/actions/_action_loop/test_get_action_loop_components.py @@ -0,0 +1,168 @@ +"""Unit tests for vizro.actions._action_loop._get_action_loop_components file.""" + +import json + +import dash +import plotly +import pytest +from dash import dcc, html + +import vizro.models as vm +import vizro.plotly.express as px +from vizro import Vizro +from vizro.actions import export_data +from vizro.actions._action_loop._get_action_loop_components import _get_action_loop_components +from vizro.managers import model_manager + + +@pytest.fixture +def fundamental_components(): + return [ + dcc.Store(id="action_finished"), + dcc.Store(id="remaining_actions", data=[]), + html.Div(id="cycle_breaker_div", style={"display": "hidden"}), + dcc.Store(id="cycle_breaker_empty_output_store"), + ] + + +@pytest.fixture +def gateway_components(request): + components = request.param + actions_chain_ids = [model_manager[component].actions[0].id for component in components] + return [ + dcc.Store( + id={"type": "gateway_input", "trigger_id": actions_chain_id}, + data=f"{actions_chain_id}", + ) + for actions_chain_id in actions_chain_ids + ] + + +@pytest.fixture +def action_trigger_components(request): + components = request.param + actions_ids = [model_manager[component].actions[0].actions[0].id for component in components] + return [dcc.Store(id={"type": "action_trigger", "action_name": action_id}) for action_id in actions_ids] + + +@pytest.fixture +def action_trigger_actions_id_component(request): + components = request.param + actions_ids = [model_manager[component].actions[0].actions[0].id for component in components] + return dcc.Store( + id="action_trigger_actions_id", + data=actions_ids, + ) + + +@pytest.fixture +def trigger_to_actions_chain_mapper_component(request): + components = request.param + actions_chain_ids = [model_manager[component].actions[0].id for component in components] + return dcc.Store( + id="trigger_to_actions_chain_mapper", + data={ + actions_chain_id: [action.id for action in model_manager[actions_chain_id].actions] + for actions_chain_id in actions_chain_ids + }, + ) + + +@pytest.fixture +def managers_one_page_two_components_two_controls(): + """Instantiates managers with one page that contains two controls and two components.""" + page = vm.Page( + id="test_page", + title="First page", + components=[ + vm.Graph( + id="scatter_chart", + figure=px.scatter(px.data.gapminder(), x="lifeExp", y="gdpPercap"), + ), + vm.Button( + id="export_data_button", + actions=[vm.Action(id="export_data_action", function=export_data())], + ), + ], + controls=[ + vm.Filter(id="filter_continent", column="continent", selector=vm.Dropdown(id="filter_continent_selector")), + vm.Parameter( + id="parameter_x", + targets=["scatter_chart.x"], + selector=vm.Dropdown( + id="parameter_x_selector", + options=["lifeExp", "gdpPercap", "pop"], + ), + ), + ], + ) + # TODO: Call the Dashboard._pre_build() method once the pages registration is moved into this method. + yield Vizro().build(vm.Dashboard(pages=[page])) + del dash.page_registry["test_page"] + + +@pytest.fixture +def managers_one_page_no_actions(): + """Instantiates managers with one "empty" page.""" + page = vm.Page( + id="test_page_no_actions", + title="Second page", + components=[vm.Card(text="")], + ) + # TODO: Call the Dashboard._pre_build() method once the pages registration is moved into this method. + yield Vizro().build(vm.Dashboard(pages=[page])) + del dash.page_registry["test_page_no_actions"] + + +class TestGetActionLoopComponents: + """Tests getting required components for the action loop.""" + + @pytest.mark.usefixtures("managers_one_page_no_actions") + def test_no_components(self): + result = _get_action_loop_components() + result = json.loads(json.dumps(result, cls=plotly.utils.PlotlyJSONEncoder)) + + expected = html.Div(id="action_loop_components_div") + expected = json.loads(json.dumps(expected, cls=plotly.utils.PlotlyJSONEncoder)) + + assert result == expected + + @pytest.mark.usefixtures("managers_one_page_two_components_two_controls") + @pytest.mark.parametrize( + "gateway_components, " + "action_trigger_components, " + "action_trigger_actions_id_component, " + "trigger_to_actions_chain_mapper_component", + [ + ( + ["test_page", "export_data_button", "filter_continent_selector", "parameter_x_selector"], + ["test_page", "export_data_button", "filter_continent_selector", "parameter_x_selector"], + ["test_page", "export_data_button", "filter_continent_selector", "parameter_x_selector"], + ["test_page", "export_data_button", "filter_continent_selector", "parameter_x_selector"], + ) + ], + indirect=True, + ) + def test_all_action_loop_components( # noqa: PLR0913 # pylint: disable=too-many-arguments + self, + fundamental_components, + gateway_components, + action_trigger_components, + action_trigger_actions_id_component, + trigger_to_actions_chain_mapper_component, + ): + result = _get_action_loop_components() + result = json.loads(json.dumps(result, cls=plotly.utils.PlotlyJSONEncoder)) + + all_action_loop_components_expected = ( + fundamental_components + + gateway_components + + action_trigger_components + + [action_trigger_actions_id_component] + + [trigger_to_actions_chain_mapper_component] + ) + + expected = html.Div(children=all_action_loop_components_expected, id="action_loop_components_div") + expected = json.loads(json.dumps(expected, cls=plotly.utils.PlotlyJSONEncoder)) + + assert result == expected diff --git a/vizro-core/tests/unit/vizro/actions/_callback_mapping/test_get_action_callback_mapping.py b/vizro-core/tests/unit/vizro/actions/_callback_mapping/test_get_action_callback_mapping.py new file mode 100644 index 000000000..c0210f636 --- /dev/null +++ b/vizro-core/tests/unit/vizro/actions/_callback_mapping/test_get_action_callback_mapping.py @@ -0,0 +1,312 @@ +"""Unit tests for vizro.actions._callback_mapping._get_action_callback_mapping file.""" + +import json + +import dash +import plotly +import pytest + +import vizro.models as vm +import vizro.plotly.express as px +from vizro import Vizro +from vizro.actions import export_data, filter_interaction +from vizro.actions._callback_mapping._get_action_callback_mapping import _get_action_callback_mapping +from vizro.models.types import capture + + +@capture("action") +def custom_action_example(): + pass + + +# custom action with same name as some predefined action +def get_custom_action_with_known_name(): + @capture("action") + def export_data(): + pass + + return export_data() + + +@pytest.fixture +def managers_one_page_four_controls_two_graphs_filter_interaction(request): + """Instantiates managers with one page that contains four controls, two graphs and filter interaction.""" + # If the fixture is parametrised set the targets. Otherwise, set export_data without targets. + export_data_action_function = export_data(targets=request.param) if hasattr(request, "param") else export_data() + + vm.Page( + id="test_page", + title="My first dashboard", + components=[ + vm.Graph( + id="scatter_chart", + figure=px.scatter(px.data.gapminder(), x="lifeExp", y="gdpPercap", custom_data=["continent"]), + actions=[ + vm.Action(id="filter_interaction_action", function=filter_interaction(targets=["scatter_chart_2"])) + ], + ), + vm.Graph( + id="scatter_chart_2", + figure=px.scatter(px.data.gapminder(), x="lifeExp", y="gdpPercap", custom_data=["continent"]), + actions=[vm.Action(id="custom_action", function=custom_action_example())], + ), + vm.Button( + id="export_data_button", + actions=[ + vm.Action(id="export_data_action", function=export_data_action_function), + vm.Action(id="export_data_custom_action", function=get_custom_action_with_known_name()), + ], + ), + ], + controls=[ + vm.Filter(id="filter_continent", column="continent", selector=vm.Dropdown(id="filter_continent_selector")), + vm.Filter(id="filter_country", column="country", selector=vm.Dropdown(id="filter_country_selector")), + vm.Parameter( + id="parameter_x", + targets=["scatter_chart.x", "scatter_chart_2.x"], + selector=vm.Dropdown( + id="parameter_x_selector", + options=["lifeExp", "gdpPercap", "pop"], + multi=False, + value="gdpPercap", + ), + ), + vm.Parameter( + id="parameter_y", + targets=["scatter_chart.y", "scatter_chart_2.y"], + selector=vm.Dropdown( + id="parameter_y_selector", + options=["lifeExp", "gdpPercap", "pop"], + multi=False, + value="lifeExp", + ), + ), + ], + ) + Vizro._pre_build() + + +@pytest.fixture +def action_callback_inputs_expected(): + return { + "filters": [ + dash.State("filter_continent_selector", "value"), + dash.State("filter_country_selector", "value"), + ], + "parameters": [ + dash.State("parameter_x_selector", "value"), + dash.State("parameter_y_selector", "value"), + ], + "filter_interaction": [ + dash.State("scatter_chart", "clickData"), + ], + "theme_selector": dash.State("theme_selector", "on"), + } + + +@pytest.fixture +def action_callback_outputs_expected(request): + targets = request.param + return { + target["component_id"]: dash.Output(target["component_id"], target["component_property"]) for target in targets + } + + +@pytest.fixture +def export_data_inputs_expected(): + return { + "filters": [ + dash.State("filter_continent_selector", "value"), + dash.State("filter_country_selector", "value"), + ], + "parameters": [], + "filter_interaction": [ + dash.State("scatter_chart", "clickData"), + ], + "theme_selector": [], + } + + +@pytest.fixture +def export_data_outputs_expected(request): + return { + f"download-dataframe_{target}": dash.Output( + {"action_id": "export_data_action", "target_id": target, "type": "download-dataframe"}, "data" + ) + for target in request.param + } + + +@pytest.fixture +def export_data_components_expected(request): + return [ + dash.dcc.Download(id={"type": "download-dataframe", "action_id": "export_data_action", "target_id": target}) + for target in request.param + ] + + +@pytest.mark.usefixtures("managers_one_page_four_controls_two_graphs_filter_interaction") +class TestCallbackMapping: + """Tests action callback mapping for predefined and custom actions.""" + + @pytest.mark.parametrize( + "action_id, callback_mapping_inputs_expected", + [ + ("filter_action_filter_continent", "action_callback_inputs_expected"), + ("filter_interaction_action", "action_callback_inputs_expected"), + ("parameter_action_parameter_x", "action_callback_inputs_expected"), + ("on_page_load_action_action_test_page", "action_callback_inputs_expected"), + ("export_data_action", "export_data_inputs_expected"), + ], + ) + def test_action_callback_mapping_inputs(self, action_id, callback_mapping_inputs_expected, request): + result = _get_action_callback_mapping( + action_id=action_id, + argument="inputs", + ) + + callback_mapping_inputs_expected = request.getfixturevalue(callback_mapping_inputs_expected) + assert result == callback_mapping_inputs_expected + + @pytest.mark.parametrize( + "action_id, action_callback_outputs_expected", + [ + ( + "filter_action_filter_continent", + [ + {"component_id": "scatter_chart", "component_property": "figure"}, + {"component_id": "scatter_chart_2", "component_property": "figure"}, + ], + ), + ("filter_interaction_action", [{"component_id": "scatter_chart_2", "component_property": "figure"}]), + ( + "parameter_action_parameter_x", + [ + {"component_id": "scatter_chart", "component_property": "figure"}, + {"component_id": "scatter_chart_2", "component_property": "figure"}, + ], + ), + ( + "on_page_load_action_action_test_page", + [ + {"component_id": "scatter_chart", "component_property": "figure"}, + {"component_id": "scatter_chart_2", "component_property": "figure"}, + ], + ), + ], + indirect=["action_callback_outputs_expected"], + ) + def test_action_callback_mapping_outputs(self, action_id, action_callback_outputs_expected): + result = _get_action_callback_mapping( + action_id=action_id, + argument="outputs", + ) + assert result == action_callback_outputs_expected + + @pytest.mark.parametrize( + "export_data_outputs_expected", + [("scatter_chart", "scatter_chart_2")], + indirect=True, + ) + def test_export_data_no_targets_set_mapping_outputs(self, export_data_outputs_expected): + result = _get_action_callback_mapping( + action_id="export_data_action", + argument="outputs", + ) + + assert result == export_data_outputs_expected + + @pytest.mark.parametrize( + "managers_one_page_four_controls_two_graphs_filter_interaction, export_data_outputs_expected", + [ + (None, ["scatter_chart", "scatter_chart_2"]), + ([], ["scatter_chart", "scatter_chart_2"]), + (["scatter_chart"], ["scatter_chart"]), + (["scatter_chart", "scatter_chart_2"], ["scatter_chart", "scatter_chart_2"]), + ], + indirect=True, + ) + def test_export_data_targets_set_mapping_outputs( + self, managers_one_page_four_controls_two_graphs_filter_interaction, export_data_outputs_expected + ): + result = _get_action_callback_mapping( + action_id="export_data_action", + argument="outputs", + ) + + assert result == export_data_outputs_expected + + @pytest.mark.parametrize( + "export_data_components_expected", + [("scatter_chart", "scatter_chart_2")], + indirect=True, + ) + def test_export_data_no_targets_set_mapping_components(self, export_data_components_expected): + result_components = _get_action_callback_mapping( + action_id="export_data_action", + argument="components", + ) + + result = json.dumps(result_components, cls=plotly.utils.PlotlyJSONEncoder) + expected = json.dumps(export_data_components_expected, cls=plotly.utils.PlotlyJSONEncoder) + assert result == expected + + @pytest.mark.parametrize( + "managers_one_page_four_controls_two_graphs_filter_interaction, export_data_components_expected", + [ + (None, ["scatter_chart", "scatter_chart_2"]), + ([], ["scatter_chart", "scatter_chart_2"]), + (["scatter_chart"], ["scatter_chart"]), + (["scatter_chart", "scatter_chart_2"], ["scatter_chart", "scatter_chart_2"]), + ], + indirect=True, + ) + def test_export_data_targets_set_mapping_components( + self, managers_one_page_four_controls_two_graphs_filter_interaction, export_data_components_expected + ): + result_components = _get_action_callback_mapping( + action_id="export_data_action", + argument="components", + ) + result = json.dumps(result_components, cls=plotly.utils.PlotlyJSONEncoder) + expected = json.dumps(export_data_components_expected, cls=plotly.utils.PlotlyJSONEncoder) + assert result == expected + + def test_known_action_unknown_argument(self): + result = _get_action_callback_mapping( + action_id="export_data_action", + argument="unknown-argument", + ) + assert result == {} + + @pytest.mark.parametrize( + "argument, expected", + [ + ("inputs", {}), + ("outputs", {}), + ("components", []), + ("unknown-argument", {}), + ], + ) + def test_custom_action_mapping(self, argument, expected): + result = _get_action_callback_mapping( + action_id="custom_action", + argument=argument, + ) + assert result == expected + + @pytest.mark.parametrize( + "argument, expected", + [ + ("inputs", {}), + ("outputs", {}), + ("components", []), + ("unknown-argument", {}), + ], + ) + def test_custom_action_with_predefined_name_mapping(self, argument, expected): + result = _get_action_callback_mapping( + action_id="export_data_custom_action", + argument=argument, + ) + assert result == expected diff --git a/vizro-core/tests/unit/vizro/actions/test_export_data_action.py b/vizro-core/tests/unit/vizro/actions/test_export_data_action.py index 1ca11bd2b..1fa8a5699 100644 --- a/vizro-core/tests/unit/vizro/actions/test_export_data_action.py +++ b/vizro-core/tests/unit/vizro/actions/test_export_data_action.py @@ -115,6 +115,28 @@ def test_graphs_no_targets(self, callback_context_export_data, gapminder_2007): assert result["download-dataframe_box_chart"]["filename"] == "box_chart.csv" assert result["download-dataframe_box_chart"]["content"] == gapminder_2007.to_csv(index=False) + @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") + @pytest.mark.parametrize( + "callback_context_export_data, targets", + [ + ([["scatter_chart", "box_chart"], None, None], None), + ([["scatter_chart", "box_chart"], None, None], []), + ], + indirect=["callback_context_export_data"], + ) + def test_graphs_false_targets(self, callback_context_export_data, targets, gapminder_2007): + # Add action to relevant component + model_manager["button"].actions = [vm.Action(id="test_action", function=export_data(targets=targets))] + + # Run action by picking the above added action function and executing it with () + result = model_manager["test_action"].function() + + assert result["download-dataframe_scatter_chart"]["filename"] == "scatter_chart.csv" + assert result["download-dataframe_scatter_chart"]["content"] == gapminder_2007.to_csv(index=False) + + assert result["download-dataframe_box_chart"]["filename"] == "box_chart.csv" + assert result["download-dataframe_box_chart"]["content"] == gapminder_2007.to_csv(index=False) + @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") @pytest.mark.parametrize("callback_context_export_data", [(["scatter_chart"], None, None)], indirect=True) def test_one_target(self, callback_context_export_data, gapminder_2007): diff --git a/vizro-core/tests/unit/vizro/models/_action/test_action.py b/vizro-core/tests/unit/vizro/models/_action/test_action.py index 89daa597f..088f6b495 100644 --- a/vizro-core/tests/unit/vizro/models/_action/test_action.py +++ b/vizro-core/tests/unit/vizro/models/_action/test_action.py @@ -146,11 +146,6 @@ def test_export_data_file_format_invalid(self): def test_export_data_xlsx_without_required_libs_installed(self, monkeypatch): monkeypatch.setitem(sys.modules, "openpyxl", None) monkeypatch.setitem(sys.modules, "xlswriter", None) - # monkeypatch.setattr( - # importlib.util, - # "find_spec", - # lambda arg: None if arg in ["openpyxl", "xlsxwriter"] else importlib.util.find_spec(arg), - # ) with pytest.raises( ModuleNotFoundError, match="You must install either openpyxl or xlsxwriter to export to xlsx format."