From 98612f1606cf815bac948bb5a7a3d5123931e1de Mon Sep 17 00:00:00 2001 From: Florian Scherf Date: Tue, 30 May 2023 22:06:24 +0200 Subject: [PATCH 1/8] tests: remove pytest-dependency and pytest incremental marker Previously, the tests were designed to be incremental so failing tests in low-level components would cancel higher level tests. This way, a failing low-level test would not result in an all-red test-suite where the actual issue is hard to find. This behavior can be achieved much simpler, by ordering all tests from low-level to high-level and stopping at the first error. This patch removes all old infrastructure which implemented the incremental tests, and orders the tests from low-level to high-level, by using numeric file prefixes. After this patch the test-suite has the following stages: 00xx Testsuite tests 01xx HTML Datastructure Tests 02xx HTML API Tests 03xx Basic IO and Rendering Tests 04xx HTML API Browser Tests xxxx Misc Signed-off-by: Florian Scherf --- pyproject.toml | 1 - pytest.ini | 5 +- tests/conftest.py | 57 -- tests/test_0001_html.py | 954 ------------------ tests/test_0100_components.py | 53 - tests/test_0101_html_attribute_dict.py | 126 +++ tests/test_0102_html_attribute_list.py | 237 +++++ tests/test_0103_html_sub_nodes.py | 119 +++ tests/test_0104_html_parsing.py | 364 +++++++ tests/test_0105_html_query_selectors.py | 99 ++ tests/test_0106_html_state.py | 24 + tests/test_0201_number_input.py | 83 ++ .../{test_routing.py => test_0301_routing.py} | 4 - ...st_responses.py => test_0302_responses.py} | 0 ...ck_events.py => test_0303_click_events.py} | 0 ...st_rendering.py => test_0304_rendering.py} | 0 ...tml_select.py => test_0401_html_select.py} | 0 ...l_select2.py => test_0402_html_select2.py} | 0 ...ber_input.py => test_0403_number_input.py} | 0 19 files changed, 1053 insertions(+), 1073 deletions(-) delete mode 100644 tests/conftest.py delete mode 100644 tests/test_0001_html.py delete mode 100644 tests/test_0100_components.py create mode 100644 tests/test_0101_html_attribute_dict.py create mode 100644 tests/test_0102_html_attribute_list.py create mode 100644 tests/test_0103_html_sub_nodes.py create mode 100644 tests/test_0104_html_parsing.py create mode 100644 tests/test_0105_html_query_selectors.py create mode 100644 tests/test_0106_html_state.py create mode 100644 tests/test_0201_number_input.py rename tests/{test_routing.py => test_0301_routing.py} (97%) rename tests/{test_responses.py => test_0302_responses.py} (100%) rename tests/{test_click_events.py => test_0303_click_events.py} (100%) rename tests/{test_rendering.py => test_0304_rendering.py} (100%) rename tests/{test_html_select.py => test_0401_html_select.py} (100%) rename tests/{test_html_select2.py => test_0402_html_select2.py} (100%) rename tests/{test_number_input.py => test_0403_number_input.py} (100%) diff --git a/pyproject.toml b/pyproject.toml index f67092f5..14a6c4c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,6 @@ test = [ "coverage==7.2.7", "pytest==7.4.0", "pytest-aiohttp==1.0.4", - "pytest-dependency==0.5.1", "pytest-mock==3.11.1", "pytest-timeout==2.1.0", "playwright==1.35.0", diff --git a/pytest.ini b/pytest.ini index 0b14683b..ebcf94cd 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,11 +1,8 @@ [pytest] -addopts= tests -v -rsx --tb=long --strict-markers -markers = - incremental: marks test class to stop execution after the first test method failed +addopts= tests -v -x -rsx --tb=long --strict-markers log_cli= false log_level= NOTSET log_format = %(levelname)-8s %(name)-30s [%(asctime)s.%(msecs)03d] %(message)s log_date_format = %H:%M:%S -automark_dependency = True timeout = 300 asyncio_mode = auto diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index ab02dd9e..00000000 --- a/tests/conftest.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -import pytest - -# see https://docs.pytest.org/en/latest/example/simple.html#incremental-testing-test-steps - -# store history of failures per test class name -# and per index in parametrize (if parametrize used) -_test_failed_incremental: dict[str, dict[tuple[int, ...], str]] = {} - - -def pytest_runtest_makereport(item, call): - if 'incremental' in item.keywords: - # incremental marker is used - if call.excinfo is not None: - # the test has failed - # retrieve the class name of the test - cls_name = str(item.cls) - # retrieve the index of the test - # (if parametrize is used in combination with incremental) - parametrize_index = ( - tuple(item.callspec.indices.values()) - if hasattr(item, 'callspec') - else () - ) - # retrieve the name of the test function - test_name = item.originalname or item.name - # store in _test_failed_incremental - # the original name of the failed test - _test_failed_incremental.setdefault(cls_name, {}).setdefault( - parametrize_index, test_name, - ) - - -def pytest_runtest_setup(item): - if 'incremental' in item.keywords: - # retrieve the class name of the test - cls_name = str(item.cls) - # check if a previous test has failed for this class - if cls_name in _test_failed_incremental: - # retrieve the index of the test - # (if parametrize is used in combination with incremental) - parametrize_index = ( - tuple(item.callspec.indices.values()) - if hasattr(item, 'callspec') - else () - ) - # retrieve the name of the first test function - # to fail for this class name and index - test_name = _test_failed_incremental[cls_name].get( - parametrize_index, - None, - ) - # if name found, test has failed - # for the combination of class name & test name - if test_name is not None: - pytest.xfail(f'previous test failed ({test_name})') diff --git a/tests/test_0001_html.py b/tests/test_0001_html.py deleted file mode 100644 index 04bc2fe0..00000000 --- a/tests/test_0001_html.py +++ /dev/null @@ -1,954 +0,0 @@ -import re - -import pytest - -from lona.html import ( - NumberInput, - TextInput, - TextArea, - CheckBox, - Select2, - Option2, - Submit, - Select, - Option, - Button, - HTML2, - HTML1, - Span, - Node, - Div, - H1, -) -from lona.compat import set_use_future_node_classes - - -@pytest.mark.incremental() -class TestAttributeDict: - def test_get_initial_values(self): - d = Div(foo='foo', bar='bar') - - assert d.attributes['foo'] == 'foo' - assert d.attributes['bar'] == 'bar' - - def test_set_attributes(self): - d = Div() - - d.attributes = { - 'foo': 'foo', - 'bar': 'bar', - } - - assert d.attributes == { - 'foo': 'foo', - 'bar': 'bar', - } - - def test_value_cant_be_dict(self): - with pytest.raises( - ValueError, - match="unsupported type: ", - ): - Div(foo={}) - - def test_cant_use_id_key(self): - d = Div() - - with pytest.raises( - RuntimeError, - match=re.escape( - "Node.attributes['id'] is not supported. " - 'Use Node.id_list instead.', - ), - ): - d.attributes['id'] = 'foo' - - def test_empty_dict_is_false(self): - d = Div() - - assert not bool(d.attributes) - - def test_non_empty_dict_is_true(self): - d = Div(foo='foo') - - assert bool(d.attributes) - - def test_pop_existing_key(self): - d = Div(foo='foo-val', bar='bar-val') - assert 'foo' in d.attributes - - val = d.attributes.pop('foo') - - assert val == 'foo-val' - assert 'foo' not in d.attributes - - def test_pop_unknown_key(self): - d = Div(foo='foo-val', bar='bar-val') - assert 'xxx' not in d.attributes - - with pytest.raises(KeyError, match='xxx'): - d.attributes.pop('xxx') - assert 'xxx' not in d.attributes - - def test_pop_unknown_key_with_default(self): - d = Div(foo='foo-val', bar='bar-val') - assert 'xxx' not in d.attributes - - val = d.attributes.pop('xxx', 'yyy') - - assert val == 'yyy' - assert 'xxx' not in d.attributes - - def test_pop_existing_key_with_default(self): - d = Div(foo='foo-val', bar='bar-val') - assert 'bar' in d.attributes - - val = d.attributes.pop('bar', 'yyy') - - assert val == 'bar-val' - assert 'bar' not in d.attributes - - def test_pop_expects_at_most_2_arguments(self): - d = Div(foo='foo-val', bar='bar-val') - - with pytest.raises( - TypeError, - match='pop expected at most 2 arguments, got 3', - ): - d.attributes.pop('xxx', 'yyy', 'zzz') - - def test_del_existing_key(self): - d = Div(foo='foo-val', bar='bar-val') - assert 'foo' in d.attributes - - del d.attributes['foo'] - - assert 'foo' not in d.attributes - - def test_del_unknown_key(self): - d = Div(foo='foo-val', bar='bar-val') - assert 'xxx' not in d.attributes - - del d.attributes['xxx'] - - assert 'xxx' not in d.attributes - - -@pytest.mark.incremental() -class TestAttributeList: - def test_default_id_list_is_empty(self): - d = Div() - - assert d.id_list == [] - - def test_initial_id_can_be_list(self): - d = Div(_id=['foo', 'bar']) - - assert d.id_list == ['foo', 'bar'] - - def test_initial_id_can_be_space_separated_str(self): - d = Div(_id='foo bar') - - assert d.id_list == ['foo', 'bar'] - - def test_initial_id_can_be_passed_via_kwargs(self): - d = Div(**{'id': 'foo bar'}) - - assert d.id_list == ['foo', 'bar'] - - def test_default_class_list_is_empty(self): - d = Div() - - assert d.class_list == [] - - def test_initial_class_can_be_list(self): - d = Div(_class=['foo', 'bar']) - - assert d.class_list == ['foo', 'bar'] - - def test_initial_class_can_be_space_separated_str(self): - d = Div(_class='foo bar') - - assert d.class_list == ['foo', 'bar'] - - def test_initial_class_can_be_passed_via_kwargs(self): - d = Div(**{'class': 'foo bar'}) - - assert d.class_list == ['foo', 'bar'] - - def test_set_id_list(self): - d = Div() - - d.id_list = ['foo', 'bar'] - - assert d.id_list == ['foo', 'bar'] - - def test_set_class_list(self): - d = Div() - - d.class_list = ['foo', 'bar'] - - assert d.class_list == ['foo', 'bar'] - - def test_initial_id_cant_be_dict(self): - with pytest.raises( - ValueError, - match='id has to be string or list of strings', - ): - Div(_id={}) - - def test_cant_set_dict(self): - d = Div() - - with pytest.raises( - ValueError, - match="unsupported type: ", - ): - d.id_list = {} - - def test_cant_set_list_with_dict(self): - d = Div() - - with pytest.raises( - ValueError, - match="unsupported type: ", - ): - d.id_list = [{}] - - def test_default_list_has_zero_len(self): - d = Div() - - assert len(d.id_list) == 0 - - def test_len_returns_number_of_elements(self): - d = Div(_id='foo bar') - - assert len(d.id_list) == 2 - - def test_non_equality(self): - d = Div(_id='foo bar') - - assert d.id_list != [] - assert d.id_list != ['foo', 'bar', 'baz'] - - def test_equality_ignores_order(self): - d = Div(_id='foo bar') - - assert d.id_list == ['bar', 'foo'] - - def test_equality_ignored_duplicates(self): - d = Div(_id='foo bar') - - assert d.id_list == ['foo', 'bar', 'foo', 'bar'] - - def test_in_keyword(self): - d = Div(_id='foo bar') - - assert 'foo' in d.id_list - assert 'bar' in d.id_list - assert 'baz' not in d.id_list - - def test_non_empty_list_is_true(self): - d = Div(_id='foo') - - assert bool(d.id_list) - - def test_empty_list_is_false(self): - d = Div() - - assert not bool(d.id_list) - - def test_add_one_element(self): - d = Div() - - d.id_list.add('foo') - - assert d.id_list == ['foo'] - - def test_add_existing_element_does_nothing(self): - d = Div() - d.id_list.add('foo') - - d.id_list.add('foo') - - assert d.id_list == ['foo'] - - def test_cant_add_dict(self): - d = Div() - - with pytest.raises( - ValueError, - match="unsupported type: ", - ): - d.id_list.add({}) - - def test_extend(self): - d = Div(_id='foo') - - d.id_list.extend(['bar', 'baz']) - - assert d.id_list == ['foo', 'bar', 'baz'] - - def test_extend_ignores_duplicates(self): - d = Div(_id='foo') - - d.id_list.extend(['foo', 'bar', 'baz']) - - assert d.id_list == ['foo', 'bar', 'baz'] - - def test_remove_existing_element(self): - d = Div(_id='foo bar') - - d.id_list.remove('foo') - - assert d.id_list == ['bar'] - - def test_remove_unknown_element(self): - d = Div(_id='bar') - - d.id_list.remove('foo') - - assert d.id_list == ['bar'] - - def test_clear(self): - d = Div(_id='foo bar') - - d.id_list.clear() - - assert d.id_list == [] - - def test_clear_empty_list(self): - d = Div() - - d.id_list.clear() - - assert d.id_list == [] - - def test_toggle_existing_element(self): - d = Div(_id='foo bar') - - d.id_list.toggle('foo') - - assert d.id_list == ['bar'] - - def test_toggle_unknown_element(self): - d = Div(_id='bar') - - d.id_list.toggle('foo') - - assert d.id_list == ['foo', 'bar'] - - -@pytest.mark.incremental() -class TestHTMLSubnodes: - def test_nodes_in_nodes(self): - div1 = Div() - - div2 = Div( - div1, - ) - - assert len(div2) == 1 - assert div2[0] is div1 - - def test_text_nodes_in_nodes(self): - div = Div('foo') - - assert len(div) == 1 - assert str(div[0]) == 'foo' - - def test_mixed_nodes(self): - sub_node1 = Div() - sub_node2 = Div() - - div = Div( - sub_node1, - 'foo', - sub_node2, - 'bar', - ) - - assert len(div) == 4 - assert div[0] is sub_node1 - assert str(div[1]) == 'foo' - assert div[2] is sub_node2 - assert str(div[3]) == 'bar' - - def test_generator(self): - div = Div( - (Div() for i in range(10)), - ) - - assert len(div) == 10 - - def test_list(self): - div = Div( - [Div() for i in range(10)], - ) - - assert len(div) == 10 - - -@pytest.mark.incremental() -class TestLegacyHtmlParsing: - def test_empty_node(self): - node = HTML1('
')[0] - - assert node.tag_name == 'div' - assert node.id_list == [] - assert node.class_list == [] - assert node.style == {} - assert node.attributes == {} - assert node.nodes == [] - - def test_node_with_attributes(self): - node = HTML1(""" -
-
- """)[0] - - assert node.tag_name == 'div' - assert node.id_list == ['foo'] - assert node.class_list == ['bar'] - assert node.style == {'color': 'black'} - assert node.attributes == {'foo': 'bar'} - assert node.nodes == [] - - def test_sub_nodes(self): - node = HTML1(""" -
- -
-

-
- """)[0] - - assert node.tag_name == 'div' - assert len(node.nodes) == 3 - assert node.nodes[0].tag_name == 'span' - assert node.nodes[1].tag_name == 'div' - assert node.nodes[2].tag_name == 'h1' - - def test_multiple_ids(self): - node = HTML1('
')[0] - - assert node.id_list == ['foo', 'bar', 'baz'] - - def test_multiple_classes(self): - node = HTML1('
')[0] - - assert node.class_list == ['foo', 'bar', 'baz'] - - def test_multiple_styles(self): - node = HTML1('
')[0] - - assert node.style == { - 'color': 'black', - 'display': 'block', - } - - def test_multiple_attributes(self): - node = HTML1('
')[0] - - assert node.attributes == { - 'foo': 'bar', - 'bar': 'baz', - } - - def test_high_level_nodes(self): - node = HTML1('')[0] - - assert type(node) is Button - - def test_boolean_property_without_value(self): - node = HTML1('')[0] - - assert node.disabled - - def test_boolean_property_with_value(self): - node = HTML1('')[0] - - assert node.disabled - - def test_missing_end_tag(self): - with pytest.raises( - ValueError, - match='Invalid html: missing end tag ', - ): - HTML1('') - - def test_wrong_end_tag(self): - with pytest.raises( - ValueError, - match='Invalid html: expected, received', - ): - HTML1('

abc

') - - def test_end_tag_without_start_tag(self): - with pytest.raises( - ValueError, - match='Invalid html: missing start tag for ', - ): - HTML1('
abc
') - - def test_missing_start_tag(self): - with pytest.raises( - ValueError, - match='Invalid html: missing start tag for ', - ): - HTML1('') - - def test_self_closing_tag_with_slash(self): - img = HTML1('
')[0].nodes[0] - - assert img.tag_name == 'img' - assert img.self_closing_tag is True - - def test_self_closing_tag_without_slash(self): - img = HTML1('
')[0].nodes[0] - - assert img.tag_name == 'img' - assert img.self_closing_tag is True - - def test_non_self_closing_tag(self): - div = HTML1('
')[0] - - assert div.tag_name == 'div' - assert div.self_closing_tag is False - - def test_non_self_closing_tag_with_slash(self): - span = HTML1('
')[0].nodes[0] - - assert span.tag_name == 'span' - assert span.self_closing_tag is True - - def test_default_input_type_is_text(self): - node = HTML1('')[0] - - assert type(node) is TextInput - assert node.value == 'abc' - assert node.disabled is False - - def test_input_type_text(self): - node = HTML1('')[0] - - assert type(node) is TextInput - assert node.value == 'xyz' - assert node.disabled is True - - def test_input_type_unknown(self): - node = HTML1('')[0] - - assert type(node) is Node - - @pytest.mark.parametrize( - 'tp', - [ - 'button', - 'color', - 'date', - 'datetime-local', - 'email', - 'file', - 'hidden', - 'image', - 'month', - 'password', - 'radio', - 'range', - 'reset', # intentionally, see 575dcf635180 ("html: remove Reset node") # NOQA: LN002 - 'search', - 'tel', - 'time', - 'url', - 'week', - ], - ) - def test_not_implemented_input_types(self, tp): - node = HTML1(f'')[0] - - assert type(node) is Node - - def test_input_type_number(self): - node = HTML1('')[0] - - assert type(node) is NumberInput - assert node.value == 123.5 - - def test_input_type_checkbox(self): - node = HTML1('')[0] - - assert type(node) is CheckBox - assert node.value is False - - def test_input_type_checkbox_checked(self): - node = HTML1('')[0] - - assert type(node) is CheckBox - assert node.value is True - - def test_input_type_submit(self): - node = HTML1('')[0] - - assert type(node) is Submit - - def test_textarea(self): - node = HTML1('')[0] - - assert type(node) is TextArea - assert node.value == 'abc' - - def test_textarea_with_self_closing_tag_inside(self): - textarea = HTML1('')[0] - - assert textarea.value == 'abc
xyz' - - def test_textarea_with_pair_tag_inside(self): - textarea = HTML1('')[0] - - assert textarea.value == 'aaa bbb ccc' - - def test_select(self): - node = HTML1(""" - - """)[0] - - assert type(node) == Select - assert type(node.nodes[0]) == Option - assert node.value == '2' - - def test_select2(self): - set_use_future_node_classes(True) - - try: - node = HTML1(""" - - """)[0] - - assert type(node) == Select2 - assert type(node.nodes[0]) == Option2 - assert node.value == '2' - - finally: - set_use_future_node_classes(False) - - -@pytest.mark.incremental() -class TestHtmlParsing: - def test_sub_nodes(self): - node = HTML2(""" -
- -
-

-
- """) - - assert not node.parent - assert node.tag_name == 'div' - assert len(node.nodes) == 3 - assert node.nodes[0].tag_name == 'span' - assert node.nodes[1].tag_name == 'div' - assert node.nodes[2].tag_name == 'h1' - - def test_wrapping(self): - node = HTML2(""" - -
-

- """) - - assert node.tag_name == 'div' - assert len(node.nodes) == 3 - assert node.nodes[0].tag_name == 'span' - assert node.nodes[1].tag_name == 'div' - assert node.nodes[2].tag_name == 'h1' - - def test_multiple_strings(self): - node = HTML2( - '', - '
', - '

', - ) - - assert node.tag_name == 'div' - assert len(node.nodes) == 4 - assert node.nodes[0].tag_name == 'span' - assert node.nodes[1].tag_name == 'span' - assert node.nodes[2].tag_name == 'div' - assert node.nodes[3].tag_name == 'h1' - - def test_attribute_cases(self): - node1 = HTML1('')[0] - node2 = HTML2('') - - assert node1.attributes['preserveAspectRatio'] == 'none' - assert node2.attributes['preserveAspectRatio'] == 'none' - - -@pytest.mark.incremental() -class TestNumberInput: - def test_default_properties(self): - node = NumberInput() - - assert node.value is None - assert node.min is None - assert node.max is None - assert node.step is None - - def test_initial_value(self): - node = NumberInput(value=12.3) - - assert node.value == 12.3 - - def test_change_value(self): - node = NumberInput() - node.value = 12.3 - - assert node.value == 12.3 - - def test_parsing_no_attributes(self): - node = HTML1('')[0] - - assert node.value is None - assert node.min is None - assert node.max is None - assert node.step is None - - def test_parsing_int_value(self): - node = HTML1('')[0] - - assert node.value == 123 - - def test_parsing_float_value(self): - node = HTML1('')[0] - - assert node.value == 12.3 - - def test_parsing_broken_step(self): - node = HTML1('')[0] - - assert node.value == 123 - assert node.step is None - - def test_parsing_int_step(self): - node = HTML1('')[0] - - assert node.value == 12.3 - assert node.step == 3 - - def test_parsing_float_step(self): - node = HTML1('')[0] - - assert node.value == 12.3 - assert node.step == 0.1 - - def test_parsing_broken_value(self): - node = HTML1('')[0] - - assert node.raw_value == 'abc' - assert node.value is None - - def test_parsing_all_attributes(self): - node = HTML1( - '', - )[0] - - assert node.value == 12.3 - assert node.min == 15.3 - assert node.max == 20.5 - assert node.step == 0.2 - - def test_attribute_escaping(self): - node = Div(style='font-family: "Times New Roman"') - - assert node.style['font-family'] == '"Times New Roman"' - assert node.style.to_sub_attribute_string() == 'font-family: "Times New Roman"' # NOQA: E501 - - node = HTML1(str(node))[0] - - assert node.style['font-family'] == '"Times New Roman"' - - # selectors ############################################################### - def test_unsupported_selector(self): - with pytest.raises(ValueError, match='unsupported selector feature:*'): - HTML1().query_selector('div > div') - - def test_query_selector(self): - # test selectors by tag name - html = Div( - HTML1(), - 'foo', - Div( - H1(), - ), - Span(), - Div(), - ) - - assert html.query_selector('h1') is html[2][0] - assert html.query_selector('span') is html[3] - - # test selectors by id - html = Div( - HTML1(), - 'foo', - Div( - Div(_id='foo'), - Div(_id='bar'), - ), - Div(_id='baz'), - ) - - assert html.query_selector('#foo') is html[2][0] - assert html.query_selector('#baz') is html[3] - - # test selectors by class - html = Div( - HTML1(), - 'foo', - Div( - Div(_class='foo'), - Div(_class='bar'), - ), - Div(_class='baz'), - ) - - assert html.query_selector('.foo') is html[2][0] - assert html.query_selector('.baz') is html[3] - - # test selectors by attribute - html = Div( - HTML1(), - 'foo', - Div( - Div(_type='foo'), - Div(_type='bar'), - ), - Div(_type='baz'), - ) - - assert html.query_selector('[type=foo]') is html[2][0] - assert html.query_selector('[type=baz]') is html[3] - - def test_query_selector_all(self): - html = Div( - Span(), - Div( - Span(), - ), - Span(), - ) - - nodes = html.query_selector_all('span') - - assert len(nodes) == 3 - assert html[0] in nodes - assert html[1][0] in nodes - assert html[2] in nodes - - def test_closest(self): - html = Div( - Div( - Div( - Span(), - ), - _id='foo', - ), - ) - - span = html[0][0][0] - node = span.closest('div#foo') - - assert node is html[0] - - # state ################################################################### - def test_state(self): - div = Div() - - assert hasattr(div, 'state') - assert hasattr(div.state, 'lock') - - assert div.state == {} - - div.state['foo'] = 'bar' - - assert div.state == {'foo': 'bar'} - - div.state.clear() - - assert div.state == {} - - def test_initial_state(self): - div = Div(state={'foo': 'bar'}) - - assert div.state == {'foo': 'bar'} - - # slices ################################################################## - def test_slices(self): - div1 = Div() - div2 = Div() - div3 = Div() - div4 = Div() - outer_div = Div(div1, div2, div3, div4) - outer_div.nodes = outer_div[1:-1] - - assert div1 not in outer_div - assert div2 in outer_div - assert div3 in outer_div - assert div4 not in outer_div - - # loop detection ########################################################## - def test_node_uniqueness(self): - """ - Nodes are unique and may be mounted in only one node tree, at only one - location. To ensure this, unmounts nodes at their parent, if they have - a parent, before mounting them. - """ - - # multiple mounts of the same node - # the resulting node should have only one child - outer_div = Div() - inner_div = Div() - - outer_div.append(inner_div) - outer_div.append(inner_div) - outer_div.append(inner_div) - - assert inner_div in outer_div - assert len(outer_div) == 1 - - # unmounting - # the given node is already mounted somewhere else in the tree, so it - # has to be unmounted before mounting it somewhere else - inner_div = Div() - - outer_div = Div( - Div(), - Div(inner_div), - ) - - outer_div[0].append(inner_div) - - assert inner_div in outer_div[0] - assert inner_div not in outer_div[1] - - def test_node_loop_detection(self): - - # simple loop - div = Div(Div()) - - with pytest.raises(RuntimeError, match='loop detected'): - div[0].append(div) - - # multi node loop - div = Div(Div(Div(Div()))) - - with pytest.raises(RuntimeError, match='loop detected'): - div[0][0][0].append(div[0][0]) diff --git a/tests/test_0100_components.py b/tests/test_0100_components.py deleted file mode 100644 index 27ebf6df..00000000 --- a/tests/test_0100_components.py +++ /dev/null @@ -1,53 +0,0 @@ -import pytest - - -@pytest.mark.dependency(depends=[ - # see https://github.com/RKrahl/pytest-dependency/issues/55 - 'tests/test_0001_html.py::TestAttributeDict::test_get_initial_values', - 'tests/test_0001_html.py::TestAttributeDict::test_set_attributes', - 'tests/test_0001_html.py::TestAttributeDict::test_value_cant_be_dict', - 'tests/test_0001_html.py::TestAttributeDict::test_cant_use_id_key', - 'tests/test_0001_html.py::TestAttributeDict::test_empty_dict_is_false', - 'tests/test_0001_html.py::TestAttributeDict::test_non_empty_dict_is_true', - 'tests/test_0001_html.py::TestAttributeDict::test_pop_existing_key', - 'tests/test_0001_html.py::TestAttributeDict::test_pop_unknown_key', - 'tests/test_0001_html.py::TestAttributeDict::test_pop_unknown_key_with_default', - 'tests/test_0001_html.py::TestAttributeDict::test_pop_existing_key_with_default', - 'tests/test_0001_html.py::TestAttributeDict::test_pop_expects_at_most_2_arguments', - 'tests/test_0001_html.py::TestAttributeDict::test_del_existing_key', - 'tests/test_0001_html.py::TestAttributeDict::test_del_unknown_key', - 'tests/test_0001_html.py::TestAttributeList::test_add_existing_element_does_nothing', - 'tests/test_0001_html.py::TestAttributeList::test_add_one_element', - 'tests/test_0001_html.py::TestAttributeList::test_cant_add_dict', - 'tests/test_0001_html.py::TestAttributeList::test_cant_set_dict', - 'tests/test_0001_html.py::TestAttributeList::test_cant_set_list_with_dict', - 'tests/test_0001_html.py::TestAttributeList::test_clear', - 'tests/test_0001_html.py::TestAttributeList::test_clear_empty_list', - 'tests/test_0001_html.py::TestAttributeList::test_default_class_list_is_empty', - 'tests/test_0001_html.py::TestAttributeList::test_default_id_list_is_empty', - 'tests/test_0001_html.py::TestAttributeList::test_default_list_has_zero_len', - 'tests/test_0001_html.py::TestAttributeList::test_empty_list_is_false', - 'tests/test_0001_html.py::TestAttributeList::test_equality_ignored_duplicates', - 'tests/test_0001_html.py::TestAttributeList::test_equality_ignores_order', - 'tests/test_0001_html.py::TestAttributeList::test_extend', - 'tests/test_0001_html.py::TestAttributeList::test_extend_ignores_duplicates', - 'tests/test_0001_html.py::TestAttributeList::test_in_keyword', - 'tests/test_0001_html.py::TestAttributeList::test_initial_class_can_be_list', - 'tests/test_0001_html.py::TestAttributeList::test_initial_class_can_be_passed_via_kwargs', - 'tests/test_0001_html.py::TestAttributeList::test_initial_class_can_be_space_separated_str', - 'tests/test_0001_html.py::TestAttributeList::test_initial_id_can_be_list', - 'tests/test_0001_html.py::TestAttributeList::test_initial_id_can_be_passed_via_kwargs', - 'tests/test_0001_html.py::TestAttributeList::test_initial_id_can_be_space_separated_str', - 'tests/test_0001_html.py::TestAttributeList::test_initial_id_cant_be_dict', - 'tests/test_0001_html.py::TestAttributeList::test_len_returns_number_of_elements', - 'tests/test_0001_html.py::TestAttributeList::test_non_empty_list_is_true', - 'tests/test_0001_html.py::TestAttributeList::test_non_equality', - 'tests/test_0001_html.py::TestAttributeList::test_remove_existing_element', - 'tests/test_0001_html.py::TestAttributeList::test_remove_unknown_element', - 'tests/test_0001_html.py::TestAttributeList::test_set_class_list', - 'tests/test_0001_html.py::TestAttributeList::test_set_id_list', - 'tests/test_0001_html.py::TestAttributeList::test_toggle_existing_element', - 'tests/test_0001_html.py::TestAttributeList::test_toggle_unknown_element', -], scope='session') -def test_node_api(): - pass diff --git a/tests/test_0101_html_attribute_dict.py b/tests/test_0101_html_attribute_dict.py new file mode 100644 index 00000000..25fafe5d --- /dev/null +++ b/tests/test_0101_html_attribute_dict.py @@ -0,0 +1,126 @@ +import re + +import pytest + +from lona.html import Div + + +def test_get_initial_values(): + d = Div(foo='foo', bar='bar') + + assert d.attributes['foo'] == 'foo' + assert d.attributes['bar'] == 'bar' + + +def test_set_attributes(): + d = Div() + + d.attributes = { + 'foo': 'foo', + 'bar': 'bar', + } + + assert d.attributes == { + 'foo': 'foo', + 'bar': 'bar', + } + + +def test_value_cant_be_dict(): + with pytest.raises( + ValueError, + match="unsupported type: ", + ): + Div(foo={}) + + +def test_cant_use_id_key(): + d = Div() + + with pytest.raises( + RuntimeError, + match=re.escape( + "Node.attributes['id'] is not supported. " + 'Use Node.id_list instead.', + ), + ): + d.attributes['id'] = 'foo' + + +def test_empty_dict_is_false(): + d = Div() + + assert not bool(d.attributes) + + +def test_non_empty_dict_is_true(): + d = Div(foo='foo') + + assert bool(d.attributes) + + +def test_pop_existing_key(): + d = Div(foo='foo-val', bar='bar-val') + assert 'foo' in d.attributes + + val = d.attributes.pop('foo') + + assert val == 'foo-val' + assert 'foo' not in d.attributes + + +def test_pop_unknown_key(): + d = Div(foo='foo-val', bar='bar-val') + assert 'xxx' not in d.attributes + + with pytest.raises(KeyError, match='xxx'): + d.attributes.pop('xxx') + assert 'xxx' not in d.attributes + + +def test_pop_unknown_key_with_default(): + d = Div(foo='foo-val', bar='bar-val') + assert 'xxx' not in d.attributes + + val = d.attributes.pop('xxx', 'yyy') + + assert val == 'yyy' + assert 'xxx' not in d.attributes + + +def test_pop_existing_key_with_default(): + d = Div(foo='foo-val', bar='bar-val') + assert 'bar' in d.attributes + + val = d.attributes.pop('bar', 'yyy') + + assert val == 'bar-val' + assert 'bar' not in d.attributes + + +def test_pop_expects_at_most_2_arguments(): + d = Div(foo='foo-val', bar='bar-val') + + with pytest.raises( + TypeError, + match='pop expected at most 2 arguments, got 3', + ): + d.attributes.pop('xxx', 'yyy', 'zzz') + + +def test_del_existing_key(): + d = Div(foo='foo-val', bar='bar-val') + assert 'foo' in d.attributes + + del d.attributes['foo'] + + assert 'foo' not in d.attributes + + +def test_del_unknown_key(): + d = Div(foo='foo-val', bar='bar-val') + assert 'xxx' not in d.attributes + + del d.attributes['xxx'] + + assert 'xxx' not in d.attributes diff --git a/tests/test_0102_html_attribute_list.py b/tests/test_0102_html_attribute_list.py new file mode 100644 index 00000000..0486d70f --- /dev/null +++ b/tests/test_0102_html_attribute_list.py @@ -0,0 +1,237 @@ +import pytest + +from lona.html import Div + + +def test_default_id_list_is_empty(): + d = Div() + + assert d.id_list == [] + + +def test_initial_id_can_be_list(): + d = Div(_id=['foo', 'bar']) + + assert d.id_list == ['foo', 'bar'] + + +def test_initial_id_can_be_space_separated_str(): + d = Div(_id='foo bar') + + assert d.id_list == ['foo', 'bar'] + + +def test_initial_id_can_be_passed_via_kwargs(): + d = Div(**{'id': 'foo bar'}) + + assert d.id_list == ['foo', 'bar'] + + +def test_default_class_list_is_empty(): + d = Div() + + assert d.class_list == [] + + +def test_initial_class_can_be_list(): + d = Div(_class=['foo', 'bar']) + + assert d.class_list == ['foo', 'bar'] + + +def test_initial_class_can_be_space_separated_str(): + d = Div(_class='foo bar') + + assert d.class_list == ['foo', 'bar'] + + +def test_initial_class_can_be_passed_via_kwargs(): + d = Div(**{'class': 'foo bar'}) + + assert d.class_list == ['foo', 'bar'] + + +def test_set_id_list(): + d = Div() + + d.id_list = ['foo', 'bar'] + + assert d.id_list == ['foo', 'bar'] + + +def test_set_class_list(): + d = Div() + + d.class_list = ['foo', 'bar'] + + assert d.class_list == ['foo', 'bar'] + + +def test_initial_id_cant_be_dict(): + with pytest.raises( + ValueError, + match='id has to be string or list of strings', + ): + Div(_id={}) + + +def test_cant_set_dict(): + d = Div() + + with pytest.raises( + ValueError, + match="unsupported type: ", + ): + d.id_list = {} + + +def test_cant_set_list_with_dict(): + d = Div() + + with pytest.raises( + ValueError, + match="unsupported type: ", + ): + d.id_list = [{}] + + +def test_default_list_has_zero_len(): + d = Div() + + assert len(d.id_list) == 0 + + +def test_len_returns_number_of_elements(): + d = Div(_id='foo bar') + + assert len(d.id_list) == 2 + + +def test_non_equality(): + d = Div(_id='foo bar') + + assert d.id_list != [] + assert d.id_list != ['foo', 'bar', 'baz'] + + +def test_equality_ignores_order(): + d = Div(_id='foo bar') + + assert d.id_list == ['bar', 'foo'] + + +def test_equality_ignored_duplicates(): + d = Div(_id='foo bar') + + assert d.id_list == ['foo', 'bar', 'foo', 'bar'] + + +def test_in_keyword(): + d = Div(_id='foo bar') + + assert 'foo' in d.id_list + assert 'bar' in d.id_list + assert 'baz' not in d.id_list + + +def test_non_empty_list_is_true(): + d = Div(_id='foo') + + assert bool(d.id_list) + + +def test_empty_list_is_false(): + d = Div() + + assert not bool(d.id_list) + + +def test_add_one_element(): + d = Div() + + d.id_list.add('foo') + + assert d.id_list == ['foo'] + + +def test_add_existing_element_does_nothing(): + d = Div() + d.id_list.add('foo') + + d.id_list.add('foo') + + assert d.id_list == ['foo'] + + +def test_cant_add_dict(): + d = Div() + + with pytest.raises( + ValueError, + match="unsupported type: ", + ): + d.id_list.add({}) + + +def test_extend(): + d = Div(_id='foo') + + d.id_list.extend(['bar', 'baz']) + + assert d.id_list == ['foo', 'bar', 'baz'] + + +def test_extend_ignores_duplicates(): + d = Div(_id='foo') + + d.id_list.extend(['foo', 'bar', 'baz']) + + assert d.id_list == ['foo', 'bar', 'baz'] + + +def test_remove_existing_element(): + d = Div(_id='foo bar') + + d.id_list.remove('foo') + + assert d.id_list == ['bar'] + + +def test_remove_unknown_element(): + d = Div(_id='bar') + + d.id_list.remove('foo') + + assert d.id_list == ['bar'] + + +def test_clear(): + d = Div(_id='foo bar') + + d.id_list.clear() + + assert d.id_list == [] + + +def test_clear_empty_list(): + d = Div() + + d.id_list.clear() + + assert d.id_list == [] + + +def test_toggle_existing_element(): + d = Div(_id='foo bar') + + d.id_list.toggle('foo') + + assert d.id_list == ['bar'] + + +def test_toggle_unknown_element(): + d = Div(_id='bar') + + d.id_list.toggle('foo') + + assert d.id_list == ['foo', 'bar'] diff --git a/tests/test_0103_html_sub_nodes.py b/tests/test_0103_html_sub_nodes.py new file mode 100644 index 00000000..71b29789 --- /dev/null +++ b/tests/test_0103_html_sub_nodes.py @@ -0,0 +1,119 @@ +import pytest + +from lona.html import Div + + +def test_nodes_in_nodes(): + div1 = Div() + + div2 = Div( + div1, + ) + + assert len(div2) == 1 + assert div2[0] is div1 + + +def test_text_nodes_in_nodes(): + div = Div('foo') + + assert len(div) == 1 + assert str(div[0]) == 'foo' + + +def test_mixed_nodes(): + sub_node1 = Div() + sub_node2 = Div() + + div = Div( + sub_node1, + 'foo', + sub_node2, + 'bar', + ) + + assert len(div) == 4 + assert div[0] is sub_node1 + assert str(div[1]) == 'foo' + assert div[2] is sub_node2 + assert str(div[3]) == 'bar' + + +def test_generator(): + div = Div( + (Div() for i in range(10)), + ) + + assert len(div) == 10 + + +def test_list(): + div = Div( + [Div() for i in range(10)], + ) + + assert len(div) == 10 + + +def test_slices(): + div1 = Div() + div2 = Div() + div3 = Div() + div4 = Div() + outer_div = Div(div1, div2, div3, div4) + outer_div.nodes = outer_div[1:-1] + + assert div1 not in outer_div + assert div2 in outer_div + assert div3 in outer_div + assert div4 not in outer_div + + +def test_node_uniqueness(): + """ + Nodes are unique and may be mounted in only one node tree, at only one + location. To ensure this, unmounts nodes at their parent, if they have + a parent, before mounting them. + """ + + # multiple mounts of the same node + # the resulting node should have only one child + outer_div = Div() + inner_div = Div() + + outer_div.append(inner_div) + outer_div.append(inner_div) + outer_div.append(inner_div) + + assert inner_div in outer_div + assert len(outer_div) == 1 + + # unmounting + # the given node is already mounted somewhere else in the tree, so it + # has to be unmounted before mounting it somewhere else + inner_div = Div() + + outer_div = Div( + Div(), + Div(inner_div), + ) + + outer_div[0].append(inner_div) + + assert inner_div in outer_div[0] + assert inner_div not in outer_div[1] + + +def test_node_loop_detection(): + + # simple loop + div = Div(Div()) + + with pytest.raises(RuntimeError, match='loop detected'): + div[0].append(div) + + # multi node loop + div = Div(Div(Div(Div()))) + + with pytest.raises(RuntimeError, match='loop detected'): + div[0][0][0].append(div[0][0]) diff --git a/tests/test_0104_html_parsing.py b/tests/test_0104_html_parsing.py new file mode 100644 index 00000000..00464f93 --- /dev/null +++ b/tests/test_0104_html_parsing.py @@ -0,0 +1,364 @@ +import pytest + +from lona.html import ( + NumberInput, + TextInput, + TextArea, + CheckBox, + Select2, + Option2, + Submit, + Select, + Option, + Button, + HTML2, + HTML1, + Node, + Div, +) +from lona.compat import set_use_future_node_classes + + +# HTML1 ####################################################################### +def test_empty_node(): + node = HTML1('
')[0] + + assert node.tag_name == 'div' + assert node.id_list == [] + assert node.class_list == [] + assert node.style == {} + assert node.attributes == {} + assert node.nodes == [] + + +def test_node_with_attributes(): + node = HTML1(""" +
+
+ """)[0] + + assert node.tag_name == 'div' + assert node.id_list == ['foo'] + assert node.class_list == ['bar'] + assert node.style == {'color': 'black'} + assert node.attributes == {'foo': 'bar'} + assert node.nodes == [] + + +def test_sub_nodes(): + node = HTML1(""" +
+ +
+

+
+ """)[0] + + assert node.tag_name == 'div' + assert len(node.nodes) == 3 + assert node.nodes[0].tag_name == 'span' + assert node.nodes[1].tag_name == 'div' + assert node.nodes[2].tag_name == 'h1' + + +def test_multiple_ids(): + node = HTML1('
')[0] + + assert node.id_list == ['foo', 'bar', 'baz'] + + +def test_multiple_classes(): + node = HTML1('
')[0] + + assert node.class_list == ['foo', 'bar', 'baz'] + + +def test_multiple_styles(): + node = HTML1('
')[0] + + assert node.style == { + 'color': 'black', + 'display': 'block', + } + + +def test_multiple_attributes(): + node = HTML1('
')[0] + + assert node.attributes == { + 'foo': 'bar', + 'bar': 'baz', + } + + +def test_high_level_nodes(): + node = HTML1('')[0] + + assert type(node) is Button + + +def test_boolean_property_without_value(): + node = HTML1('')[0] + + assert node.disabled + + +def test_boolean_property_with_value(): + node = HTML1('')[0] + + assert node.disabled + + +def test_missing_end_tag(): + with pytest.raises( + ValueError, + match='Invalid html: missing end tag ', + ): + HTML1('') + + +def test_wrong_end_tag(): + with pytest.raises( + ValueError, + match='Invalid html:
expected, received', + ): + HTML1('

abc

') + + +def test_end_tag_without_start_tag(): + with pytest.raises( + ValueError, + match='Invalid html: missing start tag for ', + ): + HTML1('
abc
') + + +def test_missing_start_tag(): + with pytest.raises( + ValueError, + match='Invalid html: missing start tag for ', + ): + HTML1('') + + +def test_self_closing_tag_with_slash(): + img = HTML1('
')[0].nodes[0] + + assert img.tag_name == 'img' + assert img.self_closing_tag is True + + +def test_self_closing_tag_without_slash(): + img = HTML1('
')[0].nodes[0] + + assert img.tag_name == 'img' + assert img.self_closing_tag is True + + +def test_non_self_closing_tag(): + div = HTML1('
')[0] + + assert div.tag_name == 'div' + assert div.self_closing_tag is False + + +def test_non_self_closing_tag_with_slash(): + span = HTML1('
')[0].nodes[0] + + assert span.tag_name == 'span' + assert span.self_closing_tag is True + + +def test_default_input_type_is_text(): + node = HTML1('')[0] + + assert type(node) is TextInput + assert node.value == 'abc' + assert node.disabled is False + + +def test_input_type_text(): + node = HTML1('')[0] + + assert type(node) is TextInput + assert node.value == 'xyz' + assert node.disabled is True + + +def test_input_type_unknown(): + node = HTML1('')[0] + + assert type(node) is Node + + +@pytest.mark.parametrize( + 'tp', + [ + 'button', + 'color', + 'date', + 'datetime-local', + 'email', + 'file', + 'hidden', + 'image', + 'month', + 'password', + 'radio', + 'range', + 'reset', # intentionally, see 575dcf635180 ("html: remove Reset node") # NOQA: LN002 + 'search', + 'tel', + 'time', + 'url', + 'week', + ], +) +def test_not_implemented_input_types(tp): + node = HTML1(f'')[0] + + assert type(node) is Node + + +def test_input_type_number(): + node = HTML1('')[0] + + assert type(node) is NumberInput + assert node.value == 123.5 + + +def test_input_type_checkbox(): + node = HTML1('')[0] + + assert type(node) is CheckBox + assert node.value is False + + +def test_input_type_checkbox_checked(): + node = HTML1('')[0] + + assert type(node) is CheckBox + assert node.value is True + + +def test_input_type_submit(): + node = HTML1('')[0] + + assert type(node) is Submit + + +def test_textarea(): + node = HTML1('')[0] + + assert type(node) is TextArea + assert node.value == 'abc' + + +def test_textarea_with_self_closing_tag_inside(): + textarea = HTML1('')[0] + + assert textarea.value == 'abc
xyz' + + +def test_textarea_with_pair_tag_inside(): + textarea = HTML1('')[0] + + assert textarea.value == 'aaa bbb ccc' + + +def test_select(): + node = HTML1(""" + + """)[0] + + assert type(node) == Select + assert type(node.nodes[0]) == Option + assert node.value == '2' + + +def test_select2(): + set_use_future_node_classes(True) + + try: + node = HTML1(""" + + """)[0] + + assert type(node) == Select2 + assert type(node.nodes[0]) == Option2 + assert node.value == '2' + + finally: + set_use_future_node_classes(False) + + +def test_attribute_escaping(): + node = Div(style='font-family: "Times New Roman"') + + assert node.style['font-family'] == '"Times New Roman"' + assert node.style.to_sub_attribute_string() == 'font-family: "Times New Roman"' # NOQA: E501 + + node = HTML1(str(node))[0] + + assert node.style['font-family'] == '"Times New Roman"' + + +# HTML2 ####################################################################### +def test_HTML2_sub_nodes(): + node = HTML2(""" +
+ +
+

+
+ """) + + assert not node.parent + assert node.tag_name == 'div' + assert len(node.nodes) == 3 + assert node.nodes[0].tag_name == 'span' + assert node.nodes[1].tag_name == 'div' + assert node.nodes[2].tag_name == 'h1' + + +def test_wrapping(): + node = HTML2(""" + +
+

+ """) + + assert node.tag_name == 'div' + assert len(node.nodes) == 3 + assert node.nodes[0].tag_name == 'span' + assert node.nodes[1].tag_name == 'div' + assert node.nodes[2].tag_name == 'h1' + + +def test_multiple_strings(): + node = HTML2( + '', + '
', + '

', + ) + + assert node.tag_name == 'div' + assert len(node.nodes) == 4 + assert node.nodes[0].tag_name == 'span' + assert node.nodes[1].tag_name == 'span' + assert node.nodes[2].tag_name == 'div' + assert node.nodes[3].tag_name == 'h1' + + +def test_attribute_cases(): + node1 = HTML1('')[0] + node2 = HTML2('') + + assert node1.attributes['preserveAspectRatio'] == 'none' + assert node2.attributes['preserveAspectRatio'] == 'none' diff --git a/tests/test_0105_html_query_selectors.py b/tests/test_0105_html_query_selectors.py new file mode 100644 index 00000000..c75fd242 --- /dev/null +++ b/tests/test_0105_html_query_selectors.py @@ -0,0 +1,99 @@ +import pytest + +from lona.html import HTML1, Span, Div, H1 + + +def test_unsupported_selector(): + with pytest.raises(ValueError, match='unsupported selector feature:*'): + HTML1().query_selector('div > div') + + +def test_query_selector(): + # test selectors by tag name + html = Div( + HTML1(), + 'foo', + Div( + H1(), + ), + Span(), + Div(), + ) + + assert html.query_selector('h1') is html[2][0] + assert html.query_selector('span') is html[3] + + # test selectors by id + html = Div( + HTML1(), + 'foo', + Div( + Div(_id='foo'), + Div(_id='bar'), + ), + Div(_id='baz'), + ) + + assert html.query_selector('#foo') is html[2][0] + assert html.query_selector('#baz') is html[3] + + # test selectors by class + html = Div( + HTML1(), + 'foo', + Div( + Div(_class='foo'), + Div(_class='bar'), + ), + Div(_class='baz'), + ) + + assert html.query_selector('.foo') is html[2][0] + assert html.query_selector('.baz') is html[3] + + # test selectors by attribute + html = Div( + HTML1(), + 'foo', + Div( + Div(_type='foo'), + Div(_type='bar'), + ), + Div(_type='baz'), + ) + + assert html.query_selector('[type=foo]') is html[2][0] + assert html.query_selector('[type=baz]') is html[3] + + +def test_query_selector_all(): + html = Div( + Span(), + Div( + Span(), + ), + Span(), + ) + + nodes = html.query_selector_all('span') + + assert len(nodes) == 3 + assert html[0] in nodes + assert html[1][0] in nodes + assert html[2] in nodes + + +def test_closest(): + html = Div( + Div( + Div( + Span(), + ), + _id='foo', + ), + ) + + span = html[0][0][0] + node = span.closest('div#foo') + + assert node is html[0] diff --git a/tests/test_0106_html_state.py b/tests/test_0106_html_state.py new file mode 100644 index 00000000..bc33ddbc --- /dev/null +++ b/tests/test_0106_html_state.py @@ -0,0 +1,24 @@ +from lona.html import Div + + +def test_state(): + div = Div() + + assert hasattr(div, 'state') + assert hasattr(div.state, 'lock') + + assert div.state == {} + + div.state['foo'] = 'bar' + + assert div.state == {'foo': 'bar'} + + div.state.clear() + + assert div.state == {} + + +def test_initial_state(): + div = Div(state={'foo': 'bar'}) + + assert div.state == {'foo': 'bar'} diff --git a/tests/test_0201_number_input.py b/tests/test_0201_number_input.py new file mode 100644 index 00000000..f3dc9eed --- /dev/null +++ b/tests/test_0201_number_input.py @@ -0,0 +1,83 @@ +from lona.html import NumberInput, HTML1 + + +def test_default_properties(): + node = NumberInput() + + assert node.value is None + assert node.min is None + assert node.max is None + assert node.step is None + + +def test_initial_value(): + node = NumberInput(value=12.3) + + assert node.value == 12.3 + + +def test_change_value(): + node = NumberInput() + node.value = 12.3 + + assert node.value == 12.3 + + +def test_parsing_no_attributes(): + node = HTML1('')[0] + + assert node.value is None + assert node.min is None + assert node.max is None + assert node.step is None + + +def test_parsing_int_value(): + node = HTML1('')[0] + + assert node.value == 123 + + +def test_parsing_float_value(): + node = HTML1('')[0] + + assert node.value == 12.3 + + +def test_parsing_broken_step(): + node = HTML1('')[0] + + assert node.value == 123 + assert node.step is None + + +def test_parsing_int_step(): + node = HTML1('')[0] + + assert node.value == 12.3 + assert node.step == 3 + + +def test_parsing_float_step(): + node = HTML1('')[0] + + assert node.value == 12.3 + assert node.step == 0.1 + + +def test_parsing_broken_value(): + node = HTML1('')[0] + + assert node.raw_value == 'abc' + assert node.value is None + + +def test_parsing_all_attributes(): + node = HTML1( + '', + )[0] + + assert node.value == 12.3 + assert node.min == 15.3 + assert node.max == 20.5 + assert node.step == 0.2 diff --git a/tests/test_routing.py b/tests/test_0301_routing.py similarity index 97% rename from tests/test_routing.py rename to tests/test_0301_routing.py index 1045a44d..971a96f5 100644 --- a/tests/test_routing.py +++ b/tests/test_0301_routing.py @@ -3,7 +3,6 @@ from lona.routing import Router, Route -@pytest.mark.incremental() class TestBasicRouting: def setup_method(self): self.routes = [ @@ -50,7 +49,6 @@ def test_cant_resolve_unknown_route(self): assert not match -@pytest.mark.incremental() class TestRoutesWithRegex: def setup_method(self): self.routes = [ @@ -73,7 +71,6 @@ def test_doesnt_match_regex(self): assert not match -@pytest.mark.incremental() class TestOptionalTrailingSlash: def setup_method(self): self.routes = [ @@ -126,7 +123,6 @@ def test_required_slash_resolves_with_slash(self): assert match_info == {} -@pytest.mark.incremental() class TestReverseMatching: def setup_method(self): routes = [ diff --git a/tests/test_responses.py b/tests/test_0302_responses.py similarity index 100% rename from tests/test_responses.py rename to tests/test_0302_responses.py diff --git a/tests/test_click_events.py b/tests/test_0303_click_events.py similarity index 100% rename from tests/test_click_events.py rename to tests/test_0303_click_events.py diff --git a/tests/test_rendering.py b/tests/test_0304_rendering.py similarity index 100% rename from tests/test_rendering.py rename to tests/test_0304_rendering.py diff --git a/tests/test_html_select.py b/tests/test_0401_html_select.py similarity index 100% rename from tests/test_html_select.py rename to tests/test_0401_html_select.py diff --git a/tests/test_html_select2.py b/tests/test_0402_html_select2.py similarity index 100% rename from tests/test_html_select2.py rename to tests/test_0402_html_select2.py diff --git a/tests/test_number_input.py b/tests/test_0403_number_input.py similarity index 100% rename from tests/test_number_input.py rename to tests/test_0403_number_input.py From 3a77a767f117871718eab6000e5a8b754c0c8ecd Mon Sep 17 00:00:00 2001 From: Florian Scherf Date: Tue, 30 May 2023 15:28:51 +0200 Subject: [PATCH 2/8] tests: rendering: refactor test parameter Signed-off-by: Florian Scherf --- tests/test_0304_rendering.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/tests/test_0304_rendering.py b/tests/test_0304_rendering.py index 0a837141..af88da6b 100644 --- a/tests/test_0304_rendering.py +++ b/tests/test_0304_rendering.py @@ -1,15 +1,9 @@ import pytest -@pytest.mark.parametrize('rendering_setup', [ - 'chromium:client-1', - 'chromium:client-2', - 'firefox:client-1', - 'firefox:client-2', - 'webkit:client-1', - 'webkit:client-2', -]) -async def test_rendering(rendering_setup, lona_project_context): +@pytest.mark.parametrize('client_version', [1, 2]) +@pytest.mark.parametrize('browser_name', ['chromium', 'firefox', 'webkit']) +async def test_rendering(browser_name, client_version, lona_project_context): """ This test tests all client side rendering features, using the rendering test view in the test project. @@ -41,9 +35,6 @@ async def test_rendering(rendering_setup, lona_project_context): TEST_PROJECT_PATH = os.path.join(__file__, '../../test_project') - browser_name, client_version = rendering_setup.split(':') - client_version = int(client_version[7:]) - context = await lona_project_context( project_root=TEST_PROJECT_PATH, settings=['settings.py'], From d8c790493f30d372693249916eb4bb9bd989c806 Mon Sep 17 00:00:00 2001 From: Florian Scherf Date: Wed, 7 Jun 2023 19:22:29 +0200 Subject: [PATCH 3/8] client2: rendering: add check for supported nodes client2 does not support `lona.html.Widget` and previously it would crash if it attempted to render widget nodes. Signed-off-by: Florian Scherf --- lona/client2/_lona/client2/rendering-engine.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lona/client2/_lona/client2/rendering-engine.js b/lona/client2/_lona/client2/rendering-engine.js index a609bfc4..26f32d86 100644 --- a/lona/client2/_lona/client2/rendering-engine.js +++ b/lona/client2/_lona/client2/rendering-engine.js @@ -174,6 +174,13 @@ export class LonaRenderingEngine { _render_node(node_spec) { const node_type = node_spec[0]; + // TODO: remove in 2.0 + if(!(node_type == Lona.protocol.NODE_TYPE.NODE || + node_type == Lona.protocol.NODE_TYPE.TEXT_NODE)) { + + throw(`unsupported node type: ${node_type}`); + }; + // TextNode if(node_type == Lona.protocol.NODE_TYPE.TEXT_NODE) { const node_id = node_spec[1]; From 241453c5de81de55bd18800306b009339133d532 Mon Sep 17 00:00:00 2001 From: Florian Scherf Date: Wed, 7 Jun 2023 20:16:11 +0200 Subject: [PATCH 4/8] server: use most abstract class of `lona.responses` for all checks Signed-off-by: Florian Scherf --- lona/server.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lona/server.py b/lona/server.py index 47c6f48d..2f37b710 100644 --- a/lona/server.py +++ b/lona/server.py @@ -29,15 +29,14 @@ HttpRedirectResponse, TemplateResponse, RedirectResponse, - JsonResponse, - HtmlResponse, ) from lona.compat import set_use_future_node_classes, set_client_version from lona.view_runtime_controller import ViewRuntimeController from lona.responses import FileResponse as LonaFileResponse +from lona.responses import AbstractResponse as LonaResponse from lona.middleware_controller import MiddlewareController +from lona.responses import JsonResponse, HtmlResponse from lona.static_file_loader import StaticFileLoader -from lona.responses import Response as LonaResponse from lona.templating import TemplatingEngine from lona.imports import acquire as _acquire from lona.worker_pool import WorkerPool From bb6cba434d048b33900aed47245fc7debaf64be0 Mon Sep 17 00:00:00 2001 From: Florian Scherf Date: Thu, 6 Jul 2023 09:46:06 +0200 Subject: [PATCH 5/8] html: node list: add support for all iterables when resetting node lists Previously, `lona.html.Node.nodes` would only accept nodes and lists of nodes. This is unhandy when creating custom node classes and using `*args` and `**kwargs` since `*args` are tuples by default. This patch changes `lona.html.node_list.NodeList._reset()` to accept any kind of iterable. Signed-off-by: Florian Scherf --- lona/html/node_list.py | 6 +++++- tests/test_0103_html_sub_nodes.py | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/lona/html/node_list.py b/lona/html/node_list.py index 00c7f825..0ff2f41a 100644 --- a/lona/html/node_list.py +++ b/lona/html/node_list.py @@ -1,3 +1,5 @@ +from collections.abc import Iterable + from lona.html.abstract_node import AbstractNode from lona.protocol import PATCH_TYPE, OPERATION from lona.html.text_node import TextNode @@ -218,7 +220,9 @@ def __contains__(self, node): def _reset(self, values): self._assert_not_frozen() - if not isinstance(values, list): + if (isinstance(values, AbstractNode) or + not isinstance(values, Iterable)): + values = [values] with self._node.lock: diff --git a/tests/test_0103_html_sub_nodes.py b/tests/test_0103_html_sub_nodes.py index 71b29789..48ee4aad 100644 --- a/tests/test_0103_html_sub_nodes.py +++ b/tests/test_0103_html_sub_nodes.py @@ -117,3 +117,23 @@ def test_node_loop_detection(): with pytest.raises(RuntimeError, match='loop detected'): div[0][0][0].append(div[0][0]) + + +def test_sub_node_reset_with_node(): + div1 = Div() + div2 = Div() + + div1.nodes = div2 + + assert len(div1.nodes) == 1 + assert div1.nodes[0] is div2 + + +def test_sub_node_reset_with_node_list(): + div1 = Div() + div2 = Div() + + div1.nodes = [div2] + + assert len(div1.nodes) == 1 + assert div1.nodes[0] is div2 From 43abe6a6d845c2090951c706cee3e607c000087a Mon Sep 17 00:00:00 2001 From: Florian Scherf Date: Thu, 6 Jul 2023 12:56:33 +0200 Subject: [PATCH 6/8] html: add support for initializing widget data via node constructors Signed-off-by: Florian Scherf --- lona/html/node.py | 8 +++++++- lona/html/widget_data.py | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lona/html/node.py b/lona/html/node.py index ea6f81d3..f95489ef 100644 --- a/lona/html/node.py +++ b/lona/html/node.py @@ -43,6 +43,7 @@ class Node(AbstractNode): ATTRIBUTES: dict[str, str] = {} EVENTS: list[EventType | ChangeEventType] = [] WIDGET: str = '' + WIDGET_DATA: dict | list = {} def __init__( self, @@ -51,6 +52,7 @@ def __init__( tag_name=None, self_closing_tag=None, widget='', + widget_data=None, **kwargs, ): @@ -61,7 +63,11 @@ def __init__( self._nodes = NodeList(self) self._events = NodeEventList(self, self.EVENTS) self._widget = widget or self.WIDGET - self._widget_data = WidgetData(widget=self) + + self._widget_data = WidgetData( + widget=self, + value=widget_data or self.WIDGET_DATA, + ) # tag overrides self._namespace = namespace or self.NAMESPACE diff --git a/lona/html/widget_data.py b/lona/html/widget_data.py index 0d53b302..baabe6ff 100644 --- a/lona/html/widget_data.py +++ b/lona/html/widget_data.py @@ -386,10 +386,10 @@ def __eq__(self, other): class WidgetData: - def __init__(self, widget): + def __init__(self, widget, value=None): self._widget = widget - self._reset({}, initial=True) + self._reset(value or {}, initial=True) def __getitem__(self, *args, **kwargs): return self._overlay.__getitem__(*args, **kwargs) From f7fc6bcac53cb1783f4176dacd93a6e7b02bb31f Mon Sep 17 00:00:00 2001 From: Florian Scherf Date: Thu, 6 Jul 2023 14:06:00 +0200 Subject: [PATCH 7/8] html: Widget: fix attribute error when comparing Previously, `lona.html.abstract_node.AbstractNode.__eq__()` would crash when one or both compared nodes where a widget. This patch fixes `lona.html.abstract_node.AbstractNode.__eq__()`, by adding proper compare logic for `lona.html.Widget`. Signed-off-by: Florian Scherf --- lona/html/abstract_node.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lona/html/abstract_node.py b/lona/html/abstract_node.py index 753363dc..aa48930e 100644 --- a/lona/html/abstract_node.py +++ b/lona/html/abstract_node.py @@ -58,6 +58,11 @@ def __eq__(self, other): if self.NODE_TYPE == NODE_TYPE.TEXT_NODE: return self._string == other._string + # widgets + # TODO: remove in 2.0 + if self.NODE_TYPE == NODE_TYPE.WIDGET: + return (self.nodes == other.nodes and + self.data == other.data) # nodes if other.namespace != self.namespace: return False From 5f79f2aeba23228244f1658d67d68551c2137576 Mon Sep 17 00:00:00 2001 From: Florian Scherf Date: Thu, 6 Jul 2023 14:06:21 +0200 Subject: [PATCH 8/8] tests: html: add tests for node comparisons Signed-off-by: Florian Scherf --- tests/test_0107_html_node_compare.py | 91 ++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 tests/test_0107_html_node_compare.py diff --git a/tests/test_0107_html_node_compare.py b/tests/test_0107_html_node_compare.py new file mode 100644 index 00000000..1f9a91d3 --- /dev/null +++ b/tests/test_0107_html_node_compare.py @@ -0,0 +1,91 @@ +from lona.html import Widget, Span, Node, Div +from lona.html.text_node import TextNode + + +def test_simple_comparison(): + assert Div() == Div() + assert Div() is not Div() + assert Div() != Widget() + assert Div() != TextNode('') + + +def test_text_nodes(): + assert TextNode('foo') == TextNode('foo') + assert TextNode('foo') != TextNode('bar') + assert TextNode('foo') is not TextNode('foo') + + +def test_legacy_widgets(): + # TODO: remove in 2.0 + + class TestWidget(Widget): + def __init__(self, *nodes, **widget_data): + self.nodes = nodes + self.data = widget_data + + assert TestWidget() == TestWidget() + + assert TestWidget(Div()) == TestWidget(Div()) + assert TestWidget(Div()) != TestWidget(Span()) + + assert TestWidget(foo=1) == TestWidget(foo=1) + assert TestWidget(foo=1) != TestWidget() + assert TestWidget(foo=1) != TestWidget(foo=2) + assert TestWidget(foo=1) != TestWidget(foo=1, bar=2) + + +def test_non_node_comparisons(): + assert Div() != 'Div' + assert Div() != object() + + +def test_namespaces(): + assert Div() != Div(namespace='foo') + assert Div(namespace='foo') != Div(namespace='bar') + + +def test_tag_name(): + assert Div() != Span() + assert Node(tag_name='div') != Node(tag_name='span') + + +def test_id_list(): + assert Div(_id='foo') == Div(_id='foo') + assert Div(_id='foo') != Div() + assert Div(_id='foo') != Div(_id='foo bar') + + +def test_class_list(): + assert Div(_class='foo') == Div(_class='foo') + assert Div(_class='foo') != Div() + assert Div(_class='foo') != Div(_class='foo bar') + + +def test_style(): + assert Div(style={'color': 'red'}) == Div(style={'color': 'red'}) + assert Div(style={'color': 'red'}) != Div(style={'color': 'blue'}) + assert Div(style={'color': 'red'}) != Div(style={'color': 'red', 'a': 'b'}) + + +def test_attributes(): + assert Div(a=1) == Div(a=1) + assert Div(a=1) != Div(a=2) + assert Div(a=1) != Div(a=1, b=2) + + +def test_sub_nodes(): + assert Div(Div()) == Div(Div()) + assert Div(Div()) != Div(Div(a=1)) + assert Div(Div()) != Div(Div(Span())) + assert Div(Div()) != Div() + + +def test_widgets(): + assert Div(widget='foo') == Div(widget='foo') + assert Div(widget='foo') != Div() + + +def test_widget_data(): + assert Div(widget_data=['foo']) == Div(widget_data=['foo']) + assert Div(widget_data=['foo']) != Div(widget_data=['foo', 'bar']) + assert Div(widget_data=['foo']) != Div()