From a5bef20e15b5e2260af7e83de3747538476ee19c Mon Sep 17 00:00:00 2001 From: maximlt Date: Sat, 18 Jun 2022 00:52:12 +0200 Subject: [PATCH 01/29] only install chromium and run the ui tests on chromium --- .github/workflows/test.yaml | 2 +- tox.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 15af32fab6..d96a72c1f1 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -173,7 +173,7 @@ jobs: run: | eval "$(conda shell.bash hook)" conda activate test-environment - playwright install + playwright install chromium - name: doit test_ui run: | eval "$(conda shell.bash hook)" diff --git a/tox.ini b/tox.ini index 5321fbff96..d521823190 100644 --- a/tox.ini +++ b/tox.ini @@ -24,7 +24,7 @@ commands = pytest panel [_ui] description = Run UI tests deps = .[tests, ui] -commands = pytest panel --ui +commands = pytest panel --ui --browser chromium [_examples] description = Test that default examples run From 3a37124576a21b2d9a63ca83980b69d08815ffe1 Mon Sep 17 00:00:00 2001 From: maximlt Date: Sat, 18 Jun 2022 00:53:43 +0200 Subject: [PATCH 02/29] add first batch of tests --- panel/tests/ui/widgets/test_tabulator.py | 450 +++++++++++++++++++++++ 1 file changed, 450 insertions(+) create mode 100644 panel/tests/ui/widgets/test_tabulator.py diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py new file mode 100644 index 0000000000..8373862f9a --- /dev/null +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -0,0 +1,450 @@ +import sys +import time + +import pytest + +from playwright.sync_api import expect + +pytestmark = pytest.mark.ui + +try: + from pandas._testing import makeMixedDataFrame +except ImportError: + pytestmark = pytest.mark.skip('pandas not available') + + +from panel.io.server import serve +from panel.widgets import Tabulator + + +@pytest.fixture +def df_mixed(): + df = makeMixedDataFrame() + df.index = [f'idx{i}' for i in range(len(df))] + return df + + +def wait_until(fn, timeout=5000, interval=100): + while timeout > 0: + if fn(): + return True + else: + time.sleep(interval / 1000) + timeout -= interval + # To raise the False assert + assert fn() + + +def get_ctrl_modifier(): + if sys.platform == 'linux': + return 'Control' + elif sys.platform == 'darwin': + return 'Meta' + else: + raise ValueError(f'No control modifier defined for platform {sys.platform}') + + +def test_tabulator_default(page, port, df_mixed): + nrows, ncols = df_mixed.shape + widget = Tabulator(df_mixed) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + expected_ncols = ncols + 2 # _index + index + data columns + + # Check that the whole table content is on the page + table = page.locator('.bk.pnx-tabulator.tabulator') + expect(table).to_have_text( + 'index\nA\nB\nC\nD\nidx0\n0.0\n0.0\nfoo1\n2009-01-01 00:00:00\nidx1\n1.0\n1.0\nfoo2\n2009-01-02 00:00:00\nidx2\n2.0\n0.0\nfoo3\n2009-01-05 00:00:00\nidx3\n3.0\n1.0\nfoo4\n2009-01-06 00:00:00\nidx4\n4.0\n0.0\nfoo5\n2009-01-07 00:00:00', # noqa + use_inner_text=True + ) + + # Check that the default layout is fitDataTable + assert widget.layout == 'fit_data_table' + assert table.get_attribute('tabulator-layout') == 'fitDataTable' + + # Check the table has the right number of rows + rows = page.locator('.tabulator-row') + assert rows.count() == nrows + + # Check that the hidden _index column is added by Panel + cols = page.locator(".tabulator-col") + assert cols.count() == expected_ncols + assert cols.nth(0).get_attribute('tabulator-field') == '_index' + assert cols.nth(0).is_hidden() + + # Check that the first visible is the index column + assert widget.show_index + assert page.locator('text="index"').is_visible() + assert cols.nth(1).is_visible() + + # Check that the columns are sortable by default + assert page.locator(".tabulator-sortable").count() == expected_ncols + # And that none of them is sorted on start + for i in range(expected_ncols): + assert cols.nth(i).get_attribute('aria-sort') == 'none' + + +def test_tabulator_buttons_display(page, port, df_mixed): + nrows, ncols = df_mixed.shape + icon_text = 'icon' + widget = Tabulator(df_mixed, buttons={'Print': icon_text}) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + expected_ncols = ncols + 3 # _index + index + data columns + button col + + # Check that an additional column has been added to the table + # with no header title + cols = page.locator(".tabulator-col") + expect(cols).to_have_count(expected_ncols) + button_col_idx = expected_ncols - 1 + assert not cols.nth(button_col_idx).get_attribute('tabulator-field') + assert cols.nth(button_col_idx).inner_text() == '\xa0' + assert cols.nth(button_col_idx).is_visible() + + # Check the button column has the right content + icons = page.locator(f'text="{icon_text}"') + assert icons.all_inner_texts() == [icon_text] * nrows + + # Check the buttons are centered + for i in range(icons.count()): + assert 'text-align: center' in icons.nth(i).get_attribute('style') + + +def test_tabulator_buttons_event(page, port, df_mixed): + button_col_name = 'Print' + widget = Tabulator(df_mixed, buttons={button_col_name: 'icon'}) + + state = [] + expected_state = [(button_col_name, 0, None)] + + def cb(e): + state.append((e.column, e.row, e.value)) + + widget.on_click(cb) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + icon = page.locator("text=icon").first + icon.wait_for() + # Click on the first button + icon.click() + assert wait_until(lambda: state == expected_state) + + +def test_tabulator_formatters(): + pass + + +def test_tabulator_editors(): + pass + + +def test_tabulator_alignment(): + pass + + +def test_tabulator_styling(): + pass + + +def test_tabulator_theming(): + pass + + +def test_tabulator_selection_selectable_by_default(page, port, df_mixed): + widget = Tabulator(df_mixed) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + assert widget.selectable + # Click on the first row of the index column to select the row + rows = page.locator('.tabulator-row') + c0 = page.locator('text="idx0"') + c0.wait_for() + c0.click() + assert wait_until(lambda: widget.selection == [0]) + assert 'tabulator-selected' in rows.first.get_attribute('class') + for i in range(1, rows.count()): + assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') + + +def test_tabulator_selection_selectable_one_at_a_time(page, port, df_mixed): + widget = Tabulator(df_mixed) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + rows = page.locator('.tabulator-row') + # Click on the first row of the index column to select the row + c0 = page.locator('text="idx0"') + c0.wait_for() + c0.click() + assert wait_until(lambda: widget.selection == [0]) + # Click on the second row should deselect the first one + page.locator('text="idx1"').click() + assert wait_until(lambda: widget.selection == [1]) + for i in range(rows.count()): + if i == 1: + assert 'tabulator-selected' in rows.nth(i).get_attribute('class') + else: + assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') + # Clicking again on the second row should not change anything + page.locator('text="idx1"').click() + assert wait_until(lambda: widget.selection == [1]) + for i in range(rows.count()): + if i == 1: + assert 'tabulator-selected' in rows.nth(i).get_attribute('class') + else: + assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') + + +def test_tabulator_selection_selectable_ctrl(page, port, df_mixed): + widget = Tabulator(df_mixed) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + rows = page.locator('.tabulator-row') + # Click on the first row of the index column to select the row + c0 = page.locator('text="idx0"') + c0.wait_for() + c0.click() + # Click on the thrid row with CTRL pressed should add that row to the selection + modifier = get_ctrl_modifier() + page.locator("text=idx2").click(modifiers=[modifier]) + expected_selection = [0, 2] + assert wait_until(lambda: widget.selection == expected_selection) + for i in range(rows.count()): + if i in expected_selection: + assert 'tabulator-selected' in rows.nth(i).get_attribute('class') + else: + assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') + # Clicking again on the third row with CTRL pressed should remove the row from the selection + page.locator("text=idx2").click(modifiers=[modifier]) + expected_selection = [0] + assert wait_until(lambda: widget.selection == expected_selection) + for i in range(rows.count()): + if i in expected_selection: + assert 'tabulator-selected' in rows.nth(i).get_attribute('class') + else: + assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') + + +def test_tabulator_selection_selectable_shift(page, port, df_mixed): + widget = Tabulator(df_mixed) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + rows = page.locator('.tabulator-row') + # Click on the first row of the index column to select the row + c0 = page.locator('text="idx0"') + c0.wait_for() + c0.click() + # Click on the thrid row with SHIFT pressed should select the 2nd row too + page.locator("text=idx2").click(modifiers=['Shift']) + expected_selection = [0, 1, 2] + assert wait_until(lambda: widget.selection == expected_selection) + for i in range(rows.count()): + if i in expected_selection: + assert 'tabulator-selected' in rows.nth(i).get_attribute('class') + else: + assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') + + +def test_tabulator_selection_selectable_disabled(page, port, df_mixed): + widget = Tabulator(df_mixed, selectable=False) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # Click on the first row of the index column + rows = page.locator('.tabulator-row') + c0 = page.locator('text="idx0"') + c0.wait_for() + c0.click() + # Wait for a potential selection event to be propagated, this should not + # be the case. + time.sleep(0.2) + assert widget.selection == [] + for i in range(rows.count()): + assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') + + +def test_tabulator_selection_default_selection(page, port, df_mixed): + selection = [0, 2] + widget = Tabulator(df_mixed, selection=[0, 2]) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + rows = page.locator('.tabulator-row') + + # Check that the rows in the selection are selected in the front-end + for i in range(rows.count()): + if i in selection: + assert 'tabulator-selected' in rows.nth(i).get_attribute('class') + else: + assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') + + +def test_tabulator_selection_selectable_checkbox_all(page, port, df_mixed): + widget = Tabulator(df_mixed, selectable='checkbox') + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # Select the first checkbox and check it + checkboxes = page.locator('input[type="checkbox"]') + checkboxes.first.wait_for() + checkboxes.first.check() + # All the checkboxes should be checked + for i in range(checkboxes.count()): + assert checkboxes.nth(i).is_checked() + # And all the rows should be selected + rows = page.locator('.tabulator-row') + for i in range(rows.count()): + assert 'tabulator-selected' in rows.nth(i).get_attribute('class') + # The selection should have all the indexes + wait_until(lambda: widget.selection == list(range(len(df_mixed)))) + + +def test_tabulator_selection_selectable_checkbox_multiple(page, port, df_mixed): + widget = Tabulator(df_mixed, selectable='checkbox') + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + checkboxes = page.locator('input[type="checkbox"]') + checkboxes.first.wait_for() + checkboxes.nth(1).check() + checkboxes.last.check() + + expected_selection = [0, len(df_mixed) - 1] + + for i in range(1, checkboxes.count()): + if (i - 1) in expected_selection: + assert checkboxes.nth(i).is_checked() + else: + assert not checkboxes.nth(i).is_checked() + + rows = page.locator('.tabulator-row') + for i in range(rows.count()): + if i in expected_selection: + assert 'tabulator-selected' in rows.nth(i).get_attribute('class') + else: + assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') + + wait_until(lambda: widget.selection == expected_selection) + + +def test_tabulator_selection_selectable_checkbox_single(page, port, df_mixed): + widget = Tabulator(df_mixed, selectable='checkbox-single') + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + checkboxes = page.locator('input[type="checkbox"]') + expect(checkboxes).to_have_count(len(df_mixed)) + checkboxes.first.check() + checkboxes.last.check() + + expected_selection = [0, len(df_mixed) - 1] + + for i in range(checkboxes.count()): + if i in expected_selection: + assert checkboxes.nth(i).is_checked() + else: + assert not checkboxes.nth(i).is_checked() + + rows = page.locator('.tabulator-row') + for i in range(rows.count()): + if i in expected_selection: + assert 'tabulator-selected' in rows.nth(i).get_attribute('class') + else: + assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') + + wait_until(lambda: widget.selection == expected_selection) + + +def test_tabulator_selection_selectable_toggle(page, port, df_mixed): + widget = Tabulator(df_mixed, selectable='toggle') + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + rows = page.locator('.tabulator-row') + # Click on the first row of the index column to select the row + c0 = page.locator('text="idx0"') + c0.wait_for() + c0.click() + for i in range(rows.count()): + if i == 0: + assert 'tabulator-selected' in rows.nth(i).get_attribute('class') + else: + assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') + assert wait_until(lambda: widget.selection == [0]) + # Click on the second row, the first row should still be selected + page.locator('text="idx1"').click() + for i in range(rows.count()): + if i in [0, 1]: + assert 'tabulator-selected' in rows.nth(i).get_attribute('class') + else: + assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') + assert wait_until(lambda: widget.selection == [0, 1]) + # Click on a selected row deselect it + page.locator('text="idx1"').click() + for i in range(rows.count()): + if i == 0: + assert 'tabulator-selected' in rows.nth(i).get_attribute('class') + else: + assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') + assert wait_until(lambda: widget.selection == [0]) + + +def test_tabulator_selection_selectable_rows(): + pass From 90c43bc7dadc6887a9c3d87da8d3e83d146a28b7 Mon Sep 17 00:00:00 2001 From: maximlt Date: Sat, 18 Jun 2022 09:02:15 +0200 Subject: [PATCH 03/29] control modifier on windows --- panel/tests/ui/widgets/test_tabulator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index 8373862f9a..1c5155129c 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -36,7 +36,7 @@ def wait_until(fn, timeout=5000, interval=100): def get_ctrl_modifier(): - if sys.platform == 'linux': + if sys.platform in ['linux', 'win32']: return 'Control' elif sys.platform == 'darwin': return 'Meta' From b13053e8fada5dbc9e4aff975fb7e670afee4825 Mon Sep 17 00:00:00 2001 From: maximlt Date: Sat, 18 Jun 2022 09:05:40 +0200 Subject: [PATCH 04/29] guard playwright import --- panel/tests/ui/widgets/test_tabulator.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index 1c5155129c..bebbb1d89c 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -3,7 +3,10 @@ import pytest -from playwright.sync_api import expect +try: + from playwright.sync_api import expect +except ImportError: + pass pytestmark = pytest.mark.ui From 834d3a952524c8e5007ad2c4e3f8578e32e163ae Mon Sep 17 00:00:00 2001 From: maximlt Date: Wed, 22 Jun 2022 02:25:30 +0200 Subject: [PATCH 05/29] add coverage to the UI tests --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index d521823190..f59056b2c4 100644 --- a/tox.ini +++ b/tox.ini @@ -24,7 +24,7 @@ commands = pytest panel [_ui] description = Run UI tests deps = .[tests, ui] -commands = pytest panel --ui --browser chromium +commands = pytest panel --cov=./panel --ui --browser chromium [_examples] description = Test that default examples run From a629b4d6127a1ad63bcffcb63a9433b88c8f0081 Mon Sep 17 00:00:00 2001 From: maximlt Date: Wed, 22 Jun 2022 02:25:40 +0200 Subject: [PATCH 06/29] add formatters ui tests --- panel/tests/ui/widgets/test_tabulator.py | 175 ++++++++++++++++++++++- 1 file changed, 169 insertions(+), 6 deletions(-) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index bebbb1d89c..4b6b27e8b6 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -1,8 +1,14 @@ +import datetime as dt import sys import time import pytest +from bokeh.models.widgets.tables import ( + BooleanFormatter, DateFormatter, HTMLTemplateFormatter, NumberFormatter, + ScientificFormatter, StringFormatter, +) + try: from playwright.sync_api import expect except ImportError: @@ -11,7 +17,12 @@ pytestmark = pytest.mark.ui try: - from pandas._testing import makeMixedDataFrame + import numpy as np +except ImportError: + pytestmark = pytest.mark.skip('numpy not available') + +try: + import pandas as pd except ImportError: pytestmark = pytest.mark.skip('pandas not available') @@ -22,8 +33,14 @@ @pytest.fixture def df_mixed(): - df = makeMixedDataFrame() - df.index = [f'idx{i}' for i in range(len(df))] + df = pd.DataFrame({ + 'int': [1, 2, 3, 4], + 'float': [3.14, 6.28, 9.42, -2.45], + 'str': ['A', 'B', 'C', 'D'], + 'bool': [True, True, True, False], + 'date': [dt.date(2019, 1, 1), dt.date(2020, 1, 1), dt.date(2020, 1, 10), dt.date(2019, 1, 10)], + 'datetime': [dt.datetime(2019, 1, 1, 10), dt.datetime(2020, 1, 1, 12), dt.datetime(2020, 1, 10, 13), dt.datetime(2020, 1, 15, 13)] + }, index=['idx0', 'idx1', 'idx2', 'idx3']) return df @@ -62,7 +79,7 @@ def test_tabulator_default(page, port, df_mixed): # Check that the whole table content is on the page table = page.locator('.bk.pnx-tabulator.tabulator') expect(table).to_have_text( - 'index\nA\nB\nC\nD\nidx0\n0.0\n0.0\nfoo1\n2009-01-01 00:00:00\nidx1\n1.0\n1.0\nfoo2\n2009-01-02 00:00:00\nidx2\n2.0\n0.0\nfoo3\n2009-01-05 00:00:00\nidx3\n3.0\n1.0\nfoo4\n2009-01-06 00:00:00\nidx4\n4.0\n0.0\nfoo5\n2009-01-07 00:00:00', # noqa + 'index\nint\nfloat\nstr\nbool\ndate\ndatetime\nidx0\n1\n3.14\nA\ntrue\n2019-01-01\n2019-01-01 10:00:00\nidx1\n2\n6.28\nB\ntrue\n2020-01-01\n2020-01-01 12:00:00\nidx2\n3\n9.42\nC\ntrue\n2020-01-10\n2020-01-10 13:00:00\nidx3\n4\n-2.45\nD\nfalse\n2019-01-10\n2020-01-15 13:00:00', # noqa use_inner_text=True ) @@ -147,8 +164,154 @@ def cb(e): assert wait_until(lambda: state == expected_state) -def test_tabulator_formatters(): - pass +def test_tabulator_formatters_bokeh_bool(page, port, df_mixed): + s = [True] * len(df_mixed) + s[-1] = False + df_mixed['bool'] = s + widget = Tabulator(df_mixed, formatters={'bool': BooleanFormatter()}) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # The BooleanFormatter renders with svg icons. + cells = page.locator(".tabulator-cell", has=page.locator("svg")) + expect(cells).to_have_count(len(df_mixed)) + + for i in range(len(df_mixed) - 1): + assert cells.nth(i).get_attribute('aria-checked') == 'true' + assert cells.last.get_attribute('aria-checked') == 'false' + + +def test_tabulator_formatters_bokeh_date(page, port, df_mixed): + widget = Tabulator( + df_mixed, + formatters={ + 'date': DateFormatter(format='COOKIE'), + 'datetime': DateFormatter(format='%H:%M'), + }, + ) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + expect(page.locator('text="10:00"')).to_have_count(1) + assert page.locator('text="Tue, 01 Jan 2019"').count() == 1 + + +@pytest.mark.xfail( + reason='NaNs not well handled by the DateFormatter with datetime.date objects.' + ' See https://github.com/bokeh/bokeh/issues/12187' +) +def test_tabulator_formatters_bokeh_date_with_nan(page, port, df_mixed): + df_mixed.loc['idx1', 'date'] = np.nan + df_mixed.loc['idx1', 'datetime'] = np.nan + widget = Tabulator( + df_mixed, + formatters={ + 'date': DateFormatter(format='COOKIE', nan_format='nan-date'), + 'datetime': DateFormatter(format='%H:%M', nan_format= 'nan-datetime'), + }, + ) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + expect(page.locator('text="10:00"')).to_have_count(1) + assert page.locator('text="Tue, 01 Jan 2019"').count() == 1 # This should fail + assert page.locator('text="nan-date"').count() == 1 + assert page.locator('text="nan-datetime"').count() == 1 + + +def test_tabulator_formatters_bokeh_number(page, port, df_mixed): + df_mixed.loc['idx1', 'int'] = np.nan + df_mixed.loc['idx1', 'float'] = np.nan + widget = Tabulator( + df_mixed, + formatters={ + 'int': NumberFormatter(format='0.000', nan_format='nan-int'), + 'float': NumberFormatter(format='0.000', nan_format='nan-float'), + }, + ) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + expect(page.locator('text="1.000"')).to_have_count(1) + assert page.locator('text="3.140"').count() == 1 + assert page.locator('text="nan-int"').count() == 1 + assert page.locator('text="nan-float"').count() == 1 + + +def test_tabulator_formatters_bokeh_string(page, port, df_mixed): + widget = Tabulator( + df_mixed, + formatters={ + 'str': StringFormatter(font_style='bold', text_align='center', text_color='red'), + }, + ) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + expect(page.locator('text="A"')).to_have_attribute( + "style", + "font-weight: bold; text-align: center; color: rgb(255, 0, 0);" + ) + + +def test_tabulator_formatters_bokeh_html(page, port, df_mixed): + widget = Tabulator( + df_mixed, + formatters={ + 'str': HTMLTemplateFormatter(template='

