diff --git a/vizro-core/changelog.d/20231219_171218_antony.milne_assert_components_equal.md b/vizro-core/changelog.d/20231219_171218_antony.milne_assert_components_equal.md new file mode 100644 index 000000000..f1f65e73c --- /dev/null +++ b/vizro-core/changelog.d/20231219_171218_antony.milne_assert_components_equal.md @@ -0,0 +1,48 @@ + + + + + + + + + diff --git a/vizro-core/src/vizro/models/_components/button.py b/vizro-core/src/vizro/models/_components/button.py index 9e063fd51..f1d46644b 100644 --- a/vizro-core/src/vizro/models/_components/button.py +++ b/vizro-core/src/vizro/models/_components/button.py @@ -32,13 +32,7 @@ class Button(VizroBaseModel): @_log_call def build(self): return html.Div( - [ - dbc.Button( - id=self.id, - children=self.text, - className="button_primary", - ), - ], + dbc.Button(id=self.id, children=self.text, className="button_primary"), className="button_container", id=f"{self.id}_outer", ) diff --git a/vizro-core/tests/integration/test_navigation.py b/vizro-core/tests/integration/test_navigation.py index 1910fd6e2..ade0737aa 100644 --- a/vizro-core/tests/integration/test_navigation.py +++ b/vizro-core/tests/integration/test_navigation.py @@ -151,4 +151,4 @@ def label_cases(cases, label): def test_navigation_build(dashboard_result, dashboard_expected): result = dashboard_result.navigation.build() expected = dashboard_expected.navigation.build() - assert_component_equal(result, expected) + assert_component_equal(result, expected, keys_to_strip={"id"}) diff --git a/vizro-core/tests/tests_utils/asserts.py b/vizro-core/tests/tests_utils/asserts.py index 1a32c93dd..c46d12e25 100644 --- a/vizro-core/tests/tests_utils/asserts.py +++ b/vizro-core/tests/tests_utils/asserts.py @@ -3,28 +3,41 @@ import dash.development import plotly +STRIP_ALL = object() -def strip_keys(object, keys): - """Strips all entries with key "id" from a dictionary, regardless of how deeply it's nested. - This makes it easy to compare dictionaries generated from Dash components we've created that contain random IDs. - """ +def _strip_keys(object, keys): + """Strips from a JSON object all entries where the key is in keys, regardless of how deeply it's nested.""" if isinstance(object, dict): - object = {key: strip_keys(value, keys) for key, value in object.items() if key not in keys} + object = {key: _strip_keys(value, keys) for key, value in object.items() if key not in keys} elif isinstance(object, list): - object = [strip_keys(item, keys) for item in object] + object = [_strip_keys(item, keys) for item in object] return object -def component_to_dict(component: dash.development.base_component.Component) -> dict: +def _component_to_dict(component: dash.development.base_component.Component) -> dict: + """Prepares a Dash component for comparison by conversion to JSON object.""" return json.loads(json.dumps(component, cls=plotly.utils.PlotlyJSONEncoder)) -# TODO: implement some sort of depth limit to comparison so can use in high level tests, roll out more widely across -# tests -def assert_component_equal(left, right, keys_to_strip=None): - # Note we check for None explicitly because {} is a valid value for keys_to_strip. - keys_to_strip = keys_to_strip if keys_to_strip is not None else {"id", "class_name", "className"} - left = strip_keys(component_to_dict(left), keys_to_strip) - right = strip_keys(component_to_dict(right), keys_to_strip) +def assert_component_equal(left, right, *, keys_to_strip=None): + """Checks that the left and right Dash components are equal, ignoring keys_to_strip. + + If keys_to_strip is set to STRIP_ALL then only the type and namespace of component + will be compared, similar to doing isinstance. + + Examples: + >>> from dash import html + >>> assert_component_equal(html.Div(), html.Div()) + >>> assert_component_equal(html.Div(id="a"), html.Div(), keys_to_strip={"id"}) + >>> assert_component_equal(html.Div([html.P(), html.P()], id="a"), html.Div(id="a"), keys_to_strip={"children"}) + >>> assert_component_equal(html.Div(html.P(), className="blah", id="a"), html.Div(), keys_to_strip=STRIP_ALL) + """ + keys_to_strip = keys_to_strip or {} + if keys_to_strip is STRIP_ALL: + # Remove all properties from the component dictionary, leaving just "type" and "namespace" behind. + keys_to_strip = {"props"} + + left = _strip_keys(_component_to_dict(left), keys_to_strip) + right = _strip_keys(_component_to_dict(right), keys_to_strip) assert left == right diff --git a/vizro-core/tests/tests_utils/demo_asserts.py b/vizro-core/tests/tests_utils/demo_asserts.py new file mode 100644 index 000000000..c61c439f7 --- /dev/null +++ b/vizro-core/tests/tests_utils/demo_asserts.py @@ -0,0 +1,48 @@ +"""Demo to show how to use asserts. These are not real tests that are run as part of testing, just a teaching aid.""" +from typing import List + +from asserts import STRIP_ALL, assert_component_equal +from dash import html + +from vizro.models import VizroBaseModel + + +class X(VizroBaseModel): + # Low-level contents model. + text: str + + def build(self): + return html.Div( + [html.H1("Heading"), html.P(self.text, id=self.id), html.Hr(), html.H2("Something")], className="inner" + ) + + +class Y(VizroBaseModel): + # Higher-level container model. + children: List[X] + + def build(self): + return html.Div([child.build() for child in self.children], id=self.id, className="container") + + +def test_X_build(): + # Test for low-level contents: compare the whole component tree. + # Sometimes setting keys_to_strip={"className"} is useful here. + result = X(id="x", text="Hello world").build() + expected = html.Div( + [html.H1("Heading"), html.P("Hello world", id="x"), html.Hr(), html.H2("Something")], className="inner" + ) + assert_component_equal(result, expected) + + +def test_Y_build(): + # Test for higher-level container. + many_x = [X(text="Hello world") for _ in range(4)] + result = Y(id="y", children=many_x).build() + # We don't want to compare the whole component tree here. Instead compare the bit that's specifically in Y and + # ignore the children: + assert_component_equal(result, html.Div(id="y", className="container"), keys_to_strip={"children"}) + # And also compare the "interface" between X and Y. Use STRIP_ALL to not look at any properties of the html.Div. + # This is basically the same as doing: + # assert all(isinstance(child, html.Div) for child in result.children) and len(result.children) == 4 + assert_component_equal(result.children, [html.Div()] * 4, keys_to_strip=STRIP_ALL) diff --git a/vizro-core/tests/unit/vizro/models/_components/test_button.py b/vizro-core/tests/unit/vizro/models/_components/test_button.py index 6584ab85e..85a02e472 100644 --- a/vizro-core/tests/unit/vizro/models/_components/test_button.py +++ b/vizro-core/tests/unit/vizro/models/_components/test_button.py @@ -1,30 +1,14 @@ """Unit tests for vizro.models.Button.""" -import json import dash_bootstrap_components as dbc -import plotly import pytest +from asserts import assert_component_equal from dash import html import vizro.models as vm from vizro.actions import export_data -@pytest.fixture -def expected_button(): - return html.Div( - [ - dbc.Button( - id="button_id", - children="Click me!", - className="button_primary", - ), - ], - className="button_container", - id="button_id_outer", - ) - - class TestButtonInstantiation: """Tests model instantiation and the validators run at that time.""" @@ -53,9 +37,11 @@ def test_set_action_via_validator(self): class TestBuildMethod: - def test_button_build(self, expected_button): - button = vm.Button(id="button_id", text="Click me!").build() - result = json.loads(json.dumps(button, cls=plotly.utils.PlotlyJSONEncoder)) - expected = json.loads(json.dumps(expected_button, cls=plotly.utils.PlotlyJSONEncoder)) - - assert result == expected + def test_button_build(self): + button = vm.Button(id="button_id", text="My text").build() + expected = html.Div( + dbc.Button(id="button_id", children="My text", className="button_primary"), + className="button_container", + id="button_id_outer", + ) + assert_component_equal(button, expected) diff --git a/vizro-core/tests/unit/vizro/models/_navigation/test_accordion.py b/vizro-core/tests/unit/vizro/models/_navigation/test_accordion.py index 41eb562b3..829005266 100644 --- a/vizro-core/tests/unit/vizro/models/_navigation/test_accordion.py +++ b/vizro-core/tests/unit/vizro/models/_navigation/test_accordion.py @@ -55,7 +55,7 @@ def test_invalid_page(self, pages): class TestAccordionBuild: """Tests accordion build method.""" - common_args = {"always_open": True, "persistence": True, "persistence_type": "session"} + common_args = {"always_open": True, "persistence": True, "persistence_type": "session", "id": "accordion"} test_cases = [ ( @@ -113,9 +113,11 @@ class TestAccordionBuild: @pytest.mark.parametrize("pages, expected", test_cases) def test_accordion(self, pages, expected): accordion = vm.Accordion(id="accordion", pages=pages).build(active_page_id="Page 1") - assert_component_equal(accordion, html.Div(id="nav_panel_outer"), keys_to_strip={"children", "className"}) - assert_component_equal(accordion["accordion"], expected) + assert_component_equal( + accordion, html.Div(id="nav_panel_outer", className="nav_panel"), keys_to_strip={"children"} + ) + assert_component_equal(accordion["accordion"], expected, keys_to_strip={"class_name", "className"}) def test_accordion_one_page(self): accordion = vm.Accordion(pages={"Group": ["Page 1"]}).build(active_page_id="Page 1") - assert_component_equal(accordion, html.Div(hidden=True, id="nav_panel_outer"), keys_to_strip={}) + assert_component_equal(accordion, html.Div(hidden=True, id="nav_panel_outer")) diff --git a/vizro-core/tests/unit/vizro/models/_navigation/test_nav_bar.py b/vizro-core/tests/unit/vizro/models/_navigation/test_nav_bar.py index 51c4d266e..372d2f5d5 100644 --- a/vizro-core/tests/unit/vizro/models/_navigation/test_nav_bar.py +++ b/vizro-core/tests/unit/vizro/models/_navigation/test_nav_bar.py @@ -4,7 +4,7 @@ import dash_bootstrap_components as dbc import dash_mantine_components as dmc import pytest -from asserts import assert_component_equal +from asserts import STRIP_ALL, assert_component_equal from dash import html try: @@ -93,11 +93,13 @@ def test_nav_bar_active_pages_as_dict(self, pages_as_dict): ) ] ) - assert_component_equal(built_nav_bar["nav_bar_outer"], expected_button) + assert_component_equal(built_nav_bar["nav_bar_outer"], expected_button, keys_to_strip={"id", "className"}) assert_component_equal( - built_nav_bar["nav_panel_outer"], html.Div(id="nav_panel_outer"), keys_to_strip={"children", "className"} + built_nav_bar["nav_panel_outer"], + html.Div(id="nav_panel_outer", className="nav_panel"), + keys_to_strip={"children"}, ) - assert all(isinstance(child, dbc.Accordion) for child in built_nav_bar["nav_panel_outer"].children) + assert_component_equal(built_nav_bar["nav_panel_outer"].children, [dbc.Accordion()], keys_to_strip=STRIP_ALL) def test_nav_bar_active_pages_as_list(self, pages_as_list): nav_bar = vm.NavBar(pages=pages_as_list) @@ -117,11 +119,10 @@ def test_nav_bar_active_pages_as_list(self, pages_as_list): ), ] ) - assert_component_equal(built_nav_bar["nav_bar_outer"], expected_buttons) + assert_component_equal(built_nav_bar["nav_bar_outer"], expected_buttons, keys_to_strip={"id", "className"}) assert_component_equal( built_nav_bar["nav_panel_outer"], html.Div(id="nav_panel_outer", hidden=True), - keys_to_strip={"children", "className"}, ) def test_nav_bar_not_active_pages_as_dict(self, pages_as_dict): @@ -137,10 +138,8 @@ def test_nav_bar_not_active_pages_as_dict(self, pages_as_dict): ) ] ) - assert_component_equal(built_nav_bar["nav_bar_outer"], expected_button) - assert_component_equal( - built_nav_bar["nav_panel_outer"], html.Div(hidden=True, id="nav_panel_outer"), keys_to_strip={} - ) + assert_component_equal(built_nav_bar["nav_bar_outer"], expected_button, keys_to_strip={"id", "className"}) + assert_component_equal(built_nav_bar["nav_panel_outer"], html.Div(hidden=True, id="nav_panel_outer")) def test_nav_bar_not_active_pages_as_list(self, pages_as_list): nav_bar = vm.NavBar(pages=pages_as_list) @@ -160,9 +159,8 @@ def test_nav_bar_not_active_pages_as_list(self, pages_as_list): ), ] ) - assert_component_equal(built_nav_bar["nav_bar_outer"], expected_buttons) + assert_component_equal(built_nav_bar["nav_bar_outer"], expected_buttons, keys_to_strip={"id", "className"}) assert_component_equal( built_nav_bar["nav_panel_outer"], html.Div(id="nav_panel_outer", hidden=True), - keys_to_strip={}, ) diff --git a/vizro-core/tests/unit/vizro/models/_navigation/test_nav_link.py b/vizro-core/tests/unit/vizro/models/_navigation/test_nav_link.py index 1f90c8efc..9956f8ecc 100644 --- a/vizro-core/tests/unit/vizro/models/_navigation/test_nav_link.py +++ b/vizro-core/tests/unit/vizro/models/_navigation/test_nav_link.py @@ -4,7 +4,7 @@ import dash_bootstrap_components as dbc import dash_mantine_components as dmc import pytest -from asserts import assert_component_equal +from asserts import STRIP_ALL, assert_component_equal from dash import html try: @@ -86,12 +86,20 @@ def test_nav_link_active(self, pages, request): nav_link.pre_build() built_nav_link = nav_link.build(active_page_id="Page 1") expected_button = dbc.Button( - children=[dmc.Tooltip(label="Label", children=[html.Span("icon")], **self.common_args)], + children=[ + dmc.Tooltip( + label="Label", + children=[html.Span("icon", className="material-symbols-outlined")], + **self.common_args, + ) + ], active=True, href="/", + className="icon-button", + id="nav_link", ) assert_component_equal(built_nav_link["nav_link"], expected_button) - assert all(isinstance(child, dbc.Accordion) for child in built_nav_link["nav_panel_outer"].children) + assert_component_equal(built_nav_link["nav_panel_outer"].children, [dbc.Accordion()], keys_to_strip=STRIP_ALL) def test_nav_link_not_active(self, pages, request): pages = request.getfixturevalue(pages) @@ -99,9 +107,17 @@ def test_nav_link_not_active(self, pages, request): nav_link.pre_build() built_nav_link = nav_link.build(active_page_id="Page 3") expected_button = dbc.Button( - children=[dmc.Tooltip(label="Label", children=[html.Span("icon")], **self.common_args)], + children=[ + dmc.Tooltip( + label="Label", + children=[html.Span("icon", className="material-symbols-outlined")], + **self.common_args, + ) + ], active=False, href="/", + className="icon-button", + id="nav_link", ) assert_component_equal(built_nav_link["nav_link"], expected_button) assert "nav_panel_outer" not in built_nav_link diff --git a/vizro-core/tests/unit/vizro/models/_navigation/test_navigation.py b/vizro-core/tests/unit/vizro/models/_navigation/test_navigation.py index 21ea9db18..6f6530a0f 100644 --- a/vizro-core/tests/unit/vizro/models/_navigation/test_navigation.py +++ b/vizro-core/tests/unit/vizro/models/_navigation/test_navigation.py @@ -3,7 +3,7 @@ import dash_bootstrap_components as dbc import pytest -from asserts import assert_component_equal +from asserts import STRIP_ALL, assert_component_equal from dash import html try: @@ -84,15 +84,15 @@ def test_default_nav_selector(self, pages, request): navigation = vm.Navigation(pages=pages) navigation.pre_build() built_navigation = navigation.build(active_page_id="Page 1") + assert_component_equal(built_navigation["nav_bar_outer"], html.Div(hidden=True, id="nav_bar_outer")) assert_component_equal( - built_navigation["nav_bar_outer"], html.Div(hidden=True, id="nav_bar_outer"), keys_to_strip={} - ) - assert_component_equal( - built_navigation["nav_panel_outer"], html.Div(id="nav_panel_outer"), keys_to_strip={"children", "className"} + built_navigation["nav_panel_outer"], + html.Div(id="nav_panel_outer", className="nav_panel"), + keys_to_strip={"children"}, ) - assert all(isinstance(child, dbc.Accordion) for child in built_navigation["nav_panel_outer"].children) + assert_component_equal(built_navigation["nav_panel_outer"].children, [dbc.Accordion()], keys_to_strip=STRIP_ALL) - def test_non_default_nav_selector_pags_as_dict(self, pages_as_dict): + def test_non_default_nav_selector_pags_as_dict(self, pages_as_dict, built_nav_link=None): navigation = vm.Navigation(pages=pages_as_dict, nav_selector=vm.NavBar()) navigation.pre_build() built_navigation = navigation.build(active_page_id="Page 1") @@ -103,10 +103,10 @@ def test_non_default_nav_selector_pags_as_dict(self, pages_as_dict): ) assert_component_equal( built_navigation["nav_panel_outer"], - html.Div(id="nav_panel_outer"), - keys_to_strip={"children", "className"}, + html.Div(id="nav_panel_outer", className="nav_panel"), + keys_to_strip={"children"}, ) - assert all(isinstance(child, dbc.Accordion) for child in built_navigation["nav_panel_outer"].children) + assert_component_equal(built_navigation["nav_panel_outer"].children, [dbc.Accordion()], keys_to_strip=STRIP_ALL) def test_non_default_nav_selector_pages_as_list(self, pages_as_list): navigation = vm.Navigation(pages=pages_as_list, nav_selector=vm.NavBar()) @@ -118,7 +118,5 @@ def test_non_default_nav_selector_pages_as_list(self, pages_as_list): keys_to_strip={"children"}, ) assert_component_equal( - built_navigation["nav_panel_outer"], - html.Div(id="nav_panel_outer", hidden=True), - keys_to_strip={"children"}, + built_navigation["nav_panel_outer"], html.Div(id="nav_panel_outer", hidden=True), keys_to_strip={"children"} ) diff --git a/vizro-core/tests/unit/vizro/models/test_dashboard.py b/vizro-core/tests/unit/vizro/models/test_dashboard.py index 91036aa72..d6dabe54e 100644 --- a/vizro-core/tests/unit/vizro/models/test_dashboard.py +++ b/vizro-core/tests/unit/vizro/models/test_dashboard.py @@ -130,7 +130,7 @@ def test_make_page_404_layout(self, vizro_app): className="page_error_container", ) - assert_component_equal(vm.Dashboard._make_page_404_layout(), expected, {}) + assert_component_equal(vm.Dashboard._make_page_404_layout(), expected) class TestDashboardBuild: