diff --git a/docs/_quartodoc-testing.yml b/docs/_quartodoc-testing.yml index 8c356c134..84de2ef27 100644 --- a/docs/_quartodoc-testing.yml +++ b/docs/_quartodoc-testing.yml @@ -26,6 +26,7 @@ quartodoc: contents: - playwright.controller.InputActionButton - playwright.controller.InputActionLink + - playwright.controller.InputBookmarkButton - playwright.controller.InputCheckbox - playwright.controller.InputCheckboxGroup - playwright.controller.InputDarkMode @@ -59,6 +60,7 @@ quartodoc: - playwright.controller.NavsetTab - playwright.controller.NavsetUnderline - playwright.controller.NavPanel + - playwright.controller.PageNavbar - title: Upload and download desc: Methods for interacting with Shiny app uploading and downloading controller. contents: diff --git a/shiny/playwright/controller/_output.py b/shiny/playwright/controller/_output.py index 614407b83..ce956385b 100644 --- a/shiny/playwright/controller/_output.py +++ b/shiny/playwright/controller/_output.py @@ -1058,13 +1058,33 @@ def expect_class_state( "cell-edit-editing", timeout=timeout ) elif value == "editing": - self.expect_cell_class("cell-edit-editing", row=row, col=col) + self.expect_cell_class( + "cell-edit-editing", + row=row, + col=col, + timeout=timeout, + ) elif value == "saving": - self.expect_cell_class("cell-edit-saving", row=row, col=col) + self.expect_cell_class( + "cell-edit-saving", + row=row, + col=col, + timeout=timeout, + ) elif value == "failure": - self.expect_cell_class("cell-edit-failure", row=row, col=col) + self.expect_cell_class( + "cell-edit-failure", + row=row, + col=col, + timeout=timeout, + ) elif value == "success": - self.expect_cell_class("cell-edit-success", row=row, col=col) + self.expect_cell_class( + "cell-edit-success", + row=row, + col=col, + timeout=timeout, + ) else: raise ValueError( "Invalid state. Select one of 'success', 'failure', 'saving', 'editing', 'ready'" diff --git a/tests/playwright/shiny/components/data_frame/validate_row_selection_edit_mode/test_validate_row_selection_edit_mode.py b/tests/playwright/shiny/components/data_frame/validate_row_selection_edit_mode/test_validate_row_selection_edit_mode.py index a154576bd..823bb33f6 100644 --- a/tests/playwright/shiny/components/data_frame/validate_row_selection_edit_mode/test_validate_row_selection_edit_mode.py +++ b/tests/playwright/shiny/components/data_frame/validate_row_selection_edit_mode/test_validate_row_selection_edit_mode.py @@ -60,6 +60,10 @@ def test_validate_row_selection_in_edit_mode( page.keyboard.press("Escape") data_frame._edit_cell_no_save("Temp value", row=1, col=16) page.keyboard.press("Escape") + # Wait for the row to be focused again after escaping edit mode + data_frame._expect_row_focus_state(True, row=1) + # Also ensure the cell is no longer in editing state + data_frame.expect_class_state("ready", row=1, col=16) page.keyboard.press("Enter") data_frame.expect_class_state( "editing", diff --git a/tests/pytest/test_controller_documentation.py b/tests/pytest/test_controller_documentation.py new file mode 100644 index 000000000..f772a0f58 --- /dev/null +++ b/tests/pytest/test_controller_documentation.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import ast +from pathlib import Path +from typing import Set + +import pytest +import yaml + +root = Path(__file__).parent.parent.parent + +CONTROLLER_DIR = root / "shiny/playwright/controller" +DOCS_CONFIG = root / "docs/_quartodoc-testing.yml" +SKIP_PATTERNS = {"Base", "Container", "Label", "StyleM"} +CONTROLLER_BASE_PATTERNS = { + "Base", + "Container", + "Label", + "StyleM", + "WidthLocM", + "InputActionButton", + "UiBase", + "UiWithLabel", + "UiWithContainer", +} + + +def _is_valid_controller_class(node: ast.ClassDef) -> bool: + class_name = node.name + base_names = {ast.unparse(base) for base in node.bases} + + return ( + not class_name.startswith("_") + and not any(pattern in class_name for pattern in SKIP_PATTERNS) + and not any(base.endswith("P") for base in base_names if isinstance(base, str)) + and any( + base.startswith("_") or any(p in base for p in CONTROLLER_BASE_PATTERNS) + for base in base_names + ) + ) + + +def get_controller_classes() -> Set[str]: + classes: Set[str] = set() + for py_file in CONTROLLER_DIR.glob("*.py"): + if py_file.name == "__init__.py": + continue + try: + tree = ast.parse(py_file.read_text(encoding="utf-8")) + classes.update( + node.name + for node in ast.walk(tree) + if isinstance(node, ast.ClassDef) and _is_valid_controller_class(node) + ) + except Exception as e: + pytest.fail(f"Failed to parse {py_file}: {e}") + return classes + + +def get_documented_controllers() -> Set[str]: + try: + config = yaml.safe_load(DOCS_CONFIG.read_text(encoding="utf-8")) + except Exception as e: + pytest.fail(f"Failed to load or parse {DOCS_CONFIG}: {e}") + + return { + content.split(".")[-1] + for section in config.get("quartodoc", {}).get("sections", []) + for content in section.get("contents", []) + if isinstance(content, str) and content.startswith("playwright.controller.") + } + + +def test_all_controllers_are_documented(): + controller_classes = get_controller_classes() + documented_controllers = get_documented_controllers() + + missing_from_docs = controller_classes - documented_controllers + extra_in_docs = documented_controllers - controller_classes + + error_messages: list[str] = [] + if missing_from_docs: + missing_list = "\n".join( + sorted(f" - playwright.controller.{c}" for c in missing_from_docs) + ) + error_messages.append( + f"Controllers missing from {DOCS_CONFIG}:\n{missing_list}" + ) + + if extra_in_docs: + extra_list = "\n".join( + sorted(f" - playwright.controller.{c}" for c in extra_in_docs) + ) + error_messages.append(f"Extraneous classes in {DOCS_CONFIG}:\n{extra_list}") + + if error_messages: + pytest.fail("\n\n".join(error_messages), pytrace=False) + + assert controller_classes, "No controller classes were found." + assert documented_controllers, "No documented controllers were found."