<%= value %>

'), + }, + ) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + expect(page.locator('text="A"')).to_have_attribute( + "style", + "font-weight: bold;" + ) + + +def test_tabulator_formatters_bokeh_scientific(page, port, df_mixed): + df_mixed['float'] = df_mixed['float'] * 1e6 + df_mixed.loc['idx1', 'float'] = np.nan + widget = Tabulator( + df_mixed, + formatters={ + 'float': ScientificFormatter(precision=3, nan_format='nan-float'), + }, + ) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + expect(page.locator('text="3.140e+6"')).to_have_count(1) + assert page.locator('text="nan-float"').count() == 1 def test_tabulator_editors(): From 40b2a0c551b30924b489ebb1c2b01fa81e02ed2a Mon Sep 17 00:00:00 2001 From: maximlt Date: Sun, 26 Jun 2022 16:12:49 +0200 Subject: [PATCH 07/29] add more tests, including xfail ones --- panel/tests/ui/widgets/test_tabulator.py | 360 ++++++++++++++++++++++- panel/tests/widgets/test_tables.py | 29 +- 2 files changed, 385 insertions(+), 4 deletions(-) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index 4b6b27e8b6..dd1066a847 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -44,6 +44,18 @@ def df_mixed(): return df +@pytest.fixture +def df_multiindex(df_mixed): + df_mi = df_mixed.copy() + df_mi.index = pd.MultiIndex.from_tuples([ + ('group0', 'subgroup0'), + ('group0', 'subgroup1'), + ('group1', 'subgroup0'), + ('group1', 'subgroup1'), + ], names=['groups', 'subgroups']) + return df_mi + + def wait_until(fn, timeout=5000, interval=100): while timeout > 0: if fn(): @@ -64,6 +76,23 @@ def get_ctrl_modifier(): raise ValueError(f'No control modifier defined for platform {sys.platform}') +def count_per_page(count: int, page_size: int): + """ + >>> count_per_page(12, 7) + [7, 5] + """ + original_count = count + count_per_page = [] + while True: + page_count = min(count, page_size) + count_per_page.append(page_count) + count -= page_count + if count == 0: + break + assert sum(count_per_page) == original_count + return count_per_page + + def test_tabulator_default(page, port, df_mixed): nrows, ncols = df_mixed.shape widget = Tabulator(df_mixed) @@ -109,6 +138,39 @@ def test_tabulator_default(page, port, df_mixed): assert cols.nth(i).get_attribute('aria-sort') == 'none' +def test_tabulator_value_changed(page, port, df_mixed): + widget = Tabulator(df_mixed) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + df_mixed.loc['idx0', 'str'] = 'AA' + # Need to trigger the value as the dataframe was modified + # in place which is not detected. + widget.param.trigger('value') + changed_cell = page.locator('text="AA"') + expect(changed_cell).to_have_count(1) + + +def test_tabulator_disabled(page, port, df_mixed): + widget = Tabulator(df_mixed, disabled=True) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + cell = page.locator('text="A"') + cell.click() + # If the cell was editable then this input element should + # be found. + expect(page.locator('input[type="text"]')).to_have_count(0) + + def test_tabulator_buttons_display(page, port, df_mixed): nrows, ncols = df_mixed.shape icon_text = 'icon' @@ -314,7 +376,19 @@ def test_tabulator_formatters_bokeh_scientific(page, port, df_mixed): assert page.locator('text="nan-float"').count() == 1 -def test_tabulator_editors(): +def test_tabulator_formatters_tabulator(): + pass + + +def test_tabulator_editors_bokeh(): + pass + + +def test_tabulator_editors_tabulator(): + pass + + +def test_tabulator_column_layouts(): pass @@ -612,5 +686,287 @@ def test_tabulator_selection_selectable_toggle(page, port, df_mixed): assert wait_until(lambda: widget.selection == [0]) -def test_tabulator_selection_selectable_rows(): +def test_tabulator_selection_selectable_rows(page, port, df_mixed): + widget = Tabulator(df_mixed, selectable_rows=lambda df: [1]) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + rows = page.locator('.tabulator-row') + # Click on the first row of the index column to select the row + c1 = page.locator('text="idx1"') + c1.wait_for() + c1.click() + assert wait_until(lambda: widget.selection == [1]) + # Click on the first row with CTRL pressed should add that row to the selection + modifier = get_ctrl_modifier() + page.locator("text=idx0").click(modifiers=[modifier]) + time.sleep(0.2) + assert widget.selection == [1] + for i in range(rows.count()): + if i == 1: + assert 'tabulator-selected' in rows.nth(i).get_attribute('class') + else: + assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') + + +def test_tabulator_selection_row_content(page, port, df_mixed): + widget = Tabulator(df_mixed, row_content=lambda i: f"{i['str']}-row-content") + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + openables = page.locator('text="►"') + expect(openables).to_have_count(len(df_mixed)) + + expected_expanded = [] + for i in range(len(df_mixed)): + openables = page.locator('text="►"') + openables.first.click() + row_content = page.locator(f'text="{df_mixed.iloc[i]["str"]}-row-content"') + expect(row_content).to_have_count(1) + closables = page.locator('text="▼"') + expect(closables).to_have_count(i + 1) + assert row_content.is_visible() + expected_expanded.append(i) + wait_until(lambda: widget.expanded == expected_expanded) + + for i in range(len(df_mixed)): + closables = page.locator('text="▼"') + closables.first.click() + row_content = page.locator(f'text="{df_mixed.iloc[i]["str"]}-row-content"') + expect(row_content).to_have_count(0) # timeout here? + expected_expanded.remove(i) + wait_until(lambda: widget.expanded == expected_expanded) + + +def test_tabulator_selection_row_content_expand_from_python_init(page, port, df_mixed): + widget = Tabulator( + df_mixed, + row_content=lambda i: f"{i['str']}-row-content", + expanded = [0, 2], + ) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + for i in range(len(df_mixed)): + row_content = page.locator(f'text="{df_mixed.iloc[i]["str"]}-row-content"') + if i in widget.expanded: + expect(row_content).to_have_count(1) + else: + expect(row_content).to_have_count(0) + + openables = page.locator('text="►"') + closables = page.locator('text="▼"') + assert closables.count() == len(widget.expanded) + assert openables.count() == len(df_mixed) - len(widget.expanded) + + +@pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3646') +def test_tabulator_selection_row_content_expand_from_python_after(page, port, df_mixed): + widget = Tabulator(df_mixed, row_content=lambda i: f"{i['str']}-row-content") + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # Expanding the rows after the server is launched + widget.expanded = [0, 2] + + for i in range(len(df_mixed)): + row_content = page.locator(f'text="{df_mixed.iloc[i]["str"]}-row-content"') + if i in widget.expanded: + expect(row_content).to_have_count(1) + else: + expect(row_content).to_have_count(0) + + openables = page.locator('text="►"') + closables = page.locator('text="▼"') + # Error here + assert closables.count() == len(widget.expanded) + assert openables.count() == len(df_mixed) - len(widget.expanded) + # End of error + + widget.expanded = [] + + openables = page.locator('text="►"') + closables = page.locator('text="▼"') + assert closables.count() == 0 + assert openables.count() == len(df_mixed) + + +def test_tabulator_grouping(): + pass + + +def test_tabulator_groupby(): pass + + +@pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3564') +def test_tabulator_hierarchical(page, port, df_multiindex): + widget = Tabulator(df_multiindex, hierarchical=True) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + expect(page.locator('text="Index: groups | subgroups"')).to_have_count(1) + + for i in range(len(df_multiindex.index.get_level_values(0).unique())): + gr = page.locator(f'text="group{i}"') + expect(gr).to_have_count(1) + assert gr.is_visible() + for i in range(len(df_multiindex.index.get_level_values(1).unique())): + subgr = page.locator(f'text="subgroup{i}"') + expect(subgr).to_have_count(0) + + page.locator("text=group1 >> div").first.click() + + for i in range(len(df_multiindex.index.get_level_values(1).unique())): + subgr = page.locator(f'text="subgroup{i}"') + expect(subgr).to_have_count(1) + assert subgr.is_visible() + + +def test_tabulator_cell_click_event(page, port, df_mixed): + widget = Tabulator(df_mixed) + + values = [] + widget.on_click(lambda e: values.append((e.column, e.row, e.value))) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + page.locator('text="idx0"').click() + wait_until(lambda: values[0] == ('index', 0, 'idx0')) + page.locator('text="A"').click() + wait_until(lambda: values[0] == ('str', 0, 'A')) + + +def test_tabulator_edit_event(page, port, df_mixed): + widget = Tabulator(df_mixed) + + values = [] + widget.on_edit(lambda e: values.append((e.column, e.row, e.old, e.value))) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + cell = page.locator('text="A"') + cell.click() + editable_cell = page.locator('input[type="text"]') + editable_cell.fill("AA") + editable_cell.press('Enter') + + wait_until(lambda: values[0] == ('str', 0, 'A', 'AA')) + assert df_mixed.at['idx0', 'str'] == 'AA' + +@pytest.mark.parametrize( + 'pagination', + [ + 'remote', + pytest.param('local', marks=pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3647')), + ], +) +def test_tabulator_pagination(page, port, df_mixed, pagination): + page_size = 2 + widget = Tabulator(df_mixed, pagination=pagination, page_size=page_size) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + counts = count_per_page(len(df_mixed), page_size) + i = 0 + while True: + wait_until(lambda: widget.page == i + 1) + rows = page.locator('.tabulator-row') + expect(rows).to_have_count(counts[i]) + assert page.locator(f'[aria-label="Show Page {i+1}"]').count() == 1 + df_page = df_mixed.iloc[i * page_size: (i + 1) * page_size] + for idx in df_page.index: + assert page.locator(f'text="{idx}"').count() == 1 + if i < len(counts) - 1: + page.locator(f'[aria-label="Show Page {i+2}"]').click() + i += 1 + else: + break + + +def test_tabulator_filter_constant_scalar(page, port, df_mixed): + widget = Tabulator(df_mixed) + + widget.add_filter('A', 'str') + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # Check the table has the right number of rows + expect(page.locator('.tabulator-row')).to_have_count(1) + + assert page.locator('text="A"').count() == 1 + assert page.locator('text="B"').count() == 0 + + +def test_tabulator_filter_constant_list(page, port, df_mixed): + widget = Tabulator(df_mixed) + + widget.add_filter(['A', 'B'], 'str') + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # Check the table has the right number of rows + expect(page.locator('.tabulator-row')).to_have_count(2) + + assert page.locator('text="A"').count() == 1 + assert page.locator('text="B"').count() == 1 + assert page.locator('text="C"').count() == 0 + + +def test_tabulator_filter_constant_tuple_range(page, port, df_mixed): + widget = Tabulator(df_mixed) + + widget.add_filter((1, 2), 'int') + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # Check the table has the right number of rows + expect(page.locator('.tabulator-row')).to_have_count(2) + + assert page.locator('text="A"').count() == 1 + assert page.locator('text="B"').count() == 1 + assert page.locator('text="C"').count() == 0 diff --git a/panel/tests/widgets/test_tables.py b/panel/tests/widgets/test_tables.py index 7e9d0b20cf..63f230f364 100644 --- a/panel/tests/widgets/test_tables.py +++ b/panel/tests/widgets/test_tables.py @@ -568,6 +568,20 @@ def test_tabulator_selectable_rows(document, comm): assert model.selectable_rows == [3, 4] +@pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3644') +def test_tabulator_selectable_rows_nonallowed_selection_error(document, comm): + df = makeMixedDataFrame() + table = Tabulator(df, selectable_rows=lambda df: [1]) + + model = table.get_root(document, comm) + + assert model.selectable_rows == [1] + + # + with pytest.raises(ValueError): + table.selection = [0] + + def test_tabulator_pagination(document, comm): df = makeMixedDataFrame() table = Tabulator(df, pagination='remote', page_size=2) @@ -1557,13 +1571,23 @@ def filter_c(df, value): for col, values in model.source.data.items(): np.testing.assert_array_equal(values, expected[col]) -def test_tabulator_constant_tuple_filter(document, comm): + +@pytest.mark.parametrize( + 'col', + [ + 'A', + pytest.param('B', marks=pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3650')), + pytest.param('C', marks=pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3650')), + pytest.param('D', marks=pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3650')), + ], +) +def test_tabulator_constant_tuple_filter(document, comm, col): df = makeMixedDataFrame() table = Tabulator(df) model = table.get_root(document, comm) - table.add_filter((2, 3), 'A') + table.add_filter((2, 3), col) expected = { 'index': np.array([2, 3]), @@ -1577,6 +1601,7 @@ def test_tabulator_constant_tuple_filter(document, comm): for col, values in model.source.data.items(): np.testing.assert_array_equal(values, expected[col]) + def test_tabulator_stream_dataframe_with_filter(document, comm): df = makeMixedDataFrame() table = Tabulator(df) From 8d61f5e43771a4c8e3ff4090fa20e24ab2236d9e Mon Sep 17 00:00:00 2001 From: maximlt Date: Tue, 28 Jun 2022 18:29:28 +0200 Subject: [PATCH 08/29] fix f-string --- panel/widgets/tables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/panel/widgets/tables.py b/panel/widgets/tables.py index 5b0fe0199c..6136c5774c 100644 --- a/panel/widgets/tables.py +++ b/panel/widgets/tables.py @@ -647,7 +647,7 @@ def patch(self, patch_value, as_index=True): {'x': [3, 4], 'y': ['c', 'd']} """ if self.value is None: - raise ValueError("Cannot patch empty {type(self).__name__}.") + raise ValueError(f"Cannot patch empty {type(self).__name__}.") import pandas as pd if not isinstance(self.value, pd.DataFrame): From a86c62e48be1c3a6072f75010d7da44975e276e1 Mon Sep 17 00:00:00 2001 From: maximlt Date: Tue, 28 Jun 2022 18:29:59 +0200 Subject: [PATCH 09/29] add codecov step in the ui job --- .github/workflows/test.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d96a72c1f1..3da3a88b36 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -179,3 +179,8 @@ jobs: eval "$(conda shell.bash hook)" conda activate test-environment doit test_ui + - name: codecov + run: | + eval "$(conda shell.bash hook)" + conda activate test-environment + codecov From 7cb7e6e529ada35047f425694e71018a1cb771e2 Mon Sep 17 00:00:00 2001 From: maximlt Date: Tue, 28 Jun 2022 18:30:13 +0200 Subject: [PATCH 10/29] reduce the default timeout --- panel/tests/conftest.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/panel/tests/conftest.py b/panel/tests/conftest.py index 68fbdb465b..babc7105f2 100644 --- a/panel/tests/conftest.py +++ b/panel/tests/conftest.py @@ -35,6 +35,12 @@ def pytest_configure(config): else: setattr(config.option, 'markexpr', 'not ui') +@pytest.fixture +def context(context): + # Reduce the default timeout to 5 secs + context.set_default_timeout(5000) + yield context + PORT = [6000] @pytest.fixture From d2d4bf2564d0989bb2422e5d664a01c81064c79a Mon Sep 17 00:00:00 2001 From: maximlt Date: Tue, 28 Jun 2022 18:31:25 +0200 Subject: [PATCH 11/29] add tests --- panel/tests/ui/widgets/test_tabulator.py | 463 ++++++++++++++++++++++- panel/tests/widgets/test_tables.py | 15 +- 2 files changed, 465 insertions(+), 13 deletions(-) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index dd1066a847..c2cf0c08fe 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -12,7 +12,7 @@ try: from playwright.sync_api import expect except ImportError: - pass + pytestmark = pytest.mark.skip('playwright not available') pytestmark = pytest.mark.ui @@ -26,7 +26,7 @@ except ImportError: pytestmark = pytest.mark.skip('pandas not available') - +from panel import state from panel.io.server import serve from panel.widgets import Tabulator @@ -171,6 +171,33 @@ def test_tabulator_disabled(page, port, df_mixed): expect(page.locator('input[type="text"]')).to_have_count(0) +def test_tabulator_show_index_disabled(page, port, df_mixed): + widget = Tabulator(df_mixed, show_index=False) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + expect(page.locator('text="index"')).to_have_count(0) + + +def test_tabulator_titles(page, port, df_mixed): + titles = {col: col.upper() for col in df_mixed.columns} + widget = Tabulator(df_mixed, titles=titles) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + for col in df_mixed.columns: + expected_title = titles[col] + expect(page.locator(f'text="{expected_title}"')).to_have_count(1) + + def test_tabulator_buttons_display(page, port, df_mixed): nrows, ncols = df_mixed.shape icon_text = 'icon' @@ -396,12 +423,41 @@ def test_tabulator_alignment(): pass -def test_tabulator_styling(): +def test_tabulator_frozen_columns(): pass -def test_tabulator_theming(): - pass +@pytest.mark.parametrize('theme', Tabulator.param['theme'].objects) +def test_tabulator_theming(page, port, df_mixed, theme): + # Subscribe the reponse events to check that the CSS is loaded + responses = [] + page.on("response", lambda response: responses.append(response)) + widget = Tabulator(df_mixed, theme=theme) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # Check that the whole table content is on the page + table = page.locator('.bk.pnx-tabulator.tabulator') + expect(table).to_have_text( + 'index\nint\nfloat\nstr\nbool\ndate\ndatetime\nidx0\n1\n3.14\nA\ntrue\n2019-01-01\n2019-01-01 10:00:00\nidx1\n2\n6.28\nB\ntrue\n2020-01-01\n2020-01-01 12:00:00\nidx2\n3\n9.42\nC\ntrue\n2020-01-10\n2020-01-10 13:00:00\nidx3\n4\n-2.45\nD\nfalse\n2019-01-10\n2020-01-15 13:00:00', # noqa + use_inner_text=True + ) + found = False + for response in responses: + base = response.url.split('/')[-1] + if base == f'tabulator_{theme}.min.css': + found = True + break + # default theme + elif base == 'tabulator.min.css': + found = True + break + assert found + assert response.status def test_tabulator_selection_selectable_by_default(page, port, df_mixed): @@ -835,7 +891,8 @@ def test_tabulator_hierarchical(page, port, df_multiindex): subgr = page.locator(f'text="subgroup{i}"') expect(subgr).to_have_count(0) - page.locator("text=group1 >> div").first.click() + # This fails + page.locator("text=group1 >> div").first.click(timeout=2000) for i in range(len(df_multiindex.index.get_level_values(1).unique())): subgr = page.locator(f'text="subgroup{i}"') @@ -856,9 +913,9 @@ def test_tabulator_cell_click_event(page, port, df_mixed): page.goto(f"http://localhost:{port}") page.locator('text="idx0"').click() - wait_until(lambda: values[0] == ('index', 0, 'idx0')) + wait_until(lambda: values[-1] == ('index', 0, 'idx0')) page.locator('text="A"').click() - wait_until(lambda: values[0] == ('str', 0, 'A')) + wait_until(lambda: values[-1] == ('str', 0, 'A')) def test_tabulator_edit_event(page, port, df_mixed): @@ -919,7 +976,8 @@ def test_tabulator_pagination(page, port, df_mixed, pagination): def test_tabulator_filter_constant_scalar(page, port, df_mixed): widget = Tabulator(df_mixed) - widget.add_filter('A', 'str') + fltr, col = 'A', 'str' + widget.add_filter(fltr, col) serve(widget, port=port, threaded=True, show=False) @@ -933,11 +991,15 @@ def test_tabulator_filter_constant_scalar(page, port, df_mixed): assert page.locator('text="A"').count() == 1 assert page.locator('text="B"').count() == 0 + expected_current_view = df_mixed.loc[ df_mixed[col] == fltr, :] + assert widget.current_view.equals(expected_current_view) + def test_tabulator_filter_constant_list(page, port, df_mixed): widget = Tabulator(df_mixed) - widget.add_filter(['A', 'B'], 'str') + fltr, col = ['A', 'B'], 'str' + widget.add_filter(fltr, col) serve(widget, port=port, threaded=True, show=False) @@ -952,11 +1014,15 @@ def test_tabulator_filter_constant_list(page, port, df_mixed): assert page.locator('text="B"').count() == 1 assert page.locator('text="C"').count() == 0 + expected_current_view = df_mixed.loc[df_mixed[col].isin(fltr), :] + assert widget.current_view.equals(expected_current_view) + def test_tabulator_filter_constant_tuple_range(page, port, df_mixed): widget = Tabulator(df_mixed) - widget.add_filter((1, 2), 'int') + fltr, col = (1, 2), 'int' + widget.add_filter(fltr, col) serve(widget, port=port, threaded=True, show=False) @@ -970,3 +1036,378 @@ def test_tabulator_filter_constant_tuple_range(page, port, df_mixed): assert page.locator('text="A"').count() == 1 assert page.locator('text="B"').count() == 1 assert page.locator('text="C"').count() == 0 + + expected_current_view = df_mixed.loc[(df_mixed[col] >= fltr[0]) & (df_mixed[col] <= fltr[1]), : ] + assert widget.current_view.equals(expected_current_view) + + +@pytest.mark.parametrize( + 'cols', + [ + ['int', 'float', 'str', 'bool'], + pytest.param(['date', 'datetime'], marks=pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3655')), + ], +) +def test_tabulator_header_filters_default(page, port, df_mixed, cols): + df_mixed = df_mixed[cols] + widget = Tabulator(df_mixed, header_filters=True) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # Check that all the columns have a header filter, including the index column + expect(page.locator('.tabulator-header-filter')).to_have_count(len(cols) + 1) + + # Check the table has the right number of rows, i.e. no filter is applied by default + assert page.locator('.tabulator-row').count() == len(df_mixed) + + assert widget.filters == [] + assert widget.current_view.equals(df_mixed) + + +@pytest.mark.parametrize( + ('index', 'expected_selector'), + ( + (['idx0', 'idx1'], 'input[type="search"]'), + ([0, 1], 'input[type="number"]'), + (np.array([0, 1], dtype=np.uint64), 'input[type="number"]'), + ([0.1, 1.1], 'input[type="number"]'), + # ([True, False], 'input[type="checkbox"]'), # Pandas cannot have boolean indexes apparently + ), +) +def test_tabulator_header_filters_default_index(page, port, index, expected_selector): + df = pd.DataFrame(index=index) + widget = Tabulator(df, header_filters=True) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # The number columns (unit, int and float) are expected to have a number input + expect(page.locator(expected_selector)).to_have_count(1) + + +def test_tabulator_header_filters_init_from_editors(page, port, df_mixed): + df_mixed = df_mixed[['float']] + editors = { + 'float': {'type': 'number', 'step': 0.5}, + 'str': {'type': 'autocomplete', 'values': True} + } + widget = Tabulator(df_mixed, header_filters=True, editors=editors) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + number_header = page.locator('input[type="number"]') + expect(number_header).to_have_count(1) + assert number_header.get_attribute('step') == '0.5' + + +def test_tabulator_header_filters_init_explicitely(page, port, df_mixed): + header_filters = { + 'float': {'type': 'number', 'func': '>=', 'placeholder': 'Placeholder float'}, + 'str': {'type': 'input', 'func': 'like', 'placeholder': 'Placeholder str'}, + } + widget = Tabulator(df_mixed, header_filters=header_filters) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # Check that only the columns explicitely given a header filter spec have a header filter + expect(page.locator('.tabulator-header-filter')).to_have_count(len(header_filters)) + + number_header = page.locator('input[type="number"]') + expect(number_header).to_have_count(1) + assert number_header.get_attribute('placeholder') == 'Placeholder float' + str_header = page.locator('input[type="search"]') + expect(str_header).to_have_count(1) + assert str_header.get_attribute('placeholder') == 'Placeholder str' + + +def test_tabulator_header_filters_set_from_client(page, port, df_mixed): + header_filters = { + 'float': {'type': 'number', 'func': '>=', 'placeholder': 'Placeholder float'}, + 'str': {'type': 'input', 'func': 'like', 'placeholder': 'Placeholder str'}, + } + widget = Tabulator(df_mixed, header_filters=header_filters) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + number_header = page.locator('input[type="number"]') + number_header.click() + val, cmp, col = '0', '>=', 'float' + number_header.fill(val) + number_header.press('Enter') + query1 = f'{col} {cmp} {val}' + expected_filter_df = df_mixed.query(query1) + expected_filter1 = {'field': col, 'type': cmp, 'value': val} + expect(page.locator('.tabulator-row')).to_have_count(len(expected_filter_df)) + wait_until(lambda: widget.filters == [expected_filter1]) + assert widget.current_view.equals(expected_filter_df) + + str_header = page.locator('input[type="search"]') + str_header.click() + val, cmp, col = 'A', 'like', 'str' + str_header.fill(val) + str_header.press('Enter') + query2 = f'{col} == {val!r}' + expected_filter_df = df_mixed.query(f'{query1} and {query2}') + expected_filter2 = {'field': col, 'type': cmp, 'value': val} + expect(page.locator('.tabulator-row')).to_have_count(len(expected_filter_df)) + wait_until(lambda: widget.filters == [expected_filter1, expected_filter2]) + assert widget.current_view.equals(expected_filter_df) + + +def test_tabulator_downloading(): + pass + +def test_tabulator_streaming_default(page, port): + df = pd.DataFrame(np.random.random((3, 2)), columns=['A', 'B']) + widget = Tabulator(df) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + expect(page.locator('.tabulator-row')).to_have_count(len(df)) + + height_start = page.locator('.bk.pnx-tabulator.tabulator').bounding_box()['height'] + + + def stream_data(): + widget.stream(df) # follow is True by default + + repetitions = 3 + state.add_periodic_callback(stream_data, period=100, count=repetitions) + + expected_len = len(df) * (repetitions + 1) + expect(page.locator('.tabulator-row')).to_have_count(expected_len) + assert len(widget.value) == expected_len + assert widget.current_view.equals(widget.value) + + assert page.locator('.bk.pnx-tabulator.tabulator').bounding_box()['height'] > height_start + + +def test_tabulator_streaming_no_follow(page, port): + nrows1 = 10 + arr = np.random.randint(10, 20, (nrows1, 2)) + val = [-1] + arr[0, :] = val[0] + df = pd.DataFrame(arr, columns=['A', 'B']) + widget = Tabulator(df, height=100) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + expect(page.locator('.tabulator-row')).to_have_count(len(df)) + assert page.locator('text="-1"').count() == 2 + + height_start = page.locator('.bk.pnx-tabulator.tabulator').bounding_box()['height'] + + recs = [] + nrows2 = 5 + def stream_data(): + arr = np.random.randint(10, 20, (nrows2, 2)) + val[0] = val[0] - 1 + arr[-1, :] = val[0] + recs.append(val[0]) + new_df = pd.DataFrame(arr, columns=['A', 'B']) + widget.stream(new_df, follow=False) + + repetitions = 3 + state.add_periodic_callback(stream_data, period=100, count=repetitions) + + # Explicit wait to make sure the periodic callback has completed + page.wait_for_timeout(500) + + expect(page.locator('text="-1"')).to_have_count(2) + # As we're not in follow mode the last row isn't visible + # and seems to be out of reach to the selector. How visibility + # is used here seems brittle though, may need to be revisited. + expect(page.locator(f'text="{val[0]}"')).to_have_count(0) + + assert len(widget.value) == nrows1 + repetitions * nrows2 + assert widget.current_view.equals(widget.value) + + assert page.locator('.bk.pnx-tabulator.tabulator').bounding_box()['height'] == height_start + + +def test_tabulator_patching(page, port, df_mixed): + widget = Tabulator(df_mixed) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + new_vals = { + 'str': ['AA', 'BB'], + 'int': [100, 101], + } + + widget.patch({ + 'str': [(0, new_vals['str'][0]), (1, new_vals['str'][1])], + 'int': [(slice(0, 2), new_vals['int'])] + }, as_index=False) + + for v in new_vals: + expect(page.locator(f'text="{v}"')).to_have_count(1) + + assert list(widget.value['str'].iloc[[0, 1]]) == new_vals['str'] + assert list(widget.value['int'].iloc[0 : 2]) == new_vals['int'] + assert df_mixed.equals(widget.current_view) + assert df_mixed.equals(widget.value) + + +def test_tabulator_patching_no_event(page, port, df_mixed): + # Patching should not emit emit any event when watching `value` + widget = Tabulator(df_mixed) + + events = [] + widget.param.watch(lambda e: events.append(e), 'value') + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + new_vals = { + 'str': ['AA', 'BB'], + } + + widget.patch({ + 'str': [(0, new_vals['str'][0]), (1, new_vals['str'][1])], + }, as_index=False) + + for v in new_vals: + expect(page.locator(f'text="{v}"')).to_have_count(1) + + assert list(widget.value['str'].iloc[[0, 1]]) == new_vals['str'] + assert df_mixed.equals(widget.value) + + assert len(events) == 0 + + +def color_false(val): + color = 'red' if not val else 'black' + return 'color: %s' % color + +def highlight_max(s): + is_max = s == s.max() + return ['background-color: yellow' if v else '' for v in is_max] + +# Playwright returns the colors as RGB +_color_mapping = { + 'red': 'rgb(255, 0, 0)', + 'black': 'rgb(0, 0, 0)', + 'yellow': 'rgb(255, 255, 0)', +} + +def test_tabulator_styling_init(page, port, df_mixed): + df_styled = ( + df_mixed.style + .apply(highlight_max, subset=['int']) + .applymap(color_false, subset=['bool']) + ) + widget = Tabulator(df_styled) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + max_int = df_mixed['int'].max() + max_cell = page.locator('.tabulator-cell', has=page.locator(f'text="{max_int}"')) + expect(max_cell).to_have_count(1) + expect(max_cell).to_have_css('background-color', _color_mapping['yellow']) + expect(page.locator('text="false"')).to_have_css('color', _color_mapping['red']) + + +def test_tabulator_patching_and_styling(page, port, df_mixed): + df_styled = df_mixed.style.apply(highlight_max, subset=['int']) + widget = Tabulator(df_styled) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # Changing the highest value in the int column should + # update the style so that this cell gets a yellow background + widget.patch({'int': [(0, 100)]}, as_index=False) + + max_int = df_mixed['int'].max() + max_cell = page.locator('.tabulator-cell', has=page.locator(f'text="{max_int}"')) + expect(max_cell).to_have_count(1) + expect(max_cell).to_have_css('background-color', _color_mapping['yellow']) + + +def test_tabulator_configuration(page, port, df_mixed): + # By default the Tabulator widget has sortable columns. + # Pass a configuration property to disable this behaviour. + widget = Tabulator(df_mixed, configuration={'headerSort': False}) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + expect(page.locator(".tabulator-sortable")).to_have_count(0) + + +@pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3620') +def test_tabulator_editor_datetime_nan(page, port, df_mixed): + df_mixed.at['idx0', 'datetime'] = np.nan + widget = Tabulator(df_mixed, configuration={'headerSort': False}) + + events = [] + def callback(e): + events.append(e) + + widget.on_edit(callback) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # Doesn't trigger a table edit event + cell = page.locator('text="-"') + cell.wait_for() + cell.click() + page.locator('input[type="date"]').press("Escape") + + # Error: these two triggers a table edit event, i.e. hit Enter + # or click away + page.locator('text="-"').click() + page.locator('input[type="date"]').press("Enter") + page.locator('text="-"').click() + page.locator("html").click() + + wait_until(lambda: len(events) == 0) diff --git a/panel/tests/widgets/test_tables.py b/panel/tests/widgets/test_tables.py index 63f230f364..0c2cae7f90 100644 --- a/panel/tests/widgets/test_tables.py +++ b/panel/tests/widgets/test_tables.py @@ -1514,13 +1514,24 @@ def test_tabulator_widget_scalar_filter(document, comm): for col, values in model.source.data.items(): np.testing.assert_array_equal(values, expected[col]) -def test_tabulator_constant_list_filter(document, comm): +@pytest.mark.parametrize( + 'col', + [ + 'A', + pytest.param('B', marks=pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3650')), + 'C', + 'D' + ], +) +def test_tabulator_constant_list_filter(document, comm, col): df = makeMixedDataFrame() table = Tabulator(df) model = table.get_root(document, comm) - table.add_filter(['foo3', 'foo5'], 'C') + values = list(df.iloc[[2, 4], :][col]) + + table.add_filter(values, col) expected = { 'index': np.array([2, 4]), From b670fe7999ea76d4811529086987ddf19c70b9fb Mon Sep 17 00:00:00 2001 From: maximlt Date: Wed, 29 Jun 2022 10:21:28 +0200 Subject: [PATCH 12/29] increase default timeout --- panel/tests/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/panel/tests/conftest.py b/panel/tests/conftest.py index babc7105f2..00ea31a011 100644 --- a/panel/tests/conftest.py +++ b/panel/tests/conftest.py @@ -37,8 +37,8 @@ def pytest_configure(config): @pytest.fixture def context(context): - # Reduce the default timeout to 5 secs - context.set_default_timeout(5000) + # Reduce the default timeout to 10 secs + context.set_default_timeout(10_000) yield context PORT = [6000] From 06dc61dc35c3594c7624bb3ec287409fdb522747 Mon Sep 17 00:00:00 2001 From: maximlt Date: Wed, 29 Jun 2022 10:25:54 +0200 Subject: [PATCH 13/29] make the tests more robust --- panel/tests/ui/widgets/test_tabulator.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index c2cf0c08fe..4b60f5145d 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -913,9 +913,11 @@ def test_tabulator_cell_click_event(page, port, df_mixed): page.goto(f"http://localhost:{port}") page.locator('text="idx0"').click() - wait_until(lambda: values[-1] == ('index', 0, 'idx0')) + wait_until(lambda: len(values) >= 1) + assert values[-1] == ('index', 0, 'idx0') page.locator('text="A"').click() - wait_until(lambda: values[-1] == ('str', 0, 'A')) + wait_until(lambda: len(values) >= 2) + assert values[-1] == ('str', 0, 'A') def test_tabulator_edit_event(page, port, df_mixed): @@ -936,7 +938,8 @@ def test_tabulator_edit_event(page, port, df_mixed): editable_cell.fill("AA") editable_cell.press('Enter') - wait_until(lambda: values[0] == ('str', 0, 'A', 'AA')) + wait_until(lambda: len(values) >= 1) + assert values[0] == ('str', 0, 'A', 'AA') assert df_mixed.at['idx0', 'str'] == 'AA' @pytest.mark.parametrize( From f00719c97edefd210ed919e5a0a2c1c897efa80f Mon Sep 17 00:00:00 2001 From: maximlt Date: Thu, 30 Jun 2022 11:12:06 +0200 Subject: [PATCH 14/29] rewrite wait_until and add more tests --- panel/tests/ui/widgets/test_tabulator.py | 492 +++++++++++++++++++++-- 1 file changed, 454 insertions(+), 38 deletions(-) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index 4b60f5145d..0949be0437 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -56,15 +56,66 @@ def df_multiindex(df_mixed): return df_mi -def wait_until(fn, timeout=5000, interval=100): - while timeout > 0: - if fn(): - return True +def wait_until(page, fn, timeout=5000, interval=100): + """ + Exercice a test function until in a loop until it times out. + + The function can either be a simple lambda that returns True or False: + >>> wait_until(page, lambda: x.values() == ['x']) + + Or a defined function with an assert: + >>> def _() + >>> assert x.values() == ['x'] + >>> wait_until(page, _) + + Parameters + ---------- + page : playwright.sync_api.Page + Playwright page + fn : callable + Callback + timeout : int, optional + Total timeout in milliseconds, by default 5000 + interval : int, optional + Waiting interval, by default 100 + + Adapted from pytest-qt. + """ + # Hide this function traceback from the pytest output if the test fails + __tracebackhide__ = True + + start = time.time() + + def timed_out(): + elapsed = time.time() - start + elapsed_ms = elapsed * 1000 + return elapsed_ms > timeout + + timeout_msg = f"wait_until timed out in {timeout} milliseconds" + + while True: + try: + result = fn() + except AssertionError as e: + if timed_out(): + raise TimeoutError(timeout_msg) from e else: - time.sleep(interval / 1000) - timeout -= interval - # To raise the False assert - assert fn() + if result not in (None, True, False): + raise ValueError( + "`wait_until` callback must return None, True or " + f"False, returned {result!r}" + ) + # None is returned when the function has an assert + if result is None: + return + # When the function returns True or False + if result: + return + if timed_out(): + raise TimeoutError(timeout_msg) + # Playwright recommends against using time.sleep + # https://playwright.dev/python/docs/intro#timesleep-leads-to-outdated-state + page.wait_for_timeout(interval) def get_ctrl_modifier(): @@ -250,7 +301,7 @@ def cb(e): icon.wait_for() # Click on the first button icon.click() - assert wait_until(lambda: state == expected_state) + wait_until(page, lambda: state == expected_state) def test_tabulator_formatters_bokeh_bool(page, port, df_mixed): @@ -475,10 +526,12 @@ def test_tabulator_selection_selectable_by_default(page, port, df_mixed): c0 = page.locator('text="idx0"') c0.wait_for() c0.click() - assert wait_until(lambda: widget.selection == [0]) + wait_until(page, lambda: widget.selection == [0]) assert 'tabulator-selected' in rows.first.get_attribute('class') for i in range(1, rows.count()): assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') + expected_selected = df_mixed.loc[['idx0'], :] + assert widget.selected_dataframe.equals(expected_selected) def test_tabulator_selection_selectable_one_at_a_time(page, port, df_mixed): @@ -495,10 +548,14 @@ def test_tabulator_selection_selectable_one_at_a_time(page, port, df_mixed): c0 = page.locator('text="idx0"') c0.wait_for() c0.click() - assert wait_until(lambda: widget.selection == [0]) + wait_until(page, lambda: widget.selection == [0]) + expected_selected = df_mixed.loc[['idx0'], :] + assert widget.selected_dataframe.equals(expected_selected) # Click on the second row should deselect the first one page.locator('text="idx1"').click() - assert wait_until(lambda: widget.selection == [1]) + wait_until(page, lambda: widget.selection == [1]) + expected_selected = df_mixed.loc[['idx1'], :] + assert widget.selected_dataframe.equals(expected_selected) for i in range(rows.count()): if i == 1: assert 'tabulator-selected' in rows.nth(i).get_attribute('class') @@ -506,7 +563,8 @@ def test_tabulator_selection_selectable_one_at_a_time(page, port, df_mixed): assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') # Clicking again on the second row should not change anything page.locator('text="idx1"').click() - assert wait_until(lambda: widget.selection == [1]) + wait_until(page, lambda: widget.selection == [1]) + assert widget.selected_dataframe.equals(expected_selected) for i in range(rows.count()): if i == 1: assert 'tabulator-selected' in rows.nth(i).get_attribute('class') @@ -532,7 +590,9 @@ def test_tabulator_selection_selectable_ctrl(page, port, df_mixed): modifier = get_ctrl_modifier() page.locator("text=idx2").click(modifiers=[modifier]) expected_selection = [0, 2] - assert wait_until(lambda: widget.selection == expected_selection) + wait_until(page, lambda: widget.selection == expected_selection) + expected_selected = df_mixed.loc[['idx0', 'idx2'], :] + assert widget.selected_dataframe.equals(expected_selected) for i in range(rows.count()): if i in expected_selection: assert 'tabulator-selected' in rows.nth(i).get_attribute('class') @@ -541,7 +601,9 @@ def test_tabulator_selection_selectable_ctrl(page, port, df_mixed): # Clicking again on the third row with CTRL pressed should remove the row from the selection page.locator("text=idx2").click(modifiers=[modifier]) expected_selection = [0] - assert wait_until(lambda: widget.selection == expected_selection) + wait_until(page, lambda: widget.selection == expected_selection) + expected_selected = df_mixed.loc[['idx0'], :] + assert widget.selected_dataframe.equals(expected_selected) for i in range(rows.count()): if i in expected_selection: assert 'tabulator-selected' in rows.nth(i).get_attribute('class') @@ -566,7 +628,9 @@ def test_tabulator_selection_selectable_shift(page, port, df_mixed): # Click on the thrid row with SHIFT pressed should select the 2nd row too page.locator("text=idx2").click(modifiers=['Shift']) expected_selection = [0, 1, 2] - assert wait_until(lambda: widget.selection == expected_selection) + wait_until(page, lambda: widget.selection == expected_selection) + expected_selected = df_mixed.loc['idx0':'idx2', :] + assert widget.selected_dataframe.equals(expected_selected) for i in range(rows.count()): if i in expected_selection: assert 'tabulator-selected' in rows.nth(i).get_attribute('class') @@ -590,8 +654,9 @@ def test_tabulator_selection_selectable_disabled(page, port, df_mixed): c0.click() # Wait for a potential selection event to be propagated, this should not # be the case. - time.sleep(0.2) + page.wait_for_timeout(200) assert widget.selection == [] + assert widget.selected_dataframe.empty for i in range(rows.count()): assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') @@ -614,6 +679,8 @@ def test_tabulator_selection_default_selection(page, port, df_mixed): assert 'tabulator-selected' in rows.nth(i).get_attribute('class') else: assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') + expected_selected = df_mixed.loc[['idx0', 'idx2'], :] + assert widget.selected_dataframe.equals(expected_selected) def test_tabulator_selection_selectable_checkbox_all(page, port, df_mixed): @@ -637,7 +704,8 @@ def test_tabulator_selection_selectable_checkbox_all(page, port, df_mixed): for i in range(rows.count()): assert 'tabulator-selected' in rows.nth(i).get_attribute('class') # The selection should have all the indexes - wait_until(lambda: widget.selection == list(range(len(df_mixed)))) + wait_until(page, lambda: widget.selection == list(range(len(df_mixed)))) + assert widget.selected_dataframe.equals(df_mixed) def test_tabulator_selection_selectable_checkbox_multiple(page, port, df_mixed): @@ -669,7 +737,9 @@ def test_tabulator_selection_selectable_checkbox_multiple(page, port, df_mixed): else: assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') - wait_until(lambda: widget.selection == expected_selection) + wait_until(page, lambda: widget.selection == expected_selection) + expected_selected = df_mixed.iloc[expected_selection, :] + assert widget.selected_dataframe.equals(expected_selected) def test_tabulator_selection_selectable_checkbox_single(page, port, df_mixed): @@ -701,7 +771,9 @@ def test_tabulator_selection_selectable_checkbox_single(page, port, df_mixed): else: assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') - wait_until(lambda: widget.selection == expected_selection) + wait_until(page, lambda: widget.selection == expected_selection) + expected_selected = df_mixed.iloc[expected_selection, :] + assert widget.selected_dataframe.equals(expected_selected) def test_tabulator_selection_selectable_toggle(page, port, df_mixed): @@ -723,7 +795,9 @@ def test_tabulator_selection_selectable_toggle(page, port, df_mixed): assert 'tabulator-selected' in rows.nth(i).get_attribute('class') else: assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') - assert wait_until(lambda: widget.selection == [0]) + wait_until(page, lambda: widget.selection == [0]) + expected_selected = df_mixed.loc[['idx0'], :] + assert widget.selected_dataframe.equals(expected_selected) # Click on the second row, the first row should still be selected page.locator('text="idx1"').click() for i in range(rows.count()): @@ -731,7 +805,9 @@ def test_tabulator_selection_selectable_toggle(page, port, df_mixed): assert 'tabulator-selected' in rows.nth(i).get_attribute('class') else: assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') - assert wait_until(lambda: widget.selection == [0, 1]) + wait_until(page, lambda: widget.selection == [0, 1]) + expected_selected = df_mixed.loc[['idx0', 'idx1'], :] + assert widget.selected_dataframe.equals(expected_selected) # Click on a selected row deselect it page.locator('text="idx1"').click() for i in range(rows.count()): @@ -739,7 +815,9 @@ def test_tabulator_selection_selectable_toggle(page, port, df_mixed): assert 'tabulator-selected' in rows.nth(i).get_attribute('class') else: assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') - assert wait_until(lambda: widget.selection == [0]) + wait_until(page, lambda: widget.selection == [0]) + expected_selected = df_mixed.loc[['idx0'], :] + assert widget.selected_dataframe.equals(expected_selected) def test_tabulator_selection_selectable_rows(page, port, df_mixed): @@ -756,20 +834,24 @@ def test_tabulator_selection_selectable_rows(page, port, df_mixed): c1 = page.locator('text="idx1"') c1.wait_for() c1.click() - assert wait_until(lambda: widget.selection == [1]) - # Click on the first row with CTRL pressed should add that row to the selection + wait_until(page, lambda: widget.selection == [1]) + expected_selected = df_mixed.loc[['idx1'], :] + assert widget.selected_dataframe.equals(expected_selected) + # Click on the first row with CTRL pressed should NOT add that row to the selection + # as this row is not selectable modifier = get_ctrl_modifier() page.locator("text=idx0").click(modifiers=[modifier]) - time.sleep(0.2) + page.wait_for_timeout(200) assert widget.selection == [1] for i in range(rows.count()): if i == 1: assert 'tabulator-selected' in rows.nth(i).get_attribute('class') else: assert 'tabulator-selected' not in rows.nth(i).get_attribute('class') + assert widget.selected_dataframe.equals(expected_selected) -def test_tabulator_selection_row_content(page, port, df_mixed): +def test_tabulator_row_content(page, port, df_mixed): widget = Tabulator(df_mixed, row_content=lambda i: f"{i['str']}-row-content") serve(widget, port=port, threaded=True, show=False) @@ -791,7 +873,7 @@ def test_tabulator_selection_row_content(page, port, df_mixed): expect(closables).to_have_count(i + 1) assert row_content.is_visible() expected_expanded.append(i) - wait_until(lambda: widget.expanded == expected_expanded) + wait_until(page, lambda: widget.expanded == expected_expanded) for i in range(len(df_mixed)): closables = page.locator('text="▼"') @@ -799,10 +881,10 @@ def test_tabulator_selection_row_content(page, port, df_mixed): row_content = page.locator(f'text="{df_mixed.iloc[i]["str"]}-row-content"') expect(row_content).to_have_count(0) # timeout here? expected_expanded.remove(i) - wait_until(lambda: widget.expanded == expected_expanded) + wait_until(page, lambda: widget.expanded == expected_expanded) -def test_tabulator_selection_row_content_expand_from_python_init(page, port, df_mixed): +def test_tabulator_row_content_expand_from_python_init(page, port, df_mixed): widget = Tabulator( df_mixed, row_content=lambda i: f"{i['str']}-row-content", @@ -829,7 +911,7 @@ def test_tabulator_selection_row_content_expand_from_python_init(page, port, df_ @pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3646') -def test_tabulator_selection_row_content_expand_from_python_after(page, port, df_mixed): +def test_tabulator_row_content_expand_from_python_after(page, port, df_mixed): widget = Tabulator(df_mixed, row_content=lambda i: f"{i['str']}-row-content") serve(widget, port=port, threaded=True, show=False) @@ -913,10 +995,10 @@ def test_tabulator_cell_click_event(page, port, df_mixed): page.goto(f"http://localhost:{port}") page.locator('text="idx0"').click() - wait_until(lambda: len(values) >= 1) + wait_until(page, lambda: len(values) >= 1) assert values[-1] == ('index', 0, 'idx0') page.locator('text="A"').click() - wait_until(lambda: len(values) >= 2) + wait_until(page, lambda: len(values) >= 2) assert values[-1] == ('str', 0, 'A') @@ -938,7 +1020,7 @@ def test_tabulator_edit_event(page, port, df_mixed): editable_cell.fill("AA") editable_cell.press('Enter') - wait_until(lambda: len(values) >= 1) + wait_until(page, lambda: len(values) >= 1) assert values[0] == ('str', 0, 'A', 'AA') assert df_mixed.at['idx0', 'str'] == 'AA' @@ -962,7 +1044,7 @@ def test_tabulator_pagination(page, port, df_mixed, pagination): counts = count_per_page(len(df_mixed), page_size) i = 0 while True: - wait_until(lambda: widget.page == i + 1) + wait_until(page, lambda: widget.page == i + 1) rows = page.locator('.tabulator-row') expect(rows).to_have_count(counts[i]) assert page.locator(f'[aria-label="Show Page {i+1}"]').count() == 1 @@ -1160,7 +1242,7 @@ def test_tabulator_header_filters_set_from_client(page, port, df_mixed): expected_filter_df = df_mixed.query(query1) expected_filter1 = {'field': col, 'type': cmp, 'value': val} expect(page.locator('.tabulator-row')).to_have_count(len(expected_filter_df)) - wait_until(lambda: widget.filters == [expected_filter1]) + wait_until(page, lambda: widget.filters == [expected_filter1]) assert widget.current_view.equals(expected_filter_df) str_header = page.locator('input[type="search"]') @@ -1172,7 +1254,7 @@ def test_tabulator_header_filters_set_from_client(page, port, df_mixed): expected_filter_df = df_mixed.query(f'{query1} and {query2}') expected_filter2 = {'field': col, 'type': cmp, 'value': val} expect(page.locator('.tabulator-row')).to_have_count(len(expected_filter_df)) - wait_until(lambda: widget.filters == [expected_filter1, expected_filter2]) + wait_until(page, lambda: widget.filters == [expected_filter1, expected_filter2]) assert widget.current_view.equals(expected_filter_df) @@ -1383,6 +1465,14 @@ def test_tabulator_configuration(page, port, df_mixed): expect(page.locator(".tabulator-sortable")).to_have_count(0) +def test_tabulator_editor_datetime(): + pass + + +def test_tabulator_editor_date(): + pass + + @pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3620') def test_tabulator_editor_datetime_nan(page, port, df_mixed): df_mixed.at['idx0', 'datetime'] = np.nan @@ -1413,4 +1503,330 @@ def callback(e): page.locator('text="-"').click() page.locator("html").click() - wait_until(lambda: len(events) == 0) + wait_until(page, lambda: len(events) == 0) + + +@pytest.mark.parametrize('col', ['index', 'int', 'float', 'str', 'date', 'datetime']) +@pytest.mark.parametrize('dir', ['asc', 'desc']) +def test_tabulator_sorters_on_init(page, port, df_mixed, col, dir): + widget = Tabulator(df_mixed, sorters=[{'field': col, 'dir': dir}]) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + sorted_header = page.locator(f'[aria-sort="{dir}"]:visible') + expect(sorted_header).to_have_attribute('tabulator-field', col) + + ascending = True if dir == 'asc' else False + if col == 'index': + expected_current_view = df_mixed.sort_index(ascending=ascending) + else: + expected_current_view = df_mixed.sort_values(col, ascending=ascending) + assert widget.current_view.equals(expected_current_view) + + +@pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3657') +def test_tabulator_sorters_on_init_multiple(page, port): + df = pd.DataFrame({ + 'col1': [1, 2, 3, 4], + 'col2': [1, 4, 3, 2], + }) + sorters = [{'field': 'col1', 'dir': 'desc'}, {'field': 'col2', 'dir': 'asc'}] + widget = Tabulator(df, sorters=sorters) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + s1 = page.locator('[aria-sort="desc"]:visible') + expect(s1).to_have_attribute('tabulator-field', 'col1') + s2 = page.locator('[aria-sort="asc"]:visible') + expect(s2).to_have_attribute('tabulator-field', 'col2') + + first_index_rendered = page.locator('.tabulator-cell:visible').first.inner_text() + df_sorted = df.sort_values('col1', ascending=True).sort_values('col2', ascending=False) + expected_first_index = df_sorted.index[0] + + # This fails + assert int(first_index_rendered) == expected_first_index + + +def test_tabulator_sorters_set_after_init(page, port, df_mixed): + widget = Tabulator(df_mixed) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + widget.sorters = [{'field': 'int', 'dir': 'desc'}] + + sheader = page.locator('[aria-sort="desc"]:visible') + expect(sheader).to_have_count(1) + assert sheader.get_attribute('tabulator-field') == 'int' + + expected_df_sorted = df_mixed.sort_values('int', ascending=False) + + assert widget.current_view.equals(expected_df_sorted) + + +def test_tabulator_sorters_from_client(page, port, df_mixed): + widget = Tabulator(df_mixed) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + page.locator('.tabulator-col', has_text='float').locator('.tabulator-col-sorter').click() + + sheader = page.locator('[aria-sort="asc"]:visible') + expect(sheader).to_have_count(1) + assert sheader.get_attribute('tabulator-field') == 'float' + + wait_until(page, lambda: widget.sorters == [{'field': 'float', 'dir': 'asc'}]) + + expected_df_sorted = df_mixed.sort_values('float', ascending=True) + assert widget.current_view.equals(expected_df_sorted) + + +@pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3658') +def test_tabulator_sorters_pagination_no_page_reset(page, port, df_mixed): + widget = Tabulator(df_mixed, pagination='remote', page_size=2) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + page.locator('text="Next"').click() + + expect(page.locator('text="idx2"')).to_have_count(1) + + widget.sorters = [{'field': 'float', 'dir': 'asc'}] + + page.locator('.tabulator-col', has_text='index').locator('.tabulator-col-sorter').click() + + # This fails, explicit timeout required + page.wait_for_timeout(500) + expect(page.locator('text="idx2"')).to_have_count(1, timeout=1000) + assert widget.page == 2 + + +@pytest.mark.parametrize('pagination', ['remote', 'local']) +def test_tabulator_sorters_pagination(page, port, df_mixed, pagination): + widget = Tabulator(df_mixed, pagination=pagination, page_size=2) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + + s = page.locator('.tabulator-col', has_text='str').locator('.tabulator-col-sorter') + s.click() + # Having to wait when pagination is set to remote before the next click, + # maybe there's a better way. + page.wait_for_timeout(100) + s.click() + + sheader = page.locator('[aria-sort="desc"]:visible') + expect(sheader).to_have_count(1) + assert sheader.get_attribute('tabulator-field') == 'str' + + expected_sorted_df = df_mixed.sort_values('str', ascending=False) + wait_until(page, lambda: widget.current_view.equals(expected_sorted_df)) + + # Check that if we go to the next page the current_view hasn't changed + page.locator('text="Next"').click() + + page.wait_for_timeout(200) + wait_until(page, lambda: widget.current_view.equals(expected_sorted_df)) + + +@pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3660') +def test_tabulator_edit_event_and_header_filters(page, port): + df = pd.DataFrame({ + 'col1': list('aaabcd'), + 'col2': list('ABCDEF') + }) + widget = Tabulator( + df, + header_filters={'col1': {'type': 'input', 'func': 'like'}}, + ) + + values = [] + widget.on_edit(lambda e: values.append((e.column, e.row, e.old, e.value))) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # Set a filter on col1 + str_header = page.locator('input[type="search"]') + str_header.click() + str_header.fill('a') + str_header.press('Enter') + + # Chankge the cell that contains B to BB + cell = page.locator('text="B"') + cell.click() + editable_cell = page.locator('input[type="text"]') + editable_cell.fill("BB") + editable_cell.press('Enter') + + wait_until(page, lambda: len(values) == 1) + # This cell was at index 1 in col2 of the original dataframe + assert values[0] == ('col2', 1, 'B', 'BB') # This fails + assert df['b'][1] == 'BB' + assert widget.value.equals(df) + assert widget.current_view.equals(widget.value) + +@pytest.mark.parametrize('sorter', ['sorter', 'no_sorter']) +@pytest.mark.parametrize('python_filter', ['python_filter', 'no_python_filter']) +@pytest.mark.parametrize('pagination', ['remote', 'local', 'no_pagination']) +def test_tabulator_edit_event_integrations(page, port, sorter, python_filter, pagination): + sorter_col = 'col3' + python_filter_col = 'col2' + python_filter_val = 'd' + target_col = 'col4' + target_val = 'F' + new_val = 'FF' + + df = pd.DataFrame({ + 'col1': list('XYYYYY'), + 'col2': list('abcddd'), + 'col3': list(range(6)), + 'col4': list('ABCDEF') + }) + + target_index = df.set_index(target_col).index.get_loc(target_val) + + kwargs = {} + if pagination != 'no_pagination': + kwargs = dict(pagination=pagination, page_size=2) + + widget = Tabulator(df, **kwargs) + + if python_filter == 'python_filter': + widget.add_filter(python_filter_val, python_filter_col) + + values = [] + widget.on_edit(lambda e: values.append((e.column, e.row, e.old, e.value))) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + if sorter == 'sorter': + s = page.locator('.tabulator-col', has_text=sorter_col).locator('.tabulator-col-sorter') + s.click() + # Having to wait when pagination is set to remote before the next click, + # maybe there's a better way. + page.wait_for_timeout(100) + s.click() + page.wait_for_timeout(100) + + if pagination != 'no_pagination' and sorter == 'no_sorter': + page.locator('text="Last"').click() + page.wait_for_timeout(100) + + # Change the cell concent + cell = page.locator(f'text="{target_val}"') + cell.click() + editable_cell = page.locator('input[type="text"]') + editable_cell.fill(new_val) + editable_cell.press('Enter') + + wait_until(page, lambda: len(values) == 1) + if python_filter == 'python_filter': + pytest.xfail(reason='See https://github.com/holoviz/panel/issues/3662') + else: + if pagination == 'remote' and sorter == 'sorter': + pytest.xfail(reason='See https://github.com/holoviz/panel/issues/3663') + assert values[0] == (target_col, target_index, target_val, new_val) + assert df[target_col][target_index] == new_val + assert widget.value.equals(df) + if sorter == 'sorter': + expected_current_view = widget.value.sort_values(sorter_col, ascending=False) + else: + expected_current_view = widget.value + if python_filter == 'python_filter': + expected_current_view = expected_current_view.query('@python_filter_col == @python_filter_val') + assert widget.current_view.equals(expected_current_view) + + +@pytest.mark.parametrize('sorter', ['sorter', 'no_sorter']) +@pytest.mark.parametrize('python_filter', ['python_filter', 'no_python_filter']) +@pytest.mark.parametrize('pagination', ['remote', 'local', 'no_pagination']) +def test_tabulator_click_event_integrations(page, port, sorter, python_filter, pagination): + sorter_col = 'col3' + python_filter_col = 'col2' + python_filter_val = 'd' + target_col = 'col4' + target_val = 'F' + + df = pd.DataFrame({ + 'col1': list('XYYYYY'), + 'col2': list('abcddd'), + 'col3': list(range(6)), + 'col4': list('ABCDEF') + }) + + target_index = df.set_index(target_col).index.get_loc(target_val) + + kwargs = {} + if pagination != 'no_pagination': + kwargs = dict(pagination=pagination, page_size=2) + + widget = Tabulator(df, **kwargs) + + if python_filter == 'python_filter': + widget.add_filter(python_filter_val, python_filter_col) + + values = [] + widget.on_click(lambda e: values.append((e.column, e.row, e.value))) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + if sorter == 'sorter': + s = page.locator('.tabulator-col', has_text=sorter_col).locator('.tabulator-col-sorter') + s.click() + # Having to wait when pagination is set to remote before the next click, + # maybe there's a better way. + page.wait_for_timeout(100) + s.click() + page.wait_for_timeout(100) + + if pagination != 'no_pagination' and sorter == 'no_sorter': + page.locator('text="Last"').click() + page.wait_for_timeout(100) + + # Change the cell concent + cell = page.locator(f'text="{target_val}"') + cell.click() + + wait_until(page, lambda: len(values) == 1) + if python_filter == 'python_filter': + pytest.xfail(reason='See https://github.com/holoviz/panel/issues/3662') + else: + if pagination == 'remote' and sorter == 'sorter': + pytest.xfail(reason='See https://github.com/holoviz/panel/issues/3663') + assert values[0] == (target_col, target_index, target_val) From 974c080e4929db493bae6ffc5c4cadc00f3e4fec Mon Sep 17 00:00:00 2001 From: maximlt Date: Mon, 4 Jul 2022 19:10:26 +0200 Subject: [PATCH 15/29] try with the codecov action --- .github/workflows/test.yaml | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 3da3a88b36..04cd8637cc 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -110,11 +110,15 @@ jobs: eval "$(conda shell.bash hook)" conda activate test-environment doit test_examples - - name: codecov - run: | - eval "$(conda shell.bash hook)" - conda activate test-environment - codecov + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + fail_ci_if_error: false # optional (default = false) + # - name: codecov + # run: | + # eval "$(conda shell.bash hook)" + # conda activate test-environment + # codecov ui_test_suite: name: UI tests on ${{ matrix.os }} with Python 3.9 needs: [pre_commit] @@ -179,8 +183,12 @@ jobs: eval "$(conda shell.bash hook)" conda activate test-environment doit test_ui - - name: codecov - run: | - eval "$(conda shell.bash hook)" - conda activate test-environment - codecov + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + fail_ci_if_error: false # optional (default = false) + # - name: codecov + # run: | + # eval "$(conda shell.bash hook)" + # conda activate test-environment + # codecov From cf9dc0e7f4b7d300263c0d1c79ac9a433a8d93ad Mon Sep 17 00:00:00 2001 From: maximlt Date: Mon, 4 Jul 2022 19:10:40 +0200 Subject: [PATCH 16/29] error on xpass --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index f59056b2c4..c8c0cd2c81 100644 --- a/tox.ini +++ b/tox.ini @@ -65,3 +65,4 @@ deps = unit: {[_unit]deps} [pytest] addopts = -v --pyargs --doctest-ignore-import-errors norecursedirs = doc .git dist build _build .ipynb_checkpoints panel/examples +xfail_strict = true From a850fca7c569e5af061e4b5a748eb6be67615063 Mon Sep 17 00:00:00 2001 From: maximlt Date: Mon, 4 Jul 2022 19:18:53 +0200 Subject: [PATCH 17/29] add more tests --- panel/tests/ui/widgets/test_tabulator.py | 575 ++++++++++++++++++++++- panel/tests/widgets/test_tables.py | 51 +- 2 files changed, 606 insertions(+), 20 deletions(-) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index 0949be0437..dc2c83ab25 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -2,6 +2,7 @@ import sys import time +import param import pytest from bokeh.models.widgets.tables import ( @@ -44,6 +45,47 @@ def df_mixed(): return df +@pytest.fixture(scope='session') +def df_mixed_as_string(): + return """ + index + int + float + str + bool + date + datetime + idx0 + 1 + 3.14 + A + true + 2019-01-01 + 2019-01-01 10:00:00 + idx1 + 2 + 6.28 + B + true + 2020-01-01 + 2020-01-01 12:00:00 + idx2 + 3 + 9.42 + C + true + 2020-01-10 + 2020-01-10 13:00:00 + idx3 + 4 + -2.45 + D + false + 2019-01-10 + 2020-01-15 13:00:00 + """ + + @pytest.fixture def df_multiindex(df_mixed): df_mi = df_mixed.copy() @@ -144,7 +186,7 @@ def count_per_page(count: int, page_size: int): return count_per_page -def test_tabulator_default(page, port, df_mixed): +def test_tabulator_default(page, port, df_mixed, df_mixed_as_string): nrows, ncols = df_mixed.shape widget = Tabulator(df_mixed) @@ -159,7 +201,7 @@ def test_tabulator_default(page, port, df_mixed): # Check that the whole table content is on the page table = page.locator('.bk.pnx-tabulator.tabulator') expect(table).to_have_text( - 'index\nint\nfloat\nstr\nbool\ndate\ndatetime\nidx0\n1\n3.14\nA\ntrue\n2019-01-01\n2019-01-01 10:00:00\nidx1\n2\n6.28\nB\ntrue\n2020-01-01\n2020-01-01 12:00:00\nidx2\n3\n9.42\nC\ntrue\n2020-01-10\n2020-01-10 13:00:00\nidx3\n4\n-2.45\nD\nfalse\n2019-01-10\n2020-01-15 13:00:00', # noqa + df_mixed_as_string, use_inner_text=True ) @@ -454,8 +496,66 @@ def test_tabulator_formatters_bokeh_scientific(page, port, df_mixed): assert page.locator('text="nan-float"').count() == 1 -def test_tabulator_formatters_tabulator(): - pass +def test_tabulator_formatters_tabulator_str(page, port, df_mixed): + widget = Tabulator( + df_mixed, + formatters={'int': 'star'}, + ) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # The star formatter renders with svg icons. + cells = page.locator(".tabulator-cell", has=page.locator("svg")) + expect(cells).to_have_count(len(df_mixed)) + + +def test_tabulator_formatters_tabulator_dict(page, port, df_mixed): + nstars = 10 + widget = Tabulator( + df_mixed, + formatters={'int': {'type': 'star', 'stars': nstars}}, + ) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # The star formatter renders with svg icons. + cells = page.locator(".tabulator-cell", has=page.locator("svg")) + expect(cells).to_have_count(len(df_mixed)) + + stars = page.locator('svg') + assert stars.count() == len(df_mixed) * nstars + + +def test_tabulator_formatters_after_init(page, port, df_mixed): + widget = Tabulator(df_mixed) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # Wait until the table is rendered + expect(page.locator('.tabulator-row')).to_have_count(len(df_mixed)) + + # Formatters can be set after initialization, the table should be + # updated accordingly + widget.formatters = { + 'str': HTMLTemplateFormatter(template='

