/
vshmidt
/
streamlit
Обзор
Документация
Войти
/
vshmidt
/
streamlit
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
e2e_playwright/st_multiselect_test.py
556 строк
21 KB
Lukas Masuch
Fix selectbox and multiselect clearing selections for custom objects (#13648)
22 янв 2026, 21:32
Не верифицирован
22 янв 2026, 21:32
8150720
Код
Авторство
О чём код?
# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2026) # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from __future__ import annotations import re from playwright.sync_api import Locator, Page, expect from e2e_playwright.conftest import ImageCompareFunction, wait_for_app_run from e2e_playwright.shared.app_utils import ( check_top_level_class, click_checkbox, click_toggle, expect_help_tooltip, expect_prefixed_markdown, expect_text, get_element_by_key, get_multiselect, ) MULTISELECT_COUNT = 21 def _get_multiselect_input(locator: Locator | Page, label: str) -> Locator: return get_multiselect(locator, label).locator("input").first def select_for_multiselect( page: Page, label: str, option_text: str, close_after_selecting: bool ) -> None: """Select an option from a multiselect widget identified by its label.""" ms = get_multiselect(page, label) ms.locator("input").click() page.locator("li").filter(has_text=option_text).first.click() if close_after_selecting: page.keyboard.press("Escape") wait_for_app_run(page) def del_from_multiselect(page: Page, label: str, option_text: str) -> None: ms = get_multiselect(page, label) ms.locator( f'span[data-baseweb="tag"] span[title="{option_text}"] + span[role="presentation"]' ).first.click() wait_for_app_run(page) def test_multiselect_on_load(themed_app: Page, assert_snapshot: ImageCompareFunction): """Should show widgets correctly when loaded.""" expect(themed_app.get_by_test_id("stMultiSelect")).to_have_count(MULTISELECT_COUNT) assert_snapshot( get_multiselect(themed_app, "multiselect 1"), name="st_multiselect-placeholder_help", ) assert_snapshot( get_multiselect(themed_app, "multiselect 2"), name="st_multiselect-format_func" ) assert_snapshot( get_multiselect(themed_app, "multiselect 3"), name="st_multiselect-empty_list" ) assert_snapshot( get_multiselect(themed_app, "multiselect 4"), name="st_multiselect-initial_value", ) assert_snapshot( get_multiselect(themed_app, "multiselect 5"), name="st_multiselect-long_values" ) assert_snapshot( get_multiselect(themed_app, "multiselect 6"), name="st_multiselect-disabled" ) assert_snapshot( get_multiselect(themed_app, "Hidden label"), name="st_multiselect-hidden_label" ) assert_snapshot( get_multiselect(themed_app, "Collapsed label"), name="st_multiselect-collapsed_label", ) # The other multiselect widgets do not need to be screenshot tested since they # don't have any visually interesting differences. assert_snapshot( get_multiselect(themed_app, "multiselect 12"), name="st_multiselect-narrow_column", ) assert_snapshot( get_multiselect(themed_app, re.compile(r"^multiselect 13")), name="st_multiselect-markdown_label", ) assert_snapshot( get_multiselect(themed_app, "multiselect 17 - show maxHeight"), name="st_multiselect-maxHeight", ) assert_snapshot( get_multiselect(themed_app, "multiselect 18 (width=300px)"), name="st_multiselect-width_300px", ) assert_snapshot( get_multiselect(themed_app, "multiselect 19 (width='stretch')"), name="st_multiselect-width_stretch", ) def test_help_tooltip_works(app: Page): element_with_help = get_multiselect(app, "multiselect 1") expect_help_tooltip(app, element_with_help, "Help text") def test_multiselect_initial_value(app: Page): """Should show the correct initial values.""" expect_text(app, "value 1: []") expect_text(app, "value 2: []") expect_text(app, "value 3: []") expect_text(app, "value 4: ['tea', 'water']") expect_text(app, "value 5: []") expect_text(app, "value 6: []") expect_text(app, "value 7: []") expect_text(app, "value 8: []") expect_text(app, "value 9: []") expect_text(app, "value 10: []") expect_text(app, "value 11: []") expect_text(app, "multiselect changed: False") expect_text(app, "value 12: ['A long option']") expect_text(app, "value 14: []") expect_text(app, "value 15: ['apple', 'orange']") expect_text(app, "value 16: []") def test_multiselect_clear_all(app: Page): """Should clear all options when clicking clear all.""" select_for_multiselect(app, "multiselect 2", "Female", True) get_multiselect(app, "multiselect 2").locator( '[role="button"][aria-label="Clear all"]' ).first.click() expect_text(app, "value 2: []") def test_multiselect_show_values_in_dropdown( app: Page, assert_snapshot: ImageCompareFunction ): """Screenshot test to check that values are shown in dropdown.""" multiselect_elem = get_multiselect(app, "multiselect 1") multiselect_elem.locator("input").click() wait_for_app_run(app) dropdown_elements = app.locator("li") expect(dropdown_elements).to_have_count(2) assert_snapshot( dropdown_elements.filter(has_text="male").first, name="st_multiselect-dropdown_0", ) assert_snapshot( dropdown_elements.filter(has_text="female").first, name="st_multiselect-dropdown_1", ) def test_multiselect_long_values_in_dropdown( app: Page, assert_snapshot: ImageCompareFunction ): """Should show long values correctly (with ellipses) in the dropdown menu.""" multiselect_elem = get_multiselect(app, "multiselect 5") multiselect_elem.locator("input").click() wait_for_app_run(app) dropdown_elems = app.locator("li").all() for idx, el in enumerate(dropdown_elems): assert_snapshot(el, name="st_multiselect-dropdown_long_label_" + str(idx)) def test_multiselect_long_values_in_narrow_column( app: Page, assert_snapshot: ImageCompareFunction ): """Should show long values correctly (with ellipses) when in narrow column widths. """ multiselect_elem = get_multiselect(app, "multiselect 12") wait_for_app_run(app) # Wait for list items to be loaded in app.locator("li").all() assert_snapshot(multiselect_elem, name="st_multiselect-dropdown_narrow_column") def test_multiselect_register_callback(app: Page): """Should call the callback when an option is selected.""" _get_multiselect_input(app, "multiselect 11").click() app.locator("li").first.click() expect_text(app, "value 11: ['male']") expect_text(app, "multiselect changed: True") def test_multiselect_max_selections_form(app: Page): """Should apply max selections when used in form.""" select_for_multiselect(app, "multiselect 10", "male", False) expect(app.locator("li")).to_have_text( "You can only select up to 1 option. Remove an option first.", use_inner_text=True, ) def test_multiselect_max_selections_1(app: Page): """Should show the correct text when maxSelections is reached and closing after selecting. """ select_for_multiselect(app, "multiselect 9", "male", True) get_multiselect(app, "multiselect 9").click() expect(app.locator("li")).to_have_text( "You can only select up to 1 option. Remove an option first.", use_inner_text=True, ) def test_multiselect_max_selections_2(app: Page): """Should show the correct text when maxSelections is reached and not closing after selecting. """ select_for_multiselect(app, "multiselect 9", "male", False) expect(app.locator("li")).to_have_text( "You can only select up to 1 option. Remove an option first.", use_inner_text=True, ) def test_multiselect_valid_options(app: Page): """Should allow selections when there are valid options.""" expect(get_multiselect(app, "multiselect 1")).to_have_text( "multiselect 1\n\nPlease select", use_inner_text=True ) def test_multiselect_no_valid_options(app: Page): """Should show that their are no options.""" expect(get_multiselect(app, "multiselect 3")).to_have_text( "multiselect 3\n\nNo options to select", use_inner_text=True ) def test_multiselect_single_selection(app: Page, assert_snapshot: ImageCompareFunction): """Should allow selections.""" select_for_multiselect(app, "multiselect 2", "Female", True) expect(get_multiselect(app, "multiselect 2").locator("span").nth(1)).to_have_text( "Female", use_inner_text=True ) assert_snapshot( get_multiselect(app, "multiselect 2"), name="st_multiselect-selection" ) expect_text(app, "value 2: ['female']") def test_multiselect_deselect_option(app: Page): """Should deselect an option when deselecting it.""" select_for_multiselect(app, "multiselect 2", "Female", True) select_for_multiselect(app, "multiselect 2", "Male", True) del_from_multiselect(app, "multiselect 2", "Female") expect_text(app, "value 2: ['male']") def test_multiselect_option_over_max_selections(app: Page): """Should show an error when more than max_selections got selected.""" click_checkbox(app, "set_multiselect_9") expect(app.get_by_test_id("stException")).to_contain_text( "Multiselect has 2 options selected but max_selections is set to 1" ) def test_multiselect_double_selection(app: Page): """Should allow multiple selections.""" select_for_multiselect(app, "multiselect 2", "Female", True) select_for_multiselect(app, "multiselect 2", "Male", True) expect_text(app, "value 2: ['female', 'male']") def test_check_top_level_class(app: Page): """Check that the top level class is correctly set.""" check_top_level_class(app, "stMultiSelect") def test_custom_css_class_via_key(app: Page): """Test that the element can have a custom css class via the key argument.""" expect(get_element_by_key(app, "multiselect 9")).to_be_visible() def test_dynamic_multiselect_props(app: Page, assert_snapshot: ImageCompareFunction): """Test that the multiselect can be updated dynamically while keeping the state. This tests that: 1. Options can be changed dynamically when a key is provided 2. Format function can be changed dynamically 3. Selection resets (filters invalid) when selected values are removed from options 4. Selection is preserved when the selected values exist in new options Initial options: [apple, banana, mango, orange] with format_func=capitalize, default=['apple'] Updated options: [mango, papaya, grape, apple] with format_func=capitalize, default=[] """ dynamic_ms = get_element_by_key(app, "dynamic_multiselect_with_key") expect(dynamic_ms).to_be_visible() # Initial state and selection expect(dynamic_ms).to_contain_text("Initial dynamic multiselect") expect_prefixed_markdown(app, "Initial multiselect value:", "['apple']") assert_snapshot(dynamic_ms, name="st_multiselect-dynamic_initial") # Check that the help tooltip is correct: expect_help_tooltip(app, dynamic_ms, "initial help") # --- Test 1: Selection RESETS when value is removed from options --- # Select "banana" (only exists in initial options, NOT in updated) select_for_multiselect(app, "Initial dynamic multiselect", "Banana", True) expect_prefixed_markdown(app, "Initial multiselect value:", "['apple', 'banana']") # Toggle to update props - options change from [apple, banana, mango, orange] # to [mango, papaya, grape, apple]. "banana" is NOT in updated options. click_toggle(app, "Update multiselect props") # Updated multiselect is visible expect(dynamic_ms).to_contain_text("Updated dynamic multiselect") # Selection should filter out "banana" (not in updated options), keeping only "apple" expect_prefixed_markdown(app, "Updated multiselect value:", "['apple']") dynamic_ms.scroll_into_view_if_needed() assert_snapshot(dynamic_ms, name="st_multiselect-dynamic_updated") # Check that the help tooltip is correct: expect_help_tooltip(app, dynamic_ms, "updated help") # --- Test 2: Selection PRESERVED when value exists in both option sets --- # Select "mango" - it exists in BOTH option sets at different indices: # Initial: index 2 (displayed "Mango"), Updated: index 0 (displayed "Mango") select_for_multiselect(app, "Updated dynamic multiselect", "Mango", True) expect_prefixed_markdown(app, "Updated multiselect value:", "['apple', 'mango']") # Toggle back to initial options - "mango" and "apple" exist in initial too click_toggle(app, "Update multiselect props") expect(dynamic_ms).to_contain_text("Initial dynamic multiselect") # Selection should be PRESERVED since both "apple" and "mango" are in both option sets expect_prefixed_markdown(app, "Initial multiselect value:", "['apple', 'mango']") # Toggle again and check that the selection is preserved: click_toggle(app, "Update multiselect props") expect(dynamic_ms).to_contain_text("Updated dynamic multiselect") expect_prefixed_markdown(app, "Updated multiselect value:", "['apple', 'mango']") def test_multiselect_accept_new_options(app: Page): """Should allow adding new options when accept_new_options is True and respect max_selections. """ # Get the last multiselect (index 13) multiselect_elem = get_multiselect(app, "multiselect 14 - accept new options") # Click to open dropdown multiselect_elem.locator("input").click() # Type and add new option "mango" input_elem = multiselect_elem.locator("input") input_elem.fill("mango") input_elem.press("Enter") wait_for_app_run(app) # Type and add another option "grape" input_elem.fill("grape") input_elem.press("Enter") wait_for_app_run(app) # Add a third option from original options multiselect_elem.locator("input").click() options_list = app.locator("li") expect(options_list).to_have_count(4) options_list.filter(has_text="apple").click() wait_for_app_run(app) # Verify three options were added successfully expect_text(app, "value 14: ['mango', 'grape', 'apple']") # Verify that format_func was applied to original option but not to the dynamically # added option expect( multiselect_elem.get_by_role("button").get_by_text("APPLE", exact=True) ).to_be_visible() expect( multiselect_elem.get_by_role("button").get_by_text("grape", exact=True) ).to_be_visible() # Try to add a fourth option - should be prevented by max_selections multiselect_elem.locator("input").click() expect(app.locator("li")).to_have_text( "You can only select up to 3 options. Remove an option first.", use_inner_text=True, ) # Type and add another option "berries" - this should not be added input_elem.fill("berries") input_elem.press("Enter") wait_for_app_run(app) # Verify that this option was not added as it would have exceeded max_selections expect_text(app, "value 14: ['mango', 'grape', 'apple']") # Remove one option del_from_multiselect(app, "multiselect 14 - accept new options", "mango") # Verify we can add another option after removing one multiselect_elem.locator("input").click() input_elem.fill("kiwi") input_elem.press("Enter") wait_for_app_run(app) # Verify final selections are correct expect_text(app, "value 14: ['grape', 'apple', 'kiwi']") def test_multiselect_preset_session_state(app: Page): """Should display values from session_state.""" # Check the initial values from session_state expect_text(app, "value 15: ['apple', 'orange']") multiselect_elem = get_multiselect(app, "multiselect 15 - session_state values") selections_button = multiselect_elem.locator('[data-baseweb="tag"]') expect(selections_button).to_have_count(2) expect(selections_button.get_by_text("apple")).to_be_visible() expect(selections_button.get_by_text("orange")).to_be_visible() def test_multiselect_empty_options_with_accept_new_options(app: Page): """Should allow adding new options when options list is empty but accept_new_options is True.""" # Get the multiselect with empty options but accept_new_options=True (index 15) multiselect_elem = get_multiselect( app, "multiselect 16 - empty options with accept_new_options" ) # Verify the initial placeholder shows "Add options" (frontend now handles default placeholders) expect(multiselect_elem).to_contain_text("Add options") # Click to open input field multiselect_elem.locator("input").click() # Type and add new option "strawberry" input_elem = multiselect_elem.locator("input") input_elem.fill("strawberry") input_elem.press("Enter") wait_for_app_run(app) # Type and add another option "blueberry" input_elem.fill("blueberry") input_elem.press("Enter") wait_for_app_run(app) # Verify options were added successfully expect_text(app, "value 16: ['strawberry', 'blueberry']") # Verify the selections are visible in the UI selections_button = multiselect_elem.locator('[data-baseweb="tag"]') expect(selections_button).to_have_count(2) expect(selections_button.get_by_text("strawberry")).to_be_visible() expect(selections_button.get_by_text("blueberry")).to_be_visible() # Remove one option del_from_multiselect( app, "multiselect 16 - empty options with accept_new_options", "strawberry" ) # Verify one option was removed expect_text(app, "value 16: ['blueberry']") def test_multiselect_empty_options_disabled_when_no_accept_new(app: Page): """Should show 'No options to select' placeholder and be disabled when empty and accept_new_options=False.""" # Get multiselect 3 (index 2) which has empty options and accept_new_options=False (default) multiselect_elem = get_multiselect(app, "multiselect 3") # Verify the placeholder shows "No options to select" expect(multiselect_elem).to_contain_text("No options to select") # Verify the input field is disabled input_elem = multiselect_elem.locator("input") expect(input_elem).to_be_disabled() # Verify clicking on the multiselect doesn't open a dropdown multiselect_elem.click() wait_for_app_run(app) # Verify no dropdown options appear dropdown_options = app.locator("li[role='option']") expect(dropdown_options).to_have_count(0) # Verify the widget value remains empty expect_text(app, "value 3: []") def test_multiselect_preserves_scroll_position_on_remove(app: Page): """Should preserve scroll position when removing an item from the multiselect.""" multiselect_elem = get_multiselect(app, "multiselect 17 - show maxHeight") # Get the value container (scrollable area) value_container = multiselect_elem.locator( '[data-baseweb="select"] > div > div:first-child' ) # Scroll to middle of the value container (not bottom, to avoid clamping issues # when items are removed and scrollHeight decreases) value_container.evaluate("el => { el.scrollTop = el.scrollHeight / 2; }") # Get initial scroll position (should be > 0 since there are many items) initial_scroll = value_container.evaluate("el => el.scrollTop") assert initial_scroll > 0 # Remove an item by clicking its delete button del_from_multiselect(app, "multiselect 17 - show maxHeight", "fifteen") # Verify scroll position is preserved final_scroll = value_container.evaluate("el => el.scrollTop") assert final_scroll == initial_scroll def test_multiselect_custom_objects_without_eq(app: Page): """Test that custom class objects without __eq__ work correctly with format_func. This tests the fix for https://github.com/streamlit/streamlit/issues/13646 where custom objects without __eq__ would have their selections cleared after script reruns because the validation used identity comparison after deepcopy created new instances. """ # Get the multiselect with custom objects multiselect_elem = get_multiselect(app, "multiselect 20 - custom objects") # Initial state - no selections expect_text(app, "value 20: []") # Select first option "Option A" select_for_multiselect(app, "multiselect 20 - custom objects", "Option A", True) # Verify selection is preserved after the script rerun # This is the key test - without the fix, the selection would be cleared # because deepcopy creates new object instances and the validation used # identity comparison (==) which fails for objects without __eq__ expect_text(app, "value 20: ['opt_a']") # Verify the selection is visible in the UI expect(multiselect_elem.locator('[data-baseweb="tag"]')).to_have_count(1) expect( multiselect_elem.get_by_role("button").get_by_text("Option A", exact=True) ).to_be_visible() # Select another option to verify multiple selections work select_for_multiselect(app, "multiselect 20 - custom objects", "Option B", True) expect_text(app, "value 20: ['opt_a', 'opt_b']") # Verify both selections are visible expect(multiselect_elem.locator('[data-baseweb="tag"]')).to_have_count(2)