<%= value %>

'), + } + + expect(page.locator('text="A"')).to_have_attribute( + "style", + "font-weight: bold;" + ) def test_tabulator_editors_bokeh(): @@ -466,20 +566,324 @@ def test_tabulator_editors_tabulator(): pass -def test_tabulator_column_layouts(): - pass +@pytest.mark.parametrize('layout', Tabulator.param['layout'].objects) +def test_tabulator_column_layouts(page, port, df_mixed, layout): + widget = Tabulator(df_mixed, layout=layout) + serve(widget, port=port, threaded=True, show=False) -def test_tabulator_alignment(): - pass + time.sleep(0.2) + page.goto(f"http://localhost:{port}") + + layout_mapping = { + "fit_data": "fitData", + "fit_data_fill": "fitDataFill", + "fit_data_stretch": "fitDataStretch", + "fit_data_table": "fitDataTable", + "fit_columns": "fitColumns", + } + + expected_layout = layout_mapping[layout] + + expect(page.locator('.pnx-tabulator')).to_have_attribute('tabulator-layout', expected_layout) + + +def test_tabulator_alignment_header_default(page, port, df_mixed): + widget = Tabulator(df_mixed) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # The default header alignment is left + for col in df_mixed.columns: + expect(page.locator(f'text="{col}"')).to_have_css('text-align', 'left') + + +def test_tabulator_alignment_text_default(page, port, df_mixed): + widget = Tabulator(df_mixed) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + findex = df_mixed.index[0] + cell = page.locator(f'text="{findex}"') + # Indexes are left aligned + expect(cell).to_have_css('text-align', 'left') + + val = df_mixed.at[findex, 'int'] + # Selecting the visible 1 as there's a non displayed 1 in the hidden index + cell = page.locator(f'text="{val}" >> visible=true') + # Integers are right aligned + expect(cell).to_have_css('text-align', 'right') + + val = df_mixed.at[findex, 'float'] + cell = page.locator(f'text="{val}"') + # Floats are right aligned + expect(cell).to_have_css('text-align', 'right') + + val = df_mixed.at[findex, 'bool'] + val = 'true' if val else 'false' + cell = page.locator(f'text="{val}"').first + # Booleans are centered + expect(cell).to_have_css('text-align', 'center') + + val = df_mixed.at[findex, 'datetime'] + val = val.strftime('%Y-%m-%d %H:%M:%S') + cell = page.locator(f'text="{val}"') + # Datetimes are right aligned + expect(cell).to_have_css('text-align', 'right') + + val = df_mixed.at[findex, 'str'] + cell = page.locator(f'text="{val}"') + # Other types are left aligned + expect(cell).to_have_css('text-align', 'left') + + +def test_tabulator_alignment_header_str(page, port, df_mixed): + halign = 'center' + widget = Tabulator(df_mixed, header_align=halign) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + for col in df_mixed.columns: + expect(page.locator(f'text="{col}"')).to_have_css('text-align', halign) + + +def test_tabulator_alignment_header_dict(page, port, df_mixed): + halign = {'int': 'left'} + widget = Tabulator(df_mixed, header_align=halign) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # for col in df_mixed.columns: + for col, align in halign.items(): + expect(page.locator(f'text="{col}"')).to_have_css('text-align', align) + + +def test_tabulator_alignment_text_str(page, port, df_mixed): + talign = 'center' + widget = Tabulator(df_mixed, text_align=talign) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + cells = page.locator('.tabulator-cell:visible') + + expect(cells).to_have_count(len(df_mixed) * (df_mixed.shape[1] + 1)) + + for i in range(cells.count()): + expect(cells.nth(i)).to_have_css('text-align', talign) + + +def test_tabulator_frozen_columns(page, port, df_mixed): + widths = 50 + width = int(((df_mixed.shape[1] + 1) * widths) / 2) + frozen_cols = ['float', 'int'] + widget = Tabulator(df_mixed, frozen_columns=frozen_cols, width=width, widths=widths) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + expected_text = """ + float + int + index + str + bool + date + datetime + 3.14 + 1 + idx0 + A + true + 2019-01-01 + 2019-01-01 10:00:00 + 6.28 + 2 + idx1 + B + true + 2020-01-01 + 2020-01-01 12:00:00 + 9.42 + 3 + idx2 + C + true + 2020-01-10 + 2020-01-10 13:00:00 + -2.45 + 4 + idx3 + D + false + 2019-01-10 + 2020-01-15 13:00:00 + """ + # Check that the whole table content is on the page, it is not in the + # same order as if the table was displayed without frozen columns + table = page.locator('.bk.pnx-tabulator.tabulator') + expect(table).to_have_text( + expected_text, + use_inner_text=True + ) + + float_bb = page.locator('text="float"').bounding_box() + int_bb = page.locator('text="int"').bounding_box() + bool_bb = page.locator('text="bool"').bounding_box() + + # Check that the float column is rendered before the int column + assert float_bb['x'] < int_bb['x'] + + # Might be a little brittle, setting the mouse somewhere in the table + # and scroll right + page.mouse.move(x=int(width/2), y=40) + page.mouse.wheel(delta_x=int(width*10), delta_y=0) + # Give it time to scroll + page.wait_for_timeout(100) + + # Check that the two frozen columns haven't moved after scrolling right + assert float_bb == page.locator('text="float"').bounding_box() + assert int_bb == page.locator('text="int"').bounding_box() + # But check that the position of one of the non frozen columns has indeed moved + assert bool_bb['x'] > page.locator('text="bool"').bounding_box()['x'] -def test_tabulator_frozen_columns(): - pass + + +@pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3669') +def test_tabulator_patch_no_horizontal_rescroll(page, port, df_mixed): + widths = 50 + width = int(((df_mixed.shape[1] + 1) * widths) / 2) + df_mixed['tomodify'] = 'target' + widget = Tabulator(df_mixed, width=width, widths=widths) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # Might be a little brittle, setting the mouse somewhere in the table + # and scroll right + page.mouse.move(x=int(width/2), y=40) + page.mouse.wheel(delta_x=int(width * 10), delta_y=0) + # Give it time to scroll + page.wait_for_timeout(100) + + bb = page.locator('text="tomodify"').bounding_box() + # Patch a cell in the latest column + widget.patch({'tomodify': [(0, 'target-modified')]}, as_index=False) + + # The table should keep the same scroll position, this fails + assert bb == page.locator('text="tomodify"').bounding_box() + + +@pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3249') +def test_tabulator_patch_no_vertical_rescroll(page, port): + size = 100 + arr = np.random.choice(list('abcd'), size=size) + + target, new_val = 'X', 'Y' + arr[-1] = target + df = pd.DataFrame({'col': arr}) + height, width = 100, 200 + widget = Tabulator(df, height=100, width=width) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # Might be a little brittle, setting the mouse somewhere in the table + # and scroll down + page.mouse.move(x=int(width/2), y=int(height/2)) + page.mouse.wheel(delta_x=0, delta_y=10000) + # Give it time to scroll + page.wait_for_timeout(200) + + bb = page.locator(f'text="{target}"').bounding_box() + # Patch a cell in the latest row + widget.patch({'col': [(size-1, new_val)]}) + + # Wait for a potential rescroll + page.wait_for_timeout(200) + # The table should keep the same scroll position, this fails + assert bb == page.locator(f'text="{new_val}"').bounding_box() + + +@pytest.mark.parametrize( + 'pagination', + ( + pytest.param('local', marks=pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3553')), + pytest.param('remote', marks=pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3553')), + None, + ) +) +def test_tabulator_header_filter_no_horizontal_rescroll(page, port, df_mixed, pagination): + widths = 100 + width = int(((df_mixed.shape[1] + 1) * widths) / 2) + col_name = 'newcol' + df_mixed[col_name] = 'on' + widget = Tabulator( + df_mixed, + width=width, + widths=widths, + header_filters={col_name: {'type': 'input', 'func': 'like'}}, + pagination=pagination + ) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # Might be a little brittle, setting the mouse somewhere in the table + # and scroll right + page.mouse.move(x=int(width/2), y=80) + page.mouse.wheel(delta_x=int(width * 10), delta_y=0) + # Give it time to scroll + page.wait_for_timeout(200) + + bb = page.locator(f'text="{col_name}"').bounding_box() + + header = page.locator('input[type="search"]') + header.click() + header.fill('off') + header.press('Enter') + + # Give it time to scroll + page.wait_for_timeout(200) + + # The table should keep the same scroll position, this fails + assert bb == page.locator(f'text="{col_name}"').bounding_box() @pytest.mark.parametrize('theme', Tabulator.param['theme'].objects) -def test_tabulator_theming(page, port, df_mixed, theme): +def test_tabulator_theming(page, port, df_mixed, df_mixed_as_string, theme): # Subscribe the reponse events to check that the CSS is loaded responses = [] page.on("response", lambda response: responses.append(response)) @@ -494,7 +898,7 @@ def test_tabulator_theming(page, port, df_mixed, theme): # Check that the whole table content is on the page table = page.locator('.bk.pnx-tabulator.tabulator') expect(table).to_have_text( - 'index\nint\nfloat\nstr\nbool\ndate\ndatetime\nidx0\n1\n3.14\nA\ntrue\n2019-01-01\n2019-01-01 10:00:00\nidx1\n2\n6.28\nB\ntrue\n2020-01-01\n2020-01-01 12:00:00\nidx2\n3\n9.42\nC\ntrue\n2020-01-10\n2020-01-10 13:00:00\nidx3\n4\n-2.45\nD\nfalse\n2019-01-10\n2020-01-15 13:00:00', # noqa + df_mixed_as_string, use_inner_text=True ) found = False @@ -1126,6 +1530,40 @@ def test_tabulator_filter_constant_tuple_range(page, port, df_mixed): assert widget.current_view.equals(expected_current_view) +def test_tabulator_filter_param(page, port, df_mixed): + widget = Tabulator(df_mixed) + + class P(param.Parameterized): + s = param.String() + + filt_val, filt_col = 'A', 'str' + p = P(s=filt_val) + widget.add_filter(p.param['s'], column=filt_col) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + df_filtered = df_mixed.loc[df_mixed[filt_col] == filt_val, :] + + wait_until(page, lambda: widget.current_view.equals(df_filtered)) + + # Check the table has the right number of rows + expect(page.locator('.tabulator-row')).to_have_count(len(df_filtered)) + + for filt_val in ['B', 'NOT']: + p.s = filt_val + page.wait_for_timeout(200) + df_filtered = df_mixed.loc[df_mixed[filt_col] == filt_val, :] + + wait_until(page, lambda: widget.current_view.equals(df_filtered)) + + # Check the table has the right number of rows + expect(page.locator('.tabulator-row')).to_have_count(len(df_filtered)) + + @pytest.mark.parametrize( 'cols', [ @@ -1693,6 +2131,7 @@ def test_tabulator_edit_event_and_header_filters(page, port): assert widget.value.equals(df) assert widget.current_view.equals(widget.value) + @pytest.mark.parametrize('sorter', ['sorter', 'no_sorter']) @pytest.mark.parametrize('python_filter', ['python_filter', 'no_python_filter']) @pytest.mark.parametrize('pagination', ['remote', 'local', 'no_pagination']) @@ -1771,11 +2210,14 @@ def test_tabulator_edit_event_integrations(page, port, sorter, python_filter, pa @pytest.mark.parametrize('sorter', ['sorter', 'no_sorter']) @pytest.mark.parametrize('python_filter', ['python_filter', 'no_python_filter']) +@pytest.mark.parametrize('header_filter', ['no_header_filter']) # TODO: add header_filter @pytest.mark.parametrize('pagination', ['remote', 'local', 'no_pagination']) -def test_tabulator_click_event_integrations(page, port, sorter, python_filter, pagination): +def test_tabulator_click_event_selection_integrations(page, port, sorter, python_filter, header_filter, pagination): sorter_col = 'col3' python_filter_col = 'col2' python_filter_val = 'd' + header_filter_col = 'col1' + header_filter_val = 'Y' target_col = 'col4' target_val = 'F' @@ -1790,9 +2232,10 @@ def test_tabulator_click_event_integrations(page, port, sorter, python_filter, p kwargs = {} if pagination != 'no_pagination': - kwargs = dict(pagination=pagination, page_size=2) - - widget = Tabulator(df, **kwargs) + kwargs.update(dict(pagination=pagination, page_size=2)) + if header_filter == 'header_filter': + kwargs.update(dict(header_filters={header_filter_col: {'type': 'input', 'func': 'like'}})) + widget = Tabulator(df, disabled=True, **kwargs) if python_filter == 'python_filter': widget.add_filter(python_filter_val, python_filter_col) @@ -1819,7 +2262,14 @@ def test_tabulator_click_event_integrations(page, port, sorter, python_filter, p page.locator('text="Last"').click() page.wait_for_timeout(100) - # Change the cell concent + if header_filter == 'header_filter': + str_header = page.locator('input[type="search"]') + str_header.click() + str_header.fill(header_filter_val) + str_header.press('Enter') + page.wait_for_timeout(100) + + # Click on the cell cell = page.locator(f'text="{target_val}"') cell.click() @@ -1830,3 +2280,94 @@ def test_tabulator_click_event_integrations(page, port, sorter, python_filter, p if pagination == 'remote' and sorter == 'sorter': pytest.xfail(reason='See https://github.com/holoviz/panel/issues/3663') assert values[0] == (target_col, target_index, target_val) + wait_until(page, lambda: widget.selection == [target_index]) + if pagination in ['local', 'no_pagination'] and python_filter == 'no_python_filter' and sorter == 'sorter': + pytest.xfail(reason='See https://github.com/holoviz/panel/issues/3664') + expected_selected = df.iloc[[target_index], :] + assert widget.selected_dataframe.equals(expected_selected) + + +@pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3664') +def test_tabulator_selection_sorters_on_init(page, port, df_mixed): + widget = Tabulator(df_mixed, sorters=[{'field': 'int', 'dir': 'desc'}]) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # Click on the last index cell to select it + last_index = df_mixed.index[-1] + cell = page.locator(f'text="{last_index}"') + cell.click() + + wait_until(page, lambda: widget.selection == [len(df_mixed) - 1]) + expected_selected = df_mixed.loc[[last_index], :] + assert widget.selected_dataframe.equals(expected_selected) # This fails + + +@pytest.mark.xfail(reason='https://github.com/holoviz/panel/issues/3664') +def test_tabulator_selection_header_filter_unchanged(page, port): + df = pd.DataFrame({ + 'col1': list('XYYYYY'), + 'col2': list('abcddd'), + 'col3': list('ABCDEF') + }) + selection = [2, 3] + widget = Tabulator( + df, + selection=selection, + header_filters={'col1': {'type': 'input', 'func': 'like'}} + ) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + str_header = page.locator('input[type="search"]') + str_header.click() + str_header.fill('Y') + str_header.press('Enter') + page.wait_for_timeout(100) + + assert widget.selection == selection + expected_selected = df.iloc[selection, :] + assert widget.selected_dataframe.equals(expected_selected) + + +@pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3670') +def test_tabulator_selection_header_filter_changed(page, port): + df = pd.DataFrame({ + 'col1': list('XYYYYY'), + 'col2': list('abcddd'), + 'col3': list('ABCDEF') + }) + selection = [0, 3] + widget = Tabulator( + df, + selection=selection, + header_filters={'col1': {'type': 'input', 'func': 'like'}} + ) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + str_header = page.locator('input[type="search"]') + str_header.click() + str_header.fill('Y') + str_header.press('Enter') + page.wait_for_timeout(100) + + assert widget.selection == selection + expected_selected = df.iloc[selection, :] + assert widget.selected_dataframe.equals(expected_selected) + + +def test_tabulator_loading(): + pass diff --git a/panel/tests/widgets/test_tables.py b/panel/tests/widgets/test_tables.py index 0c2cae7f90..0a05328ff2 100644 --- a/panel/tests/widgets/test_tables.py +++ b/panel/tests/widgets/test_tables.py @@ -17,9 +17,9 @@ pytestmark = pytest.mark.skip('pandas not available') from bokeh.models.widgets.tables import ( - AvgAggregator, CellEditor, DataCube, DateFormatter, IntEditor, - MinAggregator, NumberEditor, NumberFormatter, SelectEditor, - StringFormatter, SumAggregator, + AvgAggregator, CellEditor, CheckboxEditor, DataCube, DateEditor, + DateFormatter, IntEditor, MinAggregator, NumberEditor, NumberFormatter, + SelectEditor, StringEditor, StringFormatter, SumAggregator, ) from panel.depends import bind @@ -457,6 +457,51 @@ def test_tabulator_header_filters_column_config_dict(document, comm): ] assert model.configuration['selectable'] == True + +def test_tabulator_editors_default(document, comm): + df = pd.DataFrame({ + 'int': [1, 2], + 'float': [3.14, 6.28], + 'str': ['A', 'B'], + 'date': [dt.date(2009, 1, 8), dt.date(2010, 1, 8)], + 'datetime': [dt.datetime(2009, 1, 8), dt.datetime(2010, 1, 8)], + 'bool': [True, False], + }) + table = Tabulator(df) + model = table.get_root(document, comm) + assert isinstance(model.columns[1].editor, IntEditor) + assert isinstance(model.columns[2].editor, NumberEditor) + assert isinstance(model.columns[3].editor, StringEditor) + assert isinstance(model.columns[4].editor, DateEditor) + assert isinstance(model.columns[5].editor, DateEditor) + assert isinstance(model.columns[6].editor, CheckboxEditor) + + +def test_tabulator_formatters_default(document, comm): + df = pd.DataFrame({ + 'int': [1, 2], + 'float': [3.14, 6.28], + 'str': ['A', 'B'], + 'date': [dt.date(2009, 1, 8), dt.date(2010, 1, 8)], + 'datetime': [dt.datetime(2009, 1, 8), dt.datetime(2010, 1, 8)], + }) + table = Tabulator(df) + model = table.get_root(document, comm) + mformatter = model.columns[1].formatter + assert isinstance(mformatter, NumberFormatter) + mformatter = model.columns[2].formatter + assert isinstance(mformatter, NumberFormatter) + assert mformatter.format == '0,0.0[00000]' + mformatter = model.columns[3].formatter + assert isinstance(mformatter, StringFormatter) + mformatter = model.columns[4].formatter + assert isinstance(mformatter, DateFormatter) + assert mformatter.format == '%Y-%m-%d' + mformatter = model.columns[5].formatter + assert isinstance(mformatter, DateFormatter) + assert mformatter.format == '%Y-%m-%d %H:%M:%S' + + def test_tabulator_config_formatter_string(document, comm): df = makeMixedDataFrame() table = Tabulator(df, formatters={'B': 'tickCross'}) From 737dd78cd78b28accc085396165c0937aa4c82b8 Mon Sep 17 00:00:00 2001 From: maximlt Date: Tue, 5 Jul 2022 07:50:25 +0200 Subject: [PATCH 18/29] increase timeout --- panel/tests/ui/widgets/test_tabulator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index dc2c83ab25..d3fabdc4f7 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -822,14 +822,14 @@ def test_tabulator_patch_no_vertical_rescroll(page, port): page.mouse.move(x=int(width/2), y=int(height/2)) page.mouse.wheel(delta_x=0, delta_y=10000) # Give it time to scroll - page.wait_for_timeout(200) + page.wait_for_timeout(400) bb = page.locator(f'text="{target}"').bounding_box() # Patch a cell in the latest row widget.patch({'col': [(size-1, new_val)]}) # Wait for a potential rescroll - page.wait_for_timeout(200) + page.wait_for_timeout(400) # The table should keep the same scroll position, this fails assert bb == page.locator(f'text="{new_val}"').bounding_box() From cfdeb9d73fb3de302141a658bcd7ddc81e4dba27 Mon Sep 17 00:00:00 2001 From: maximlt Date: Tue, 5 Jul 2022 08:13:41 +0200 Subject: [PATCH 19/29] generate xml coverage report --- .github/workflows/test.yaml | 4 ++++ tox.ini | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 04cd8637cc..b4c2bf6939 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -113,6 +113,8 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: + files: ./coverage.xml + flags: unit-tests/example-tests fail_ci_if_error: false # optional (default = false) # - name: codecov # run: | @@ -186,6 +188,8 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: + files: ./coverage.xml + flags: ui-tests fail_ci_if_error: false # optional (default = false) # - name: codecov # run: | diff --git a/tox.ini b/tox.ini index c8c0cd2c81..26d9b6e700 100644 --- a/tox.ini +++ b/tox.ini @@ -14,7 +14,7 @@ commands = flake8 [_unit] description = Run unit tests deps = .[tests] -commands = pytest panel --cov=./panel +commands = pytest panel --cov=./panel --cov-report=xml [_unit_deploy] description = Run unit tests without coverage @@ -24,7 +24,7 @@ commands = pytest panel [_ui] description = Run UI tests deps = .[tests, ui] -commands = pytest panel --cov=./panel --ui --browser chromium +commands = pytest panel --cov=./panel --cov-report=xml --ui --browser chromium [_examples] description = Test that default examples run From 0faac50ca272570cd76277c24ff67b8c436a39bf Mon Sep 17 00:00:00 2001 From: maximlt Date: Tue, 5 Jul 2022 11:47:25 +0200 Subject: [PATCH 20/29] increase timeouts --- panel/tests/ui/widgets/test_tabulator.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index d3fabdc4f7..d8d239114c 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -822,7 +822,7 @@ def test_tabulator_patch_no_vertical_rescroll(page, port): page.mouse.move(x=int(width/2), y=int(height/2)) page.mouse.wheel(delta_x=0, delta_y=10000) # Give it time to scroll - page.wait_for_timeout(400) + page.wait_for_timeout(800) bb = page.locator(f'text="{target}"').bounding_box() # Patch a cell in the latest row @@ -866,7 +866,7 @@ def test_tabulator_header_filter_no_horizontal_rescroll(page, port, df_mixed, pa page.mouse.move(x=int(width/2), y=80) page.mouse.wheel(delta_x=int(width * 10), delta_y=0) # Give it time to scroll - page.wait_for_timeout(200) + page.wait_for_timeout(400) bb = page.locator(f'text="{col_name}"').bounding_box() @@ -876,7 +876,7 @@ def test_tabulator_header_filter_no_horizontal_rescroll(page, port, df_mixed, pa header.press('Enter') # Give it time to scroll - page.wait_for_timeout(200) + page.wait_for_timeout(400) # The table should keep the same scroll position, this fails assert bb == page.locator(f'text="{col_name}"').bounding_box() @@ -2175,13 +2175,13 @@ def test_tabulator_edit_event_integrations(page, port, sorter, python_filter, pa s.click() # Having to wait when pagination is set to remote before the next click, # maybe there's a better way. - page.wait_for_timeout(100) + page.wait_for_timeout(200) s.click() - page.wait_for_timeout(100) + page.wait_for_timeout(200) if pagination != 'no_pagination' and sorter == 'no_sorter': page.locator('text="Last"').click() - page.wait_for_timeout(100) + page.wait_for_timeout(200) # Change the cell concent cell = page.locator(f'text="{target_val}"') From 88b1721ed3f4b1a70631ffab0bb7786bbc4a9340 Mon Sep 17 00:00:00 2001 From: maximlt Date: Wed, 6 Jul 2022 19:18:10 +0200 Subject: [PATCH 21/29] more tests and attempt to stabilize some --- panel/tests/ui/widgets/test_tabulator.py | 358 ++++++++++++++++++++--- 1 file changed, 319 insertions(+), 39 deletions(-) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index d8d239114c..d1ef71b1a7 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -6,8 +6,10 @@ import pytest from bokeh.models.widgets.tables import ( - BooleanFormatter, DateFormatter, HTMLTemplateFormatter, NumberFormatter, - ScientificFormatter, StringFormatter, + BooleanFormatter, CheckboxEditor, DateEditor, DateFormatter, + HTMLTemplateFormatter, IntEditor, NumberEditor, NumberFormatter, + ScientificFormatter, SelectEditor, StringEditor, StringFormatter, + TextEditor, ) try: @@ -291,6 +293,42 @@ def test_tabulator_titles(page, port, df_mixed): expect(page.locator(f'text="{expected_title}"')).to_have_count(1) +def test_tabulator_hidden_columns(page, port, df_mixed): + widget = Tabulator(df_mixed, hidden_columns=['float', 'date', 'datetime']) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + expected_text = """ + index + int + str + bool + idx0 + 1 + A + true + idx1 + 2 + B + true + idx2 + 3 + C + true + idx3 + 4 + D + false + """ + # Check that the whole table content is on the page + table = page.locator('.bk.pnx-tabulator.tabulator') + expect(table).to_have_text(expected_text, use_inner_text=True) + + def test_tabulator_buttons_display(page, port, df_mixed): nrows, ncols = df_mixed.shape icon_text = 'icon' @@ -558,12 +596,257 @@ def test_tabulator_formatters_after_init(page, port, df_mixed): ) -def test_tabulator_editors_bokeh(): - pass +def test_tabulator_editors_bokeh_string(page, port, df_mixed): + widget = Tabulator(df_mixed, editors={'str': StringEditor()}) + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + cell = page.locator('text="A"') + cell.click() + # A StringEditor is turned into an input text tabulator editor + expect(page.locator('input[type="text"]')).to_have_count(1) + + +def test_tabulator_editors_bokeh_string_completions(page, port, df_mixed): + widget = Tabulator(df_mixed, editors={'str': StringEditor(completions=['AAA'])}) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + cell = page.locator('text="A"') + cell.click() + # A StringEditor with completions is turned into an autocomplete + # tabulator editor. + expect(page.locator('text="AAA"')).to_have_count(1) + + +def test_tabulator_editors_bokeh_text(page, port, df_mixed): + widget = Tabulator(df_mixed, editors={'str': TextEditor()}) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + cell = page.locator('text="A"') + cell.click() + # A TextEditor with completions is turned into a textarea + # tabulator editor. + expect(page.locator('textarea')).to_have_count(1) + + +def test_tabulator_editors_bokeh_int(page, port, df_mixed): + step = 2 + widget = Tabulator(df_mixed, editors={'int': IntEditor(step=step)}) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + cell = page.locator('text="1" >> visible=true') + cell.click() + # An IntEditor with step is turned into a number tabulator editor + # with step respected + input = page.locator('input[type="number"]') + expect(input).to_have_count(1) + assert int(input.get_attribute('step')) == step + + +def test_tabulator_editors_bokeh_number(page, port, df_mixed): + step = 0.1 + widget = Tabulator(df_mixed, editors={'float': NumberEditor(step=step)}) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + cell = page.locator('text="3.14"') + cell.click() + # A NumberEditor with step is turned into a number tabulator editor + # with step respected + input = page.locator('input[type="number"]') + expect(input).to_have_count(1) + assert input.get_attribute('step') == str(step) + + +def test_tabulator_editors_bokeh_checkbox(page, port, df_mixed): + widget = Tabulator(df_mixed, editors={'bool': CheckboxEditor()}) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + cell = page.locator('text="true"').first + cell.click() + # A CheckboxEditor is turned into a tickCross tabulator editor + input = page.locator('input[type="checkbox"]') + expect(input).to_have_count(1) + assert input.get_attribute('value') == "true" + + +def test_tabulator_editors_bokeh_date(page, port, df_mixed): + widget = Tabulator(df_mixed, editors={'date': DateEditor()}) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + cell = page.locator('text="2019-01-01"') + cell.click() + # A DateEditor is turned into a Panel date editor + expect(page.locator('input[type="date"]')).to_have_count(1) + + +def test_tabulator_editors_bokeh_select(page, port, df_mixed): + widget = Tabulator(df_mixed, editors={'str': SelectEditor(options=['option1'])}) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + cell = page.locator('text="A"') + cell.click() + # A SelectEditor with options is turned into a select tabulator editor. + expect(page.locator('text="option1"')).to_have_count(1) + + +def test_tabulator_editors_panel_date(page, port, df_mixed): + widget = Tabulator(df_mixed, editors={'date': 'date'}) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + cell = page.locator('text="2019-01-01"') + cell.click() + # A date editor is turned into an date input + cell_edit = page.locator('input[type="date"]') + new_date = "1980-01-01" + cell_edit.fill(new_date) + # Need to Enter to validate the change + page.locator('input[type="date"]').press('Enter') + expect(page.locator(f'text="{new_date}"')).to_have_count(1) + new_date = dt.datetime.strptime(new_date, '%Y-%m-%d').date() + assert new_date in widget.value['date'].tolist() + + cell = page.locator(f'text="{new_date}"') + cell.click() + cell_edit = page.locator('input[type="date"]') + new_date2 = "1990-01-01" + cell_edit.fill(new_date2) + # Escape invalidates the change + page.locator('input[type="date"]').press('Escape') + expect(page.locator(f'text="{new_date2}"')).to_have_count(0) + new_date2 = dt.datetime.strptime(new_date2, '%Y-%m-%d').date() + assert new_date2 not in widget.value['date'].tolist() -def test_tabulator_editors_tabulator(): - pass + +def test_tabulator_editors_panel_datetime(page, port, df_mixed): + widget = Tabulator(df_mixed, editors={'datetime': 'datetime'}) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + cell = page.locator('text="2019-01-01 10:00:00"') + cell.click() + # A date editor is turned into an date input + cell_edit = page.locator('input[type="datetime-local"]') + new_datetime = dt.datetime(1980, 11, 30, 4, 51, 0) + time_to_fill = new_datetime.isoformat() + # Somehow the seconds don't seem to be handled by datetime-local + time_to_fill = time_to_fill[:-3] + cell_edit.fill(time_to_fill) + # Need to Enter to validate the change + page.locator('input[type="datetime-local"]').press('Enter') + new_datetime_display = new_datetime.strftime('%Y-%m-%d %H:%M:%S') + expect(page.locator(f'text="{new_datetime_display}"')).to_have_count(1) + assert new_datetime in widget.value['datetime'].tolist() + + cell = page.locator(f'text="{new_datetime_display}"') + cell.click() + cell_edit = page.locator('input[type="datetime-local"]') + new_datetime2 = dt.datetime(1990, 3, 31, 12, 45, 0) + time_to_fill2 = new_datetime2.isoformat() + time_to_fill2 = time_to_fill2[:-3] + cell_edit.fill(time_to_fill2) + # Escape invalidates the change + page.locator('input[type="datetime-local"]').press('Escape') + new_datetime_display2 = new_datetime2.strftime('%Y-%m-%d %H:%M:%S') + expect(page.locator(f'text="{new_datetime_display2}"')).to_have_count(0) + assert new_datetime2 not in widget.value['datetime'].tolist() + + +def test_tabulator_editors_tabulator_disable_one(page, port, df_mixed): + widget = Tabulator( + df_mixed, + editors={'float': None}, + ) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + page.locator('text="3.14"').click() + page.wait_for_timeout(200) + expect(page.locator('input[type="number"]')).to_have_count(0) + + +def test_tabulator_editors_tabulator_str(page, port, df_mixed): + widget = Tabulator(df_mixed, editors={'str': 'textarea'}) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + cell = page.locator('text="A"') + cell.click() + expect(page.locator('textarea')).to_have_count(1) + + +def test_tabulator_editors_tabulator_dict(page, port, df_mixed): + widget = Tabulator( + df_mixed, + editors={'str': {'type': 'textarea', 'elementAttributes': {'maxlength': '10'}}} + ) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + cell = page.locator('text="A"') + cell.click() + textarea = page.locator('textarea') + expect(textarea).to_have_count(1) + assert textarea.get_attribute('maxlength') == "10" @pytest.mark.parametrize('layout', Tabulator.param['layout'].objects) @@ -774,7 +1057,7 @@ def test_tabulator_frozen_columns(page, port, df_mixed): @pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3669') def test_tabulator_patch_no_horizontal_rescroll(page, port, df_mixed): - widths = 50 + widths = 100 width = int(((df_mixed.shape[1] + 1) * widths) / 2) df_mixed['tomodify'] = 'target' widget = Tabulator(df_mixed, width=width, widths=widths) @@ -785,31 +1068,31 @@ def test_tabulator_patch_no_horizontal_rescroll(page, port, df_mixed): page.goto(f"http://localhost:{port}") - # Might be a little brittle, setting the mouse somewhere in the table - # and scroll right - page.mouse.move(x=int(width/2), y=40) - page.mouse.wheel(delta_x=int(width * 10), delta_y=0) - # Give it time to scroll - page.wait_for_timeout(100) - + cell = page.locator('text="target"').first + # Scroll to the right + cell.scroll_into_view_if_needed() + page.wait_for_timeout(200) bb = page.locator('text="tomodify"').bounding_box() # Patch a cell in the latest column widget.patch({'tomodify': [(0, 'target-modified')]}, as_index=False) - # The table should keep the same scroll position, this fails + # Catch a potential rescroll + page.wait_for_timeout(400) + # The table should keep the same scroll position + # This fails assert bb == page.locator('text="tomodify"').bounding_box() @pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3249') def test_tabulator_patch_no_vertical_rescroll(page, port): - size = 100 + size = 10 arr = np.random.choice(list('abcd'), size=size) target, new_val = 'X', 'Y' arr[-1] = target df = pd.DataFrame({'col': arr}) height, width = 100, 200 - widget = Tabulator(df, height=100, width=width) + widget = Tabulator(df, height=height, width=width) serve(widget, port=port, threaded=True, show=False) @@ -817,20 +1100,27 @@ def test_tabulator_patch_no_vertical_rescroll(page, port): page.goto(f"http://localhost:{port}") + # Scroll to the bottom + target_cell = page.locator(f'text="{target}"') + target_cell.scroll_into_view_if_needed() + page.wait_for_timeout(400) + # Unfortunately that doesn't scroll down quite enough, it's missing + # a little scroll down so we do it manually which is more brittle. # Might be a little brittle, setting the mouse somewhere in the table # and scroll down page.mouse.move(x=int(width/2), y=int(height/2)) page.mouse.wheel(delta_x=0, delta_y=10000) # Give it time to scroll - page.wait_for_timeout(800) + page.wait_for_timeout(400) bb = page.locator(f'text="{target}"').bounding_box() # Patch a cell in the latest row widget.patch({'col': [(size-1, new_val)]}) - # Wait for a potential rescroll + # Wait to catch a potential rescroll page.wait_for_timeout(400) - # The table should keep the same scroll position, this fails + # The table should keep the same scroll position + # This fails assert bb == page.locator(f'text="{new_val}"').bounding_box() @@ -861,25 +1151,23 @@ def test_tabulator_header_filter_no_horizontal_rescroll(page, port, df_mixed, pa page.goto(f"http://localhost:{port}") - # Might be a little brittle, setting the mouse somewhere in the table - # and scroll right - page.mouse.move(x=int(width/2), y=80) - page.mouse.wheel(delta_x=int(width * 10), delta_y=0) - # Give it time to scroll - page.wait_for_timeout(400) - - bb = page.locator(f'text="{col_name}"').bounding_box() + header = page.locator(f'text="{col_name}"') + # Scroll to the right + header.scroll_into_view_if_needed() + bb = header.bounding_box() header = page.locator('input[type="search"]') header.click() header.fill('off') header.press('Enter') - # Give it time to scroll + # Wait to catch a potential rescroll page.wait_for_timeout(400) - + header = page.locator(f'text="{col_name}"') + header.wait_for() # The table should keep the same scroll position, this fails - assert bb == page.locator(f'text="{col_name}"').bounding_box() + assert bb == header.bounding_box() + # assert bb == page.locator(f'text="{col_name}"').bounding_box() @pytest.mark.parametrize('theme', Tabulator.param['theme'].objects) @@ -1903,14 +2191,6 @@ def test_tabulator_configuration(page, port, df_mixed): expect(page.locator(".tabulator-sortable")).to_have_count(0) -def test_tabulator_editor_datetime(): - pass - - -def test_tabulator_editor_date(): - pass - - @pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3620') def test_tabulator_editor_datetime_nan(page, port, df_mixed): df_mixed.at['idx0', 'datetime'] = np.nan From 02efa5d180b84331f1930c24476d101f5e3195ea Mon Sep 17 00:00:00 2001 From: maximlt Date: Fri, 8 Jul 2022 10:10:40 +0200 Subject: [PATCH 22/29] increase timeouts --- panel/tests/ui/widgets/test_tabulator.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index d1ef71b1a7..a3f1c26c39 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -2611,10 +2611,11 @@ def test_tabulator_selection_header_filter_unchanged(page, port): str_header.click() str_header.fill('Y') str_header.press('Enter') - page.wait_for_timeout(100) + page.wait_for_timeout(300) assert widget.selection == selection expected_selected = df.iloc[selection, :] + # This fails assert widget.selected_dataframe.equals(expected_selected) @@ -2642,10 +2643,11 @@ def test_tabulator_selection_header_filter_changed(page, port): str_header.click() str_header.fill('Y') str_header.press('Enter') - page.wait_for_timeout(100) + page.wait_for_timeout(300) assert widget.selection == selection expected_selected = df.iloc[selection, :] + # This fails assert widget.selected_dataframe.equals(expected_selected) From b6e00433833491583e42256a7c561fa22bc9669f Mon Sep 17 00:00:00 2001 From: maximlt Date: Fri, 8 Jul 2022 16:23:16 +0200 Subject: [PATCH 23/29] rename codecov flag --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b4c2bf6939..79c0381850 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -114,7 +114,7 @@ jobs: uses: codecov/codecov-action@v3 with: files: ./coverage.xml - flags: unit-tests/example-tests + flags: unitexamples-tests fail_ci_if_error: false # optional (default = false) # - name: codecov # run: | From 6aae8717f9081d17cd194fe6d6e041cebcc91786 Mon Sep 17 00:00:00 2001 From: maximlt Date: Sat, 9 Jul 2022 10:41:00 +0200 Subject: [PATCH 24/29] set coverage concurrency lib for ui tests --- .uicoveragerc | 2 ++ tox.ini | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 .uicoveragerc diff --git a/.uicoveragerc b/.uicoveragerc new file mode 100644 index 0000000000..0099493102 --- /dev/null +++ b/.uicoveragerc @@ -0,0 +1,2 @@ +[run] +concurrency = greenlet diff --git a/tox.ini b/tox.ini index 26d9b6e700..1e0fdd8a3b 100644 --- a/tox.ini +++ b/tox.ini @@ -24,7 +24,7 @@ commands = pytest panel [_ui] description = Run UI tests deps = .[tests, ui] -commands = pytest panel --cov=./panel --cov-report=xml --ui --browser chromium +commands = pytest panel --cov=./panel --cov-report=xml --cov-config=.uicoveragerc --ui --browser chromium [_examples] description = Test that default examples run From 2704220de5beb37e7451156c82477f90b226c997 Mon Sep 17 00:00:00 2001 From: maximlt Date: Sun, 10 Jul 2022 11:56:57 +0200 Subject: [PATCH 25/29] move utils to the util test module --- panel/tests/ui/widgets/test_tabulator.py | 73 +--------------------- panel/tests/util.py | 78 ++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 72 deletions(-) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index a3f1c26c39..1baec6fd15 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -1,5 +1,4 @@ import datetime as dt -import sys import time import param @@ -31,6 +30,7 @@ from panel import state from panel.io.server import serve +from panel.tests.util import get_ctrl_modifier, wait_until from panel.widgets import Tabulator @@ -100,77 +100,6 @@ def df_multiindex(df_mixed): return df_mi -def wait_until(page, fn, timeout=5000, interval=100): - """ - Exercice a test function until in a loop until it times out. - - The function can either be a simple lambda that returns True or False: - >>> wait_until(page, lambda: x.values() == ['x']) - - Or a defined function with an assert: - >>> def _() - >>> assert x.values() == ['x'] - >>> wait_until(page, _) - - Parameters - ---------- - page : playwright.sync_api.Page - Playwright page - fn : callable - Callback - timeout : int, optional - Total timeout in milliseconds, by default 5000 - interval : int, optional - Waiting interval, by default 100 - - Adapted from pytest-qt. - """ - # Hide this function traceback from the pytest output if the test fails - __tracebackhide__ = True - - start = time.time() - - def timed_out(): - elapsed = time.time() - start - elapsed_ms = elapsed * 1000 - return elapsed_ms > timeout - - timeout_msg = f"wait_until timed out in {timeout} milliseconds" - - while True: - try: - result = fn() - except AssertionError as e: - if timed_out(): - raise TimeoutError(timeout_msg) from e - else: - if result not in (None, True, False): - raise ValueError( - "`wait_until` callback must return None, True or " - f"False, returned {result!r}" - ) - # None is returned when the function has an assert - if result is None: - return - # When the function returns True or False - if result: - return - if timed_out(): - raise TimeoutError(timeout_msg) - # Playwright recommends against using time.sleep - # https://playwright.dev/python/docs/intro#timesleep-leads-to-outdated-state - page.wait_for_timeout(interval) - - -def get_ctrl_modifier(): - if sys.platform in ['linux', 'win32']: - return 'Control' - elif sys.platform == 'darwin': - return 'Meta' - else: - raise ValueError(f'No control modifier defined for platform {sys.platform}') - - def count_per_page(count: int, page_size: int): """ >>> count_per_page(12, 7) diff --git a/panel/tests/util.py b/panel/tests/util.py index 0273a16877..51cb983e94 100644 --- a/panel/tests/util.py +++ b/panel/tests/util.py @@ -1,3 +1,6 @@ +import sys +import time + import numpy as np import pytest @@ -87,3 +90,78 @@ def check_layoutable_properties(layoutable, model): layoutable.height_policy = 'min' assert model.height_policy == 'min' + + +def wait_until(page, fn, timeout=5000, interval=100): + """ + Exercice a test function in a loop until it evaluates to True + or times out. + + The function can either be a simple lambda that returns True or False: + >>> wait_until(page, lambda: x.values() == ['x']) + + Or a defined function with an assert: + >>> def _() + >>> assert x.values() == ['x'] + >>> wait_until(page, _) + + Parameters + ---------- + page : playwright.sync_api.Page + Playwright page + fn : callable + Callback + timeout : int, optional + Total timeout in milliseconds, by default 5000 + interval : int, optional + Waiting interval, by default 100 + + Adapted from pytest-qt. + """ + # Hide this function traceback from the pytest output if the test fails + __tracebackhide__ = True + + start = time.time() + + def timed_out(): + elapsed = time.time() - start + elapsed_ms = elapsed * 1000 + return elapsed_ms > timeout + + timeout_msg = f"wait_until timed out in {timeout} milliseconds" + + while True: + try: + result = fn() + except AssertionError as e: + if timed_out(): + raise TimeoutError(timeout_msg) from e + else: + if result not in (None, True, False): + raise ValueError( + "`wait_until` callback must return None, True or " + f"False, returned {result!r}" + ) + # None is returned when the function has an assert + if result is None: + return + # When the function returns True or False + if result: + return + if timed_out(): + raise TimeoutError(timeout_msg) + # Playwright recommends against using time.sleep + # https://playwright.dev/python/docs/intro#timesleep-leads-to-outdated-state + page.wait_for_timeout(interval) + + +def get_ctrl_modifier(): + """ + Get the CTRL modifier on the current platform. + """ + if sys.platform in ['linux', 'win32']: + return 'Control' + elif sys.platform == 'darwin': + return 'Meta' + else: + raise ValueError(f'No control modifier defined for platform {sys.platform}') From c795fb40b5963ba6ba709701455287ed8414c522 Mon Sep 17 00:00:00 2001 From: maximlt Date: Sun, 10 Jul 2022 11:57:23 +0200 Subject: [PATCH 26/29] increase default playwright timeout --- panel/tests/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/panel/tests/conftest.py b/panel/tests/conftest.py index 00ea31a011..f8d5920be4 100644 --- a/panel/tests/conftest.py +++ b/panel/tests/conftest.py @@ -37,8 +37,8 @@ def pytest_configure(config): @pytest.fixture def context(context): - # Reduce the default timeout to 10 secs - context.set_default_timeout(10_000) + # Set the default timeout to 20 secs + context.set_default_timeout(20_000) yield context PORT = [6000] From 2aca2bcad1a2a80e1d9b2d022cf038fb078de3be Mon Sep 17 00:00:00 2001 From: maximlt Date: Sun, 10 Jul 2022 21:30:24 -0500 Subject: [PATCH 27/29] more tests --- panel/tests/ui/widgets/test_tabulator.py | 294 +++++++++++++++++++++-- 1 file changed, 275 insertions(+), 19 deletions(-) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index 1baec6fd15..32a9370481 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -906,7 +906,7 @@ def test_tabulator_alignment_text_str(page, port, df_mixed): def test_tabulator_frozen_columns(page, port, df_mixed): - widths = 50 + widths = 100 width = int(((df_mixed.shape[1] + 1) * widths) / 2) frozen_cols = ['float', 'int'] widget = Tabulator(df_mixed, frozen_columns=frozen_cols, width=width, widths=widths) @@ -969,12 +969,9 @@ def test_tabulator_frozen_columns(page, port, df_mixed): # Check that the float column is rendered before the int column assert float_bb['x'] < int_bb['x'] - # Might be a little brittle, setting the mouse somewhere in the table - # and scroll right - page.mouse.move(x=int(width/2), y=40) - page.mouse.wheel(delta_x=int(width*10), delta_y=0) - # Give it time to scroll - page.wait_for_timeout(100) + # Scroll to the right, and give it a little extra time + page.locator('text="2019-01-01 10:00:00"').scroll_into_view_if_needed() + page.wait_for_timeout(200) # Check that the two frozen columns haven't moved after scrolling right assert float_bb == page.locator('text="float"').bounding_box() @@ -983,6 +980,66 @@ def test_tabulator_frozen_columns(page, port, df_mixed): assert bool_bb['x'] > page.locator('text="bool"').bounding_box()['x'] +def test_tabulator_frozen_rows(page, port): + arr = np.array(['a'] * 10) + + arr[1] = 'X' + arr[-2] = 'Y' + arr[-1] = 'T' + df = pd.DataFrame({'col': arr}) + height, width = 200, 200 + widget = Tabulator(df, frozen_rows=[-2, 1], height=height, width=width) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + expected_text = """ + index + col + 8 + Y + 1 + X + 0 + a + 2 + a + 3 + a + 4 + a + 5 + a + 6 + a + 7 + a + 9 + T + """ + + expect(page.locator('.tabulator')).to_have_text( + expected_text, + use_inner_text=True + ) + + X_bb = page.locator('text="X"').bounding_box() + Y_bb = page.locator('text="Y"').bounding_box() + + # Check that the Y row is rendered before the X column + assert Y_bb['y'] < X_bb['y'] + + # Scroll to the bottom, and give it a little extra time + page.locator('text="T"').scroll_into_view_if_needed() + page.wait_for_timeout(200) + + # Check that the two frozen columns haven't moved after scrolling right + assert X_bb == page.locator('text="X"').bounding_box() + assert Y_bb == page.locator('text="Y"').bounding_box() + @pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3669') def test_tabulator_patch_no_horizontal_rescroll(page, port, df_mixed): @@ -1566,12 +1623,125 @@ def test_tabulator_row_content_expand_from_python_after(page, port, df_mixed): assert openables.count() == len(df_mixed) -def test_tabulator_grouping(): - pass +def test_tabulator_groups(page, port, df_mixed): + widget = Tabulator( + df_mixed, + groups={'Group1': ['int', 'float'], 'Group2': ['date', 'datetime']}, + ) + + serve(widget, port=port, threaded=True, show=False) + time.sleep(0.2) -def test_tabulator_groupby(): - pass + page.goto(f"http://localhost:{port}") + + expected_text = """ + index + Group1 + int + float + str + bool + Group2 + date + datetime + idx0 + 1 + 3.14 + A + true + 2019-01-01 + 2019-01-01 10:00:00 + idx1 + 2 + 6.28 + B + true + 2020-01-01 + 2020-01-01 12:00:00 + idx2 + 3 + 9.42 + C + true + 2020-01-10 + 2020-01-10 13:00:00 + idx3 + 4 + -2.45 + D + false + 2019-01-10 + 2020-01-15 13:00:00 + """ + + expect(page.locator('.tabulator')).to_have_text( + expected_text, + use_inner_text=True, + ) + + expect(page.locator('.tabulator-col-group')).to_have_count(2) + + +def test_tabulator_groupby(page, port): + df = pd.DataFrame({ + 'cat1': ['A', 'B', 'A', 'A', 'B', 'B', 'B'], + 'cat2': ['X', 'X', 'X', 'X', 'Y', 'Y', 'Y'], + 'value': list(range(7)), + }) + + widget = Tabulator(df, groupby=['cat1', 'cat2']) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + expected_text = """ + index + cat1 + cat2 + value + cat1: A, cat2: X(3 items) + 0 + A + X + 0 + 2 + A + X + 2 + 3 + A + X + 3 + cat1: B, cat2: X(1 item) + 1 + B + X + 1 + cat1: B, cat2: Y(3 items) + 4 + B + Y + 4 + 5 + B + Y + 5 + 6 + B + Y + 6 + """ + + expect(page.locator('.tabulator')).to_have_text( + expected_text, + use_inner_text=True, + ) + + expect(page.locator('.tabulator-group')).to_have_count(3) @pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3564') @@ -1913,8 +2083,40 @@ def test_tabulator_header_filters_set_from_client(page, port, df_mixed): assert widget.current_view.equals(expected_filter_df) -def test_tabulator_downloading(): - pass +def test_tabulator_download(page, port, df_mixed, df_mixed_as_string): + widget = Tabulator(df_mixed) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # Check that the whole table content is on the page, just + # to make sure the page is loaded before triggering the + # download. + table = page.locator('.tabulator') + expect(table).to_have_text( + df_mixed_as_string, + use_inner_text=True + ) + + # Start waiting for the download + with page.expect_download() as download_info: + widget.download() + download = download_info.value + # Wait for the download process to complete + path = download.path() + + saved_df = pd.read_csv(path, index_col='index') + # Some transformations required to reform the dataframe as the original one. + saved_df['date'] = pd.to_datetime(saved_df['date'], unit='ms') + saved_df['date'] = saved_df['date'].astype(object) + saved_df['datetime'] = pd.to_datetime(saved_df['datetime'], unit='ms') + saved_df.index.name = None + + pd.testing.assert_frame_equal(df_mixed, saved_df) + def test_tabulator_streaming_default(page, port): df = pd.DataFrame(np.random.random((3, 2)), columns=['A', 'B']) @@ -2463,20 +2665,20 @@ def test_tabulator_click_event_selection_integrations(page, port, sorter, python s.click() # Having to wait when pagination is set to remote before the next click, # maybe there's a better way. - page.wait_for_timeout(100) + page.wait_for_timeout(200) s.click() - page.wait_for_timeout(100) + page.wait_for_timeout(200) if pagination != 'no_pagination' and sorter == 'no_sorter': page.locator('text="Last"').click() - page.wait_for_timeout(100) + page.wait_for_timeout(200) if header_filter == 'header_filter': str_header = page.locator('input[type="search"]') str_header.click() str_header.fill(header_filter_val) str_header.press('Enter') - page.wait_for_timeout(100) + page.wait_for_timeout(200) # Click on the cell cell = page.locator(f'text="{target_val}"') @@ -2580,5 +2782,59 @@ def test_tabulator_selection_header_filter_changed(page, port): assert widget.selected_dataframe.equals(expected_selected) -def test_tabulator_loading(): - pass +def test_tabulator_loading_no_horizontal_rescroll(page, port, df_mixed): + widths = 100 + width = int(((df_mixed.shape[1] + 1) * widths) / 2) + df_mixed['Target'] = 'target' + widget = Tabulator(df_mixed, width=width, widths=widths) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + cell = page.locator('text="target"').first + # Scroll to the right + cell.scroll_into_view_if_needed() + page.wait_for_timeout(200) + bb = page.locator('text="Target"').bounding_box() + + widget.loading = True + page.wait_for_timeout(200) + widget.loading = False + + # To catch a potential rescroll + page.wait_for_timeout(400) + # The table should keep the same scroll position + assert bb == page.locator('text="Target"').bounding_box() + + +def test_tabulator_loading_no_vertical_rescroll(page, port): + arr = np.array(['a'] * 10) + + arr[-1] = 'T' + df = pd.DataFrame({'col': arr}) + height, width = 200, 200 + widget = Tabulator(df, height=height, width=width) + + serve(widget, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + # Scroll to the bottom, and give it a little extra time + page.locator('text="T"').scroll_into_view_if_needed() + page.wait_for_timeout(200) + + bb = page.locator('text="T"').bounding_box() + + widget.loading = True + page.wait_for_timeout(200) + widget.loading = False + + # To catch a potential rescroll + page.wait_for_timeout(400) + # The table should keep the same scroll position + assert bb == page.locator('text="T"').bounding_box() From 44f736cd8e81749c283493745201102416ed1f91 Mon Sep 17 00:00:00 2001 From: maximlt Date: Sun, 10 Jul 2022 21:31:38 -0500 Subject: [PATCH 28/29] codecov cleanup --- .github/workflows/test.yaml | 10 ---------- setup.py | 1 - 2 files changed, 11 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 79c0381850..c322175281 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -116,11 +116,6 @@ jobs: files: ./coverage.xml flags: unitexamples-tests fail_ci_if_error: false # optional (default = false) - # - name: codecov - # run: | - # eval "$(conda shell.bash hook)" - # conda activate test-environment - # codecov ui_test_suite: name: UI tests on ${{ matrix.os }} with Python 3.9 needs: [pre_commit] @@ -191,8 +186,3 @@ jobs: files: ./coverage.xml flags: ui-tests fail_ci_if_error: false # optional (default = false) - # - name: codecov - # run: | - # eval "$(conda shell.bash hook)" - # conda activate test-environment - # codecov diff --git a/setup.py b/setup.py index ac37abca9c..7d82d41b04 100644 --- a/setup.py +++ b/setup.py @@ -125,7 +125,6 @@ def run(self): 'scipy', 'nbval', 'pytest-cov', - 'codecov', 'folium', 'ipympl', 'twine', From 391c49195ee1c49c0d42671bd7f1710d4f020af2 Mon Sep 17 00:00:00 2001 From: maximlt Date: Sun, 10 Jul 2022 21:51:54 -0500 Subject: [PATCH 29/29] add comment about greenlet/coverage --- tox.ini | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tox.ini b/tox.ini index 1e0fdd8a3b..8af08b93fc 100644 --- a/tox.ini +++ b/tox.ini @@ -24,6 +24,9 @@ commands = pytest panel [_ui] description = Run UI tests deps = .[tests, ui] +; Read the .uicoveragerc file to set the concurrency library to greenlet +; when the test suite relies on playwright, see +; https://github.com/microsoft/playwright-python/issues/313 commands = pytest panel --cov=./panel --cov-report=xml --cov-config=.uicoveragerc --ui --browser chromium [_examples]