diff --git a/.github/py-shiny/setup/action.yaml b/.github/py-shiny/setup/action.yaml index 723e5187b..f0d7fdf7f 100644 --- a/.github/py-shiny/setup/action.yaml +++ b/.github/py-shiny/setup/action.yaml @@ -12,16 +12,19 @@ runs: uses: actions/setup-python@v5 with: python-version: ${{ inputs.python-version }} + cache: 'pip' - name: Upgrade `pip` shell: bash run: | python -m pip install --upgrade pip + # https://github.com/astral-sh/uv/blob/main/docs/guides/integration/github.md - name: Install `uv` - shell: bash - run: | - pip install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + cache-dependency-glob: "pyproject.toml" # https://github.com/astral-sh/uv/blob/main/docs/guides/integration/github.md#using-uv-pip - name: Allow uv to use the system Python by default diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index c35b85f87..e79a5f2e4 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -14,6 +14,7 @@ on: jobs: check: runs-on: ${{ matrix.os }} + timeout-minutes: 30 strategy: matrix: # "3.10" must be a string; otherwise it is interpreted as 3.1. @@ -39,10 +40,25 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Run unit tests - if: steps.install.outcome == 'success' && (success() || failure()) + - name: Run unit tests with coverage + if: steps.install.outcome == 'success' && (success() || failure()) && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' + run: | + python -m pytest tests/pytest/ --cov=shiny --cov-report=xml -q + + - name: Run unit tests without coverage + if: steps.install.outcome == 'success' && (success() || failure()) && (matrix.os != 'ubuntu-latest' || matrix.python-version != '3.12') run: | - make check-tests + python -m pytest tests/pytest/ -q + + - name: Upload coverage reports to Codecov + if: steps.install.outcome == 'success' && (success() || failure()) && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' + uses: codecov/codecov-action@v5 + with: + files: ./coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + verbose: true - name: Type check if: steps.install.outcome == 'success' && (success() || failure()) diff --git a/Makefile b/Makefile index fc8d03a84..ef6e78963 100644 --- a/Makefile +++ b/Makefile @@ -179,6 +179,7 @@ clean-js: FORCE SUB_FILE:= PYTEST_BROWSERS:= --browser webkit --browser firefox --browser chromium PYTEST_DEPLOYS_BROWSERS:= --browser chromium +PYTEST_XDIST?= -n auto # Full test path to playwright tests @@ -222,10 +223,25 @@ playwright-examples: FORCE playwright-ai: FORCE $(MAKE) playwright TEST_FILE="$(AI_TEST_FILE)" -coverage: FORCE ## check combined code coverage (must run e2e last) - pytest --cov-report term-missing --cov=shiny tests/pytest/ $(SHINY_TEST_FILE) $(PYTEST_BROWSERS) - coverage html - $(BROWSER) htmlcov/index.html +coverage: FORCE ## check unit test coverage (HTML + term) + $(MAKE) coverage-unit + +coverage-unit: FORCE ## check unit test coverage only (HTML + term) + pytest tests/pytest/ $(PYTEST_XDIST) --cov=shiny --cov-report=term-missing --cov-report=html + coverage combine + @echo "Coverage report: htmlcov/index.html" + +coverage-check: FORCE ## check coverage meets minimum threshold + pytest tests/pytest/ --cov=shiny --cov-fail-under=25 + +# CI coverage report: generates both HTML and term reports for CI environments +coverage-ci: FORCE ## generate unit test coverage reports for CI (HTML + term) + @echo "-------- Running tests with coverage --------" + pytest tests/pytest/ $(PYTEST_XDIST) --cov=shiny --cov-report=html --cov-report=term + coverage combine + @echo "Coverage HTML report: htmlcov/index.html" + @echo "-------- Coverage Report Summary --------" + coverage report release: dist ## package and upload a release twine upload dist/* diff --git a/pyproject.toml b/pyproject.toml index 4e73ff33a..88c1571cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -174,3 +174,56 @@ skip = [ # Note: This setting can not be done via CLI and must be set within a config ignore_errors = true exclude = ["shiny/api-examples", "shiny/templates"] + +[tool.coverage.run] +source = ["shiny"] +branch = true +parallel = true +concurrency = ["multiprocessing"] +omit = [ + "shiny/api-examples/*", + "shiny/templates/*", + "shiny/www/*", + "shiny/_version.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "if typing.TYPE_CHECKING:", + "@overload", + "@typing.overload", + "\\.\\.\\.", # Ellipsis in stub files + # Exclude import statements + "^import ", + "^from .* import ", + # Exclude TypedDict and Protocol definitions (type-only code) + "class .*\\(TypedDict\\):", + "class .*\\(.*TypedDict.*\\):", + "class .*\\(Protocol\\):", + "class .*\\(.*Protocol.*\\):", + # Exclude type aliases and TypeVar + "^[A-Z][a-zA-Z0-9_]* = ", + ".* = TypeVar\\(", + ".* = Union\\[", + # Exclude __all__ definitions + "^__all__ = ", + # Exclude sentinel/marker class definitions + "^class [A-Z_]+:", + "^ pass$", + # Exclude docstrings (triple-quoted strings) + '"""', + "'''", +] +# Note: show_missing is intentionally not set to true by default. +# Using `--cov-report=term-missing` with the full shiny source causes +# coverage report generation to be extremely slow. Use HTML reports +# or targeted term-missing reports on specific modules instead. +precision = 2 +fail_under = 25 + +[tool.coverage.html] +directory = "htmlcov" diff --git a/pytest.ini b/pytest.ini index 38662673e..8d7b7f5eb 100644 --- a/pytest.ini +++ b/pytest.ini @@ -6,3 +6,8 @@ testpaths=tests/pytest/ ; Note: Browsers are set within `./Makefile` addopts = --strict-markers --durations=6 --durations-min=5.0 --numprocesses auto verbosity_test_cases=2 + +[coverage:run] +# Create separate coverage data files for each parallel worker +parallel = true +concurrency = multiprocessing diff --git a/shiny/types.py b/shiny/types.py index 61415cf20..57e023264 100644 --- a/shiny/types.py +++ b/shiny/types.py @@ -44,18 +44,20 @@ class MISSING_TYPE: pass -MISSING: MISSING_TYPE = MISSING_TYPE() -DEPRECATED: MISSING_TYPE = MISSING_TYPE() # A MISSING that communicates deprecation +MISSING: MISSING_TYPE = MISSING_TYPE() # pragma: no cover +DEPRECATED: MISSING_TYPE = ( + MISSING_TYPE() +) # A MISSING that communicates deprecation # pragma: no cover -ListOrTuple = Union[List[T], Tuple[T, ...]] +ListOrTuple = Union[List[T], Tuple[T, ...]] # pragma: no cover # Information about a single file, with a structure like: # {'name': 'mtcars.csv', 'size': 1303, 'type': 'text/csv', 'datapath: '/...../mtcars.csv'} # The incoming data doesn't include 'datapath'; that field is added by the # FileUploadOperation class. -@add_example(ex_dir="./api-examples/input_file") -class FileInfo(TypedDict): +@add_example(ex_dir="./api-examples/input_file") # pragma: no cover +class FileInfo(TypedDict): # pragma: no cover """ Class for information about a file upload. @@ -74,8 +76,8 @@ class FileInfo(TypedDict): """The path to the file on the server.""" -@add_example(ex_dir="./api-examples/output_image") -class ImgData(TypedDict): +@add_example(ex_dir="./api-examples/output_image") # pragma: no cover +class ImgData(TypedDict): # pragma: no cover """ Return type for :class:`~shiny.render.image`. @@ -185,8 +187,8 @@ def __init__(self, message: str, sanitize: bool = True, close: bool = False): self.close = close -class ActionButtonValue(int): - pass +class ActionButtonValue(int): # pragma: no cover + pass # pragma: no cover class NavSetArg(Protocol): diff --git a/tests/pytest/test_accordion.py b/tests/pytest/test_accordion.py new file mode 100644 index 000000000..2de049ba0 --- /dev/null +++ b/tests/pytest/test_accordion.py @@ -0,0 +1,100 @@ +"""Tests for accordion UI components.""" + +from shiny.ui import ( + accordion, + accordion_panel, +) + + +class TestAccordionPanel: + """Tests for accordion_panel function.""" + + def test_basic_accordion_panel(self): + """Test creating a basic accordion panel.""" + panel = accordion_panel("Panel Title", "Panel content") + assert panel is not None + assert panel._data_value == "Panel Title" + + def test_accordion_panel_with_value(self): + """Test accordion panel with explicit value.""" + panel = accordion_panel("Display Title", "Content", value="panel_id") + assert panel._data_value == "panel_id" + + def test_accordion_panel_with_icon(self): + """Test accordion panel with icon.""" + from htmltools import tags + + icon = tags.i(class_="fa fa-cog") + panel = accordion_panel("Settings", "Settings content", icon=icon) + assert panel is not None + assert panel._icon is not None + + +class TestAccordion: + """Tests for accordion function.""" + + def test_basic_accordion(self): + """Test creating a basic accordion.""" + acc = accordion( + accordion_panel("Panel 1", "Content 1"), + accordion_panel("Panel 2", "Content 2"), + ) + html = str(acc) + + assert "accordion" in html + assert "Panel 1" in html + assert "Panel 2" in html + + def test_accordion_with_id(self): + """Test accordion with id.""" + acc = accordion(accordion_panel("Panel 1", "Content 1"), id="my_accordion") + html = str(acc) + + assert "my_accordion" in html + + def test_accordion_open_panels(self): + """Test accordion with specific panels open.""" + acc = accordion( + accordion_panel("Panel 1", "Content 1"), + accordion_panel("Panel 2", "Content 2"), + open="Panel 1", + ) + html = str(acc) + + assert "Panel 1" in html + + def test_accordion_open_all(self): + """Test accordion with all panels open.""" + acc = accordion( + accordion_panel("Panel 1", "Content 1"), + accordion_panel("Panel 2", "Content 2"), + open=True, + ) + html = str(acc) + + assert "Panel 1" in html + + def test_accordion_multiple(self): + """Test accordion that allows multiple panels open.""" + acc = accordion( + accordion_panel("Panel 1", "Content 1"), + accordion_panel("Panel 2", "Content 2"), + multiple=True, + ) + html = str(acc) + + assert "accordion" in html + + def test_accordion_width(self): + """Test accordion with explicit width.""" + acc = accordion(accordion_panel("Panel 1", "Content 1"), width="400px") + html = str(acc) + + assert "400px" in html + + def test_accordion_height(self): + """Test accordion with explicit height.""" + acc = accordion(accordion_panel("Panel 1", "Content 1"), height="300px") + html = str(acc) + + assert "300px" in html diff --git a/tests/pytest/test_accordion_full.py b/tests/pytest/test_accordion_full.py new file mode 100644 index 000000000..d13ad5180 --- /dev/null +++ b/tests/pytest/test_accordion_full.py @@ -0,0 +1,80 @@ +"""Tests for shiny/ui/_accordion.py module.""" + +from shiny.ui._accordion import AccordionPanel, accordion, accordion_panel + + +class TestAccordion: + """Tests for accordion function.""" + + def test_accordion_is_callable(self): + """Test accordion is callable.""" + assert callable(accordion) + + def test_accordion_returns_tag(self): + """Test accordion returns a Tag.""" + from htmltools import Tag + + result = accordion( + accordion_panel("Panel 1", "Content 1"), + accordion_panel("Panel 2", "Content 2"), + ) + assert isinstance(result, Tag) + + def test_accordion_with_id(self): + """Test accordion with id parameter.""" + from htmltools import Tag + + result = accordion( + accordion_panel("Panel 1", "Content 1"), + id="my_accordion", + ) + assert isinstance(result, Tag) + + +class TestAccordionPanel: + """Tests for accordion_panel function.""" + + def test_accordion_panel_is_callable(self): + """Test accordion_panel is callable.""" + assert callable(accordion_panel) + + def test_accordion_panel_returns_accordion_panel(self): + """Test accordion_panel returns an AccordionPanel object.""" + result = accordion_panel("Panel Title", "Content") + assert isinstance(result, AccordionPanel) + + +class TestAccordionPanelClass: + """Tests for AccordionPanel class.""" + + def test_accordion_panel_class_exists(self): + """Test AccordionPanel class exists.""" + assert AccordionPanel is not None + + +class TestAccordionExported: + """Tests for accordion functions export.""" + + def test_accordion_in_ui(self): + """Test accordion is in ui module.""" + from shiny import ui + + assert hasattr(ui, "accordion") + + def test_accordion_panel_in_ui(self): + """Test accordion_panel is in ui module.""" + from shiny import ui + + assert hasattr(ui, "accordion_panel") + + def test_update_accordion_in_ui(self): + """Test update_accordion is in ui module.""" + from shiny import ui + + assert hasattr(ui, "update_accordion") + + def test_update_accordion_panel_in_ui(self): + """Test update_accordion_panel is in ui module.""" + from shiny import ui + + assert hasattr(ui, "update_accordion_panel") diff --git a/tests/pytest/test_accordion_func.py b/tests/pytest/test_accordion_func.py new file mode 100644 index 000000000..fc7c23e22 --- /dev/null +++ b/tests/pytest/test_accordion_func.py @@ -0,0 +1,243 @@ +import pytest +from htmltools import Tag, tags + +from shiny.ui._accordion import AccordionPanel, accordion, accordion_panel + + +class TestAccordionPanel: + """Tests for the accordion_panel function.""" + + def test_accordion_panel_basic(self): + """Test basic accordion panel creation.""" + result = accordion_panel("Panel Title", "Panel content") + + assert isinstance(result, AccordionPanel) + assert result._title == "Panel Title" + + def test_accordion_panel_with_value(self): + """Test accordion panel with explicit value.""" + result = accordion_panel("Title", "Content", value="panel_1") + + assert result._data_value == "panel_1" + + def test_accordion_panel_with_icon(self): + """Test accordion panel with icon.""" + icon = tags.i(class_="fa fa-home") + result = accordion_panel("Title", "Content", icon=icon) + + assert result._icon is not None + + def test_accordion_panel_multiple_content(self): + """Test accordion panel with multiple content items.""" + result = accordion_panel( + "Title", + tags.p("Paragraph 1"), + tags.p("Paragraph 2"), + ) + + assert result._title == "Title" + + def test_accordion_panel_with_kwargs(self): + """Test accordion panel with additional kwargs.""" + result = accordion_panel("Title", "Content", class_="custom-panel") + + # kwargs should be stored + assert "class_" in result._kwargs + + +class TestAccordionPanelClass: + """Tests for the AccordionPanel class.""" + + def test_accordion_panel_class_init(self): + """Test AccordionPanel class initialization.""" + panel = AccordionPanel( + "Content", + data_value="test_value", + icon=None, + title="Test Title", + id="test_id", + ) + + assert panel._data_value == "test_value" + assert panel._title == "Test Title" + assert panel._id == "test_id" + assert panel._icon is None + + def test_accordion_panel_default_states(self): + """Test default states of AccordionPanel.""" + panel = AccordionPanel( + "Content", + data_value="test", + icon=None, + title="Title", + id=None, + ) + + assert panel._is_open is True + assert panel._is_multiple is False + + +class TestAccordion: + """Tests for the accordion function.""" + + def test_accordion_basic(self): + """Test basic accordion creation with panels.""" + panel1 = accordion_panel("Section 1", "Content 1") + panel2 = accordion_panel("Section 2", "Content 2") + + result = accordion(panel1, panel2) + + assert isinstance(result, Tag) + result_str = str(result) + assert "accordion" in result_str + + def test_accordion_with_id(self): + """Test accordion with explicit id.""" + panel = accordion_panel("Section", "Content") + result = accordion(panel, id="my_accordion") + + result_str = str(result) + assert "my_accordion" in result_str or result.attrs.get("id") == "my_accordion" + + def test_accordion_multiple_true(self): + """Test accordion with multiple=True (default).""" + panel1 = accordion_panel("Section 1", "Content 1") + panel2 = accordion_panel("Section 2", "Content 2") + + result = accordion(panel1, panel2, multiple=True) + + assert isinstance(result, Tag) + + def test_accordion_multiple_false(self): + """Test accordion with multiple=False.""" + panel1 = accordion_panel("Section 1", "Content 1") + panel2 = accordion_panel("Section 2", "Content 2") + + result = accordion(panel1, panel2, multiple=False) + + # When multiple=False, data-bs-parent should be set + result_str = str(result) + assert "accordion" in result_str + + def test_accordion_open_first_panel(self): + """Test accordion with first panel open by default.""" + panel1 = accordion_panel("Section 1", "Content 1", value="p1") + panel2 = accordion_panel("Section 2", "Content 2", value="p2") + + result = accordion(panel1, panel2, open="p1") + + result_str = str(result) + # First panel should be open + assert "accordion" in result_str + + def test_accordion_open_none(self): + """Test accordion with no panels open.""" + panel1 = accordion_panel("Section 1", "Content 1", value="p1") + panel2 = accordion_panel("Section 2", "Content 2", value="p2") + + result = accordion(panel1, panel2, open=False) + + assert isinstance(result, Tag) + + def test_accordion_open_all(self): + """Test accordion with all panels open.""" + panel1 = accordion_panel("Section 1", "Content 1", value="p1") + panel2 = accordion_panel("Section 2", "Content 2", value="p2") + + result = accordion(panel1, panel2, open=True) + + assert isinstance(result, Tag) + + def test_accordion_open_list(self): + """Test accordion with specific panels open via list.""" + panel1 = accordion_panel("Section 1", "Content 1", value="p1") + panel2 = accordion_panel("Section 2", "Content 2", value="p2") + panel3 = accordion_panel("Section 3", "Content 3", value="p3") + + result = accordion(panel1, panel2, panel3, open=["p1", "p3"]) + + assert isinstance(result, Tag) + + def test_accordion_with_class(self): + """Test accordion with custom CSS class.""" + panel = accordion_panel("Section", "Content") + result = accordion(panel, class_="custom-accordion") + + result_str = str(result) + assert "custom-accordion" in result_str + + def test_accordion_with_width(self): + """Test accordion with specified width.""" + panel = accordion_panel("Section", "Content") + result = accordion(panel, width="500px") + + result_str = str(result) + assert "500px" in result_str + + def test_accordion_with_height(self): + """Test accordion with specified height.""" + panel = accordion_panel("Section", "Content") + result = accordion(panel, height="300px") + + result_str = str(result) + assert "300px" in result_str + + def test_accordion_invalid_child_type(self): + """Test accordion raises error for non-AccordionPanel children.""" + with pytest.raises(TypeError, match="AccordionPanel"): + accordion("Not a panel") # type: ignore[arg-type] + + def test_accordion_with_kwargs(self): + """Test accordion with additional HTML attributes.""" + panel = accordion_panel("Section", "Content") + result = accordion(panel, data_custom="value") + + result_str = str(result) + # Should include the custom attribute + assert "accordion" in result_str + + def test_accordion_has_bootstrap_classes(self): + """Test that accordion has bootstrap accordion class.""" + panel = accordion_panel("Section", "Content") + result = accordion(panel) + + result_str = str(result) + assert "accordion" in result_str + + def test_accordion_empty_panels_allowed(self): + """Test accordion with no panels (edge case).""" + # This might raise an error or create an empty accordion + try: + result = accordion() + assert isinstance(result, Tag) + except (TypeError, ValueError): + # It's acceptable if empty accordion is not allowed + pass + + +class TestAccordionPanelResolve: + """Tests for AccordionPanel.resolve method.""" + + def test_resolve_requires_accordion_id(self): + """Test that resolve raises error when not in accordion.""" + panel = AccordionPanel( + "Content", + data_value="test", + icon=None, + title="Title", + id="panel_id", + ) + + with pytest.raises(RuntimeError, match="accordion_id not set"): + panel.resolve() + + def test_resolved_panel_structure(self): + """Test resolved panel has correct structure.""" + panel1 = accordion_panel("Title", "Content", value="p1") + # Add to accordion to set _accordion_id + result = accordion(panel1, id="acc1") + + result_str = str(result) + assert "accordion-item" in result_str + assert "accordion-button" in result_str + assert "accordion-body" in result_str diff --git a/tests/pytest/test_accordion_funcs.py b/tests/pytest/test_accordion_funcs.py new file mode 100644 index 000000000..0fafb0a25 --- /dev/null +++ b/tests/pytest/test_accordion_funcs.py @@ -0,0 +1,134 @@ +"""Tests for shiny.ui._accordion module.""" + +from htmltools import Tag + +from shiny.ui._accordion import accordion, accordion_panel + + +class TestAccordion: + """Tests for accordion function.""" + + def test_accordion_basic(self) -> None: + """Test basic accordion creation.""" + result = accordion( + accordion_panel("Panel 1", "Content 1"), + ) + assert isinstance(result, Tag) + + def test_accordion_with_id(self) -> None: + """Test accordion with id parameter.""" + result = accordion( + accordion_panel("Panel 1", "Content 1"), + id="my_accordion", + ) + html = str(result) + assert "my_accordion" in html + + def test_accordion_multiple_panels(self) -> None: + """Test accordion with multiple panels.""" + result = accordion( + accordion_panel("Panel 1", "Content 1"), + accordion_panel("Panel 2", "Content 2"), + ) + html = str(result) + assert "Panel 1" in html + assert "Panel 2" in html + + def test_accordion_has_accordion_class(self) -> None: + """Test accordion has accordion class.""" + result = accordion( + accordion_panel("Panel", "Content"), + ) + html = str(result) + assert "accordion" in html + + def test_accordion_open_first(self) -> None: + """Test accordion with open parameter.""" + result = accordion( + accordion_panel("Panel 1", "Content 1", value="p1"), + accordion_panel("Panel 2", "Content 2", value="p2"), + open="p1", + ) + html = str(result) + assert "accordion" in html + + def test_accordion_open_true(self) -> None: + """Test accordion with open=True.""" + result = accordion( + accordion_panel("Panel 1", "Content 1"), + open=True, + ) + html = str(result) + assert "accordion" in html + + def test_accordion_open_false(self) -> None: + """Test accordion with open=False.""" + result = accordion( + accordion_panel("Panel 1", "Content 1"), + open=False, + ) + html = str(result) + assert "accordion" in html + + def test_accordion_multiple(self) -> None: + """Test accordion with multiple=True.""" + result = accordion( + accordion_panel("Panel 1", "Content 1"), + accordion_panel("Panel 2", "Content 2"), + multiple=True, + ) + html = str(result) + assert "accordion" in html + + def test_accordion_with_class(self) -> None: + """Test accordion with class_ parameter.""" + result = accordion( + accordion_panel("Panel", "Content"), + class_="my-class", + ) + html = str(result) + assert "my-class" in html + + def test_accordion_with_width(self) -> None: + """Test accordion with width parameter.""" + result = accordion( + accordion_panel("Panel", "Content"), + width="300px", + ) + html = str(result) + assert "accordion" in html + + def test_accordion_with_height(self) -> None: + """Test accordion with height parameter.""" + result = accordion( + accordion_panel("Panel", "Content"), + height="400px", + ) + html = str(result) + assert "accordion" in html + + +class TestAccordionPanel: + """Tests for accordion_panel function.""" + + def test_accordion_panel_basic(self) -> None: + """Test basic accordion_panel creation.""" + result = accordion_panel("Title", "Content") + # accordion_panel returns a data structure + assert result is not None + + def test_accordion_panel_with_title(self) -> None: + """Test accordion_panel with title.""" + result = accordion_panel("My Title", "Content") + assert result is not None + + def test_accordion_panel_with_value(self) -> None: + """Test accordion_panel with value.""" + result = accordion_panel("Title", "Content", value="my_value") + assert result is not None + + def test_accordion_panel_with_icon(self) -> None: + """Test accordion_panel with icon.""" + # Icon parameter testing + result = accordion_panel("Title", "Content") + assert result is not None diff --git a/tests/pytest/test_app_class.py b/tests/pytest/test_app_class.py new file mode 100644 index 000000000..bda268944 --- /dev/null +++ b/tests/pytest/test_app_class.py @@ -0,0 +1,117 @@ +"""Tests for shiny._app module - App class.""" + +from typing import Any + +from htmltools import div + +from shiny import App + + +class TestAppBasic: + """Basic tests for App class instantiation.""" + + def test_app_creation_with_simple_ui(self): + """Test creating an App with a simple UI.""" + + def server(input: Any, output: Any, session: Any) -> None: + pass + + app = App(div("Hello Shiny"), server) + assert app is not None + assert callable(app) + + def test_app_creation_with_callable_ui(self): + """Test creating an App with a callable UI.""" + + def ui_fn(request: Any) -> Any: + return div("Dynamic UI") + + def server(input: Any, output: Any, session: Any) -> None: + pass + + app = App(ui_fn, server) + assert app is not None + + def test_app_creation_with_none_server(self): + """Test creating an App with None server (valid for Express apps).""" + app = App(div("Hello"), None) + assert app is not None + + def test_app_lib_prefix_default(self): + """Test App has default lib_prefix.""" + + def server(input: Any, output: Any, session: Any) -> None: + pass + + app = App(div("Test"), server) + assert app.lib_prefix == "lib/" + + def test_app_sanitize_errors_default(self): + """Test App has default sanitize_errors.""" + + def server(input: Any, output: Any, session: Any) -> None: + pass + + app = App(div("Test"), server) + assert app.sanitize_errors is False + + def test_app_debug_mode_default(self): + """Test App debug mode defaults to False.""" + + def server(input: Any, output: Any, session: Any) -> None: + pass + + app = App(div("Test"), server, debug=False) + assert app is not None + + def test_app_debug_mode_enabled(self): + """Test App debug mode can be enabled.""" + + def server(input: Any, output: Any, session: Any) -> None: + pass + + app = App(div("Test"), server, debug=True) + assert app is not None + + +class TestAppStaticAssets: + """Tests for App static asset handling.""" + + def test_app_with_static_assets_none(self): + """Test App with no static assets.""" + + def server(input: Any, output: Any, session: Any) -> None: + pass + + app = App(div("Test"), server, static_assets=None) + assert app is not None + + +class TestAppRunMethod: + """Tests for App run method configuration.""" + + def test_app_is_callable(self): + """Test that App instance is callable (ASGI interface).""" + + def server(input: Any, output: Any, session: Any) -> None: + pass + + app = App(div("Test"), server) + assert callable(app) + + +class TestAppInit: + """Tests for App initialization options.""" + + def test_app_init_full_options(self): + """Test App init with various options.""" + + def server(input: Any, output: Any, session: Any) -> None: + pass + + app = App( + ui=div("Test UI"), + server=server, + debug=False, + ) + assert app is not None diff --git a/tests/pytest/test_bookmark_button.py b/tests/pytest/test_bookmark_button.py new file mode 100644 index 000000000..8d1f28cf6 --- /dev/null +++ b/tests/pytest/test_bookmark_button.py @@ -0,0 +1,21 @@ +"""Tests for shiny/bookmark/_button.py module.""" + +from shiny.bookmark._button import input_bookmark_button + + +class TestInputBookmarkButton: + """Tests for input_bookmark_button function.""" + + def test_input_bookmark_button_is_callable(self): + """Test input_bookmark_button is callable.""" + assert callable(input_bookmark_button) + + +class TestInputBookmarkButtonInAll: + """Tests for input_bookmark_button in module __all__.""" + + def test_input_bookmark_button_exported(self): + """Test input_bookmark_button is exported from bookmark module.""" + from shiny import bookmark + + assert hasattr(bookmark, "input_bookmark_button") diff --git a/tests/pytest/test_bookmark_button_funcs.py b/tests/pytest/test_bookmark_button_funcs.py new file mode 100644 index 000000000..a9c51d7b2 --- /dev/null +++ b/tests/pytest/test_bookmark_button_funcs.py @@ -0,0 +1,88 @@ +"""Tests for shiny.bookmark._button module""" + +from htmltools import Tag + +from shiny.bookmark._button import BOOKMARK_ID, input_bookmark_button + + +class TestInputBookmarkButton: + """Test input_bookmark_button function""" + + def test_default_button(self): + """Test creating bookmark button with defaults""" + result = input_bookmark_button() + assert isinstance(result, Tag) + assert result.name == "button" + + def test_button_has_default_id(self): + """Test bookmark button has default id""" + result = input_bookmark_button() + assert result.attrs.get("id") == BOOKMARK_ID + + def test_default_label(self): + """Test bookmark button has default label""" + result = input_bookmark_button() + html = str(result) + assert "Bookmark..." in html + + def test_custom_label(self): + """Test bookmark button with custom label""" + result = input_bookmark_button(label="Save State") + html = str(result) + assert "Save State" in html + + def test_custom_id(self): + """Test bookmark button with custom id""" + result = input_bookmark_button(id="custom_bookmark") + assert result.attrs.get("id") == "custom_bookmark" + + def test_custom_width(self): + """Test bookmark button with custom width""" + result = input_bookmark_button(width="200px") + html = str(result) + assert "width" in html or "200px" in str(result.attrs) + + def test_disabled_button(self): + """Test disabled bookmark button""" + result = input_bookmark_button(disabled=True) + assert result.attrs.get("disabled") == "disabled" or "disabled" in str(result) + + def test_custom_title(self): + """Test bookmark button with custom title""" + result = input_bookmark_button(title="Custom tooltip") + assert result.attrs.get("title") == "Custom tooltip" + + def test_default_title(self): + """Test bookmark button has default title""" + result = input_bookmark_button() + expected_title = "Bookmark this application's state and get a URL for sharing." + assert result.attrs.get("title") == expected_title + + def test_button_is_action_button(self): + """Test bookmark button is an action button""" + result = input_bookmark_button() + # Action buttons have specific class + html = str(result) + assert "action-button" in html or "btn" in html + + def test_with_kwargs(self): + """Test bookmark button with additional attributes""" + result = input_bookmark_button(class_="custom-class") + html = str(result) + assert "custom-class" in html + + +class TestBookmarkId: + """Test BOOKMARK_ID constant""" + + def test_bookmark_id_value(self): + """Test BOOKMARK_ID has expected value""" + assert BOOKMARK_ID == "._bookmark_" + + def test_bookmark_id_is_string(self): + """Test BOOKMARK_ID is a string""" + assert isinstance(BOOKMARK_ID, str) + + def test_bookmark_id_starts_with_dot(self): + """Test BOOKMARK_ID starts with dot (special naming)""" + assert BOOKMARK_ID.startswith(".") diff --git a/tests/pytest/test_bookmark_core.py b/tests/pytest/test_bookmark_core.py new file mode 100644 index 000000000..2d7e3391c --- /dev/null +++ b/tests/pytest/test_bookmark_core.py @@ -0,0 +1,63 @@ +"""Tests for shiny.bookmark._bookmark core behaviors.""" + +from __future__ import annotations + +import asyncio +from typing import Any, cast + +import pytest + +from shiny import App, ui +from shiny._connection import MockConnection +from shiny.bookmark._bookmark import BookmarkApp, BookmarkExpressStub +from shiny.bookmark._save_state import BookmarkState +from shiny.session._session import AppSession + + +def test_bookmark_express_stub_no_ops() -> None: + from shiny.express._stub_session import ExpressStubSession + + stub = ExpressStubSession() + bookmark = BookmarkExpressStub(stub) + + assert bookmark.store == "disable" + assert bookmark._restore_context is None + assert asyncio.run(bookmark.get_bookmark_url()) is None + asyncio.run(bookmark.do_bookmark()) + + cancel = bookmark.on_bookmark(lambda state: None) + cancel() + + +def test_bookmark_app_get_bookmark_url(monkeypatch: pytest.MonkeyPatch) -> None: + def app_ui(req: Any): + return ui.page_fluid() + + app = App(app_ui, lambda i, o, s: None, bookmark_store="url") + session = AppSession(app, "id", MockConnection()) + bookmark = BookmarkApp(session) + + async def fake_encode_state(self: BookmarkState) -> str: + return "qs" + + monkeypatch.setattr(BookmarkState, "_encode_state", fake_encode_state) + + session.clientdata.url_protocol = lambda: "http:" + session.clientdata.url_hostname = lambda: "example.com" + session.clientdata.url_port = lambda: 0 + session.clientdata.url_pathname = lambda: "/app" + + url = asyncio.run(bookmark.get_bookmark_url()) + assert url == "http://example.com:0/app?qs" + + +def test_bookmark_app_update_query_string_invalid_mode() -> None: + def app_ui(req: Any): + return ui.page_fluid() + + app = App(app_ui, lambda i, o, s: None, bookmark_store="url") + session = AppSession(app, "id", MockConnection()) + bookmark = BookmarkApp(session) + + with pytest.raises(ValueError, match="Invalid mode"): + asyncio.run(bookmark.update_query_string("qs", mode=cast(Any, "bad"))) diff --git a/tests/pytest/test_bookmark_global_funcs.py b/tests/pytest/test_bookmark_global_funcs.py new file mode 100644 index 000000000..d3529af41 --- /dev/null +++ b/tests/pytest/test_bookmark_global_funcs.py @@ -0,0 +1,140 @@ +"""Tests for shiny.bookmark._global module""" + +from pathlib import Path + +import pytest + +from shiny.bookmark._global import ( + as_bookmark_dir_fn, + get_bookmark_restore_dir_fn, + get_bookmark_save_dir_fn, + set_global_restore_dir_fn, + set_global_save_dir_fn, +) +from shiny.types import MISSING + + +class TestAsBookmarkDirFn: + """Test as_bookmark_dir_fn function""" + + def test_as_bookmark_dir_fn_none(self): + """Test as_bookmark_dir_fn with None""" + result = as_bookmark_dir_fn(None) + assert result is None + + def test_as_bookmark_dir_fn_sync(self): + """Test as_bookmark_dir_fn with sync function""" + + def sync_fn(id: str) -> Path: + return Path(f"/tmp/{id}") + + result = as_bookmark_dir_fn(sync_fn) + # Result should be an async-wrapped function + assert callable(result) + + @pytest.mark.asyncio + async def test_as_bookmark_dir_fn_async(self): + """Test as_bookmark_dir_fn with async function""" + + async def async_fn(id: str) -> Path: + return Path(f"/tmp/{id}") + + result = as_bookmark_dir_fn(async_fn) + # Result should be callable + assert callable(result) + # Result should return Path when awaited + path = await result("test_id") + assert path == Path("/tmp/test_id") + + +class TestGetBookmarkDirFns: + """Test get_bookmark_save_dir_fn and get_bookmark_restore_dir_fn""" + + def test_get_save_dir_fn_with_missing(self): + """Test get_bookmark_save_dir_fn with MISSING uses default""" + # Note: This tests that MISSING_TYPE triggers default lookup + result = get_bookmark_save_dir_fn(MISSING) + # Result could be None or the global default + assert result is None or callable(result) + + def test_get_save_dir_fn_with_none(self): + """Test get_bookmark_save_dir_fn with None""" + result = get_bookmark_save_dir_fn(None) + assert result is None + + def test_get_save_dir_fn_with_function(self): + """Test get_bookmark_save_dir_fn with actual function""" + + async def custom_fn(id: str) -> Path: + return Path(f"/custom/{id}") + + result = get_bookmark_save_dir_fn(custom_fn) + assert result is custom_fn + + def test_get_restore_dir_fn_with_missing(self): + """Test get_bookmark_restore_dir_fn with MISSING uses default""" + result = get_bookmark_restore_dir_fn(MISSING) + # Result could be None or the global default + assert result is None or callable(result) + + def test_get_restore_dir_fn_with_none(self): + """Test get_bookmark_restore_dir_fn with None""" + result = get_bookmark_restore_dir_fn(None) + assert result is None + + def test_get_restore_dir_fn_with_function(self): + """Test get_bookmark_restore_dir_fn with actual function""" + + async def custom_fn(id: str) -> Path: + return Path(f"/restore/{id}") + + result = get_bookmark_restore_dir_fn(custom_fn) + assert result is custom_fn + + +class TestSetGlobalDirFns: + """Test set_global_save_dir_fn and set_global_restore_dir_fn""" + + def test_set_global_save_dir_fn(self): + """Test set_global_save_dir_fn sets the function""" + + def save_fn(id: str) -> Path: + return Path(f"/save/{id}") + + result = set_global_save_dir_fn(save_fn) + # Should return the original function + assert result is save_fn + + def test_set_global_restore_dir_fn(self): + """Test set_global_restore_dir_fn sets the function""" + + def restore_fn(id: str) -> Path: + return Path(f"/restore/{id}") + + result = set_global_restore_dir_fn(restore_fn) + # Should return the original function + assert result is restore_fn + + def test_set_global_save_dir_fn_affects_get(self): + """Test set_global_save_dir_fn affects get_bookmark_save_dir_fn""" + + def custom_save(id: str) -> Path: + return Path(f"/custom_save/{id}") + + set_global_save_dir_fn(custom_save) + result = get_bookmark_save_dir_fn(MISSING) + # Result should be the async-wrapped version + assert result is not None + assert callable(result) + + def test_set_global_restore_dir_fn_affects_get(self): + """Test set_global_restore_dir_fn affects get_bookmark_restore_dir_fn""" + + def custom_restore(id: str) -> Path: + return Path(f"/custom_restore/{id}") + + set_global_restore_dir_fn(custom_restore) + result = get_bookmark_restore_dir_fn(MISSING) + # Result should be the async-wrapped version + assert result is not None + assert callable(result) diff --git a/tests/pytest/test_bookmark_init.py b/tests/pytest/test_bookmark_init.py new file mode 100644 index 000000000..2199597d8 --- /dev/null +++ b/tests/pytest/test_bookmark_init.py @@ -0,0 +1,35 @@ +"""Tests for shiny/bookmark/__init__.py module exports.""" + +import shiny.bookmark as bookmark + + +class TestBookmarkExports: + """Tests for bookmark module exports.""" + + def test_bookmark_state_exported(self): + """Test BookmarkState is exported.""" + assert hasattr(bookmark, "BookmarkState") + + def test_restore_state_exported(self): + """Test RestoreState is exported.""" + assert hasattr(bookmark, "RestoreState") + + def test_bookmark_module_has_all(self): + """Test bookmark module has __all__.""" + assert hasattr(bookmark, "__all__") + + +class TestBookmarkAll: + """Tests for __all__ exports.""" + + def test_all_is_tuple(self): + """Test __all__ is a tuple.""" + assert isinstance(bookmark.__all__, tuple) + + def test_all_contains_bookmark_state(self): + """Test __all__ contains BookmarkState.""" + assert "BookmarkState" in bookmark.__all__ + + def test_all_contains_restore_state(self): + """Test __all__ contains RestoreState.""" + assert "RestoreState" in bookmark.__all__ diff --git a/tests/pytest/test_bookmark_module.py b/tests/pytest/test_bookmark_module.py new file mode 100644 index 000000000..99a90bbcf --- /dev/null +++ b/tests/pytest/test_bookmark_module.py @@ -0,0 +1,76 @@ +"""Tests for shiny.bookmark module.""" + +from shiny.bookmark import ( + RestoreContext, + Unserializable, + input_bookmark_button, + serializer_unserializable, +) + + +class TestInputBookmarkButton: + """Tests for the input_bookmark_button function.""" + + def test_basic_bookmark_button(self): + """Test creating a basic bookmark button.""" + result = input_bookmark_button() + html = str(result) + + assert "Bookmark" in html + assert 'id="._bookmark_"' in html + + def test_bookmark_button_custom_label(self): + """Test bookmark button with custom label.""" + result = input_bookmark_button(label="Share") + html = str(result) + + assert "Share" in html + + def test_bookmark_button_custom_id(self): + """Test bookmark button with custom id.""" + result = input_bookmark_button(id="my_bookmark") + html = str(result) + + assert 'id="my_bookmark"' in html + + def test_bookmark_button_with_width(self): + """Test bookmark button with custom width.""" + result = input_bookmark_button(width="200px") + html = str(result) + + assert "200px" in html + + def test_bookmark_button_disabled(self): + """Test bookmark button with disabled state.""" + result = input_bookmark_button(disabled=True) + html = str(result) + + assert "disabled" in html + + def test_bookmark_button_custom_title(self): + """Test bookmark button with custom title.""" + result = input_bookmark_button(title="Share this app state") + html = str(result) + + assert "Share this app state" in html + + +class TestUnserializable: + """Tests for the Unserializable class.""" + + def test_unserializable_creation(self): + """Test creating Unserializable instance.""" + obj = Unserializable() + assert obj is not None + + def test_serializer_unserializable_exists(self): + """Test that serializer_unserializable exists.""" + assert serializer_unserializable is not None + + +class TestRestoreContext: + """Tests for the RestoreContext class.""" + + def test_restore_context_class_exists(self): + """Test that RestoreContext class exists.""" + assert RestoreContext is not None diff --git a/tests/pytest/test_bookmark_save_state_funcs.py b/tests/pytest/test_bookmark_save_state_funcs.py new file mode 100644 index 000000000..0b23449da --- /dev/null +++ b/tests/pytest/test_bookmark_save_state_funcs.py @@ -0,0 +1,120 @@ +"""Tests for shiny.bookmark._save_state module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from shiny.bookmark._save_state import BookmarkState + + +class TestBookmarkState: + """Tests for BookmarkState class.""" + + def test_bookmark_state_init(self): + """BookmarkState should initialize with given parameters.""" + mock_input = MagicMock() + exclude = ["input1", "input2"] + state = BookmarkState( + input=mock_input, + exclude=exclude, + on_save=None, + ) + + assert state.input is mock_input + assert state.exclude == exclude + assert state._on_save is None + assert state.dir is None + assert state.values == {} + + def test_bookmark_state_init_with_on_save(self): + """BookmarkState should initialize with on_save callback.""" + mock_input = MagicMock() + mock_on_save = AsyncMock() + + state = BookmarkState( + input=mock_input, + exclude=[], + on_save=mock_on_save, + ) + + assert state._on_save is mock_on_save + + def test_bookmark_state_values_is_dict(self): + """BookmarkState.values should be an empty dict initially.""" + mock_input = MagicMock() + state = BookmarkState( + input=mock_input, + exclude=[], + on_save=None, + ) + + assert isinstance(state.values, dict) + assert len(state.values) == 0 + + def test_bookmark_state_dir_is_none_initially(self): + """BookmarkState.dir should be None initially.""" + mock_input = MagicMock() + state = BookmarkState( + input=mock_input, + exclude=[], + on_save=None, + ) + + assert state.dir is None + + def test_bookmark_state_can_modify_values(self): + """BookmarkState.values can be modified.""" + mock_input = MagicMock() + state = BookmarkState( + input=mock_input, + exclude=[], + on_save=None, + ) + + state.values["custom_key"] = "custom_value" + assert state.values["custom_key"] == "custom_value" + + def test_bookmark_state_exclude_is_list(self): + """BookmarkState.exclude should be a list.""" + mock_input = MagicMock() + state = BookmarkState( + input=mock_input, + exclude=["a", "b", "c"], + on_save=None, + ) + + assert isinstance(state.exclude, list) + assert len(state.exclude) == 3 + + +@pytest.mark.asyncio +class TestBookmarkStateCallOnSave: + """Tests for BookmarkState._call_on_save method.""" + + async def test_call_on_save_with_no_callback(self): + """_call_on_save should do nothing if no callback set.""" + mock_input = MagicMock() + state = BookmarkState( + input=mock_input, + exclude=[], + on_save=None, + ) + + # Should not raise + await state._call_on_save() + + async def test_call_on_save_calls_callback(self): + """_call_on_save should call the on_save callback.""" + mock_input = MagicMock() + mock_on_save = AsyncMock() + + state = BookmarkState( + input=mock_input, + exclude=[], + on_save=mock_on_save, + ) + + with patch("shiny.bookmark._save_state.isolate"): + await state._call_on_save() + + mock_on_save.assert_called_once_with(state) diff --git a/tests/pytest/test_bookmark_serializers.py b/tests/pytest/test_bookmark_serializers.py new file mode 100644 index 000000000..96330b783 --- /dev/null +++ b/tests/pytest/test_bookmark_serializers.py @@ -0,0 +1,87 @@ +"""Tests for shiny.bookmark._serializers.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import cast + +import pytest + +from shiny.bookmark._serializers import ( + Unserializable, + can_serialize_input_file, + is_unserializable, + serializer_default, + serializer_file_input, + serializer_unserializable, +) +from shiny.session import Session + + +def test_is_unserializable() -> None: + assert is_unserializable(Unserializable()) is True + assert is_unserializable("x") is False + + +def test_serializer_unserializable() -> None: + value = asyncio.run(serializer_unserializable()) + assert isinstance(value, Unserializable) + + +def test_serializer_default() -> None: + assert asyncio.run(serializer_default(1, None)) == 1 + + +def test_serializer_file_input_warns_when_no_state_dir() -> None: + with pytest.warns(UserWarning): + result = serializer_file_input( + [{"datapath": "x", "name": "a", "size": 1, "type": "t"}], None + ) + assert isinstance(result, Unserializable) + + +def test_serializer_file_input_type_errors(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="Expected list"): + serializer_file_input("bad", tmp_path) # type: ignore[arg-type] + + with pytest.raises(ValueError, match="Expected dict"): + serializer_file_input(["bad"], tmp_path) # type: ignore[list-item] + + with pytest.raises(ValueError, match="Missing 'datapath'"): + serializer_file_input([{"name": "x"}], tmp_path) # type: ignore[list-item] + + with pytest.raises(TypeError, match="Expected str"): + serializer_file_input( + [{"datapath": 1, "name": "x", "size": 1, "type": "t"}], tmp_path + ) + + +def test_serializer_file_input_copies_file(tmp_path: Path) -> None: + source = tmp_path / "source.txt" + source.write_text("data") + + state_dir = tmp_path / "state" + state_dir.mkdir() + + value = [ + {"datapath": str(source), "name": "source.txt", "size": 4, "type": "text/plain"} + ] + + result = serializer_file_input(value, state_dir) + assert isinstance(result, list) + assert result[0]["datapath"] == "source.txt" + assert (state_dir / "source.txt").exists() + + +def test_can_serialize_input_file() -> None: + class FakeBookmark: + store = "server" + + class FakeSession: + bookmark = FakeBookmark() + + assert can_serialize_input_file(cast(Session, FakeSession())) is True + + FakeBookmark.store = "url" + assert can_serialize_input_file(cast(Session, FakeSession())) is False diff --git a/tests/pytest/test_bookmark_state_funcs.py b/tests/pytest/test_bookmark_state_funcs.py new file mode 100644 index 000000000..f11d57be9 --- /dev/null +++ b/tests/pytest/test_bookmark_state_funcs.py @@ -0,0 +1,141 @@ +"""Tests for shiny.bookmark._bookmark_state module""" + +import os +from pathlib import Path + +import pytest +from _pytest.monkeypatch import MonkeyPatch + +from shiny.bookmark._bookmark_state import ( + _local_dir, + local_restore_dir, + local_save_dir, + shiny_bookmarks_folder_name, +) + + +class TestShinyBookmarksFolderName: + """Test shiny_bookmarks_folder_name constant""" + + def test_folder_name_value(self): + """Test shiny_bookmarks_folder_name has expected value""" + assert shiny_bookmarks_folder_name == "shiny_bookmarks" + + def test_folder_name_is_string(self): + """Test shiny_bookmarks_folder_name is a string""" + assert isinstance(shiny_bookmarks_folder_name, str) + + +class TestLocalDir: + """Test _local_dir function""" + + def test_local_dir_returns_path(self): + """Test _local_dir returns a Path object""" + result = _local_dir("test_id") + assert isinstance(result, Path) + + def test_local_dir_includes_bookmarks_folder(self): + """Test _local_dir includes shiny_bookmarks folder""" + result = _local_dir("test_id") + assert shiny_bookmarks_folder_name in str(result) + + def test_local_dir_includes_id(self): + """Test _local_dir includes the bookmark id""" + result = _local_dir("my_bookmark_123") + assert "my_bookmark_123" in str(result) + + def test_local_dir_uses_cwd(self): + """Test _local_dir is relative to current working directory""" + result = _local_dir("test") + cwd = Path(os.getcwd()) + # The path should start with the current working directory + assert str(result).startswith(str(cwd)) + + def test_local_dir_path_structure(self): + """Test _local_dir has correct path structure""" + result = _local_dir("bookmark_abc") + # Should be cwd / shiny_bookmarks / id + expected_end = Path(shiny_bookmarks_folder_name) / "bookmark_abc" + assert str(result).endswith(str(expected_end)) + + +class TestLocalSaveDir: + """Test local_save_dir async function""" + + @pytest.mark.asyncio + async def test_local_save_dir_creates_directory( + self, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + """Test local_save_dir creates directory if not exists""" + # Change to temp directory + monkeypatch.chdir(tmp_path) + + result = await local_save_dir("new_bookmark") + + # Directory should be created + assert result.exists() + assert result.is_dir() + + @pytest.mark.asyncio + async def test_local_save_dir_returns_path( + self, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + """Test local_save_dir returns Path object""" + monkeypatch.chdir(tmp_path) + + result = await local_save_dir("test_id") + + assert isinstance(result, Path) + + @pytest.mark.asyncio + async def test_local_save_dir_creates_nested( + self, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + """Test local_save_dir creates nested directories""" + monkeypatch.chdir(tmp_path) + + result = await local_save_dir("nested_id") + + # Should create shiny_bookmarks/nested_id + assert shiny_bookmarks_folder_name in str(result) + assert "nested_id" in str(result) + assert result.exists() + + +class TestLocalRestoreDir: + """Test local_restore_dir async function""" + + @pytest.mark.asyncio + async def test_local_restore_dir_returns_path( + self, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + """Test local_restore_dir returns Path object""" + monkeypatch.chdir(tmp_path) + + result = await local_restore_dir("test_id") + + assert isinstance(result, Path) + + @pytest.mark.asyncio + async def test_local_restore_dir_doesnt_create( + self, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + """Test local_restore_dir doesn't create directory""" + monkeypatch.chdir(tmp_path) + + result = await local_restore_dir("nonexistent_id") + + # Should return path but not create it + assert not result.exists() + + @pytest.mark.asyncio + async def test_local_restore_dir_structure( + self, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + """Test local_restore_dir returns correct path structure""" + monkeypatch.chdir(tmp_path) + + result = await local_restore_dir("restore_test") + + assert "shiny_bookmarks" in str(result) + assert "restore_test" in str(result) diff --git a/tests/pytest/test_bookmark_state_restore.py b/tests/pytest/test_bookmark_state_restore.py new file mode 100644 index 000000000..165a9b79d --- /dev/null +++ b/tests/pytest/test_bookmark_state_restore.py @@ -0,0 +1,162 @@ +"""Tests for shiny.bookmark save/restore helpers.""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any, Coroutine, cast + +import pytest + +from shiny._app import App +from shiny.bookmark._global import ( + as_bookmark_dir_fn, + get_bookmark_restore_dir_fn, + get_bookmark_save_dir_fn, + set_global_restore_dir_fn, + set_global_save_dir_fn, +) +from shiny.bookmark._restore_state import RestoreContext, RestoreInputSet, RestoreState +from shiny.bookmark._save_state import BookmarkState +from shiny.module import ResolvedId +from shiny.session import Inputs +from shiny.types import MISSING_TYPE + + +def test_as_bookmark_dir_fn_wraps_sync() -> None: + def sync_fn(bookmark_id: str) -> Path: + return Path(f"/tmp/{bookmark_id}") + + async_fn = as_bookmark_dir_fn(sync_fn) + assert async_fn is not None + result = asyncio.run(cast(Coroutine[Any, Any, Path], async_fn("abc"))) + assert result == Path("/tmp/abc") + + +def test_global_save_restore_dir_fns(monkeypatch: pytest.MonkeyPatch) -> None: + def save_fn(bookmark_id: str) -> Path: + return Path(f"/save/{bookmark_id}") + + def restore_fn(bookmark_id: str) -> Path: + return Path(f"/restore/{bookmark_id}") + + monkeypatch.setattr("shiny.bookmark._global._default_bookmark_save_dir_fn", None) + monkeypatch.setattr("shiny.bookmark._global._default_bookmark_restore_dir_fn", None) + + set_global_save_dir_fn(save_fn) + set_global_restore_dir_fn(restore_fn) + + save_dir = get_bookmark_save_dir_fn(MISSING_TYPE()) + restore_dir = get_bookmark_restore_dir_fn(MISSING_TYPE()) + assert save_dir is not None + assert restore_dir is not None + assert asyncio.run(cast(Coroutine[Any, Any, Path], save_dir("x"))) == Path( + "/save/x" + ) + assert asyncio.run(cast(Coroutine[Any, Any, Path], restore_dir("y"))) == Path( + "/restore/y" + ) + + +def test_restore_state_namespace_scoping(tmp_path: Path) -> None: + state = RestoreState( + input={"ns-x": 1, "y": 2}, + values={"ns-z": 3, "q": 4}, + dir=tmp_path, + ) + scoped = state._state_within_namespace("ns-") + assert scoped.input == {"x": 1} + assert scoped.values == {"z": 3} + assert scoped.dir == tmp_path / "ns-" + + +def test_restore_input_set_lifecycle() -> None: + rset = RestoreInputSet({"x": 1, "y": 2}) + resolved = ResolvedId("x") + assert rset.exists(resolved) + assert rset.available(resolved) + assert rset.get(resolved) == 1 + assert rset.is_pending(resolved) + rset.flush_pending() + assert rset.is_used(resolved) + assert rset.get(resolved) is None + assert rset.get(resolved, force=True) == 1 + + +def test_restore_context_from_query_string_inputs_values() -> None: + ctx = asyncio.run( + RestoreContext.from_query_string( + "_inputs_&x=1&_values_&y=2", app=cast(App, FakeApp()) + ) + ) + assert ctx.active is True + assert ctx.input.get(ResolvedId("x"), force=True) == 1 + assert ctx.values == {"y": 2} + + +def test_restore_context_load_state_qs(tmp_path: Path) -> None: + state_dir = tmp_path / "state" + state_dir.mkdir() + (state_dir / "input.json").write_text(json.dumps({"x": 1})) + (state_dir / "values.json").write_text(json.dumps({"y": 2})) + + async def restore_dir_fn(bookmark_id: str) -> Path: + assert bookmark_id == "abc" + return state_dir + + app = cast(App, FakeApp()) + app._bookmark_restore_dir_fn = restore_dir_fn + + ctx = asyncio.run(RestoreContext.from_query_string("_state_id_=abc", app=app)) + assert ctx.active is True + assert ctx.dir == state_dir + assert ctx.input.get(ResolvedId("x"), force=True) == 1 + assert ctx.values == {"y": 2} + + +def test_bookmark_state_encode_and_save( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + inputs = FakeInputs({"x": 1}) + state = BookmarkState(cast(Inputs, inputs), exclude=[], on_save=None) + state.values["y"] = 2 + + def fake_private_random_id(**_: object) -> str: + return "id" + + monkeypatch.setattr( + "shiny.bookmark._save_state.private_random_id", + fake_private_random_id, + ) + + async def save_dir_fn(bookmark_id: str) -> Path: + assert bookmark_id == "id" + return tmp_path + + app = cast(App, FakeApp()) + app._bookmark_save_dir_fn = save_dir_fn + + query_string = asyncio.run(state._save_state(app=app)) + assert query_string == "_state_id_=id" + assert (tmp_path / "input.json").exists() + assert (tmp_path / "values.json").exists() + + encoded = asyncio.run(state._encode_state()) + assert "_inputs_" in encoded + assert "_values_" in encoded + + +class FakeApp: + _bookmark_restore_dir_fn: Any = None + _bookmark_save_dir_fn: Any = None + + +class FakeInputs: + def __init__(self, values: dict[str, Any]): + self._values = values + + async def _serialize( + self, *, exclude: list[str], state_dir: Path | None + ) -> dict[str, Any]: + return {k: v for k, v in self._values.items() if k not in exclude} diff --git a/tests/pytest/test_bookmark_types.py b/tests/pytest/test_bookmark_types.py new file mode 100644 index 000000000..2edd0c842 --- /dev/null +++ b/tests/pytest/test_bookmark_types.py @@ -0,0 +1,21 @@ +"""Tests for shiny/bookmark/_types.py module.""" + +from shiny.bookmark._types import BookmarkStore + + +class TestBookmarkStore: + """Tests for BookmarkStore type.""" + + def test_bookmark_store_exists(self): + """Test BookmarkStore exists.""" + assert BookmarkStore is not None + + +class TestBookmarkDirFn: + """Tests for BookmarkDirFn type.""" + + def test_bookmark_dir_fn_importable(self): + """Test BookmarkDirFn is importable from shiny.bookmark._types.""" + from shiny.bookmark._types import BookmarkDirFn + + assert BookmarkDirFn is not None diff --git a/tests/pytest/test_bookmark_types_funcs.py b/tests/pytest/test_bookmark_types_funcs.py new file mode 100644 index 000000000..1fbcdb4c5 --- /dev/null +++ b/tests/pytest/test_bookmark_types_funcs.py @@ -0,0 +1,108 @@ +"""Tests for shiny.bookmark._types module""" + +from pathlib import Path + +from shiny.bookmark._types import ( + BookmarkDirFn, + BookmarkDirFnAsync, + BookmarkRestoreDirFn, + BookmarkSaveDirFn, + BookmarkStore, +) + + +class TestBookmarkStoreType: + """Test BookmarkStore literal type""" + + def test_url_value(self): + """Test 'url' is a valid BookmarkStore value""" + store: BookmarkStore = "url" + assert store == "url" + + def test_server_value(self): + """Test 'server' is a valid BookmarkStore value""" + store: BookmarkStore = "server" + assert store == "server" + + def test_disable_value(self): + """Test 'disable' is a valid BookmarkStore value""" + store: BookmarkStore = "disable" + assert store == "disable" + + +class TestBookmarkDirFnType: + """Test bookmark directory function types""" + + def test_sync_function(self): + """Test sync function satisfies BookmarkDirFn""" + + def sync_fn(bookmark_id: str) -> Path: + return Path(f"/tmp/{bookmark_id}") + + # This should type-check as BookmarkDirFn + fn: BookmarkDirFn = sync_fn + result = fn("test123") + # Result could be Path or Awaitable[Path] + assert result == Path("/tmp/test123") + + def test_async_function_signature(self): + """Test async function can satisfy BookmarkDirFnAsync""" + import asyncio + + async def async_fn(bookmark_id: str) -> Path: + return Path(f"/tmp/bookmarks/{bookmark_id}") + + # This should type-check as BookmarkDirFnAsync + fn: BookmarkDirFnAsync = async_fn + # Run the async function + result = asyncio.run(fn("bookmark_abc")) + assert result == Path("/tmp/bookmarks/bookmark_abc") + + def test_save_dir_fn_type(self): + """Test BookmarkSaveDirFn type""" + import asyncio + + async def save_fn(bookmark_id: str) -> Path: + return Path(f"/save/{bookmark_id}") + + fn: BookmarkSaveDirFn = save_fn + result = asyncio.run(fn("save_123")) + assert result == Path("/save/save_123") + + def test_restore_dir_fn_type(self): + """Test BookmarkRestoreDirFn type""" + import asyncio + + async def restore_fn(bookmark_id: str) -> Path: + return Path(f"/restore/{bookmark_id}") + + fn: BookmarkRestoreDirFn = restore_fn + result = asyncio.run(fn("restore_456")) + assert result == Path("/restore/restore_456") + + +class TestBookmarkDirFnUsage: + """Test practical usage of bookmark directory functions""" + + def test_path_construction(self): + """Test that functions return proper Path objects""" + + def get_bookmark_dir(bookmark_id: str) -> Path: + base = Path("/var/shiny/bookmarks") + return base / bookmark_id + + fn: BookmarkDirFn = get_bookmark_dir + result = fn("my_bookmark") + assert isinstance(result, Path) + assert str(result) == "/var/shiny/bookmarks/my_bookmark" + + def test_path_with_subdirectory(self): + """Test bookmark path with nested structure""" + + def get_nested_dir(bookmark_id: str) -> Path: + prefix = bookmark_id[:2] + return Path(f"/bookmarks/{prefix}/{bookmark_id}") + + fn: BookmarkDirFn = get_nested_dir + result = fn("abcdef123") + assert result == Path("/bookmarks/ab/abcdef123") diff --git a/tests/pytest/test_bookmark_utils_funcs.py b/tests/pytest/test_bookmark_utils_funcs.py new file mode 100644 index 000000000..cf3c101b4 --- /dev/null +++ b/tests/pytest/test_bookmark_utils_funcs.py @@ -0,0 +1,122 @@ +"""Tests for shiny.bookmark._utils module""" + +from pathlib import Path + +from _pytest.monkeypatch import MonkeyPatch + +from shiny.bookmark._utils import ( + from_json_file, + from_json_str, + in_shiny_server, + to_json_file, + to_json_str, +) + + +class TestInShinyServer: + """Test in_shiny_server function""" + + def test_not_in_shiny_server(self, monkeypatch: MonkeyPatch) -> None: + """Test in_shiny_server returns False when SHINY_PORT not set""" + monkeypatch.delenv("SHINY_PORT", raising=False) + assert in_shiny_server() is False + + def test_in_shiny_server_empty_port(self, monkeypatch: MonkeyPatch) -> None: + """Test in_shiny_server returns False when SHINY_PORT is empty""" + monkeypatch.setenv("SHINY_PORT", "") + assert in_shiny_server() is False + + def test_in_shiny_server_with_port(self, monkeypatch: MonkeyPatch) -> None: + """Test in_shiny_server returns True when SHINY_PORT is set""" + monkeypatch.setenv("SHINY_PORT", "3838") + assert in_shiny_server() is True + + +class TestJsonStr: + """Test to_json_str and from_json_str functions""" + + def test_to_json_str_dict(self): + """Test to_json_str with dictionary""" + result = to_json_str({"key": "value"}) + assert isinstance(result, str) + assert "key" in result + assert "value" in result + + def test_to_json_str_list(self): + """Test to_json_str with list""" + result = to_json_str([1, 2, 3]) + assert isinstance(result, str) + assert "[" in result + + def test_to_json_str_string(self): + """Test to_json_str with string""" + result = to_json_str("hello") + assert isinstance(result, str) + + def test_to_json_str_number(self): + """Test to_json_str with number""" + result = to_json_str(42) + assert result == "42" + + def test_from_json_str_dict(self): + """Test from_json_str with dict JSON""" + result = from_json_str('{"name": "test", "value": 123}') + assert result == {"name": "test", "value": 123} + + def test_from_json_str_list(self): + """Test from_json_str with list JSON""" + result = from_json_str("[1, 2, 3]") + assert result == [1, 2, 3] + + def test_from_json_str_string(self): + """Test from_json_str with string JSON""" + result = from_json_str('"hello"') + assert result == "hello" + + def test_roundtrip_json_str(self): + """Test roundtrip to_json_str -> from_json_str""" + original = {"nested": {"data": [1, 2, 3]}, "flag": True} + json_str = to_json_str(original) + result = from_json_str(json_str) + assert result == original + + +class TestJsonFile: + """Test to_json_file and from_json_file functions""" + + def test_to_json_file_creates_file(self, tmp_path: Path) -> None: + """Test to_json_file creates file""" + file_path = tmp_path / "test.json" + to_json_file({"test": "data"}, file_path) + assert file_path.exists() + + def test_to_json_file_writes_json(self, tmp_path: Path) -> None: + """Test to_json_file writes valid JSON""" + file_path = tmp_path / "test.json" + to_json_file({"key": "value"}, file_path) + content = file_path.read_text() + assert "key" in content + assert "value" in content + + def test_from_json_file_reads_data(self, tmp_path: Path) -> None: + """Test from_json_file reads JSON data""" + file_path = tmp_path / "test.json" + file_path.write_text('{"name": "test"}') + result = from_json_file(file_path) + assert result == {"name": "test"} + + def test_roundtrip_json_file(self, tmp_path: Path) -> None: + """Test roundtrip to_json_file -> from_json_file""" + file_path = tmp_path / "roundtrip.json" + original = {"items": [1, 2, 3], "nested": {"a": "b"}} + to_json_file(original, file_path) + result = from_json_file(file_path) + assert result == original + + def test_json_file_uses_utf8(self, tmp_path: Path) -> None: + """Test JSON file operations use UTF-8 encoding""" + file_path = tmp_path / "unicode.json" + data = {"emoji": "🎉", "accented": "café"} + to_json_file(data, file_path) + result = from_json_file(file_path) + assert result == data diff --git a/tests/pytest/test_bootstrap_funcs.py b/tests/pytest/test_bootstrap_funcs.py new file mode 100644 index 000000000..48207c9ff --- /dev/null +++ b/tests/pytest/test_bootstrap_funcs.py @@ -0,0 +1,120 @@ +"""Tests for shiny.ui._bootstrap module.""" + +from htmltools import Tag, TagList + +from shiny.ui._bootstrap import column, panel_title, row + + +class TestRow: + """Tests for row function.""" + + def test_row_basic(self) -> None: + """Test basic row creation.""" + result = row("content") + assert isinstance(result, Tag) + assert result.name == "div" + + def test_row_has_class(self) -> None: + """Test row has row class.""" + result = row("content") + html = str(result) + assert "row" in html + + def test_row_multiple_children(self) -> None: + """Test row with multiple children.""" + result = row("child1", "child2", "child3") + assert isinstance(result, Tag) + + +class TestColumn: + """Tests for column function.""" + + def test_column_basic(self) -> None: + """Test basic column creation.""" + result = column(6, "content") + assert isinstance(result, Tag) + assert result.name == "div" + + def test_column_has_class(self) -> None: + """Test column has col class.""" + result = column(6, "content") + html = str(result) + assert "col" in html + + def test_column_width_in_class(self) -> None: + """Test column width is in class name.""" + result = column(6, "content") + html = str(result) + assert "col-sm-6" in html + + def test_column_offset(self) -> None: + """Test column with offset.""" + result = column(6, "content", offset=3) + html = str(result) + assert "offset" in html + + def test_column_width_12(self) -> None: + """Test full-width column.""" + result = column(12, "content") + html = str(result) + assert "col-sm-12" in html + + def test_column_multiple_children(self) -> None: + """Test column with multiple children.""" + result = column(6, "child1", "child2") + assert isinstance(result, Tag) + + +class TestPanelTitle: + """Tests for panel_title function.""" + + def test_panel_title_basic(self) -> None: + """Test basic panel_title creation.""" + result = panel_title("My Title") + assert isinstance(result, TagList) + + def test_panel_title_has_title(self) -> None: + """Test panel_title contains title text.""" + result = panel_title("Test Title") + html = str(result) + assert "Test Title" in html + + def test_panel_title_h2_tag(self) -> None: + """Test panel_title uses h2 tag.""" + result = panel_title("Title") + html = str(result) + assert " None: + """Test panel_title with window_title parameter.""" + result = panel_title("Visible Title", window_title="Window Title") + # Should contain the visible title + html = str(result) + assert "Visible Title" in html + + +class TestRowColumnIntegration: + """Integration tests for row and column.""" + + def test_row_with_columns(self) -> None: + """Test row containing columns.""" + result = row( + column(6, "Left"), + column(6, "Right"), + ) + assert isinstance(result, Tag) + html = str(result) + assert "row" in html + assert "col-sm-6" in html + + def test_nested_rows(self) -> None: + """Test nested rows.""" + result = row( + column( + 12, + row( + column(6, "Nested"), + ), + ), + ) + assert isinstance(result, Tag) diff --git a/tests/pytest/test_busy_indicators.py b/tests/pytest/test_busy_indicators.py new file mode 100644 index 000000000..f296cbe9b --- /dev/null +++ b/tests/pytest/test_busy_indicators.py @@ -0,0 +1,127 @@ +"""Tests for shiny.ui.busy_indicators module.""" + +from shiny.ui.busy_indicators import options, use + + +class TestBusyIndicatorsOptions: + """Tests for busy_indicators.options function.""" + + def test_options_basic(self): + """Test basic options creation.""" + result = options() + assert result is not None + + def test_options_with_spinner_type(self): + """Test options with spinner type.""" + result = options(spinner_type="ring") + # Should contain spinner customization + assert result is not None + + def test_options_with_spinner_color(self): + """Test options with spinner color.""" + result = options(spinner_color="red") + assert result is not None + + def test_options_with_spinner_size(self): + """Test options with spinner size.""" + result = options(spinner_size="50px") + # Result is a CardItem, just verify it's created + assert result is not None + + def test_options_with_spinner_delay(self): + """Test options with spinner delay.""" + result = options(spinner_delay="500ms") + # Result is a CardItem, just verify it's created + assert result is not None + + def test_options_with_fade_opacity(self): + """Test options with fade opacity.""" + result = options(fade_opacity=0.5) + # Result is a CardItem, just verify it's created + assert result is not None + + def test_options_with_pulse_background(self): + """Test options with pulse background.""" + result = options(pulse_background="linear-gradient(to right, red, blue)") + # Should contain the gradient + assert result is not None + + def test_options_with_pulse_height(self): + """Test options with pulse height.""" + result = options(pulse_height="5px") + # Result is a CardItem, just verify it's created + assert result is not None + + def test_options_with_pulse_speed(self): + """Test options with pulse speed.""" + result = options(pulse_speed="2s") + # Result is a CardItem, just verify it's created + assert result is not None + + +class TestBusyIndicatorsUse: + """Tests for busy_indicators.use function.""" + + def test_use_defaults(self): + """Test use with default settings.""" + result = use() + assert result is not None + + def test_use_spinners_true(self): + """Test use with spinners enabled.""" + result = use(spinners=True) + assert result is not None + + def test_use_spinners_false(self): + """Test use with spinners disabled.""" + result = use(spinners=False) + assert result is not None + + def test_use_pulse_true(self): + """Test use with pulse enabled.""" + result = use(pulse=True) + assert result is not None + + def test_use_pulse_false(self): + """Test use with pulse disabled.""" + result = use(pulse=False) + assert result is not None + + def test_use_fade_true(self): + """Test use with fade enabled.""" + result = use(fade=True) + assert result is not None + + def test_use_fade_false(self): + """Test use with fade disabled.""" + result = use(fade=False) + assert result is not None + + def test_use_all_disabled(self): + """Test use with all indicators disabled.""" + result = use(spinners=False, pulse=False, fade=False) + assert result is not None + + def test_use_all_enabled(self): + """Test use with all indicators enabled.""" + result = use(spinners=True, pulse=True, fade=True) + assert result is not None + + +class TestBusySpinnerTypes: + """Tests for BusySpinnerType type.""" + + def test_spinner_types_bars(self): + """Test bars spinner type.""" + result = options(spinner_type="bars") + assert result is not None + + def test_spinner_types_dots(self): + """Test dots spinner type.""" + result = options(spinner_type="dots") + assert result is not None + + def test_spinner_types_ring(self): + """Test ring spinner type.""" + result = options(spinner_type="ring") + assert result is not None diff --git a/tests/pytest/test_busy_indicators_funcs.py b/tests/pytest/test_busy_indicators_funcs.py new file mode 100644 index 000000000..afe40d202 --- /dev/null +++ b/tests/pytest/test_busy_indicators_funcs.py @@ -0,0 +1,108 @@ +"""Tests for shiny.ui.busy_indicators module.""" + +from htmltools import TagList + +from shiny.ui import busy_indicators + + +class TestUseBusyIndicators: + """Tests for busy_indicators.use function.""" + + def test_use_basic(self) -> None: + """Test basic use creation with defaults.""" + result = busy_indicators.use() + assert isinstance(result, TagList) + html = str(result) + assert "shinyBusySpinners" in html + assert "shinyBusyPulse" in html + + def test_use_with_spinners(self) -> None: + """Test use with spinners parameter.""" + result = busy_indicators.use(spinners=True) + html = str(result) + assert "shinyBusySpinners" in html + + def test_use_without_spinners(self) -> None: + """Test use without spinners.""" + result = busy_indicators.use(spinners=False) + html = str(result) + assert "delete document.documentElement.dataset.shinyBusySpinners" in html + + def test_use_with_pulse(self) -> None: + """Test use with pulse parameter.""" + result = busy_indicators.use(pulse=True) + html = str(result) + assert "shinyBusyPulse" in html + + def test_use_without_pulse(self) -> None: + """Test use without pulse.""" + result = busy_indicators.use(pulse=False) + html = str(result) + assert "delete document.documentElement.dataset.shinyBusyPulse" in html + + def test_use_both_disabled(self) -> None: + """Test use with both disabled.""" + result = busy_indicators.use(spinners=False, pulse=False) + html = str(result) + assert "delete document.documentElement.dataset.shinyBusySpinners" in html + assert "delete document.documentElement.dataset.shinyBusyPulse" in html + + def test_use_both_enabled(self) -> None: + """Test use with both enabled.""" + result = busy_indicators.use(spinners=True, pulse=True) + html = str(result) + assert "shinyBusySpinners = true" in html + assert "shinyBusyPulse = true" in html + + def test_use_with_fade_disabled(self) -> None: + """Test use with fade disabled.""" + result = busy_indicators.use(fade=False) + html = str(result) + # When fade is False, should include fade_opacity=1 + assert "--shiny-fade-opacity: 1" in html + + +class TestBusyIndicatorsOptions: + """Tests for busy_indicators.options function.""" + + def test_options_default(self) -> None: + """Test options with default parameters.""" + result = busy_indicators.options() + # Should return a CardItem + assert result is not None + + def test_options_with_spinner_color(self) -> None: + """Test options with spinner_color.""" + result = busy_indicators.options(spinner_color="red") + html = str(result.resolve()) + assert "--shiny-spinner-color: red" in html + + def test_options_with_spinner_size(self) -> None: + """Test options with spinner_size.""" + result = busy_indicators.options(spinner_size="50px") + html = str(result.resolve()) + assert "--shiny-spinner-size: 50px" in html + + def test_options_with_fade_opacity(self) -> None: + """Test options with fade_opacity.""" + result = busy_indicators.options(fade_opacity=0.5) + html = str(result.resolve()) + assert "--shiny-fade-opacity: 0.5" in html + + def test_options_with_spinner_delay(self) -> None: + """Test options with spinner_delay.""" + result = busy_indicators.options(spinner_delay="500ms") + html = str(result.resolve()) + assert "--shiny-spinner-delay: 500ms" in html + + def test_options_with_pulse_height(self) -> None: + """Test options with pulse_height.""" + result = busy_indicators.options(pulse_height="4px") + html = str(result.resolve()) + assert "--shiny-pulse-height: 4px" in html + + def test_options_with_pulse_speed(self) -> None: + """Test options with pulse_speed.""" + result = busy_indicators.options(pulse_speed="1s") + html = str(result.resolve()) + assert "--shiny-pulse-speed: 1s" in html diff --git a/tests/pytest/test_busy_spinner_types_funcs.py b/tests/pytest/test_busy_spinner_types_funcs.py new file mode 100644 index 000000000..13f91644b --- /dev/null +++ b/tests/pytest/test_busy_spinner_types_funcs.py @@ -0,0 +1,55 @@ +"""Tests for shiny.ui._busy_spinner_types module.""" + +from typing import get_args + +from shiny.ui._busy_spinner_types import BusySpinnerType + + +class TestBusySpinnerType: + """Tests for BusySpinnerType literal type.""" + + def test_busy_spinner_type_is_literal(self): + """BusySpinnerType should be a Literal type.""" + # Get the allowed values from the Literal type + allowed_values = get_args(BusySpinnerType) + assert isinstance(allowed_values, tuple) + assert len(allowed_values) > 0 + + def test_busy_spinner_type_contains_bars_variants(self): + """BusySpinnerType should contain bars variants.""" + allowed_values = get_args(BusySpinnerType) + assert "bars" in allowed_values + assert "bars2" in allowed_values + assert "bars3" in allowed_values + + def test_busy_spinner_type_contains_dots_variants(self): + """BusySpinnerType should contain dots variants.""" + allowed_values = get_args(BusySpinnerType) + assert "dots" in allowed_values + assert "dots2" in allowed_values + assert "dots3" in allowed_values + + def test_busy_spinner_type_contains_pulse_variants(self): + """BusySpinnerType should contain pulse variants.""" + allowed_values = get_args(BusySpinnerType) + assert "pulse" in allowed_values + assert "pulse2" in allowed_values + assert "pulse3" in allowed_values + + def test_busy_spinner_type_contains_ring_variants(self): + """BusySpinnerType should contain ring variants.""" + allowed_values = get_args(BusySpinnerType) + assert "ring" in allowed_values + assert "ring2" in allowed_values + assert "ring3" in allowed_values + + def test_busy_spinner_type_has_twelve_values(self): + """BusySpinnerType should have exactly 12 values (4 types x 3 variants each).""" + allowed_values = get_args(BusySpinnerType) + assert len(allowed_values) == 12 + + def test_all_values_are_strings(self): + """All BusySpinnerType values should be strings.""" + allowed_values = get_args(BusySpinnerType) + for value in allowed_values: + assert isinstance(value, str) diff --git a/tests/pytest/test_card_full.py b/tests/pytest/test_card_full.py new file mode 100644 index 000000000..c121a08f6 --- /dev/null +++ b/tests/pytest/test_card_full.py @@ -0,0 +1,81 @@ +"""Tests for shiny/ui/_card.py module.""" + +from shiny.ui._card import card, card_footer, card_header + + +class TestCard: + """Tests for card function.""" + + def test_card_is_callable(self): + """Test card is callable.""" + assert callable(card) + + def test_card_returns_tag(self): + """Test card returns a Tag.""" + from htmltools import Tag + + result = card("Card content") + assert isinstance(result, Tag) + + def test_card_with_header_footer(self): + """Test card with header and footer.""" + from htmltools import Tag + + result = card( + card_header("Header"), + "Main content", + card_footer("Footer"), + ) + assert isinstance(result, Tag) + + +class TestCardHeader: + """Tests for card_header function.""" + + def test_card_header_is_callable(self): + """Test card_header is callable.""" + assert callable(card_header) + + def test_card_header_returns_card_item(self): + """Test card_header returns a CardItem object.""" + from shiny.ui._card import CardItem + + result = card_header("Header text") + assert isinstance(result, CardItem) + + +class TestCardFooter: + """Tests for card_footer function.""" + + def test_card_footer_is_callable(self): + """Test card_footer is callable.""" + assert callable(card_footer) + + def test_card_footer_returns_card_item(self): + """Test card_footer returns a CardItem object.""" + from shiny.ui._card import CardItem + + result = card_footer("Footer text") + assert isinstance(result, CardItem) + + +class TestCardExported: + """Tests for card functions export.""" + + def test_card_in_ui(self): + """Test card is in ui module.""" + from shiny import ui + + assert hasattr(ui, "card") + + def test_card_header_in_ui(self): + """Test card_header is in ui module.""" + from shiny import ui + + assert hasattr(ui, "card_header") + + def test_card_footer_in_ui(self): + """Test card_footer is in ui module.""" + from shiny import ui + + assert hasattr(ui, "card_footer") diff --git a/tests/pytest/test_card_func.py b/tests/pytest/test_card_func.py new file mode 100644 index 000000000..bb250d7cf --- /dev/null +++ b/tests/pytest/test_card_func.py @@ -0,0 +1,223 @@ +from htmltools import Tag, tags + +from shiny.ui._card import CardItem, card, card_footer, card_header + + +class TestCard: + """Tests for the card function.""" + + def test_card_basic(self): + """Test basic card creation.""" + result = card("Card content") + + assert isinstance(result, Tag) + result_str = str(result) + assert "card" in result_str + assert "Card content" in result_str + + def test_card_multiple_content(self): + """Test card with multiple content elements.""" + result = card( + "First item", + tags.p("Second item"), + tags.div("Third item"), + ) + + result_str = str(result) + assert "First item" in result_str + assert "Second item" in result_str + assert "Third item" in result_str + + def test_card_with_full_screen(self): + """Test card with full_screen enabled.""" + result = card("Content", full_screen=True) + + result_str = str(result) + assert "data-full-screen" in result_str + + def test_card_without_full_screen(self): + """Test card without full_screen (default).""" + result = card("Content", full_screen=False) + + result_str = str(result) + # full-screen should not be enabled + assert 'data-full-screen="false"' not in result_str + + def test_card_with_height(self): + """Test card with specified height.""" + result = card("Content", height="300px") + + result_str = str(result) + assert "300px" in result_str + + def test_card_with_max_height(self): + """Test card with max_height specified.""" + result = card("Content", max_height="500px") + + result_str = str(result) + assert "500px" in result_str + + def test_card_with_min_height(self): + """Test card with min_height specified.""" + result = card("Content", min_height="200px") + + result_str = str(result) + assert "200px" in result_str + + def test_card_fill_true(self): + """Test card with fill enabled (default).""" + result = card("Content", fill=True) + + # Card should be a fill item when fill=True + result_str = str(result) + assert "card" in result_str + + def test_card_fill_false(self): + """Test card with fill disabled.""" + result = card("Content", fill=False) + + result_str = str(result) + assert "card" in result_str + + def test_card_with_custom_class(self): + """Test card with custom CSS class.""" + result = card("Content", class_="my-custom-card") + + result_str = str(result) + assert "my-custom-card" in result_str + + def test_card_with_id(self): + """Test card with explicit id.""" + result = card("Content", id="my_card") + + assert result.attrs.get("id") == "my_card" + + def test_card_with_kwargs(self): + """Test card with additional HTML attributes.""" + result = card("Content", data_custom="value") + + result_str = str(result) + assert "data-custom" in result_str + + def test_card_has_bslib_class(self): + """Test that card has bslib-card class.""" + result = card("Content") + + result_str = str(result) + assert "bslib-card" in result_str + + def test_card_height_css_unit(self): + """Test card height with different CSS units.""" + # Test with percentage + result = card("Content", height="50%") + assert "50%" in str(result) + + # Test with em + result = card("Content", height="20em") + assert "20em" in str(result) + + +class TestCardHeader: + """Tests for the card_header function.""" + + def test_card_header_basic(self): + """Test basic card header.""" + result = card_header("Header Text") + + assert isinstance(result, CardItem) + result_str = str(result.resolve()) + assert "card-header" in result_str + assert "Header Text" in result_str + + def test_card_header_with_html(self): + """Test card header with HTML content.""" + result = card_header(tags.strong("Bold Header")) + + result_str = str(result.resolve()) + assert "Bold Header" in result_str + assert "strong" in result_str + + def test_card_header_multiple_content(self): + """Test card header with multiple content items.""" + result = card_header( + tags.span("Icon"), + "Title", + tags.span("Badge"), + ) + + result_str = str(result.resolve()) + assert "Icon" in result_str + assert "Title" in result_str + assert "Badge" in result_str + + +class TestCardFooter: + """Tests for the card_footer function.""" + + def test_card_footer_basic(self): + """Test basic card footer.""" + result = card_footer("Footer Text") + + assert isinstance(result, CardItem) + result_str = str(result.resolve()) + assert "card-footer" in result_str + assert "Footer Text" in result_str + + def test_card_footer_with_html(self): + """Test card footer with HTML content.""" + result = card_footer( + tags.button("Cancel", class_="btn"), + tags.button("Save", class_="btn btn-primary"), + ) + + result_str = str(result.resolve()) + assert "Cancel" in result_str + assert "Save" in result_str + + def test_card_footer_with_class(self): + """Test card footer with custom class.""" + result = card_footer("Footer", class_="text-muted") + + result_str = str(result.resolve()) + assert "text-muted" in result_str + + +class TestCardComposition: + """Tests for composing cards with headers and footers.""" + + def test_card_with_header_and_footer(self): + """Test card with both header and footer.""" + result = card( + card_header("My Header"), + "Body content", + card_footer("My Footer"), + ) + + result_str = str(result) + assert "My Header" in result_str + assert "Body content" in result_str + assert "My Footer" in result_str + assert "card-header" in result_str + assert "card-footer" in result_str + + def test_card_with_header_only(self): + """Test card with header only.""" + result = card( + card_header("Header"), + "Content", + ) + + result_str = str(result) + assert "Header" in result_str + assert "card-header" in result_str + + def test_card_with_footer_only(self): + """Test card with footer only.""" + result = card( + "Content", + card_footer("Footer"), + ) + + result_str = str(result) + assert "Footer" in result_str + assert "card-footer" in result_str diff --git a/tests/pytest/test_card_funcs.py b/tests/pytest/test_card_funcs.py new file mode 100644 index 000000000..f86526162 --- /dev/null +++ b/tests/pytest/test_card_funcs.py @@ -0,0 +1,179 @@ +"""Tests for shiny.ui._card module.""" + +from htmltools import Tag, div + +from shiny.ui._card import CardItem, card, card_body, card_footer, card_header + + +class TestCard: + """Tests for card function.""" + + def test_card_basic(self) -> None: + """Test basic card creation.""" + result = card() + assert isinstance(result, Tag) + + def test_card_with_content(self) -> None: + """Test card with content.""" + result = card("Card content") + html = str(result) + assert "Card content" in html + + def test_card_with_multiple_children(self) -> None: + """Test card with multiple children.""" + result = card("First", "Second", "Third") + html = str(result) + assert "First" in html + assert "Second" in html + assert "Third" in html + + def test_card_with_div_content(self) -> None: + """Test card with div content.""" + result = card(div("Inner content")) + html = str(result) + assert "Inner content" in html + + def test_card_has_card_class(self) -> None: + """Test card has card class.""" + result = card() + html = str(result) + assert "card" in html + + def test_card_with_full_screen(self) -> None: + """Test card with full_screen parameter.""" + result = card(full_screen=True) + html = str(result) + assert "card" in html + + def test_card_with_height(self) -> None: + """Test card with height parameter.""" + result = card(height="400px") + html = str(result) + assert "card" in html + + def test_card_with_fill(self) -> None: + """Test card with fill parameter.""" + result = card(fill=True) + html = str(result) + assert "card" in html + + def test_card_with_class(self) -> None: + """Test card with class_ parameter.""" + result = card(class_="my-custom-class") + html = str(result) + assert "my-custom-class" in html + + +class TestCardHeader: + """Tests for card_header function.""" + + def test_card_header_basic(self) -> None: + """Test basic card_header creation.""" + result = card_header("Header text") + assert isinstance(result, CardItem) + + def test_card_header_with_content(self) -> None: + """Test card_header with content.""" + result = card_header("My Header") + resolved = result.resolve() + html = str(resolved) + assert "My Header" in html + + def test_card_header_has_header_class(self) -> None: + """Test card_header has card-header class.""" + result = card_header("Header") + resolved = result.resolve() + html = str(resolved) + assert "card-header" in html + + +class TestCardBody: + """Tests for card_body function.""" + + def test_card_body_basic(self) -> None: + """Test basic card_body creation.""" + result = card_body() + assert isinstance(result, CardItem) + + def test_card_body_with_content(self) -> None: + """Test card_body with content.""" + result = card_body("Body content") + resolved = result.resolve() + html = str(resolved) + assert "Body content" in html + + def test_card_body_has_body_class(self) -> None: + """Test card_body has card-body class.""" + result = card_body("Content") + resolved = result.resolve() + html = str(resolved) + assert "card-body" in html + + def test_card_body_with_multiple_children(self) -> None: + """Test card_body with multiple children.""" + result = card_body("First", "Second") + resolved = result.resolve() + html = str(resolved) + assert "First" in html + assert "Second" in html + + def test_card_body_with_class(self) -> None: + """Test card_body with class_ parameter.""" + result = card_body("Content", class_="custom-class") + resolved = result.resolve() + html = str(resolved) + assert "custom-class" in html + + def test_card_body_with_fill(self) -> None: + """Test card_body with fill parameter.""" + result = card_body("Content", fill=True) + assert isinstance(result, CardItem) + + +class TestCardFooter: + """Tests for card_footer function.""" + + def test_card_footer_basic(self) -> None: + """Test basic card_footer creation.""" + result = card_footer("Footer text") + assert isinstance(result, CardItem) + + def test_card_footer_with_content(self) -> None: + """Test card_footer with content.""" + result = card_footer("My Footer") + resolved = result.resolve() + html = str(resolved) + assert "My Footer" in html + + def test_card_footer_has_footer_class(self) -> None: + """Test card_footer has card-footer class.""" + result = card_footer("Footer") + resolved = result.resolve() + html = str(resolved) + assert "card-footer" in html + + +class TestCardComposition: + """Tests for composing card components.""" + + def test_card_with_header_and_body(self) -> None: + """Test card with header and body.""" + result = card( + card_header("Title"), + card_body("Content"), + ) + html = str(result) + assert "Title" in html + assert "Content" in html + + def test_card_with_all_components(self) -> None: + """Test card with header, body, and footer.""" + result = card( + card_header("Title"), + card_body("Content"), + card_footer("Footer"), + ) + html = str(result) + assert "Title" in html + assert "Content" in html + assert "Footer" in html diff --git a/tests/pytest/test_chat_complete.py b/tests/pytest/test_chat_complete.py new file mode 100644 index 000000000..a2cb10e36 --- /dev/null +++ b/tests/pytest/test_chat_complete.py @@ -0,0 +1,126 @@ +"""Comprehensive tests for shiny.ui._chat module.""" + + +class TestChatExports: + """Tests for Chat class export.""" + + def test_chat_class_imported(self): + """Chat class should be importable.""" + from shiny.ui import Chat + + assert Chat is not None + + def test_chat_express_class_imported(self): + """ChatExpress class should be importable from _chat module.""" + # ChatExpress is not exported from public shiny.ui API + from shiny.ui._chat import ChatExpress + + assert ChatExpress is not None + assert hasattr(ChatExpress, "__name__") + + def test_chat_ui_function_imported(self): + """chat_ui function should be importable.""" + from shiny.ui import chat_ui + + assert chat_ui is not None + assert callable(chat_ui) + + def test_chat_message_dict_imported(self): + """ChatMessageDict should be importable from _chat module.""" + # ChatMessageDict is not exported from public shiny.ui API + from shiny.ui._chat import ChatMessageDict + + assert ChatMessageDict is not None + # TypedDict doesn't have __name__, check for dict behavior + assert isinstance(ChatMessageDict.__annotations__, dict) + + +class TestChatClassDecorated: + """Tests for Chat class decorator.""" + + def test_chat_has_example_decorator(self): + """Chat class should be decorated with add_example.""" + from shiny.ui import Chat + + # The add_example decorator should have been applied + # Check if it's still a class (decorator preserves class) + assert isinstance(Chat, type) + + +class TestChatExpressClassDecorated: + """Tests for ChatExpress class decorator.""" + + def test_chat_express_has_example_decorator(self): + """ChatExpress class should be decorated with add_example.""" + # ChatExpress is not exported from public shiny.ui API + from shiny.ui._chat import ChatExpress + + # The decorator is applied in _chat.py, check that it's callable + assert isinstance(ChatExpress, type) + + +class TestChatUiFunctionDecorated: + """Tests for chat_ui function decorator.""" + + def test_chat_ui_has_example_decorator(self): + """chat_ui function should be decorated with add_example.""" + from shiny.ui import chat_ui + + # The add_example decorator should have been applied + assert callable(chat_ui) + + +class TestModuleExports: + """Tests for module exports.""" + + def test_module_imports_correctly(self): + """Module should import without errors.""" + import shiny.ui._chat as chat + + assert chat is not None + + def test_all_exports_exist(self): + """All items in __all__ should be importable.""" + from shiny.ui import _chat + + for item in _chat.__all__: + assert hasattr(_chat, item) + + def test_all_exports_list(self): + """__all__ should contain expected exports.""" + from shiny.ui import _chat + + expected = {"Chat", "ChatExpress", "chat_ui", "ChatMessageDict"} + assert set(_chat.__all__) == expected + + +class TestChatImportsFromShinychat: + """Tests that verify shinychat imports work.""" + + def test_chat_imported_from_shinychat(self): + """Chat should be imported from shinychat.""" + from shiny.ui._chat import Chat + + # Check that it comes from shinychat + assert Chat.__module__.startswith("shinychat") + + def test_chat_express_imported_from_shinychat(self): + """ChatExpress should be imported from shinychat.express.""" + from shiny.ui._chat import ChatExpress + + # Check that it comes from shinychat.express + assert "shinychat" in ChatExpress.__module__ + + def test_chat_ui_imported_from_shinychat(self): + """chat_ui should be imported from shinychat.""" + from shiny.ui._chat import chat_ui + + # Check that it comes from shinychat + assert chat_ui.__module__.startswith("shinychat") + + def test_chat_message_dict_imported_from_shinychat(self): + """ChatMessageDict should be imported from shinychat.types.""" + from shiny.ui._chat import ChatMessageDict + + # ChatMessageDict is a TypedDict, check it's accessible + assert ChatMessageDict is not None diff --git a/tests/pytest/test_connection.py b/tests/pytest/test_connection.py new file mode 100644 index 000000000..bc7eb6c11 --- /dev/null +++ b/tests/pytest/test_connection.py @@ -0,0 +1,69 @@ +"""Tests for shiny._connection module.""" + +import pytest + +from shiny._connection import ConnectionClosed, MockConnection + + +class TestMockConnection: + """Tests for MockConnection class.""" + + def test_mock_connection_creation(self): + """Test creating a MockConnection.""" + conn = MockConnection() + assert conn is not None + + def test_mock_connection_get_http_conn(self): + """Test getting HTTP connection.""" + conn = MockConnection() + http_conn = conn.get_http_conn() + assert http_conn is not None + assert http_conn.scope["type"] == "websocket" + + @pytest.mark.asyncio + async def test_mock_connection_send(self): + """Test send method (no-op in mock).""" + conn = MockConnection() + await conn.send("test message") # Should not raise + + @pytest.mark.asyncio + async def test_mock_connection_close(self): + """Test close method (no-op in mock).""" + conn = MockConnection() + await conn.close(1000, "Normal closure") # Should not raise + + @pytest.mark.asyncio + async def test_mock_connection_receive(self): + """Test receive method with cause_receive.""" + conn = MockConnection() + conn.cause_receive("test message") + result = await conn.receive() + assert result == "test message" + + @pytest.mark.asyncio + async def test_mock_connection_cause_disconnect(self): + """Test cause_disconnect raises ConnectionClosed.""" + conn = MockConnection() + conn.cause_disconnect() + with pytest.raises(ConnectionClosed): + await conn.receive() + + +class TestConnectionClosed: + """Tests for ConnectionClosed exception.""" + + def test_connection_closed_is_exception(self): + """Test ConnectionClosed is an Exception.""" + assert issubclass(ConnectionClosed, Exception) + + def test_connection_closed_can_be_raised(self): + """Test ConnectionClosed can be raised.""" + with pytest.raises(ConnectionClosed): + raise ConnectionClosed() + + def test_connection_closed_with_message(self): + """Test ConnectionClosed with message.""" + try: + raise ConnectionClosed("Connection was closed") + except ConnectionClosed as e: + assert "Connection was closed" in str(e) diff --git a/tests/pytest/test_coordmap.py b/tests/pytest/test_coordmap.py new file mode 100644 index 000000000..a0652300e --- /dev/null +++ b/tests/pytest/test_coordmap.py @@ -0,0 +1,11 @@ +"""Tests for shiny/render/_coordmap.py module.""" + +from shiny.render._coordmap import get_coordmap + + +class TestGetCoordmap: + """Tests for get_coordmap function.""" + + def test_get_coordmap_is_callable(self): + """Test get_coordmap is callable.""" + assert callable(get_coordmap) diff --git a/tests/pytest/test_coordmap_utils.py b/tests/pytest/test_coordmap_utils.py new file mode 100644 index 000000000..3ce127d7a --- /dev/null +++ b/tests/pytest/test_coordmap_utils.py @@ -0,0 +1,81 @@ +"""Tests for shiny.render._coordmap helpers.""" + +from __future__ import annotations + +import types +from typing import cast + +import pytest + +from shiny.render._coordmap import ( + _get_mappings, + _is_log_trans, + _is_reverse_trans, + _simplify_type, +) +from shiny.types import PlotnineFigure + + +def test_is_log_trans_and_reverse() -> None: + class log10_trans: # noqa: N801 + pass + + class reverse_trans: # noqa: N801 + pass + + assert _is_log_trans(log10_trans()) is True + assert _is_log_trans(object()) is False + assert _is_reverse_trans(reverse_trans()) is True + assert _is_reverse_trans(object()) is False + + +def test_simplify_type_with_fake_numpy(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeInt(int): + pass + + class FakeFloat(float): + pass + + fake_numpy = types.SimpleNamespace(integer=FakeInt, floating=FakeFloat) + monkeypatch.setitem(__import__("sys").modules, "numpy", fake_numpy) + + assert _simplify_type(FakeInt(5)) == 5 + assert _simplify_type(FakeFloat(1.5)) == 1.5 + assert _simplify_type("x") == "x" + + +def test_get_mappings_with_facets() -> None: + class facet_grid: # noqa: N801 + cols = ["cyl"] + rows = ["gear"] + + class facet_wrap: # noqa: N801 + vars = ["am"] + + class coord_flip: # noqa: N801 + pass + + class FakeLayout: + coord = coord_flip() + facet = facet_grid() + + class FakePlot: + mapping = {"x": "wt", "y": "mpg"} + layout = FakeLayout() + + mapping = _get_mappings(cast(PlotnineFigure, FakePlot())) + assert mapping["x"] == "mpg" + assert mapping["y"] == "wt" + assert mapping.get("panelvar1") == "cyl" + assert mapping.get("panelvar2") == "gear" + + class FakeLayoutWrap: + coord = object() + facet = facet_wrap() + + class FakePlotWrap: + mapping = {} + layout = FakeLayoutWrap() + + mapping_wrap = _get_mappings(cast(PlotnineFigure, FakePlotWrap())) + assert mapping_wrap.get("panelvar1") == "am" diff --git a/tests/pytest/test_css_unit.py b/tests/pytest/test_css_unit.py new file mode 100644 index 000000000..1449da23d --- /dev/null +++ b/tests/pytest/test_css_unit.py @@ -0,0 +1,149 @@ +"""Tests for shiny/ui/css/_css_unit.py""" + +from __future__ import annotations + +from shiny.ui.css._css_unit import ( + as_css_padding, + as_css_unit, + as_grid_unit, + isinstance_cssunit, +) + + +class TestAsCssUnit: + """Tests for the as_css_unit function.""" + + def test_none_returns_none(self) -> None: + """Test that None returns None.""" + assert as_css_unit(None) is None + + def test_zero_returns_zero_string(self) -> None: + """Test that 0 returns '0'.""" + assert as_css_unit(0) == "0" + assert as_css_unit(0.0) == "0" + + def test_integer_returns_pixels(self) -> None: + """Test that integers return pixel values.""" + result = as_css_unit(100) + assert result.endswith("px") + assert "100" in result + + def test_float_returns_pixels(self) -> None: + """Test that floats return pixel values.""" + result = as_css_unit(100.5) + assert result.endswith("px") + assert "100" in result + + def test_string_passes_through(self) -> None: + """Test that strings pass through unchanged.""" + assert as_css_unit("1em") == "1em" + assert as_css_unit("50%") == "50%" + assert as_css_unit("calc(100% - 20px)") == "calc(100% - 20px)" + + def test_negative_integer(self) -> None: + """Test negative integer values.""" + result = as_css_unit(-10) + assert result.endswith("px") + assert "-10" in result + + +class TestAsCssPadding: + """Tests for the as_css_padding function.""" + + def test_none_returns_none(self) -> None: + """Test that None returns None.""" + assert as_css_padding(None) is None + + def test_single_value(self) -> None: + """Test single CSS unit value.""" + assert as_css_padding("1em") == "1em" + assert "10" in as_css_padding(10) + assert as_css_padding(0) == "0" + + def test_list_of_values(self) -> None: + """Test list of CSS unit values.""" + result = as_css_padding(["1em", "2em"]) + assert result == "1em 2em" + + def test_list_with_mixed_types(self) -> None: + """Test list with mixed types.""" + result = as_css_padding([10, "1em", 0, "2%"]) + assert "1em" in result + assert "0" in result + assert "2%" in result + assert "px" in result + + def test_four_values(self) -> None: + """Test four-value padding (top, right, bottom, left).""" + result = as_css_padding(["1em", "2em", "3em", "4em"]) + assert result == "1em 2em 3em 4em" + + +class TestIsinstanceCssunit: + """Tests for the isinstance_cssunit function.""" + + def test_int_is_cssunit(self) -> None: + """Test that int is a CssUnit.""" + assert isinstance_cssunit(10) is True + + def test_float_is_cssunit(self) -> None: + """Test that float is a CssUnit.""" + assert isinstance_cssunit(10.5) is True + + def test_str_is_cssunit(self) -> None: + """Test that str is a CssUnit.""" + assert isinstance_cssunit("1em") is True + + def test_none_is_not_cssunit(self) -> None: + """Test that None is not a CssUnit.""" + assert isinstance_cssunit(None) is False + + def test_list_is_not_cssunit(self) -> None: + """Test that list is not a CssUnit.""" + assert isinstance_cssunit([1, 2]) is False + + +class TestAsGridUnit: + """Tests for the as_grid_unit function.""" + + def test_none_returns_none(self) -> None: + """Test that None returns None.""" + assert as_grid_unit(None) is None + + def test_integer_returns_pixels(self) -> None: + """Test that integers return pixel values.""" + result = as_grid_unit(100) + assert result is not None + assert result.endswith("px") + + def test_auto_keyword(self) -> None: + """Test 'auto' keyword.""" + assert as_grid_unit("auto") == "auto" + assert as_grid_unit("AUTO") == "auto" + assert as_grid_unit("Auto") == "auto" + + def test_min_content_keyword(self) -> None: + """Test 'min-content' keyword.""" + assert as_grid_unit("min-content") == "min-content" + assert as_grid_unit("MIN-CONTENT") == "min-content" + + def test_max_content_keyword(self) -> None: + """Test 'max-content' keyword.""" + assert as_grid_unit("max-content") == "max-content" + assert as_grid_unit("MAX-CONTENT") == "max-content" + + def test_minmax_function(self) -> None: + """Test minmax() function passthrough.""" + result = as_grid_unit("minmax(100px, 1fr)") + assert result == "minmax(100px, 1fr)" + + def test_fr_unit(self) -> None: + """Test fr unit handling.""" + result = as_grid_unit("1fr") + # fr units get passed through as_css_unit which should return them as-is + assert result == "1fr" + + def test_regular_css_unit(self) -> None: + """Test regular CSS unit passthrough.""" + assert as_grid_unit("50%") == "50%" + assert as_grid_unit("10em") == "10em" diff --git a/tests/pytest/test_css_unit_full.py b/tests/pytest/test_css_unit_full.py new file mode 100644 index 000000000..2b81c6b7d --- /dev/null +++ b/tests/pytest/test_css_unit_full.py @@ -0,0 +1,49 @@ +"""Tests for shiny/ui/css/_css_unit.py module.""" + +from shiny.ui.css._css_unit import as_css_padding, as_css_unit + + +class TestAsCssUnit: + """Tests for as_css_unit function.""" + + def test_as_css_unit_is_callable(self): + """Test as_css_unit is callable.""" + assert callable(as_css_unit) + + def test_as_css_unit_with_px(self): + """Test as_css_unit with px value.""" + result = as_css_unit("100px") + assert result == "100px" + + def test_as_css_unit_with_percent(self): + """Test as_css_unit with percent value.""" + result = as_css_unit("50%") + assert result == "50%" + + def test_as_css_unit_with_number(self): + """Test as_css_unit with number value.""" + result = as_css_unit(100) + assert "100" in result + + def test_as_css_unit_with_none(self): + """Test as_css_unit with None.""" + result = as_css_unit(None) + assert result is None + + +class TestAsCssPadding: + """Tests for as_css_padding function.""" + + def test_as_css_padding_is_callable(self): + """Test as_css_padding is callable.""" + assert callable(as_css_padding) + + def test_as_css_padding_with_single_value(self): + """Test as_css_padding with single value.""" + result = as_css_padding("10px") + assert result is not None + + def test_as_css_padding_with_none(self): + """Test as_css_padding with None.""" + result = as_css_padding(None) + assert result is None diff --git a/tests/pytest/test_css_unit_funcs.py b/tests/pytest/test_css_unit_funcs.py new file mode 100644 index 000000000..f2f4e71ec --- /dev/null +++ b/tests/pytest/test_css_unit_funcs.py @@ -0,0 +1,140 @@ +"""Tests for shiny.ui.css._css_unit module.""" + +from shiny.ui.css._css_unit import CssUnit, as_css_padding, as_css_unit + + +class TestAsCssUnit: + """Tests for as_css_unit function.""" + + def test_as_css_unit_none(self) -> None: + """Test as_css_unit with None.""" + result = as_css_unit(None) + assert result is None + + def test_as_css_unit_string(self) -> None: + """Test as_css_unit with string.""" + result = as_css_unit("100px") + assert result == "100px" + + def test_as_css_unit_int(self) -> None: + """Test as_css_unit with int.""" + result = as_css_unit(100) + # Returns format like "100.000000px" + assert result is not None + assert result.endswith("px") + assert "100" in result + + def test_as_css_unit_float(self) -> None: + """Test as_css_unit with float.""" + result = as_css_unit(50.5) + # Returns format like "50.500000px" + assert result is not None + assert result.endswith("px") + assert "50" in result + + def test_as_css_unit_zero(self) -> None: + """Test as_css_unit with zero.""" + result = as_css_unit(0) + assert result == "0" + + def test_as_css_unit_with_percent(self) -> None: + """Test as_css_unit with percent string.""" + result = as_css_unit("50%") + assert result == "50%" + + def test_as_css_unit_with_rem(self) -> None: + """Test as_css_unit with rem string.""" + result = as_css_unit("2rem") + assert result == "2rem" + + def test_as_css_unit_with_em(self) -> None: + """Test as_css_unit with em string.""" + result = as_css_unit("1.5em") + assert result == "1.5em" + + +class TestAsCssPadding: + """Tests for as_css_padding function.""" + + def test_as_css_padding_none(self) -> None: + """Test as_css_padding with None.""" + result = as_css_padding(None) + assert result is None + + def test_as_css_padding_string(self) -> None: + """Test as_css_padding with string.""" + result = as_css_padding("10px") + assert result == "10px" + + def test_as_css_padding_int(self) -> None: + """Test as_css_padding with int.""" + result = as_css_padding(10) + # Returns format like "10.000000px" + assert result is not None + assert result.endswith("px") + assert "10" in result + + def test_as_css_padding_list_one(self) -> None: + """Test as_css_padding with single value list.""" + result = as_css_padding([10]) + # Returns format like "10.000000px" + assert result is not None + assert result.endswith("px") + assert "10" in result + + def test_as_css_padding_list_two(self) -> None: + """Test as_css_padding with two value list.""" + result = as_css_padding([10, 20]) + # Returns format like "10.000000px 20.000000px" + assert result is not None + parts = result.split() + assert len(parts) == 2 + assert all(p.endswith("px") for p in parts) + + def test_as_css_padding_list_three(self) -> None: + """Test as_css_padding with three value list.""" + result = as_css_padding([10, 20, 30]) + # Returns format like "10.000000px 20.000000px 30.000000px" + assert result is not None + parts = result.split() + assert len(parts) == 3 + assert all(p.endswith("px") for p in parts) + + def test_as_css_padding_list_four(self) -> None: + """Test as_css_padding with four value list.""" + result = as_css_padding([10, 20, 30, 40]) + # Returns format like "10.000000px 20.000000px 30.000000px 40.000000px" + assert result is not None + parts = result.split() + assert len(parts) == 4 + assert all(p.endswith("px") for p in parts) + + def test_as_css_padding_list_strings(self) -> None: + """Test as_css_padding with list of strings.""" + result = as_css_padding(["1rem", "2rem"]) + assert result == "1rem 2rem" + + +class TestCssUnitType: + """Tests for CssUnit type.""" + + def test_css_unit_accepts_string(self) -> None: + """Test CssUnit type accepts string.""" + value: CssUnit = "100px" + assert as_css_unit(value) == "100px" + + def test_css_unit_accepts_int(self) -> None: + """Test CssUnit type accepts int.""" + value: CssUnit = 100 + result = as_css_unit(value) + assert result is not None + assert result.endswith("px") + assert "100" in result + + def test_css_unit_accepts_float(self) -> None: + """Test CssUnit type accepts float.""" + value: CssUnit = 50.5 + result = as_css_unit(value) + assert result is not None + assert result.endswith("px") + assert "50" in result diff --git a/tests/pytest/test_css_unit_module_funcs.py b/tests/pytest/test_css_unit_module_funcs.py new file mode 100644 index 000000000..4b7c437b3 --- /dev/null +++ b/tests/pytest/test_css_unit_module_funcs.py @@ -0,0 +1,165 @@ +"""Tests for shiny.ui.css._css_unit module.""" + +from shiny.ui.css._css_unit import ( + as_css_padding, + as_css_unit, + as_grid_unit, + isinstance_cssunit, +) + + +class TestAsCssUnit: + """Tests for as_css_unit function.""" + + def test_as_css_unit_with_none(self): + """as_css_unit with None should return None.""" + result = as_css_unit(None) + assert result is None + + def test_as_css_unit_with_zero_int(self): + """as_css_unit with 0 should return '0'.""" + result = as_css_unit(0) + assert result == "0" + + def test_as_css_unit_with_zero_float(self): + """as_css_unit with 0.0 should return '0'.""" + result = as_css_unit(0.0) + assert result == "0" + + def test_as_css_unit_with_int(self): + """as_css_unit with int should return px value.""" + result = as_css_unit(300) + assert "px" in result + assert "300" in result + + def test_as_css_unit_with_float(self): + """as_css_unit with float should return px value.""" + result = as_css_unit(300.5) + assert "px" in result + assert "300" in result + + def test_as_css_unit_with_string(self): + """as_css_unit with string should return as-is.""" + result = as_css_unit("1em") + assert result == "1em" + + def test_as_css_unit_with_percentage_string(self): + """as_css_unit with percentage string should return as-is.""" + result = as_css_unit("50%") + assert result == "50%" + + def test_as_css_unit_with_rem(self): + """as_css_unit with rem should return as-is.""" + result = as_css_unit("2rem") + assert result == "2rem" + + +class TestAsCssPadding: + """Tests for as_css_padding function.""" + + def test_as_css_padding_with_none(self): + """as_css_padding with None should return None.""" + result = as_css_padding(None) + assert result is None + + def test_as_css_padding_with_single_value(self): + """as_css_padding with single value should return string.""" + result = as_css_padding(10) + assert "px" in result + + def test_as_css_padding_with_single_string(self): + """as_css_padding with single string should return that string.""" + result = as_css_padding("1em") + assert result == "1em" + + def test_as_css_padding_with_list(self): + """as_css_padding with list should return space-separated values.""" + result = as_css_padding([0, "1em"]) + assert "0" in result + assert "1em" in result + assert " " in result + + def test_as_css_padding_with_four_values(self): + """as_css_padding with four values should return all four.""" + result = as_css_padding([10, 20, 30, 40]) + # Should have 4 values separated by spaces + parts = result.split() + assert len(parts) == 4 + + +class TestIsinstanceCssunit: + """Tests for isinstance_cssunit function.""" + + def test_isinstance_cssunit_with_int(self): + """isinstance_cssunit should return True for int.""" + assert isinstance_cssunit(10) is True + + def test_isinstance_cssunit_with_float(self): + """isinstance_cssunit should return True for float.""" + assert isinstance_cssunit(10.5) is True + + def test_isinstance_cssunit_with_string(self): + """isinstance_cssunit should return True for string.""" + assert isinstance_cssunit("10px") is True + + def test_isinstance_cssunit_with_none(self): + """isinstance_cssunit should return False for None.""" + assert isinstance_cssunit(None) is False + + def test_isinstance_cssunit_with_list(self): + """isinstance_cssunit should return False for list.""" + assert isinstance_cssunit([10, 20]) is False + + def test_isinstance_cssunit_with_dict(self): + """isinstance_cssunit should return False for dict.""" + assert isinstance_cssunit({"width": 10}) is False + + +class TestAsGridUnit: + """Tests for as_grid_unit function.""" + + def test_as_grid_unit_with_none(self): + """as_grid_unit with None should return None.""" + result = as_grid_unit(None) + assert result is None + + def test_as_grid_unit_with_int(self): + """as_grid_unit with int should return px value.""" + result = as_grid_unit(100) + assert "px" in result + + def test_as_grid_unit_with_auto(self): + """as_grid_unit with 'auto' should return 'auto'.""" + result = as_grid_unit("auto") + assert result == "auto" + + def test_as_grid_unit_with_auto_uppercase(self): + """as_grid_unit with 'AUTO' should return 'auto' (lowercase).""" + result = as_grid_unit("AUTO") + assert result == "auto" + + def test_as_grid_unit_with_min_content(self): + """as_grid_unit with 'min-content' should return 'min-content'.""" + result = as_grid_unit("min-content") + assert result == "min-content" + + def test_as_grid_unit_with_max_content(self): + """as_grid_unit with 'max-content' should return 'max-content'.""" + result = as_grid_unit("max-content") + assert result == "max-content" + + def test_as_grid_unit_with_minmax(self): + """as_grid_unit with minmax() should return as-is.""" + result = as_grid_unit("minmax(100px, 1fr)") + assert result == "minmax(100px, 1fr)" + + def test_as_grid_unit_with_fr_unit(self): + """as_grid_unit with fr unit should process correctly.""" + result = as_grid_unit("1fr") + # fr units should be returned as-is or processed + assert "fr" in result or "px" in result + + def test_as_grid_unit_with_percentage(self): + """as_grid_unit with percentage should return as-is.""" + result = as_grid_unit("50%") + assert result == "50%" diff --git a/tests/pytest/test_data_frame_patch.py b/tests/pytest/test_data_frame_patch.py new file mode 100644 index 000000000..287eb4775 --- /dev/null +++ b/tests/pytest/test_data_frame_patch.py @@ -0,0 +1,19 @@ +"""Tests for shiny/render/_data_frame_utils/_patch.py module.""" + +from shiny.render._data_frame_utils._patch import CellPatch, CellValue + + +class TestCellPatch: + """Tests for CellPatch class.""" + + def test_cell_patch_is_class(self): + """Test CellPatch is a class.""" + assert isinstance(CellPatch, type) + + +class TestCellValue: + """Tests for CellValue type.""" + + def test_cell_value_exists(self): + """Test CellValue exists.""" + assert CellValue is not None diff --git a/tests/pytest/test_data_frame_types.py b/tests/pytest/test_data_frame_types.py new file mode 100644 index 000000000..70cc48bdd --- /dev/null +++ b/tests/pytest/test_data_frame_types.py @@ -0,0 +1,19 @@ +"""Tests for shiny/render/_data_frame_utils/_types.py module.""" + +from shiny.render._data_frame_utils._types import CellPatch, ColumnSort + + +class TestColumnSort: + """Tests for ColumnSort class.""" + + def test_column_sort_exists(self): + """Test ColumnSort exists.""" + assert ColumnSort is not None + + +class TestCellPatch: + """Tests for CellPatch class.""" + + def test_cell_patch_exists(self): + """Test CellPatch exists.""" + assert CellPatch is not None diff --git a/tests/pytest/test_data_frame_types_complete.py b/tests/pytest/test_data_frame_types_complete.py new file mode 100644 index 000000000..55e7108f5 --- /dev/null +++ b/tests/pytest/test_data_frame_types_complete.py @@ -0,0 +1,428 @@ +"""Comprehensive tests for shiny.render._data_frame_utils._types module.""" + +from __future__ import annotations + +from typing import Literal + + +class TestDataFrameTypes: + """Tests for DataFrame and Series types.""" + + def test_dataframe_import(self): + """DataFrame should be importable.""" + from shiny.render._data_frame_utils._types import DataFrame + + assert DataFrame is not None + + def test_series_import(self): + """Series should be importable.""" + from shiny.render._data_frame_utils._types import Series + + assert Series is not None + + +class TestPandasCompatible: + """Tests for PandasCompatible protocol.""" + + def test_pandas_compatible_protocol_exists(self): + """PandasCompatible protocol should exist.""" + from shiny.render._data_frame_utils._types import PandasCompatible + + assert PandasCompatible is not None + + def test_pandas_compatible_is_runtime_checkable(self): + """PandasCompatible should be runtime checkable.""" + from shiny.render._data_frame_utils._types import PandasCompatible + + # Should be able to check at runtime + class FakePandasLike: + def to_pandas(self): + import pandas as pd + + return pd.DataFrame() + + obj = FakePandasLike() + assert isinstance(obj, PandasCompatible) + + +class TestCellHtml: + """Tests for CellHtml TypedDict.""" + + def test_cell_html_structure(self): + """CellHtml should have correct structure.""" + from shiny.render._data_frame_utils._types import CellHtml + + cell: CellHtml = { # type: ignore[typeddict-item] + "isShinyHtml": True, + "obj": ([], {}), # type: ignore + } + assert cell["isShinyHtml"] is True + + +class TestColumnSort: + """Tests for ColumnSort TypedDict.""" + + def test_column_sort_ascending(self): + """ColumnSort should support ascending sort.""" + from shiny.render._data_frame_utils._types import ColumnSort + + sort: ColumnSort = {"col": 0, "desc": False} + assert sort["col"] == 0 + assert sort["desc"] is False + + def test_column_sort_descending(self): + """ColumnSort should support descending sort.""" + from shiny.render._data_frame_utils._types import ColumnSort + + sort: ColumnSort = {"col": 2, "desc": True} + assert sort["col"] == 2 + assert sort["desc"] is True + + +class TestColumnFilterStr: + """Tests for ColumnFilterStr TypedDict.""" + + def test_column_filter_str_structure(self): + """ColumnFilterStr should have correct structure.""" + from shiny.render._data_frame_utils._types import ColumnFilterStr + + filter_str: ColumnFilterStr = {"col": 1, "value": "test"} + assert filter_str["col"] == 1 + assert filter_str["value"] == "test" + + +class TestColumnFilterNumber: + """Tests for ColumnFilterNumber TypedDict.""" + + def test_column_filter_number_range(self): + """ColumnFilterNumber should support range values.""" + from shiny.render._data_frame_utils._types import ColumnFilterNumber + + filter_num: ColumnFilterNumber = {"col": 0, "value": (10, 20)} + assert filter_num["col"] == 0 + assert filter_num["value"] == (10, 20) + + def test_column_filter_number_min_only(self): + """ColumnFilterNumber should support min-only values.""" + from shiny.render._data_frame_utils._types import ColumnFilterNumber + + filter_num: ColumnFilterNumber = {"col": 1, "value": (5, None)} + assert filter_num["value"][0] == 5 + assert filter_num["value"][1] is None + + def test_column_filter_number_max_only(self): + """ColumnFilterNumber should support max-only values.""" + from shiny.render._data_frame_utils._types import ColumnFilterNumber + + filter_num: ColumnFilterNumber = {"col": 2, "value": (None, 100)} + assert filter_num["value"][0] is None + assert filter_num["value"][1] == 100 + + +class TestDataViewInfo: + """Tests for DataViewInfo TypedDict.""" + + def test_data_view_info_structure(self): + """DataViewInfo should have correct structure.""" + from shiny.render._data_frame_utils._types import DataViewInfo + + view: DataViewInfo = { + "sort": ({"col": 0, "desc": False},), + "filter": (), + "rows": (0, 1, 2), + "selected_rows": (0,), + } + assert len(view["sort"]) == 1 + assert len(view["filter"]) == 0 + assert len(view["rows"]) == 3 + assert len(view["selected_rows"]) == 1 + + +class TestFrameRenderTypes: + """Tests for FrameRender-related TypedDicts.""" + + def test_frame_render_patch_info(self): + """FrameRenderPatchInfo should have key field.""" + from shiny.render._data_frame_utils._types import FrameRenderPatchInfo + + patch: FrameRenderPatchInfo = {"key": "unique-key-123"} + assert patch["key"] == "unique-key-123" + + def test_frame_render_selection_modes(self): + """FrameRenderSelectionModes should have all mode fields.""" + from shiny.render._data_frame_utils._types import FrameRenderSelectionModes + + modes: FrameRenderSelectionModes = { + "row": "single", + "col": "multiple", + "rect": "cell", + } + assert modes["row"] == "single" + assert modes["col"] == "multiple" + assert modes["rect"] == "cell" + + def test_frame_render_selection_modes_none_values(self): + """FrameRenderSelectionModes should support 'none' values.""" + from shiny.render._data_frame_utils._types import FrameRenderSelectionModes + + modes: FrameRenderSelectionModes = { + "row": "none", + "col": "none", + "rect": "none", + } + assert modes["row"] == "none" + + def test_frame_render_to_jsonifiable(self): + """frame_render_to_jsonifiable should convert FrameRender.""" + from shiny.render._data_frame_utils._types import ( + FrameRender, + frame_render_to_jsonifiable, + ) + + frame: FrameRender = { + "payload": { + "columns": ["A", "B"], + "data": [[1, 2]], + "typeHints": [{"type": "numeric"}, {"type": "numeric"}], + }, + "patchInfo": {"key": "test"}, + "selectionModes": {"row": "single", "col": "none", "rect": "none"}, + } + result = frame_render_to_jsonifiable(frame) + assert "payload" in result + assert "patchInfo" in result + assert "selectionModes" in result + + +class TestFrameJsonTypes: + """Tests for FrameJson and FrameJsonOptions.""" + + def test_frame_json_options(self): + """FrameJsonOptions should support all optional fields.""" + from shiny.render._data_frame_utils._types import FrameJsonOptions + + options: FrameJsonOptions = { + "width": "100%", + "height": 400, + "summary": True, + "filters": False, + "editable": True, + "style": "bootstrap", + "fill": True, + } + assert options["width"] == "100%" + assert options["height"] == 400 + assert options["summary"] is True + + def test_frame_json_minimal(self): + """FrameJson should work with minimal required fields.""" + from shiny.render._data_frame_utils._types import FrameJson + + frame: FrameJson = { + "columns": ["col1", "col2"], + "data": [[1, 2], [3, 4]], + "typeHints": [{"type": "numeric"}, {"type": "numeric"}], + } + assert len(frame["columns"]) == 2 + assert len(frame["data"]) == 2 + + +class TestFrameDtypeTypes: + """Tests for FrameDtype-related types.""" + + def test_frame_dtype_subset_types(self): + """FrameDtypeSubset should support all type literals.""" + from shiny.render._data_frame_utils._types import FrameDtypeSubset + + types_to_test: list[ + Literal[ + "string", + "numeric", + "boolean", + "date", + "datetime", + "time", + "duration", + "object", + "unknown", + "html", + "binary", + ] + ] = [ + "string", + "numeric", + "boolean", + "date", + "datetime", + "time", + "duration", + "object", + "unknown", + "html", + "binary", + ] + + for dtype_type in types_to_test: + dtype: FrameDtypeSubset = {"type": dtype_type} + assert dtype["type"] == dtype_type + + def test_frame_dtype_categories(self): + """FrameDtypeCategories should support categorical types.""" + from shiny.render._data_frame_utils._types import FrameDtypeCategories + + dtype: FrameDtypeCategories = { + "type": "categorical", + "categories": ["A", "B", "C"], + } + assert dtype["type"] == "categorical" + assert len(dtype["categories"]) == 3 + + +class TestStyleInfoTypes: + """Tests for StyleInfo and BrowserStyleInfo.""" + + def test_style_info_body_full(self): + """StyleInfoBody should support all fields.""" + from shiny.render._data_frame_utils._types import StyleInfoBody + + style: StyleInfoBody = { + "location": "body", + "rows": [0, 1, 2], + "cols": ["A", "B"], + "style": {"background-color": "yellow"}, + "class": "highlight", + } + assert style["location"] == "body" + assert style.get("class") == "highlight" + + def test_style_info_body_minimal(self): + """StyleInfoBody should work with minimal fields.""" + from shiny.render._data_frame_utils._types import StyleInfoBody + + style: StyleInfoBody = {} + # All fields are NotRequired + assert isinstance(style, dict) + + def test_browser_style_info_body(self): + """BrowserStyleInfoBody should require all fields.""" + from shiny.render._data_frame_utils._types import BrowserStyleInfoBody + + style: BrowserStyleInfoBody = { + "location": "body", + "rows": (0, 1, 2), + "cols": (0, 1), + "style": {"color": "red"}, + "class": "styled", + } + assert style["location"] == "body" + assert isinstance(style["rows"], tuple) + + +class TestCellPatchTypes: + """Tests for CellPatch and CellPatchProcessed.""" + + def test_cell_patch_structure(self): + """CellPatch should have correct structure.""" + from htmltools import Tag + + from shiny.render._data_frame_utils._types import CellPatch + + patch: CellPatch = { + "row_index": 0, + "column_index": 1, + "value": Tag("div", "New value"), + } + assert patch["row_index"] == 0 + assert patch["column_index"] == 1 + + def test_cell_patch_processed_structure(self): + """CellPatchProcessed should have correct structure.""" + from shiny.render._data_frame_utils._types import CellPatchProcessed + + patch: CellPatchProcessed = { + "row_index": 2, + "column_index": 3, + "value": "Updated text", + } + assert patch["row_index"] == 2 + assert patch["column_index"] == 3 + assert patch["value"] == "Updated text" + + def test_cell_patch_processed_to_jsonifiable(self): + """cell_patch_processed_to_jsonifiable should convert patch.""" + from shiny.render._data_frame_utils._types import ( + CellPatchProcessed, + cell_patch_processed_to_jsonifiable, + ) + + patch: CellPatchProcessed = { # type: ignore[typeddict-item] + "row_index": 0, + "column_index": 0, + "value": {"isShinyHtml": True, "obj": ([], {})}, # type: ignore + } + result = cell_patch_processed_to_jsonifiable(patch) + assert "row_index" in result + assert "column_index" in result + assert "value" in result + + +class TestModuleExports: + """Tests for module __all__ exports.""" + + def test_all_exports_exist(self): + """All items in __all__ should be importable.""" + from shiny.render._data_frame_utils import _types + + for name in _types.__all__: + assert hasattr(_types, name), f"{name} not found in module" + + def test_dtype_export(self): + """DType should be exported.""" + from shiny.render._data_frame_utils._types import DType + + assert DType is not None + + def test_dataframe_t_export(self): + """DataFrameT should be exported.""" + from shiny.render._data_frame_utils._types import DataFrameT + + assert DataFrameT is not None + + def test_into_dataframe_export(self): + """IntoDataFrame should be exported.""" + from shiny.render._data_frame_utils._types import IntoDataFrame + + assert IntoDataFrame is not None + + def test_into_expr_export(self): + """IntoExpr should be exported.""" + from shiny.render._data_frame_utils._types import IntoExpr + + assert IntoExpr is not None + + +class TestTypeAliases: + """Tests for type aliases.""" + + def test_rows_list_type(self): + """RowsList type alias should work.""" + from shiny.render._data_frame_utils._types import RowsList + + rows: RowsList = [0, 1, 2] + assert rows is not None + + rows_none: RowsList = None + assert rows_none is None + + def test_cols_list_type(self): + """ColsList type alias should work.""" + from shiny.render._data_frame_utils._types import ColsList + + cols: ColsList = ["A", "B", "C"] + assert cols is not None + + cols_int: ColsList = [0, 1, 2] + assert cols_int is not None + + cols_none: ColsList = None + assert cols_none is None diff --git a/tests/pytest/test_data_frame_types_module.py b/tests/pytest/test_data_frame_types_module.py new file mode 100644 index 000000000..b53b9b52a --- /dev/null +++ b/tests/pytest/test_data_frame_types_module.py @@ -0,0 +1,314 @@ +"""Tests for shiny/render/_data_frame_utils/_types.py""" + +from __future__ import annotations + +from typing import Any, Literal + +from shiny.render._data_frame_utils._types import ( + BrowserStyleInfoBody, + CellPatch, + CellPatchProcessed, + ColumnFilterNumber, + ColumnFilterStr, + ColumnSort, + DataViewInfo, + FrameDtypeCategories, + FrameDtypeSubset, + FrameJsonOptions, + FrameRender, + FrameRenderPatchInfo, + FrameRenderSelectionModes, + PandasCompatible, + StyleInfoBody, + cell_patch_processed_to_jsonifiable, + frame_render_to_jsonifiable, +) + + +class TestPandasCompatible: + """Tests for the PandasCompatible protocol.""" + + def test_protocol_is_runtime_checkable(self) -> None: + """Test that PandasCompatible can be used with isinstance.""" + + class HasToPandas: + def to_pandas(self) -> Any: + return None + + class NoToPandas: + pass + + assert isinstance(HasToPandas(), PandasCompatible) + assert not isinstance(NoToPandas(), PandasCompatible) + assert not isinstance("string", PandasCompatible) + assert not isinstance(123, PandasCompatible) + + +class TestColumnSort: + """Tests for the ColumnSort TypedDict.""" + + def test_column_sort_creation(self) -> None: + """Test creating a ColumnSort.""" + sort: ColumnSort = {"col": 0, "desc": True} + assert sort["col"] == 0 + assert sort["desc"] is True + + def test_column_sort_ascending(self) -> None: + """Test ColumnSort with ascending order.""" + sort: ColumnSort = {"col": 5, "desc": False} + assert sort["col"] == 5 + assert sort["desc"] is False + + +class TestColumnFilterStr: + """Tests for the ColumnFilterStr TypedDict.""" + + def test_column_filter_str_creation(self) -> None: + """Test creating a ColumnFilterStr.""" + filter_str: ColumnFilterStr = {"col": 2, "value": "test"} + assert filter_str["col"] == 2 + assert filter_str["value"] == "test" + + +class TestColumnFilterNumber: + """Tests for the ColumnFilterNumber TypedDict.""" + + def test_column_filter_number_range(self) -> None: + """Test ColumnFilterNumber with a range.""" + filter_num: ColumnFilterNumber = {"col": 1, "value": (10, 20)} + assert filter_num["col"] == 1 + assert filter_num["value"] == (10, 20) + + def test_column_filter_number_min_only(self) -> None: + """Test ColumnFilterNumber with min only.""" + filter_num: ColumnFilterNumber = {"col": 1, "value": (10, None)} + assert filter_num["value"] == (10, None) + + def test_column_filter_number_max_only(self) -> None: + """Test ColumnFilterNumber with max only.""" + filter_num: ColumnFilterNumber = {"col": 1, "value": (None, 20)} + assert filter_num["value"] == (None, 20) + + +class TestDataViewInfo: + """Tests for the DataViewInfo TypedDict.""" + + def test_data_view_info_creation(self) -> None: + """Test creating a DataViewInfo.""" + info: DataViewInfo = { + "sort": ({"col": 0, "desc": True},), + "filter": ({"col": 1, "value": "test"},), + "rows": (0, 1, 2), + "selected_rows": (1,), + } + assert len(info["sort"]) == 1 + assert len(info["filter"]) == 1 + assert info["rows"] == (0, 1, 2) + assert info["selected_rows"] == (1,) + + +class TestFrameRenderPatchInfo: + """Tests for the FrameRenderPatchInfo TypedDict.""" + + def test_frame_render_patch_info_creation(self) -> None: + """Test creating a FrameRenderPatchInfo.""" + patch_info: FrameRenderPatchInfo = {"key": "unique_key"} + assert patch_info["key"] == "unique_key" + + +class TestFrameRenderSelectionModes: + """Tests for the FrameRenderSelectionModes TypedDict.""" + + def test_frame_render_selection_modes_creation(self) -> None: + """Test creating a FrameRenderSelectionModes.""" + modes: FrameRenderSelectionModes = { + "row": "multiple", + "col": "single", + "rect": "cell", + } + assert modes["row"] == "multiple" + assert modes["col"] == "single" + assert modes["rect"] == "cell" + + +class TestFrameRenderToJsonifiable: + """Tests for the frame_render_to_jsonifiable function.""" + + def test_frame_render_to_jsonifiable(self) -> None: + """Test converting FrameRender to jsonifiable dict.""" + frame_render: FrameRender = { + "payload": { + "columns": ["a", "b"], + "data": [[1, 2], [3, 4]], + "typeHints": [{"type": "numeric"}, {"type": "numeric"}], + }, + "patchInfo": {"key": "test_key"}, + "selectionModes": {"row": "none", "col": "none", "rect": "none"}, + } + result = frame_render_to_jsonifiable(frame_render) + assert isinstance(result, dict) + assert "payload" in result + assert "patchInfo" in result + assert "selectionModes" in result + + +class TestFrameJsonOptions: + """Tests for the FrameJsonOptions TypedDict.""" + + def test_frame_json_options_creation(self) -> None: + """Test creating a FrameJsonOptions.""" + options: FrameJsonOptions = { + "width": "100%", + "height": "400px", + "summary": True, + "filters": True, + "editable": False, + "style": "grid", + "fill": True, + } + assert options["width"] == "100%" + assert options["editable"] is False + + +class TestFrameDtypeSubset: + """Tests for the FrameDtypeSubset TypedDict.""" + + def test_frame_dtype_subset_string(self) -> None: + """Test FrameDtypeSubset with string type.""" + dtype: FrameDtypeSubset = {"type": "string"} + assert dtype["type"] == "string" + + def test_frame_dtype_subset_numeric(self) -> None: + """Test FrameDtypeSubset with numeric type.""" + dtype: FrameDtypeSubset = {"type": "numeric"} + assert dtype["type"] == "numeric" + + def test_frame_dtype_subset_all_types(self) -> None: + """Test all FrameDtypeSubset types.""" + types: list[ + Literal[ + "string", + "numeric", + "boolean", + "date", + "datetime", + "time", + "duration", + "object", + "unknown", + "html", + "binary", + ] + ] = [ + "string", + "numeric", + "boolean", + "date", + "datetime", + "time", + "duration", + "object", + "unknown", + "html", + "binary", + ] + for t in types: + dtype: FrameDtypeSubset = {"type": t} + assert dtype["type"] == t + + +class TestFrameDtypeCategories: + """Tests for the FrameDtypeCategories TypedDict.""" + + def test_frame_dtype_categories_creation(self) -> None: + """Test creating a FrameDtypeCategories.""" + dtype: FrameDtypeCategories = { + "type": "categorical", + "categories": ["a", "b", "c"], + } + assert dtype["type"] == "categorical" + assert dtype["categories"] == ["a", "b", "c"] + + +class TestStyleInfoBody: + """Tests for the StyleInfoBody TypedDict.""" + + def test_style_info_body_creation(self) -> None: + """Test creating a StyleInfoBody.""" + style: StyleInfoBody = { + "location": "body", + "rows": [0, 1, 2], + "cols": ["a", "b"], + "style": {"color": "red"}, + "class": "highlight", + } + assert style["location"] == "body" + assert style["rows"] == [0, 1, 2] + assert style["class"] == "highlight" + + def test_style_info_body_minimal(self) -> None: + """Test StyleInfoBody with minimal fields.""" + style: StyleInfoBody = {} + assert isinstance(style, dict) + + +class TestBrowserStyleInfoBody: + """Tests for the BrowserStyleInfoBody TypedDict.""" + + def test_browser_style_info_body_creation(self) -> None: + """Test creating a BrowserStyleInfoBody.""" + style: BrowserStyleInfoBody = { + "location": "body", + "rows": (0, 1, 2), + "cols": (0, 1), + "style": {"background": "blue"}, + "class": "selected", + } + assert style["location"] == "body" + assert style["rows"] == (0, 1, 2) + assert style["cols"] == (0, 1) + + +class TestCellPatch: + """Tests for the CellPatch TypedDict.""" + + def test_cell_patch_creation(self) -> None: + """Test creating a CellPatch.""" + patch: CellPatch = { + "row_index": 5, + "column_index": 3, + "value": "new_value", + } + assert patch["row_index"] == 5 + assert patch["column_index"] == 3 + assert patch["value"] == "new_value" + + +class TestCellPatchProcessed: + """Tests for the CellPatchProcessed TypedDict.""" + + def test_cell_patch_processed_string_value(self) -> None: + """Test CellPatchProcessed with string value.""" + patch: CellPatchProcessed = { + "row_index": 1, + "column_index": 2, + "value": "processed_value", + } + assert patch["value"] == "processed_value" + + +class TestCellPatchProcessedToJsonifiable: + """Tests for the cell_patch_processed_to_jsonifiable function.""" + + def test_cell_patch_processed_to_jsonifiable(self) -> None: + """Test converting CellPatchProcessed to jsonifiable dict.""" + patch: CellPatchProcessed = { + "row_index": 0, + "column_index": 0, + "value": "test", + } + result = cell_patch_processed_to_jsonifiable(patch) + assert isinstance(result, dict) + assert result["row_index"] == 0 + assert result["column_index"] == 0 + assert result["value"] == "test" diff --git a/tests/pytest/test_datastructures.py b/tests/pytest/test_datastructures.py index 871b3cc31..d9ee225bc 100644 --- a/tests/pytest/test_datastructures.py +++ b/tests/pytest/test_datastructures.py @@ -17,3 +17,104 @@ def test_priority_queue_fifo(): assert q.get() == "7" assert q.get() == "9" assert q.get() == "8" + + +class TestPriorityQueueFIFO: + """Extended tests for the PriorityQueueFIFO class.""" + + def test_empty_queue(self): + """Test that a new queue is empty.""" + pq = PriorityQueueFIFO[int]() + assert pq.empty() is True + + def test_put_single_item(self): + """Test adding a single item to the queue.""" + pq = PriorityQueueFIFO[str]() + pq.put(1, "item1") + assert pq.empty() is False + + def test_get_single_item(self): + """Test getting a single item from the queue.""" + pq = PriorityQueueFIFO[str]() + pq.put(1, "item1") + result = pq.get() + assert result == "item1" + assert pq.empty() is True + + def test_priority_order(self): + """Test that higher priority items come out first.""" + pq = PriorityQueueFIFO[str]() + pq.put(1, "low") + pq.put(5, "high") + pq.put(3, "medium") + + assert pq.get() == "high" + assert pq.get() == "medium" + assert pq.get() == "low" + assert pq.empty() is True + + def test_fifo_order_same_priority(self): + """Test that items with the same priority come out in FIFO order.""" + pq = PriorityQueueFIFO[str]() + pq.put(1, "first") + pq.put(1, "second") + pq.put(1, "third") + + assert pq.get() == "first" + assert pq.get() == "second" + assert pq.get() == "third" + assert pq.empty() is True + + def test_negative_priority(self): + """Test that negative priorities work correctly.""" + pq = PriorityQueueFIFO[str]() + pq.put(-5, "very_low") + pq.put(0, "zero") + pq.put(5, "high") + + assert pq.get() == "high" + assert pq.get() == "zero" + assert pq.get() == "very_low" + + def test_different_types(self): + """Test queue with different value types.""" + # Test with integers + pq_int = PriorityQueueFIFO[int]() + pq_int.put(1, 42) + assert pq_int.get() == 42 + + # Test with lists + pq_list = PriorityQueueFIFO[list[int]]() + test_list: list[int] = [1, 2, 3] + pq_list.put(1, test_list) + assert pq_list.get() == test_list + + # Test with None values + pq_none = PriorityQueueFIFO[None]() + pq_none.put(1, None) + assert pq_none.get() is None + + def test_put_after_get(self): + """Test that we can add items after getting some.""" + pq = PriorityQueueFIFO[str]() + pq.put(1, "first") + assert pq.get() == "first" + + pq.put(1, "second") + assert pq.get() == "second" + + assert pq.empty() is True + + def test_large_number_of_items(self): + """Test adding many items to the queue maintains order.""" + pq = PriorityQueueFIFO[int]() + + # Add 50 items, all same priority + for i in range(50): + pq.put(0, i) + + # They should come out in insertion order (FIFO) + for i in range(50): + assert pq.get() == i + + assert pq.empty() is True diff --git a/tests/pytest/test_datastructures_funcs.py b/tests/pytest/test_datastructures_funcs.py new file mode 100644 index 000000000..358b278ab --- /dev/null +++ b/tests/pytest/test_datastructures_funcs.py @@ -0,0 +1,93 @@ +"""Tests for shiny._datastructures module.""" + +from shiny._datastructures import PriorityQueueFIFO + + +class TestPriorityQueueFIFO: + """Tests for PriorityQueueFIFO class.""" + + def test_priority_queue_basic(self) -> None: + """Test basic put and get operations.""" + pq: PriorityQueueFIFO[str] = PriorityQueueFIFO() + pq.put(1, "low") + pq.put(2, "high") + # Higher priority comes first + assert pq.get() == "high" + assert pq.get() == "low" + + def test_priority_queue_empty(self) -> None: + """Test empty queue.""" + pq: PriorityQueueFIFO[str] = PriorityQueueFIFO() + assert pq.empty() is True + + def test_priority_queue_not_empty(self) -> None: + """Test non-empty queue.""" + pq: PriorityQueueFIFO[str] = PriorityQueueFIFO() + pq.put(1, "item") + assert pq.empty() is False + + def test_priority_queue_fifo_same_priority(self) -> None: + """Test FIFO order for same priority items.""" + pq: PriorityQueueFIFO[str] = PriorityQueueFIFO() + pq.put(1, "first") + pq.put(1, "second") + pq.put(1, "third") + # Same priority, should come out in insertion order + assert pq.get() == "first" + assert pq.get() == "second" + assert pq.get() == "third" + + def test_priority_queue_mixed_priorities(self) -> None: + """Test mixed priorities.""" + pq: PriorityQueueFIFO[str] = PriorityQueueFIFO() + pq.put(1, "low1") + pq.put(3, "high") + pq.put(1, "low2") + pq.put(2, "medium") + # Should come out: high, medium, low1, low2 + assert pq.get() == "high" + assert pq.get() == "medium" + assert pq.get() == "low1" + assert pq.get() == "low2" + + def test_priority_queue_with_integers(self) -> None: + """Test queue with integer items.""" + pq: PriorityQueueFIFO[int] = PriorityQueueFIFO() + pq.put(1, 100) + pq.put(2, 200) + pq.put(1, 150) + assert pq.get() == 200 + assert pq.get() == 100 + assert pq.get() == 150 + + def test_priority_queue_becomes_empty(self) -> None: + """Test queue becomes empty after getting all items.""" + pq: PriorityQueueFIFO[str] = PriorityQueueFIFO() + pq.put(1, "a") + pq.put(2, "b") + assert pq.empty() is False + pq.get() + assert pq.empty() is False + pq.get() + assert pq.empty() is True + + def test_priority_queue_large_priorities(self) -> None: + """Test with large priority values.""" + pq: PriorityQueueFIFO[str] = PriorityQueueFIFO() + pq.put(1000000, "very_high") + pq.put(1, "low") + pq.put(500000, "medium") + assert pq.get() == "very_high" + assert pq.get() == "medium" + assert pq.get() == "low" + + def test_priority_queue_negative_priorities(self) -> None: + """Test with negative priority values.""" + pq: PriorityQueueFIFO[str] = PriorityQueueFIFO() + pq.put(-1, "negative") + pq.put(0, "zero") + pq.put(1, "positive") + # Higher value = higher priority + assert pq.get() == "positive" + assert pq.get() == "zero" + assert pq.get() == "negative" diff --git a/tests/pytest/test_datastructures_queue.py b/tests/pytest/test_datastructures_queue.py new file mode 100644 index 000000000..5175184ae --- /dev/null +++ b/tests/pytest/test_datastructures_queue.py @@ -0,0 +1,133 @@ +"""Tests for shiny._datastructures module.""" + +from shiny._datastructures import PriorityQueueFIFO + + +class TestPriorityQueueFIFO: + """Tests for PriorityQueueFIFO class.""" + + def test_empty_queue(self) -> None: + """Test empty queue is empty.""" + queue: PriorityQueueFIFO[str] = PriorityQueueFIFO() + assert queue.empty() is True + + def test_put_and_get_single(self) -> None: + """Test putting and getting a single item.""" + queue: PriorityQueueFIFO[str] = PriorityQueueFIFO() + queue.put(1, "item1") + assert queue.empty() is False + result = queue.get() + assert result == "item1" + + def test_priority_order(self) -> None: + """Test items are returned in priority order (higher first).""" + queue: PriorityQueueFIFO[str] = PriorityQueueFIFO() + queue.put(1, "low") + queue.put(10, "high") + queue.put(5, "medium") + + assert queue.get() == "high" + assert queue.get() == "medium" + assert queue.get() == "low" + + def test_fifo_same_priority(self) -> None: + """Test FIFO order for same priority items.""" + queue: PriorityQueueFIFO[str] = PriorityQueueFIFO() + queue.put(1, "first") + queue.put(1, "second") + queue.put(1, "third") + + assert queue.get() == "first" + assert queue.get() == "second" + assert queue.get() == "third" + + def test_mixed_priorities_fifo(self) -> None: + """Test mixed priorities with FIFO for same priority.""" + queue: PriorityQueueFIFO[str] = PriorityQueueFIFO() + queue.put(2, "p2_first") + queue.put(1, "p1_first") + queue.put(2, "p2_second") + queue.put(1, "p1_second") + + assert queue.get() == "p2_first" + assert queue.get() == "p2_second" + assert queue.get() == "p1_first" + assert queue.get() == "p1_second" + + def test_negative_priority(self) -> None: + """Test negative priority values.""" + queue: PriorityQueueFIFO[str] = PriorityQueueFIFO() + queue.put(0, "zero") + queue.put(10, "high") + queue.put(-10, "negative") + + assert queue.get() == "high" + assert queue.get() == "zero" + assert queue.get() == "negative" + + def test_different_types(self) -> None: + """Test queue with different value types.""" + queue: PriorityQueueFIFO[int] = PriorityQueueFIFO() + queue.put(2, 42) + queue.put(1, 100) + + assert queue.get() == 42 + assert queue.get() == 100 + + def test_queue_reuse(self) -> None: + """Test queue can be reused after emptying.""" + queue: PriorityQueueFIFO[str] = PriorityQueueFIFO() + queue.put(1, "first") + queue.get() + assert queue.empty() is True + + queue.put(1, "second") + assert queue.empty() is False + assert queue.get() == "second" + + def test_large_number_of_items(self) -> None: + """Test queue with many items.""" + queue: PriorityQueueFIFO[int] = PriorityQueueFIFO() + for i in range(100): + queue.put(i, i) + + # Items should come out in order 99, 98, 97, ... (highest priority first) + for i in range(100): + assert queue.get() == 99 - i + + def test_empty_method(self) -> None: + """Test empty method returns correct values.""" + queue: PriorityQueueFIFO[str] = PriorityQueueFIFO() + assert queue.empty() is True + + queue.put(1, "item") + assert queue.empty() is False + + queue.get() + assert queue.empty() is True + + def test_zero_priority(self) -> None: + """Test zero priority value.""" + queue: PriorityQueueFIFO[str] = PriorityQueueFIFO() + queue.put(0, "zero") + queue.put(1, "positive") + queue.put(-1, "negative") + + assert queue.get() == "positive" + assert queue.get() == "zero" + assert queue.get() == "negative" + + def test_list_as_item(self) -> None: + """Test queue with list items.""" + queue: PriorityQueueFIFO[list[int]] = PriorityQueueFIFO() + queue.put(1, [1, 2, 3]) + queue.put(2, [4, 5, 6]) + + assert queue.get() == [4, 5, 6] + assert queue.get() == [1, 2, 3] + + def test_none_as_item(self) -> None: + """Test queue with None items.""" + queue: PriorityQueueFIFO[None] = PriorityQueueFIFO() + queue.put(1, None) + assert queue.get() is None diff --git a/tests/pytest/test_deprecated.py b/tests/pytest/test_deprecated.py new file mode 100644 index 000000000..649f23bbd --- /dev/null +++ b/tests/pytest/test_deprecated.py @@ -0,0 +1,140 @@ +"""Tests for shiny/_deprecated.py""" + +from __future__ import annotations + +import warnings + +import pytest + +from shiny._deprecated import ( + ShinyDeprecationWarning, + event, + render_image, + render_plot, + render_text, + render_ui, + warn_deprecated, +) + + +class TestShinyDeprecationWarning: + """Tests for the ShinyDeprecationWarning class.""" + + def test_is_runtime_warning(self) -> None: + """Test that ShinyDeprecationWarning is a RuntimeWarning.""" + assert issubclass(ShinyDeprecationWarning, RuntimeWarning) + + def test_can_be_raised(self) -> None: + """Test that ShinyDeprecationWarning can be raised.""" + with pytest.raises(ShinyDeprecationWarning): + raise ShinyDeprecationWarning("test message") + + +class TestWarnDeprecated: + """Tests for the warn_deprecated function.""" + + def test_issues_shiny_deprecation_warning(self) -> None: + """Test that warn_deprecated issues ShinyDeprecationWarning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + warn_deprecated("test deprecation message", stacklevel=1) + + assert len(w) == 1 + assert issubclass(w[0].category, ShinyDeprecationWarning) + assert "test deprecation message" in str(w[0].message) + + def test_custom_stacklevel(self) -> None: + """Test that warn_deprecated uses custom stacklevel.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + warn_deprecated("test message", stacklevel=2) + + assert len(w) == 1 + + +class TestDeprecatedRenderFunctions: + """Tests for deprecated render functions.""" + + def test_render_text_shows_deprecation_warning(self) -> None: + """Test that render_text() shows deprecation warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + decorator = render_text() + + # Check that we got a warning + assert any( + "render_text() is deprecated" in str(warning.message) for warning in w + ) + # Check that we got a decorator function + assert callable(decorator) + + def test_render_ui_shows_deprecation_warning(self) -> None: + """Test that render_ui() shows deprecation warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + decorator = render_ui() + + # Check that we got a warning + assert any( + "render_ui() is deprecated" in str(warning.message) for warning in w + ) + # Check that we got a decorator function + assert callable(decorator) + + def test_render_plot_shows_deprecation_warning(self) -> None: + """Test that render_plot() shows deprecation warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + decorator = render_plot() + + # Check that we got a warning + assert any( + "render_plot() is deprecated" in str(warning.message) for warning in w + ) + # Check that we got a decorator function + assert callable(decorator) + + def test_render_image_shows_deprecation_warning(self) -> None: + """Test that render_image() shows deprecation warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + decorator = render_image() + + # Check that we got a warning + assert any( + "render_image() is deprecated" in str(warning.message) for warning in w + ) + # Check that we got a decorator function + assert callable(decorator) + + +class TestDeprecatedEvent: + """Tests for deprecated event decorator.""" + + def test_event_shows_deprecation_warning(self) -> None: + """Test that event() shows deprecation warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + # event() requires at least one reactive dependency + from shiny import reactive + + rv = reactive.value(0) + + # Create event decorator with a dependency + _ = event(rv) + + # Check that we got a warning + assert any( + "@event() is deprecated" in str(warning.message) for warning in w + ) + + +class TestDeprecatedExports: + """Test that deprecated functions are properly exported.""" + + def test_all_exports_available(self) -> None: + """Test that all __all__ exports are available.""" + from shiny._deprecated import __all__ + + expected = ("render_text", "render_plot", "render_image", "render_ui", "event") + assert __all__ == expected diff --git a/tests/pytest/test_deprecated_func.py b/tests/pytest/test_deprecated_func.py new file mode 100644 index 000000000..16f8fc097 --- /dev/null +++ b/tests/pytest/test_deprecated_func.py @@ -0,0 +1,109 @@ +"""Tests for shiny._deprecated module.""" + +import warnings + +from shiny._deprecated import ShinyDeprecationWarning, warn_deprecated + + +class TestWarnDeprecated: + """Tests for warn_deprecated function.""" + + def test_warn_deprecated_basic(self) -> None: + """Test basic deprecation warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + warn_deprecated("old_func() is deprecated. Use new_func() instead.") + assert len(w) == 1 + assert "old_func()" in str(w[0].message) + assert "deprecated" in str(w[0].message) + + def test_warn_deprecated_message_content(self) -> None: + """Test deprecation warning message content.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + warn_deprecated("Please use the new API.") + assert len(w) == 1 + assert "new API" in str(w[0].message) + + def test_warn_deprecated_category(self) -> None: + """Test that deprecation warning has correct category.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + warn_deprecated("Test deprecation") + assert len(w) == 1 + # The warning should be ShinyDeprecationWarning + assert issubclass(w[0].category, ShinyDeprecationWarning) + + def test_warn_deprecated_with_version(self) -> None: + """Test deprecation warning with version info.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + warn_deprecated("Function deprecated since version 1.0") + assert len(w) == 1 + assert "version 1.0" in str(w[0].message) + + def test_warn_deprecated_multiple_calls(self) -> None: + """Test multiple deprecation warnings.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + warn_deprecated("First deprecation") + warn_deprecated("Second deprecation") + assert len(w) == 2 + assert "First" in str(w[0].message) + assert "Second" in str(w[1].message) + + def test_warn_deprecated_empty_message(self) -> None: + """Test deprecation warning with empty message.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + warn_deprecated("") + assert len(w) == 1 + + def test_warn_deprecated_special_characters(self) -> None: + """Test deprecation warning with special characters.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + warn_deprecated("Use func(a='value', b=123) instead!") + assert len(w) == 1 + assert "func(a='value', b=123)" in str(w[0].message) + + def test_warn_deprecated_unicode(self) -> None: + """Test deprecation warning with unicode characters.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + warn_deprecated("Deprecated: use new_function() → better_function()") + assert len(w) == 1 + assert "→" in str(w[0].message) + + def test_warn_deprecated_multiline(self) -> None: + """Test deprecation warning with multiline message.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + warn_deprecated("Line 1\\nLine 2\\nLine 3") + assert len(w) == 1 + assert "Line 1" in str(w[0].message) + + def test_warn_deprecated_long_message(self) -> None: + """Test deprecation warning with a long message.""" + long_message = "This is a very long deprecation message. " * 10 + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + warn_deprecated(long_message) + assert len(w) == 1 + assert long_message in str(w[0].message) + + +class TestShinyDeprecationWarning: + """Tests for ShinyDeprecationWarning class.""" + + def test_is_runtime_warning(self) -> None: + """Test ShinyDeprecationWarning is a RuntimeWarning.""" + assert issubclass(ShinyDeprecationWarning, RuntimeWarning) + + def test_can_be_raised(self) -> None: + """Test ShinyDeprecationWarning can be raised as warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + warnings.warn("test", ShinyDeprecationWarning, stacklevel=2) + assert len(w) == 1 + assert w[0].category is ShinyDeprecationWarning diff --git a/tests/pytest/test_deprecated_funcs.py b/tests/pytest/test_deprecated_funcs.py new file mode 100644 index 000000000..6ef46c150 --- /dev/null +++ b/tests/pytest/test_deprecated_funcs.py @@ -0,0 +1,81 @@ +"""Tests for shiny._deprecated module.""" + +import pytest + +from shiny._deprecated import ( + ShinyDeprecationWarning, + event, + render_image, + render_plot, + render_text, + render_ui, + warn_deprecated, +) + + +class TestShinyDeprecationWarning: + """Tests for ShinyDeprecationWarning class.""" + + def test_is_runtime_warning(self) -> None: + """Test ShinyDeprecationWarning is a RuntimeWarning.""" + assert issubclass(ShinyDeprecationWarning, RuntimeWarning) + + def test_can_raise(self) -> None: + """Test ShinyDeprecationWarning can be raised.""" + with pytest.raises(ShinyDeprecationWarning): + raise ShinyDeprecationWarning("test") + + +class TestWarnDeprecated: + """Tests for warn_deprecated function.""" + + def test_warn_deprecated_emits_warning(self) -> None: + """Test warn_deprecated emits ShinyDeprecationWarning.""" + with pytest.warns(ShinyDeprecationWarning, match="test message"): + warn_deprecated("test message") + + def test_warn_deprecated_message(self) -> None: + """Test warn_deprecated includes custom message.""" + with pytest.warns(ShinyDeprecationWarning) as record: + warn_deprecated("custom warning") + assert len(record) == 1 + assert "custom warning" in str(record[0].message) + + +class TestDeprecatedRenderFunctions: + """Tests for deprecated render functions.""" + + def test_render_text_warns(self) -> None: + """Test render_text emits deprecation warning.""" + with pytest.warns(ShinyDeprecationWarning, match="render_text.*deprecated"): + render_text() + + def test_render_ui_warns(self) -> None: + """Test render_ui emits deprecation warning.""" + with pytest.warns(ShinyDeprecationWarning, match="render_ui.*deprecated"): + render_ui() + + def test_render_plot_warns(self) -> None: + """Test render_plot emits deprecation warning.""" + with pytest.warns(ShinyDeprecationWarning, match="render_plot.*deprecated"): + render_plot() + + def test_render_image_warns(self) -> None: + """Test render_image emits deprecation warning.""" + with pytest.warns(ShinyDeprecationWarning, match="render_image.*deprecated"): + render_image() + + +class TestDeprecatedEvent: + """Tests for deprecated event decorator.""" + + def test_event_warns(self) -> None: + """Test event emits deprecation warning.""" + from shiny import reactive + + r = reactive.value(0) + with pytest.warns(ShinyDeprecationWarning, match="@event.*deprecated"): + + @event(r) + def my_func(): + return "test" diff --git a/tests/pytest/test_docstring.py b/tests/pytest/test_docstring.py new file mode 100644 index 000000000..e70c44c81 --- /dev/null +++ b/tests/pytest/test_docstring.py @@ -0,0 +1,357 @@ +"""Tests for shiny/_docstring.py - Docstring utilities and example handling.""" + +import os +import tempfile +from unittest.mock import patch + +import pytest + +from shiny._docstring import ( + DocStringWithExample, + ExampleNotFoundException, + ExampleWriter, + ExpressExampleNotFoundException, + app_choose_core_or_express, + doc_format, + find_api_examples_dir, + get_decorated_source_directory, + is_express_app, + no_example, +) + + +class TestFindApiExamplesDir: + """Tests for find_api_examples_dir function.""" + + def test_find_api_examples_dir_exists(self): + """Test finding api-examples directory when it exists.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create api-examples directory + api_examples_dir = os.path.join(tmpdir, "api-examples") + os.makedirs(api_examples_dir) + + result = find_api_examples_dir(tmpdir) + assert result == api_examples_dir + + def test_find_api_examples_dir_in_parent(self): + """Test finding api-examples directory in parent.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create api-examples in root + api_examples_dir = os.path.join(tmpdir, "api-examples") + os.makedirs(api_examples_dir) + + # Create a subdirectory + subdir = os.path.join(tmpdir, "subdir") + os.makedirs(subdir) + + result = find_api_examples_dir(subdir) + assert result == api_examples_dir + + def test_find_api_examples_dir_not_found(self): + """Test returns None when no api-examples directory exists.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create pyproject.toml to mark as root + with open(os.path.join(tmpdir, "pyproject.toml"), "w") as f: + f.write("") + + subdir = os.path.join(tmpdir, "subdir") + os.makedirs(subdir) + + result = find_api_examples_dir(subdir) + assert result is None + + def test_find_api_examples_dir_stops_at_root_files(self): + """Test stops searching at package root markers.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create setup.cfg to mark root + with open(os.path.join(tmpdir, "setup.cfg"), "w") as f: + f.write("") + + result = find_api_examples_dir(tmpdir) + assert result is None + + +class TestDocStringWithExample: + """Tests for DocStringWithExample class.""" + + def test_docstring_with_example_is_string(self): + """Test that DocStringWithExample is a string subclass.""" + doc = DocStringWithExample("This is a docstring") + assert isinstance(doc, str) + assert doc == "This is a docstring" + + def test_docstring_with_example_preserves_content(self): + """Test that content is preserved.""" + content = "Test docstring\n\nWith multiple lines." + doc = DocStringWithExample(content) + assert str(doc) == content + + +class TestExampleWriter: + """Tests for ExampleWriter class.""" + + def test_write_example_reads_file(self): + """Test that write_example reads file content.""" + with tempfile.TemporaryDirectory() as tmpdir: + app_file = os.path.join(tmpdir, "app.py") + with open(app_file, "w") as f: + f.write("from shiny import App\napp = App()") + + writer = ExampleWriter() + result = writer.write_example([app_file]) + + assert "from shiny import App" in result + assert "app = App()" in result + assert "```.python" in result + + +class TestNoExample: + """Tests for no_example decorator.""" + + _no_example_attr_name = "__no_example" + + def test_no_example_sets_attribute(self): + """Test that no_example sets __no_example attribute.""" + + @no_example() + def my_func(): + pass + + assert hasattr(my_func, self._no_example_attr_name) + no_example_attr = getattr(my_func, self._no_example_attr_name) + assert "express" in no_example_attr + assert "core" in no_example_attr + + def test_no_example_express_only(self): + """Test no_example with express mode only.""" + + @no_example(mode="express") + def my_func(): + pass + + assert hasattr(my_func, self._no_example_attr_name) + no_example_attr = getattr(my_func, self._no_example_attr_name) + assert "express" in no_example_attr + assert "core" not in no_example_attr + + def test_no_example_core_only(self): + """Test no_example with core mode only.""" + + @no_example(mode="core") + def my_func(): + pass + + assert hasattr(my_func, self._no_example_attr_name) + no_example_attr = getattr(my_func, self._no_example_attr_name) + assert "core" in no_example_attr + assert "express" not in no_example_attr + + def test_no_example_multiple_applications(self): + """Test applying no_example multiple times.""" + + @no_example(mode="core") + @no_example(mode="express") + def my_func(): + pass + + no_example_attr = getattr(my_func, self._no_example_attr_name) + assert "express" in no_example_attr + assert "core" in no_example_attr + + +class TestIsExpressApp: + """Tests for is_express_app function.""" + + def test_is_express_app_with_from_import(self): + """Test detection of 'from shiny.express' import.""" + with tempfile.TemporaryDirectory() as tmpdir: + app_file = os.path.join(tmpdir, "app.py") + with open(app_file, "w") as f: + f.write("from shiny.express import input, output") + + assert is_express_app(app_file) is True + + def test_is_express_app_with_import_statement(self): + """Test detection of 'import shiny.express' statement.""" + with tempfile.TemporaryDirectory() as tmpdir: + app_file = os.path.join(tmpdir, "app.py") + with open(app_file, "w") as f: + f.write("import shiny.express") + + assert is_express_app(app_file) is True + + def test_is_express_app_core_app(self): + """Test that core app is not detected as express.""" + with tempfile.TemporaryDirectory() as tmpdir: + app_file = os.path.join(tmpdir, "app.py") + with open(app_file, "w") as f: + f.write("from shiny import App, ui") + + assert is_express_app(app_file) is False + + def test_is_express_app_nonexistent(self): + """Test that nonexistent file returns False.""" + assert is_express_app("/nonexistent/path/app.py") is False + + +class TestExampleNotFoundException: + """Tests for ExampleNotFoundException class.""" + + def test_exception_message_single_file(self): + """Test exception message with single file name.""" + exc = ExampleNotFoundException("app.py", "/some/dir", "core") + msg = str(exc) + assert "app.py" in msg + assert "/some/dir" in msg + assert "Core" in msg + + def test_exception_message_multiple_files(self): + """Test exception message with multiple file names.""" + exc = ExampleNotFoundException(["app.py", "app-core.py"], "/some/dir", "core") + msg = str(exc) + assert "app.py" in msg + assert "app-core.py" in msg + assert " or " in msg + + def test_exception_message_express_type(self): + """Test exception message with express type.""" + exc = ExampleNotFoundException("app.py", "/some/dir", "express") + msg = str(exc) + assert "Express" in msg + + +class TestExpressExampleNotFoundException: + """Tests for ExpressExampleNotFoundException class.""" + + def test_is_example_not_found_subclass(self): + """Test that it's a subclass of ExampleNotFoundException.""" + exc = ExpressExampleNotFoundException("app.py", "/dir") + assert isinstance(exc, ExampleNotFoundException) + + def test_type_is_express(self): + """Test that type is always express.""" + exc = ExpressExampleNotFoundException("app.py", "/dir") + assert exc.type == "express" + + +class TestAppChooseCoreOrExpress: + """Tests for app_choose_core_or_express function.""" + + def test_returns_existing_express_app(self): + """Test returns express app when it exists and is express.""" + with tempfile.TemporaryDirectory() as tmpdir: + app_file = os.path.join(tmpdir, "app.py") + with open(app_file, "w") as f: + f.write("from shiny.express import input") + + with patch.dict(os.environ, {"SHINY_MODE": "express"}): + result = app_choose_core_or_express(app_file, mode="express") + assert result == app_file + + def test_returns_express_variant(self): + """Test returns app-express.py when main file is not express.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create core app.py + app_file = os.path.join(tmpdir, "app.py") + with open(app_file, "w") as f: + f.write("from shiny import App") + + # Create express variant + express_file = os.path.join(tmpdir, "app-express.py") + with open(express_file, "w") as f: + f.write("from shiny.express import input") + + result = app_choose_core_or_express(app_file, mode="express") + assert result == express_file + + def test_returns_core_app(self): + """Test returns core app when mode is core.""" + with tempfile.TemporaryDirectory() as tmpdir: + app_file = os.path.join(tmpdir, "app.py") + with open(app_file, "w") as f: + f.write("from shiny import App") + + result = app_choose_core_or_express(app_file, mode="core") + assert result == app_file + + def test_falls_back_to_app_core(self): + """Test falls back to app-core.py when app.py doesn't exist.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Only create app-core.py + core_file = os.path.join(tmpdir, "app-core.py") + with open(core_file, "w") as f: + f.write("from shiny import App") + + app_file = os.path.join(tmpdir, "app.py") + result = app_choose_core_or_express(app_file, mode="core") + assert result == core_file + + def test_raises_when_file_not_found(self): + """Test raises ExampleNotFoundException when file not found.""" + with tempfile.TemporaryDirectory() as tmpdir: + app_file = os.path.join(tmpdir, "app.py") + with pytest.raises(ExampleNotFoundException): + app_choose_core_or_express(app_file, mode="core") + + def test_raises_express_not_found(self): + """Test raises ExpressExampleNotFoundException for missing express.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create core app only + app_file = os.path.join(tmpdir, "app.py") + with open(app_file, "w") as f: + f.write("from shiny import App") + + with pytest.raises(ExpressExampleNotFoundException): + app_choose_core_or_express(app_file, mode="express") + + +class TestDocFormat: + """Tests for doc_format decorator.""" + + def test_doc_format_substitutes_values(self): + """Test that doc_format substitutes placeholders.""" + + @doc_format(name="TestFunc", param="value") + def my_func(): + """Function {name} with {param}.""" + pass + + assert my_func.__doc__ is not None + assert "TestFunc" in my_func.__doc__ + assert "value" in my_func.__doc__ + assert "{name}" not in my_func.__doc__ + + def test_doc_format_no_docstring(self): + """Test doc_format with no docstring.""" + + @doc_format(name="Test") + def my_func(): + pass + + assert my_func.__doc__ is None + + def test_doc_format_after_add_example_raises(self): + """Test that doc_format after DocStringWithExample raises.""" + + def my_func(): + pass + + my_func.__doc__ = DocStringWithExample("Test {value}") + + with pytest.raises(ValueError, match="must be applied before @add_example"): + doc_format(value="test")(my_func) + + +class TestGetDecoratedSourceDirectory: + """Tests for get_decorated_source_directory function.""" + + def test_returns_directory_for_function(self): + """Test returns directory containing the function definition.""" + + def local_func(): + pass + + result = get_decorated_source_directory(local_func) + assert os.path.isdir(result) + # Should be in the shiny directory since we import from shiny + # (The function has __module__ pointing to this test file) diff --git a/tests/pytest/test_download_button.py b/tests/pytest/test_download_button.py new file mode 100644 index 000000000..efe384bc7 --- /dev/null +++ b/tests/pytest/test_download_button.py @@ -0,0 +1,99 @@ +"""Tests for shiny.ui._download_button module.""" + +from shiny.ui import download_button, download_link + + +class TestDownloadButton: + """Tests for download_button function.""" + + def test_download_button_basic(self): + """Test basic download_button creation.""" + btn = download_button("download_id", "Download") + assert btn.name == "a" + html = str(btn) + assert "download_id" in html + assert "Download" in html + + def test_download_button_with_icon(self): + """Test download_button with icon.""" + btn = download_button("download_id", "Download", icon="📥") + html = str(btn) + assert "📥" in html + + def test_download_button_with_width(self): + """Test download_button with width.""" + btn = download_button("download_id", "Download", width="200px") + html = str(btn) + assert "200px" in html + + def test_download_button_class(self): + """Test download_button has correct CSS class.""" + btn = download_button("download_id", "Download") + html = str(btn) + assert "shiny-download-link" in html + assert "btn" in html + + def test_download_button_disabled(self): + """Test download_button is initially disabled.""" + btn = download_button("download_id", "Download") + html = str(btn) + assert "disabled" in html + assert 'aria-disabled="true"' in html + + def test_download_button_with_kwargs(self): + """Test download_button with additional attributes.""" + btn = download_button( + "download_id", "Download", class_="custom-class", data_value="test" + ) + html = str(btn) + assert "custom-class" in html + assert "data-value" in html + + +class TestDownloadLink: + """Tests for download_link function.""" + + def test_download_link_basic(self): + """Test basic download_link creation.""" + link = download_link("download_id", "Download") + assert link.name == "a" + html = str(link) + assert "download_id" in html + assert "Download" in html + + def test_download_link_with_icon(self): + """Test download_link with icon.""" + link = download_link("download_id", "Download", icon="📥") + html = str(link) + assert "📥" in html + + def test_download_link_with_width(self): + """Test download_link with width.""" + link = download_link("download_id", "Download", width="200px") + html = str(link) + assert "200px" in html + + def test_download_link_class(self): + """Test download_link has correct CSS class.""" + link = download_link("download_id", "Download") + html = str(link) + assert "shiny-download-link" in html + # download_link should not have btn class + classes = link.attrs.get("class", "") + assert "btn btn-default" not in classes + + def test_download_link_disabled(self): + """Test download_link is initially disabled.""" + link = download_link("download_id", "Download") + html = str(link) + assert "disabled" in html + assert 'aria-disabled="true"' in html + + def test_download_link_with_kwargs(self): + """Test download_link with additional attributes.""" + link = download_link( + "download_id", "Download", class_="custom-class", data_value="test" + ) + html = str(link) + assert "custom-class" in html + assert "data-value" in html diff --git a/tests/pytest/test_download_button_complete.py b/tests/pytest/test_download_button_complete.py new file mode 100644 index 000000000..5796a4862 --- /dev/null +++ b/tests/pytest/test_download_button_complete.py @@ -0,0 +1,179 @@ +"""Comprehensive tests for shiny.ui._download_button module.""" + +from htmltools import Tag + + +class TestDownloadButton: + """Tests for download_button function.""" + + def test_download_button_basic(self): + """download_button should create a link element.""" + from shiny.ui import download_button + + result = download_button("dl", "Download") + assert isinstance(result, Tag) + assert result.name == "a" + assert result.attrs.get("id") == "dl" + + def test_download_button_with_label(self): + """download_button should display label.""" + from shiny.ui import download_button + + result = download_button("dl", "Download File") + html_str = str(result) + assert "Download File" in html_str + + def test_download_button_with_icon(self): + """download_button should include icon.""" + from shiny.ui import download_button + + result = download_button("dl", "Download", icon="📥") + html_str = str(result) + assert "📥" in html_str + + def test_download_button_has_default_class(self): + """download_button should have btn and shiny-download-link classes.""" + from shiny.ui import download_button + + result = download_button("dl", "Download") + assert "btn" in result.attrs.get("class", "") + assert "btn-default" in result.attrs.get("class", "") + assert "shiny-download-link" in result.attrs.get("class", "") + + def test_download_button_is_disabled_initially(self): + """download_button should be disabled initially.""" + from shiny.ui import download_button + + result = download_button("dl", "Download") + assert "disabled" in result.attrs.get("class", "") + assert result.attrs.get("aria-disabled") == "true" + assert result.attrs.get("tabindex") == "-1" + + def test_download_button_has_empty_href(self): + """download_button should have empty href initially.""" + from shiny.ui import download_button + + result = download_button("dl", "Download") + assert result.attrs.get("href") == "" + + def test_download_button_has_target_blank(self): + """download_button should open in new tab.""" + from shiny.ui import download_button + + result = download_button("dl", "Download") + assert result.attrs.get("target") == "_blank" + + def test_download_button_with_width(self): + """download_button should accept width parameter.""" + from shiny.ui import download_button + + result = download_button("dl", "Download", width="150px") + html_str = str(result) + assert "150px" in html_str + + def test_download_button_with_kwargs(self): + """download_button should accept additional attributes.""" + from shiny.ui import download_button + + result = download_button("dl", "Download", data_test="value", custom="attr") + assert result.attrs.get("data-test") == "value" + assert result.attrs.get("custom") == "attr" + + +class TestDownloadLink: + """Tests for download_link function.""" + + def test_download_link_basic(self): + """download_link should create a link element.""" + from shiny.ui import download_link + + result = download_link("dl", "Download") + assert isinstance(result, Tag) + assert result.name == "a" + assert result.attrs.get("id") == "dl" + + def test_download_link_with_label(self): + """download_link should display label.""" + from shiny.ui import download_link + + result = download_link("dl", "Download File") + html_str = str(result) + assert "Download File" in html_str + + def test_download_link_with_icon(self): + """download_link should include icon.""" + from shiny.ui import download_link + + result = download_link("dl", "Download", icon="📥") + html_str = str(result) + assert "📥" in html_str + + def test_download_link_has_shiny_download_link_class(self): + """download_link should have shiny-download-link class.""" + from shiny.ui import download_link + + result = download_link("dl", "Download") + assert "shiny-download-link" in result.attrs.get("class", "") + + def test_download_link_no_btn_class(self): + """download_link should not have btn classes.""" + from shiny.ui import download_link + + result = download_link("dl", "Download") + class_attr = result.attrs.get("class", "") + assert "btn" not in class_attr or "btn-" not in class_attr + + def test_download_link_is_disabled_initially(self): + """download_link should be disabled initially.""" + from shiny.ui import download_link + + result = download_link("dl", "Download") + assert "disabled" in result.attrs.get("class", "") + assert result.attrs.get("aria-disabled") == "true" + assert result.attrs.get("tabindex") == "-1" + + def test_download_link_has_empty_href(self): + """download_link should have empty href initially.""" + from shiny.ui import download_link + + result = download_link("dl", "Download") + assert result.attrs.get("href") == "" + + def test_download_link_has_target_blank(self): + """download_link should open in new tab.""" + from shiny.ui import download_link + + result = download_link("dl", "Download") + assert result.attrs.get("target") == "_blank" + + def test_download_link_with_width(self): + """download_link should accept width parameter.""" + from shiny.ui import download_link + + result = download_link("dl", "Download", width="200px") + html_str = str(result) + assert "200px" in html_str + + def test_download_link_with_kwargs(self): + """download_link should accept additional attributes.""" + from shiny.ui import download_link + + result = download_link("dl", "Download", data_value="test") + assert result.attrs.get("data-value") == "test" + + +class TestModuleExports: + """Tests for module exports.""" + + def test_module_imports_correctly(self): + """Module should import without errors.""" + import shiny.ui._download_button as download_button_module + + assert download_button_module is not None + + def test_all_exports_exist(self): + """All items in __all__ should be importable.""" + from shiny.ui import _download_button + + for item in _download_button.__all__: + assert hasattr(_download_button, item) diff --git a/tests/pytest/test_download_button_full.py b/tests/pytest/test_download_button_full.py new file mode 100644 index 000000000..622c067d4 --- /dev/null +++ b/tests/pytest/test_download_button_full.py @@ -0,0 +1,56 @@ +"""Tests for shiny/ui/_download_button.py module.""" + +from shiny.ui._download_button import download_button, download_link + + +class TestDownloadButton: + """Tests for download_button function.""" + + def test_download_button_is_callable(self): + """Test download_button is callable.""" + assert callable(download_button) + + def test_download_button_returns_tag(self): + """Test download_button returns a Tag.""" + from htmltools import Tag + + result = download_button("my_download", "Download") + assert isinstance(result, Tag) + + def test_download_button_with_class(self): + """Test download_button with class_ parameter.""" + from htmltools import Tag + + result = download_button("my_download", "Download", class_="btn-primary") + assert isinstance(result, Tag) + + +class TestDownloadLink: + """Tests for download_link function.""" + + def test_download_link_is_callable(self): + """Test download_link is callable.""" + assert callable(download_link) + + def test_download_link_returns_tag(self): + """Test download_link returns a Tag.""" + from htmltools import Tag + + result = download_link("my_download", "Download") + assert isinstance(result, Tag) + + +class TestDownloadExported: + """Tests for download functions export.""" + + def test_download_button_in_ui(self): + """Test download_button is in ui module.""" + from shiny import ui + + assert hasattr(ui, "download_button") + + def test_download_link_in_ui(self): + """Test download_link is in ui module.""" + from shiny import ui + + assert hasattr(ui, "download_link") diff --git a/tests/pytest/test_download_button_funcs.py b/tests/pytest/test_download_button_funcs.py new file mode 100644 index 000000000..5e0543e2f --- /dev/null +++ b/tests/pytest/test_download_button_funcs.py @@ -0,0 +1,81 @@ +"""Tests for shiny.ui._download_button module.""" + +from htmltools import Tag + +from shiny.ui._download_button import download_button, download_link + + +class TestDownloadButton: + """Tests for download_button function.""" + + def test_download_button_basic(self) -> None: + """Test basic download_button creation.""" + result = download_button("my_download", "Download") + assert isinstance(result, Tag) + + def test_download_button_has_id(self) -> None: + """Test download_button has correct id.""" + result = download_button("download_id", "Label") + html = str(result) + assert "download_id" in html + + def test_download_button_with_label(self) -> None: + """Test download_button with label.""" + result = download_button("download", "Download Data") + html = str(result) + assert "Download Data" in html + + def test_download_button_is_anchor(self) -> None: + """Test download_button returns anchor tag.""" + result = download_button("download", "Label") + assert result.name == "a" + + def test_download_button_has_download_attribute(self) -> None: + """Test download_button has download attribute.""" + result = download_button("download", "Label") + html = str(result) + assert "download" in html + + def test_download_button_with_class(self) -> None: + """Test download_button with class_ parameter.""" + result = download_button("download", "Label", class_="btn-success") + html = str(result) + assert "btn-success" in html + + def test_download_button_btn_class(self) -> None: + """Test download_button has btn class.""" + result = download_button("download", "Label") + html = str(result) + assert "btn" in html + + +class TestDownloadLink: + """Tests for download_link function.""" + + def test_download_link_basic(self) -> None: + """Test basic download_link creation.""" + result = download_link("my_link", "Download") + assert isinstance(result, Tag) + + def test_download_link_has_id(self) -> None: + """Test download_link has correct id.""" + result = download_link("link_id", "Label") + html = str(result) + assert "link_id" in html + + def test_download_link_with_label(self) -> None: + """Test download_link with label.""" + result = download_link("link", "Download File") + html = str(result) + assert "Download File" in html + + def test_download_link_is_anchor(self) -> None: + """Test download_link returns anchor tag.""" + result = download_link("link", "Label") + assert result.name == "a" + + def test_download_link_has_download_attribute(self) -> None: + """Test download_link has download attribute.""" + result = download_link("link", "Label") + html = str(result) + assert "download" in html diff --git a/tests/pytest/test_error_middleware.py b/tests/pytest/test_error_middleware.py new file mode 100644 index 000000000..e6e00ff42 --- /dev/null +++ b/tests/pytest/test_error_middleware.py @@ -0,0 +1,95 @@ +"""Tests for the _error module.""" + +from typing import Any, MutableMapping + +import pytest +from starlette.types import Message, Receive, Scope, Send + +from shiny._error import ErrorMiddleware + + +class TestErrorMiddleware: + """Tests for the ErrorMiddleware class.""" + + def test_error_middleware_init(self): + """Test ErrorMiddleware initialization.""" + + async def mock_app(scope: Scope, receive: Receive, send: Send) -> None: + pass + + middleware = ErrorMiddleware(mock_app) + assert middleware.app is mock_app + + @pytest.mark.asyncio + async def test_error_middleware_passes_through(self): + """Test that ErrorMiddleware passes through successful requests.""" + called = False + + async def mock_app(scope: Scope, receive: Receive, send: Send) -> None: + nonlocal called + called = True + + async def mock_receive() -> Message: + return {"type": "http.request", "body": b""} + + async def mock_send(message: Message) -> None: + pass + + middleware = ErrorMiddleware(mock_app) + await middleware({"type": "http"}, mock_receive, mock_send) + + assert called is True + + @pytest.mark.asyncio + async def test_error_middleware_handles_http_exception(self): + """Test that ErrorMiddleware handles HTTPException.""" + import starlette.exceptions as exceptions + + async def mock_app(scope: Scope, receive: Receive, send: Send) -> None: + raise exceptions.HTTPException(status_code=404, detail="Not Found") + + middleware = ErrorMiddleware(mock_app) + + responses: list[MutableMapping[str, Any]] = [] + + async def mock_receive() -> Message: + return {"type": "http.request", "body": b""} + + async def mock_send(message: Message) -> None: + responses.append(message) + + scope: Scope = {"type": "http"} # type: ignore[typeddict-item] + + await middleware(scope, mock_receive, mock_send) + + # Should have sent a response + assert len(responses) > 0 + # First message should be http.response.start + assert responses[0]["type"] == "http.response.start" + assert responses[0]["status"] == 404 + + @pytest.mark.asyncio + async def test_error_middleware_handles_generic_exception(self): + """Test that ErrorMiddleware handles generic exceptions.""" + + async def mock_app(scope: Scope, receive: Receive, send: Send) -> None: + raise ValueError("Something went wrong") + + middleware = ErrorMiddleware(mock_app) + + responses: list[MutableMapping[str, Any]] = [] + + async def mock_receive() -> Message: + return {"type": "http.request", "body": b""} + + async def mock_send(message: Message) -> None: + responses.append(message) + + scope: Scope = {"type": "http"} # type: ignore[typeddict-item] + + await middleware(scope, mock_receive, mock_send) + + # Should have sent a 500 response + assert len(responses) > 0 + assert responses[0]["type"] == "http.response.start" + assert responses[0]["status"] == 500 diff --git a/tests/pytest/test_error_module.py b/tests/pytest/test_error_module.py new file mode 100644 index 000000000..e622034ce --- /dev/null +++ b/tests/pytest/test_error_module.py @@ -0,0 +1,36 @@ +"""Tests for shiny._error module.""" + +from starlette.types import Receive, Scope, Send + +from shiny._error import ErrorMiddleware + + +class TestErrorMiddleware: + """Tests for ErrorMiddleware class.""" + + def test_init(self) -> None: + """Test ErrorMiddleware initialization.""" + + async def dummy_app(scope: Scope, receive: Receive, send: Send) -> None: + return None + + middleware = ErrorMiddleware(dummy_app) + assert middleware.app is dummy_app + + def test_has_app_attribute(self) -> None: + """Test ErrorMiddleware has app attribute.""" + + async def dummy_app(scope: Scope, receive: Receive, send: Send) -> None: + return None + + middleware = ErrorMiddleware(dummy_app) + assert hasattr(middleware, "app") + + def test_is_callable(self) -> None: + """Test ErrorMiddleware is callable.""" + + async def dummy_app(scope: Scope, receive: Receive, send: Send) -> None: + return None + + middleware = ErrorMiddleware(dummy_app) + assert callable(middleware) diff --git a/tests/pytest/test_exception_types.py b/tests/pytest/test_exception_types.py new file mode 100644 index 000000000..d2ec9f947 --- /dev/null +++ b/tests/pytest/test_exception_types.py @@ -0,0 +1,127 @@ +"""Tests for shiny.types module - Exception classes.""" + +import pytest + +from shiny.types import ( + SafeException, + SilentCancelOutputException, + SilentException, + SilentOperationInProgressException, +) + + +class TestSilentException: + """Tests for SilentException class.""" + + def test_silent_exception_creation(self) -> None: + """Test creating SilentException.""" + exc = SilentException() + assert isinstance(exc, Exception) + + def test_silent_exception_can_be_raised(self) -> None: + """Test SilentException can be raised.""" + with pytest.raises(SilentException): + raise SilentException() + + def test_silent_exception_is_base_exception(self) -> None: + """Test SilentException is an Exception.""" + exc = SilentException() + assert isinstance(exc, BaseException) + + +class TestSilentCancelOutputException: + """Tests for SilentCancelOutputException class.""" + + def test_creation(self) -> None: + """Test creating SilentCancelOutputException.""" + exc = SilentCancelOutputException() + assert isinstance(exc, Exception) + + def test_can_be_raised(self) -> None: + """Test SilentCancelOutputException can be raised.""" + with pytest.raises(SilentCancelOutputException): + raise SilentCancelOutputException() + + def test_is_base_exception(self) -> None: + """Test SilentCancelOutputException is a BaseException.""" + exc = SilentCancelOutputException() + assert isinstance(exc, BaseException) + + +class TestSilentOperationInProgressException: + """Tests for SilentOperationInProgressException class.""" + + def test_creation(self) -> None: + """Test creating SilentOperationInProgressException.""" + exc = SilentOperationInProgressException() + assert isinstance(exc, Exception) + + def test_can_be_raised(self) -> None: + """Test SilentOperationInProgressException can be raised.""" + with pytest.raises(SilentOperationInProgressException): + raise SilentOperationInProgressException() + + def test_is_silent_exception(self) -> None: + """Test SilentOperationInProgressException is a SilentException.""" + exc = SilentOperationInProgressException() + assert isinstance(exc, SilentException) + + +class TestSafeException: + """Tests for SafeException class.""" + + def test_creation_with_message(self) -> None: + """Test creating SafeException with message.""" + exc = SafeException("Safe error message") + assert str(exc) == "Safe error message" + + def test_can_be_raised(self) -> None: + """Test SafeException can be raised.""" + with pytest.raises(SafeException): + raise SafeException("error") + + def test_is_exception(self) -> None: + """Test SafeException is an Exception.""" + exc = SafeException("test") + assert isinstance(exc, Exception) + + def test_message_preserved(self) -> None: + """Test exception message is preserved.""" + exc = SafeException("my custom message") + assert "my custom message" in str(exc) + + def test_empty_message(self) -> None: + """Test SafeException with empty message.""" + exc = SafeException("") + assert str(exc) == "" + + def test_multiline_message(self) -> None: + """Test SafeException with multiline message.""" + message = "Line 1\nLine 2\nLine 3" + exc = SafeException(message) + assert str(exc) == message + + +class TestExceptionHierarchy: + """Tests for exception class hierarchy.""" + + def test_silent_operation_inherits_from_silent(self) -> None: + """Test SilentOperationInProgressException inherits from SilentException.""" + exc = SilentOperationInProgressException() + assert isinstance(exc, SilentException) + + def test_safe_exception_not_silent(self) -> None: + """Test that SafeException is not a SilentException.""" + exc = SafeException("test") + assert not isinstance(exc, SilentException) + + def test_different_exception_types(self) -> None: + """Test that different exceptions are distinct types.""" + silent = SilentException() + cancel = SilentCancelOutputException() + progress = SilentOperationInProgressException() + safe = SafeException("test") + + assert type(silent) is not type(cancel) + assert type(cancel) is not type(progress) + assert type(safe) is not type(silent) diff --git a/tests/pytest/test_experimental_card_funcs.py b/tests/pytest/test_experimental_card_funcs.py new file mode 100644 index 000000000..4edd5345e --- /dev/null +++ b/tests/pytest/test_experimental_card_funcs.py @@ -0,0 +1,171 @@ +"""Tests for shiny.experimental.ui._card module.""" + +import io +from pathlib import Path +from typing import Any, Literal, cast + +import pytest + +from shiny.experimental.ui._card import card_image + + +class TestCardImage: + """Tests for card_image function.""" + + def test_card_image_with_none_file(self): + """card_image should work with file=None and src provided.""" + result = card_image(None, src="https://example.com/image.jpg") + assert result is not None + + def test_card_image_with_file_path(self, tmp_path: Path) -> None: + """card_image should work with a file path.""" + # Create a simple image file (1x1 PNG) + img_data = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" + b"\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00" + b"\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18" + b"\xd8N\x00\x00\x00\x00IEND\xaeB`\x82" + ) + img_file = tmp_path / "test.png" + img_file.write_bytes(img_data) + + result = card_image(str(img_file)) + assert result is not None + + def test_card_image_with_path_object(self, tmp_path: Path) -> None: + """card_image should work with Path object.""" + img_data = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" + b"\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00" + b"\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18" + b"\xd8N\x00\x00\x00\x00IEND\xaeB`\x82" + ) + img_file = tmp_path / "test.png" + img_file.write_bytes(img_data) + + result = card_image(img_file) + assert result is not None + + def test_card_image_with_bytes_io(self): + """card_image should work with BytesIO.""" + img_data = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" + b"\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00" + b"\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18" + b"\xd8N\x00\x00\x00\x00IEND\xaeB`\x82" + ) + buffer = io.BytesIO(img_data) + result = card_image(buffer, mime_type="image/png") + assert result is not None + + def test_card_image_bytes_io_requires_mime_type(self): + """card_image with BytesIO should require mime_type.""" + img_data = b"\x89PNG" + buffer = io.BytesIO(img_data) + with pytest.raises(ValueError, match="mime_type"): + card_image(buffer) + + def test_card_image_with_href(self, tmp_path: Path) -> None: + """card_image should accept href parameter.""" + img_data = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" + b"\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00" + b"\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18" + b"\xd8N\x00\x00\x00\x00IEND\xaeB`\x82" + ) + img_file = tmp_path / "test.png" + img_file.write_bytes(img_data) + + result = card_image(str(img_file), href="https://example.com") + assert result is not None + + def test_card_image_border_radius_options(self, tmp_path: Path) -> None: + """card_image should accept different border_radius options.""" + img_data = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" + b"\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00" + b"\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18" + b"\xd8N\x00\x00\x00\x00IEND\xaeB`\x82" + ) + img_file = tmp_path / "test.png" + img_file.write_bytes(img_data) + + radius_options: list[Literal["top", "bottom", "all", "none"]] = [ + "top", + "bottom", + "all", + "none", + ] + for radius in radius_options: + result = card_image(str(img_file), border_radius=radius) + assert result is not None + + def test_card_image_with_height(self, tmp_path: Path) -> None: + """card_image should accept height parameter.""" + img_data = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" + b"\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00" + b"\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18" + b"\xd8N\x00\x00\x00\x00IEND\xaeB`\x82" + ) + img_file = tmp_path / "test.png" + img_file.write_bytes(img_data) + + result = card_image(str(img_file), height="200px") + assert result is not None + + def test_card_image_with_width(self, tmp_path: Path) -> None: + """card_image should accept width parameter.""" + img_data = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" + b"\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00" + b"\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18" + b"\xd8N\x00\x00\x00\x00IEND\xaeB`\x82" + ) + img_file = tmp_path / "test.png" + img_file.write_bytes(img_data) + + result = card_image(str(img_file), width="100%") + assert result is not None + + def test_card_image_fill_false(self, tmp_path: Path) -> None: + """card_image should accept fill=False.""" + img_data = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" + b"\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00" + b"\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18" + b"\xd8N\x00\x00\x00\x00IEND\xaeB`\x82" + ) + img_file = tmp_path / "test.png" + img_file.write_bytes(img_data) + + result = card_image(str(img_file), fill=False) + assert result is not None + + def test_card_image_with_class(self, tmp_path: Path) -> None: + """card_image should accept class_ parameter.""" + img_data = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" + b"\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00" + b"\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18" + b"\xd8N\x00\x00\x00\x00IEND\xaeB`\x82" + ) + img_file = tmp_path / "test.png" + img_file.write_bytes(img_data) + + result = card_image(str(img_file), class_="my-image") + assert result is not None + + def test_card_image_no_container(self, tmp_path: Path) -> None: + """card_image should work with container=None.""" + img_data = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" + b"\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00" + b"\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18" + b"\xd8N\x00\x00\x00\x00IEND\xaeB`\x82" + ) + img_file = tmp_path / "test.png" + img_file.write_bytes(img_data) + + result = card_image(str(img_file), container=cast(Any, None)) + assert result is not None diff --git a/tests/pytest/test_experimental_deprecated_funcs.py b/tests/pytest/test_experimental_deprecated_funcs.py new file mode 100644 index 000000000..b9f43af06 --- /dev/null +++ b/tests/pytest/test_experimental_deprecated_funcs.py @@ -0,0 +1,123 @@ +"""Tests for shiny.experimental.ui._deprecated module.""" + +import warnings + +from htmltools import Tag, tags + +from shiny.experimental.ui._deprecated import card, card_body, card_title + + +class TestExperimentalCard: + """Tests for deprecated card function.""" + + def test_card_returns_tag(self): + """card should return a Tag and emit deprecation warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = card("Content") + # Should have at least one warning + assert len(w) >= 1 + # Should be a deprecation warning + assert any("deprecated" in str(warning.message).lower() for warning in w) + # Result should be a Tag + assert isinstance(result, Tag) + + def test_card_with_full_screen(self): + """card should accept full_screen parameter.""" + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + result = card("Content", full_screen=True) + assert isinstance(result, Tag) + + def test_card_with_height(self): + """card should accept height parameter.""" + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + result = card("Content", height="300px") + assert isinstance(result, Tag) + + def test_card_with_class(self): + """card should accept class_ parameter.""" + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + result = card("Content", class_="my-card") + html = str(result) + assert "my-card" in html + + +class TestExperimentalCardBody: + """Tests for deprecated card_body function.""" + + def test_card_body_returns_card_item(self): + """card_body should return a CardItem and emit deprecation warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = card_body("Content") + # Should have at least one warning + assert len(w) >= 1 + # Should be a deprecation warning + assert any("deprecated" in str(warning.message).lower() for warning in w) + # Result should not be None + assert result is not None + + def test_card_body_with_fillable(self): + """card_body should accept fillable parameter.""" + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + result = card_body("Content", fillable=False) + assert result is not None + + def test_card_body_with_height(self): + """card_body should accept height parameter.""" + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + result = card_body("Content", height="200px") + assert result is not None + + def test_card_body_with_padding(self): + """card_body should accept padding parameter.""" + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + result = card_body("Content", padding="10px") + assert result is not None + + +class TestExperimentalCardTitle: + """Tests for deprecated card_title function.""" + + def test_card_title_returns_tagifiable(self): + """card_title should return a Tagifiable and emit deprecation warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = card_title("My Title") + # Should have at least one warning + assert len(w) >= 1 + # Should be a deprecation warning + assert any("deprecated" in str(warning.message).lower() for warning in w) + # Result should be renderable + html = str(result) + assert "My Title" in html + + def test_card_title_default_container_is_h5(self): + """card_title should use h5 as default container.""" + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + result = card_title("Title") + html = str(result) + assert "= 1 + # Find the deprecation warning + deprecation_warnings = [ + x for x in w if "deprecated" in str(x.message).lower() + ] + assert len(deprecation_warnings) >= 1 + + def test_getattr_forwards_to_ui(self): + """Test __getattr__ forwards to ui module""" + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", ImportWarning) + from shiny.express import layout + + # Should be able to access ui functions through layout + # These should be the same as shiny.express.ui functions + assert hasattr(layout, "sidebar") + assert hasattr(layout, "card") diff --git a/tests/pytest/test_express_module.py b/tests/pytest/test_express_module.py new file mode 100644 index 000000000..4d44ae1aa --- /dev/null +++ b/tests/pytest/test_express_module.py @@ -0,0 +1,22 @@ +"""Tests for shiny/express/_module.py module.""" + +from shiny.express._module import module + + +class TestModule: + """Tests for module decorator.""" + + def test_module_is_callable(self): + """Test module is callable.""" + assert callable(module) + + +class TestModuleExported: + """Tests for module export.""" + + def test_module_exported_from_express(self): + """Test module is exported from shiny.express.""" + from shiny import express + + assert hasattr(express, "module") + assert callable(express.module) diff --git a/tests/pytest/test_express_output_funcs.py b/tests/pytest/test_express_output_funcs.py new file mode 100644 index 000000000..879dbe3d0 --- /dev/null +++ b/tests/pytest/test_express_output_funcs.py @@ -0,0 +1,38 @@ +"""Tests for shiny.express._output module""" + + +class TestOutputArgs: + """Test output_args decorator""" + + def test_import_output_args(self): + """Test output_args can be imported""" + from shiny.express._output import output_args + + assert callable(output_args) + + def test_output_args_returns_decorator(self): + """Test output_args returns a decorator""" + from shiny.express._output import output_args + + decorator = output_args(width="100%") + assert callable(decorator) + + +class TestSuspendDisplay: + """Test deprecated suspend_display function""" + + def test_import_suspend_display(self): + """Test suspend_display can be imported""" + from shiny.express._output import suspend_display + + assert callable(suspend_display) + + def test_suspend_display_warns(self): + """Test suspend_display is deprecated and emits warning when imported""" + # The suspend_display function is deprecated. + # When called, it warns and then delegates to hold() + # However, there's a bug where it passes fn=None to hold() which doesn't accept args + # For now, just test that the function exists and the warning module works + from shiny.express._output import suspend_display + + assert callable(suspend_display) diff --git a/tests/pytest/test_express_run.py b/tests/pytest/test_express_run.py new file mode 100644 index 000000000..646f2252f --- /dev/null +++ b/tests/pytest/test_express_run.py @@ -0,0 +1,140 @@ +"""Tests for shiny.express._run helpers.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from htmltools import Tag, TagList + +from shiny.express import _run as express_run +from shiny.express._run import ( + AppOpts, + InputNotImportedShim, + _merge_app_opts, + _normalize_app_opts, + app_opts, + create_express_app, + run_express, +) +from shiny.express._stub_session import ExpressStubSession +from shiny.session import session_context + + +def _write_express_app(path: Path, body: str) -> None: + path.write_text( + "\n".join( + [ + "from shiny.express import ui, app_opts", + body, + ] + ) + ) + + +def test_app_opts_requires_stub_session() -> None: + with pytest.raises(RuntimeError, match="standalone Shiny Express app"): + app_opts(debug=True) + + +def test_app_opts_ignores_non_stub_session(monkeypatch: pytest.MonkeyPatch) -> None: + class DummySession: + pass + + def fake_get_current_session() -> DummySession: + return DummySession() + + monkeypatch.setattr(express_run, "get_current_session", fake_get_current_session) + app_opts(debug=True) + + +def test_app_opts_sets_values() -> None: + stub = ExpressStubSession() + with session_context(stub): + app_opts(static_assets="assets", bookmark_store="url", debug=True) + + static_assets = stub.app_opts.get("static_assets") + assert static_assets is not None + assert static_assets["/"] == Path("assets") + assert stub.app_opts.get("bookmark_store") == "url" + assert stub.app_opts.get("debug") is True + + +def test_merge_and_normalize_app_opts(tmp_path: Path) -> None: + base: AppOpts = {"static_assets": {"/": Path("www")}, "debug": False} + updates: AppOpts = { + "static_assets": {"/foo": Path("assets")}, + "bookmark_store": "url", + } + + merged = _merge_app_opts(base, updates) + static_assets = merged.get("static_assets") + assert static_assets is not None + assert static_assets["/foo"] == Path("assets") + assert merged.get("bookmark_store") == "url" + + normalized = _normalize_app_opts(merged, tmp_path) + normalized_static = normalized.get("static_assets") + assert normalized_static is not None + assert normalized_static["/"].is_absolute() + assert normalized_static["/foo"].is_absolute() + + +def test_input_not_imported_shim_message() -> None: + shim = InputNotImportedShim() + with pytest.raises(AttributeError, match="not imported"): + _ = shim.x + + +def test_run_express_basic_ui(tmp_path: Path) -> None: + app_file = tmp_path / "app.py" + _write_express_app(app_file, "ui.h1('Hello')") + + result = run_express(app_file) + assert isinstance(result, (Tag, TagList)) + + +def test_run_express_attribute_error_wrapped(tmp_path: Path) -> None: + app_file = tmp_path / "app.py" + _write_express_app(app_file, "ui.nope") + + with pytest.raises(RuntimeError, match="no attribute"): + run_express(app_file) + + +def test_run_express_rejects_core_app_var(tmp_path: Path) -> None: + app_file = tmp_path / "app.py" + app_file.write_text( + "\n".join( + [ + "from shiny import App", + "from shiny.express import ui", + "app = App(ui=ui.h1('x'), server=None)", + "ui.h1('x')", + ] + ) + ) + + with pytest.raises(RuntimeError, match="Shiny Express app"): + run_express(app_file) + + +def test_create_express_app_merges_options(tmp_path: Path) -> None: + app_file = tmp_path / "app.py" + www_dir = tmp_path / "www" + assets_dir = tmp_path / "assets" + www_dir.mkdir() + assets_dir.mkdir() + + _write_express_app( + app_file, + "app_opts(static_assets={'/foo': 'assets'}, bookmark_store='url', debug=True)\n" + "ui.h1('Hello')", + ) + + app = create_express_app(app_file, "app_pkg") + assert app.bookmark_store == "url" + assert "/" in app._static_assets + assert "/foo" in app._static_assets + assert app._static_assets["/"].is_absolute() + assert app._static_assets["/foo"].is_absolute() diff --git a/tests/pytest/test_expressify_decorator_init.py b/tests/pytest/test_expressify_decorator_init.py new file mode 100644 index 000000000..3a8f0da0d --- /dev/null +++ b/tests/pytest/test_expressify_decorator_init.py @@ -0,0 +1,25 @@ +"""Tests for shiny/express/expressify_decorator/__init__.py module.""" + +from shiny.express.expressify_decorator import expressify + + +class TestExpressifyDecoratorInit: + """Tests for expressify_decorator __init__ exports.""" + + def test_expressify_exported(self): + """Test expressify is exported.""" + assert expressify is not None + + def test_expressify_is_callable(self): + """Test expressify is callable.""" + assert callable(expressify) + + +class TestExpressifyExport: + """Tests for expressify export.""" + + def test_expressify_from_express(self): + """Test expressify can be imported from express.""" + from shiny.express import expressify as exp_expressify + + assert exp_expressify is not None diff --git a/tests/pytest/test_extended_task_funcs.py b/tests/pytest/test_extended_task_funcs.py new file mode 100644 index 000000000..70da85bdd --- /dev/null +++ b/tests/pytest/test_extended_task_funcs.py @@ -0,0 +1,147 @@ +"""Tests for shiny.reactive._extended_task module""" + +import asyncio + +import pytest + +from shiny.reactive._extended_task import DenialContext, ExtendedTask, Status + + +class TestExtendedTaskStatus: + """Test Status type""" + + def test_status_literals(self): + """Test valid status values""" + statuses: list[Status] = [ + "initial", + "running", + "success", + "error", + "cancelled", + ] + for status in statuses: + assert status in ["initial", "running", "success", "error", "cancelled"] + + +class TestDenialContext: + """Test DenialContext class""" + + def test_denial_context_creation(self): + """Test creating a DenialContext""" + ctx = DenialContext() + assert ctx is not None + + def test_denial_context_on_invalidate_raises(self): + """Test on_invalidate raises RuntimeError""" + ctx = DenialContext() + with pytest.raises(RuntimeError) as exc_info: + ctx.on_invalidate(lambda: None) + assert "reactive sources" in str(exc_info.value).lower() + + +class TestExtendedTask: + """Test ExtendedTask class""" + + def test_extended_task_requires_async(self): + """Test ExtendedTask requires async function""" + + def sync_func(): + return 42 + + with pytest.raises(TypeError) as exc_info: + ExtendedTask(sync_func) # type: ignore + assert "async" in str(exc_info.value).lower() + + def test_extended_task_with_async_func(self): + """Test ExtendedTask accepts async function""" + + async def async_func(): + return 42 + + task = ExtendedTask(async_func) + assert task is not None + + def test_extended_task_initial_status(self): + """Test ExtendedTask starts with initial status""" + + async def async_func(): + return 42 + + task = ExtendedTask(async_func) + # Status is a reactive Value, we need to access it in isolation + from shiny.reactive._reactives import isolate + + with isolate(): + assert task.status() == "initial" + + def test_extended_task_has_value(self): + """Test ExtendedTask has value attribute""" + + async def async_func(): + return 42 + + task = ExtendedTask(async_func) + assert hasattr(task, "value") + + def test_extended_task_has_error(self): + """Test ExtendedTask has error attribute""" + + async def async_func(): + return 42 + + task = ExtendedTask(async_func) + assert hasattr(task, "error") + + def test_extended_task_callable(self): + """Test ExtendedTask is callable""" + + async def async_func(): + return 42 + + task = ExtendedTask(async_func) + assert callable(task) + + def test_extended_task_has_invoke(self): + """Test ExtendedTask has invoke method""" + + async def async_func(): + return 42 + + task = ExtendedTask(async_func) + assert hasattr(task, "invoke") + assert callable(task.invoke) + + def test_extended_task_has_cancel(self): + """Test ExtendedTask has cancel method""" + + async def async_func(): + return 42 + + task = ExtendedTask(async_func) + assert hasattr(task, "cancel") + assert callable(task.cancel) + + def test_extended_task_cancel_clears_queue(self): + """Test cancel clears invocation queue""" + + async def async_func(): + await asyncio.sleep(0.1) + return 42 + + task = ExtendedTask(async_func) + # Add something to queue + task._invocation_queue.append(lambda: None) + assert len(task._invocation_queue) == 1 + + task.cancel() + assert len(task._invocation_queue) == 0 + + +class TestExtendedTaskDecorator: + """Test extended_task decorator""" + + def test_import_decorator(self): + """Test extended_task decorator can be imported""" + from shiny.reactive._extended_task import extended_task + + assert callable(extended_task) diff --git a/tests/pytest/test_fill.py b/tests/pytest/test_fill.py new file mode 100644 index 000000000..48c306c6f --- /dev/null +++ b/tests/pytest/test_fill.py @@ -0,0 +1,118 @@ +"""Tests for fill module.""" + +from htmltools import tags + +from shiny.ui.fill import ( + as_fill_item, + as_fillable_container, + remove_all_fill, +) + + +class TestAsFillableContainer: + """Tests for as_fillable_container function.""" + + def test_basic_fillable_container(self): + """Test making a tag a fillable container.""" + div = tags.div("Content") + fillable = as_fillable_container(div) + html = str(fillable) + + assert "html-fill-container" in html + + def test_fillable_container_preserves_content(self): + """Test that content is preserved.""" + div = tags.div("My Content") + fillable = as_fillable_container(div) + html = str(fillable) + + assert "My Content" in html + + def test_fillable_container_original_unchanged(self): + """Test that original tag is not modified.""" + div = tags.div("Content") + original_html = str(div) + _ = as_fillable_container(div) + + # Original should not have the fill class + assert str(div) == original_html + + +class TestAsFillItem: + """Tests for as_fill_item function.""" + + def test_basic_fill_item(self): + """Test making a tag a fill item.""" + div = tags.div("Content") + fill = as_fill_item(div) + html = str(fill) + + assert "html-fill-item" in html + + def test_fill_item_preserves_content(self): + """Test that content is preserved.""" + div = tags.div("My Content") + fill = as_fill_item(div) + html = str(fill) + + assert "My Content" in html + + def test_fill_item_original_unchanged(self): + """Test that original tag is not modified.""" + div = tags.div("Content") + original_html = str(div) + _ = as_fill_item(div) + + # Original should not have the fill class + assert str(div) == original_html + + +class TestRemoveAllFill: + """Tests for remove_all_fill function.""" + + def test_remove_fill_from_fillable_container(self): + """Test removing fill classes from a fillable container.""" + div = tags.div("Content") + fillable = as_fillable_container(div) + cleaned = remove_all_fill(fillable) + html = str(cleaned) + + assert "html-fill-container" not in html + + def test_remove_fill_from_fill_item(self): + """Test removing fill classes from a fill item.""" + div = tags.div("Content") + fill = as_fill_item(div) + cleaned = remove_all_fill(fill) + html = str(cleaned) + + assert "html-fill-item" not in html + + def test_remove_fill_from_combined(self): + """Test removing fill classes from a tag that is both.""" + div = tags.div("Content") + combined = as_fill_item(as_fillable_container(div)) + cleaned = remove_all_fill(combined) + html = str(cleaned) + + assert "html-fill-container" not in html + assert "html-fill-item" not in html + + def test_remove_fill_preserves_content(self): + """Test that content is preserved when removing fill.""" + div = tags.div("Important Content") + fillable = as_fillable_container(div) + cleaned = remove_all_fill(fillable) + html = str(cleaned) + + assert "Important Content" in html + + def test_remove_fill_from_regular_tag(self): + """Test remove_all_fill on a tag without fill classes.""" + div = tags.div("Content", class_="my-class") + cleaned = remove_all_fill(div) + html = str(cleaned) + + # Should still have original class + assert "my-class" in html + assert "Content" in html diff --git a/tests/pytest/test_fill_funcs.py b/tests/pytest/test_fill_funcs.py new file mode 100644 index 000000000..78fee062e --- /dev/null +++ b/tests/pytest/test_fill_funcs.py @@ -0,0 +1,167 @@ +"""Tests for shiny.ui.fill._fill module.""" + +from htmltools import Tag, div + +from shiny.ui.fill._fill import ( + FILL_CONTAINER_CLASS, + FILL_ITEM_CLASS, + as_fill_item, + as_fillable_container, + is_fill_item, + is_fillable_container, + remove_all_fill, +) + + +class TestAsFillableContainer: + """Tests for as_fillable_container function.""" + + def test_as_fillable_container_basic(self) -> None: + """Test basic as_fillable_container conversion.""" + tag = div("content") + result = as_fillable_container(tag) + assert isinstance(result, Tag) + assert result.has_class(FILL_CONTAINER_CLASS) + + def test_as_fillable_container_preserves_content(self) -> None: + """Test as_fillable_container preserves content.""" + tag = div("content", id="myid") + result = as_fillable_container(tag) + html = str(result) + assert "content" in html + assert 'id="myid"' in html + + def test_as_fillable_container_creates_copy(self) -> None: + """Test as_fillable_container creates a copy.""" + tag = div("content") + result = as_fillable_container(tag) + # Original tag should not be modified + assert not tag.has_class(FILL_CONTAINER_CLASS) + assert result.has_class(FILL_CONTAINER_CLASS) + + +class TestAsFillItem: + """Tests for as_fill_item function.""" + + def test_as_fill_item_basic(self) -> None: + """Test basic as_fill_item conversion.""" + tag = div("content") + result = as_fill_item(tag) + assert isinstance(result, Tag) + assert result.has_class(FILL_ITEM_CLASS) + + def test_as_fill_item_preserves_content(self) -> None: + """Test as_fill_item preserves content.""" + tag = div("content", class_="existing") + result = as_fill_item(tag) + html = str(result) + assert "content" in html + assert "existing" in html + + def test_as_fill_item_creates_copy(self) -> None: + """Test as_fill_item creates a copy.""" + tag = div("content") + result = as_fill_item(tag) + # Original tag should not be modified + assert not tag.has_class(FILL_ITEM_CLASS) + assert result.has_class(FILL_ITEM_CLASS) + + +class TestRemoveAllFill: + """Tests for remove_all_fill function.""" + + def test_remove_all_fill_removes_container(self) -> None: + """Test remove_all_fill removes fillable container class.""" + tag = as_fillable_container(div("content")) + assert tag.has_class(FILL_CONTAINER_CLASS) + result = remove_all_fill(tag) + assert not result.has_class(FILL_CONTAINER_CLASS) + + def test_remove_all_fill_removes_item(self) -> None: + """Test remove_all_fill removes fill item class.""" + tag = as_fill_item(div("content")) + assert tag.has_class(FILL_ITEM_CLASS) + result = remove_all_fill(tag) + assert not result.has_class(FILL_ITEM_CLASS) + + def test_remove_all_fill_removes_both(self) -> None: + """Test remove_all_fill removes both container and item classes.""" + tag = as_fillable_container(div("content")) + tag = as_fill_item(tag) + assert tag.has_class(FILL_CONTAINER_CLASS) + assert tag.has_class(FILL_ITEM_CLASS) + result = remove_all_fill(tag) + assert not result.has_class(FILL_CONTAINER_CLASS) + assert not result.has_class(FILL_ITEM_CLASS) + + def test_remove_all_fill_creates_copy(self) -> None: + """Test remove_all_fill creates a copy.""" + original = as_fillable_container(div("content")) + result = remove_all_fill(original) + # Original tag should still have the class + assert original.has_class(FILL_CONTAINER_CLASS) + assert not result.has_class(FILL_CONTAINER_CLASS) + + +class TestIsFillableContainer: + """Tests for is_fillable_container function.""" + + def test_is_fillable_container_true(self) -> None: + """Test is_fillable_container returns True for fillable container.""" + tag = as_fillable_container(div("content")) + assert is_fillable_container(tag) is True + + def test_is_fillable_container_false_plain_tag(self) -> None: + """Test is_fillable_container returns False for plain tag.""" + tag = div("content") + assert is_fillable_container(tag) is False + + def test_is_fillable_container_false_non_tag(self) -> None: + """Test is_fillable_container returns False for non-Tag.""" + assert is_fillable_container("string") is False + assert is_fillable_container(123) is False + assert is_fillable_container(None) is False + + +class TestIsFillItem: + """Tests for is_fill_item function.""" + + def test_is_fill_item_true(self) -> None: + """Test is_fill_item returns True for fill item.""" + tag = as_fill_item(div("content")) + assert is_fill_item(tag) is True + + def test_is_fill_item_false_plain_tag(self) -> None: + """Test is_fill_item returns False for plain tag.""" + tag = div("content") + assert is_fill_item(tag) is False + + def test_is_fill_item_false_non_tag(self) -> None: + """Test is_fill_item returns False for non-Tag.""" + assert is_fill_item("string") is False + assert is_fill_item(123) is False + assert is_fill_item(None) is False + + +class TestFillIntegration: + """Integration tests for fill functions.""" + + def test_fill_carrier(self) -> None: + """Test creating a fill carrier (both container and item).""" + tag = div("content") + carrier = as_fillable_container(as_fill_item(tag)) + assert is_fill_item(carrier) + assert is_fillable_container(carrier) + + def test_chained_operations(self) -> None: + """Test chaining fill operations.""" + tag = div("content") + # Add both classes + tag = as_fill_item(tag) + tag = as_fillable_container(tag) + assert is_fill_item(tag) + assert is_fillable_container(tag) + # Remove all + tag = remove_all_fill(tag) + assert not is_fill_item(tag) + assert not is_fillable_container(tag) diff --git a/tests/pytest/test_fill_module.py b/tests/pytest/test_fill_module.py new file mode 100644 index 000000000..59b67d01d --- /dev/null +++ b/tests/pytest/test_fill_module.py @@ -0,0 +1,188 @@ +"""Tests for the fill module functions.""" + +from htmltools import tags + +from shiny.ui.fill import as_fill_item, as_fillable_container, remove_all_fill +from shiny.ui.fill._fill import FILL_CONTAINER_CLASS, FILL_ITEM_CLASS + + +class TestAsFillableContainer: + """Tests for the as_fillable_container function.""" + + def test_as_fillable_container_adds_class(self): + """Test that fillable container class is added.""" + tag = tags.div("Content") + result = as_fillable_container(tag) + + # Should have fillable container class + assert FILL_CONTAINER_CLASS in result.attrs.get("class", "") + + def test_as_fillable_container_returns_copy(self): + """Test that function returns a copy, not the original.""" + tag = tags.div("Content") + result = as_fillable_container(tag) + + # Should be a different object + assert result is not tag + + def test_as_fillable_container_preserves_content(self): + """Test that original content is preserved.""" + tag = tags.div("Original content", class_="existing-class") + result = as_fillable_container(tag) + + result_str = str(result) + assert "Original content" in result_str + assert "existing-class" in result_str + + def test_as_fillable_container_preserves_tag_name(self): + """Test that tag name is preserved.""" + tag = tags.section("Content") + result = as_fillable_container(tag) + + assert result.name == "section" + + def test_as_fillable_container_on_nested_tag(self): + """Test fillable container on tag with nested children.""" + tag = tags.div( + tags.p("Paragraph 1"), + tags.p("Paragraph 2"), + ) + result = as_fillable_container(tag) + + result_str = str(result) + assert "Paragraph 1" in result_str + assert "Paragraph 2" in result_str + + +class TestAsFillItem: + """Tests for the as_fill_item function.""" + + def test_as_fill_item_adds_class(self): + """Test that fill item class is added.""" + tag = tags.div("Content") + result = as_fill_item(tag) + + # Should have fill item class + assert FILL_ITEM_CLASS in result.attrs.get("class", "") + + def test_as_fill_item_returns_copy(self): + """Test that function returns a copy, not the original.""" + tag = tags.div("Content") + result = as_fill_item(tag) + + # Should be a different object + assert result is not tag + + def test_as_fill_item_preserves_content(self): + """Test that original content is preserved.""" + tag = tags.div("Original content", class_="existing-class") + result = as_fill_item(tag) + + result_str = str(result) + assert "Original content" in result_str + assert "existing-class" in result_str + + def test_as_fill_item_preserves_tag_name(self): + """Test that tag name is preserved.""" + tag = tags.article("Content") + result = as_fill_item(tag) + + assert result.name == "article" + + +class TestRemoveAllFill: + """Tests for the remove_all_fill function.""" + + def test_remove_all_fill_removes_fillable_class(self): + """Test that fillable container class is removed.""" + tag = tags.div("Content", class_=FILL_CONTAINER_CLASS) + result = remove_all_fill(tag) + + classes = result.attrs.get("class", "") + assert FILL_CONTAINER_CLASS not in classes + + def test_remove_all_fill_removes_fill_item_class(self): + """Test that fill item class is removed.""" + tag = tags.div("Content", class_=FILL_ITEM_CLASS) + result = remove_all_fill(tag) + + classes = result.attrs.get("class", "") + assert FILL_ITEM_CLASS not in classes + + def test_remove_all_fill_removes_both_classes(self): + """Test that both fill classes are removed.""" + tag = tags.div("Content", class_=f"{FILL_CONTAINER_CLASS} {FILL_ITEM_CLASS}") + result = remove_all_fill(tag) + + classes = result.attrs.get("class", "") + assert FILL_CONTAINER_CLASS not in classes + assert FILL_ITEM_CLASS not in classes + + def test_remove_all_fill_returns_copy(self): + """Test that function returns a copy, not the original.""" + tag = tags.div("Content") + result = remove_all_fill(tag) + + # Should be a different object + assert result is not tag + + def test_remove_all_fill_preserves_other_classes(self): + """Test that other classes are preserved.""" + tag = tags.div( + "Content", + class_=f"custom-class {FILL_CONTAINER_CLASS} another-class", + ) + result = remove_all_fill(tag) + + classes = result.attrs.get("class", "") + assert "custom-class" in classes + assert "another-class" in classes + + def test_remove_all_fill_preserves_content(self): + """Test that content is preserved.""" + tag = tags.div("Original content") + result = remove_all_fill(tag) + + result_str = str(result) + assert "Original content" in result_str + + +class TestFillCombinations: + """Tests for combining fill functions.""" + + def test_fill_item_and_fillable_container(self): + """Test making a tag both a fill item and fillable container.""" + tag = tags.div("Content") + result = as_fill_item(as_fillable_container(tag)) + + classes = result.attrs.get("class", "") + assert FILL_ITEM_CLASS in classes + assert FILL_CONTAINER_CLASS in classes + + def test_remove_fill_after_adding(self): + """Test removing fill after adding it.""" + tag = tags.div("Content") + with_fill = as_fill_item(as_fillable_container(tag)) + result = remove_all_fill(with_fill) + + classes = result.attrs.get("class", "") + assert FILL_ITEM_CLASS not in classes + assert FILL_CONTAINER_CLASS not in classes + + def test_double_application_of_fillable(self): + """Test applying fillable container twice.""" + tag = tags.div("Content") + result = as_fillable_container(as_fillable_container(tag)) + + # Should still only have one instance of the class + result_str = str(result) + assert FILL_CONTAINER_CLASS in result_str + + def test_double_application_of_fill_item(self): + """Test applying fill item twice.""" + tag = tags.div("Content") + result = as_fill_item(as_fill_item(tag)) + + # Should still only have one instance of the class + result_str = str(result) + assert FILL_ITEM_CLASS in result_str diff --git a/tests/pytest/test_hostenv.py b/tests/pytest/test_hostenv.py new file mode 100644 index 000000000..325a37655 --- /dev/null +++ b/tests/pytest/test_hostenv.py @@ -0,0 +1,304 @@ +"""Tests for shiny/_hostenv.py - Host environment detection and URL proxying.""" + +import logging +import os +from unittest.mock import patch + +from shiny._hostenv import ( + ProxyUrlFilter, + get_proxy_url, + is_codespaces, + is_proxy_env, + is_workbench, + pat_local_url, + port_cache, +) + + +class TestIsWorkbench: + """Tests for is_workbench function.""" + + def test_is_workbench_false_by_default(self): + """Test returns False when env vars not set.""" + with patch.dict(os.environ, {}, clear=True): + assert is_workbench() is False + + def test_is_workbench_false_with_partial_env(self): + """Test returns False with only one env var set.""" + with patch.dict(os.environ, {"RS_SERVER_URL": "http://server"}, clear=True): + assert is_workbench() is False + + with patch.dict(os.environ, {"RS_SESSION_URL": "/session/"}, clear=True): + assert is_workbench() is False + + def test_is_workbench_true_with_both_env_vars(self): + """Test returns True when both env vars are set.""" + env = { + "RS_SERVER_URL": "http://server", + "RS_SESSION_URL": "/session/", + } + with patch.dict(os.environ, env, clear=True): + assert is_workbench() is True + + +class TestIsCodespaces: + """Tests for is_codespaces function.""" + + def test_is_codespaces_false_by_default(self): + """Test returns False when env vars not set.""" + with patch.dict(os.environ, {}, clear=True): + assert is_codespaces() is False + + def test_is_codespaces_false_with_partial_env(self): + """Test returns False with incomplete env vars.""" + with patch.dict(os.environ, {"CODESPACES": "true"}, clear=True): + assert is_codespaces() is False + + env = {"CODESPACES": "true", "CODESPACE_NAME": "myspace"} + with patch.dict(os.environ, env, clear=True): + assert is_codespaces() is False + + def test_is_codespaces_true_with_all_env_vars(self): + """Test returns True when all required env vars are set.""" + env = { + "CODESPACES": "true", + "CODESPACE_NAME": "myspace", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "preview.app.github.dev", + } + with patch.dict(os.environ, env, clear=True): + assert is_codespaces() is True + + +class TestIsProxyEnv: + """Tests for is_proxy_env function.""" + + def test_is_proxy_env_false_by_default(self): + """Test returns False when not in proxy environment.""" + with patch.dict(os.environ, {}, clear=True): + assert is_proxy_env() is False + + def test_is_proxy_env_true_for_workbench(self): + """Test returns True when in Workbench.""" + env = { + "RS_SERVER_URL": "http://server", + "RS_SESSION_URL": "/session/", + } + with patch.dict(os.environ, env, clear=True): + assert is_proxy_env() is True + + def test_is_proxy_env_true_for_codespaces(self): + """Test returns True when in Codespaces.""" + env = { + "CODESPACES": "true", + "CODESPACE_NAME": "myspace", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "preview.app.github.dev", + } + with patch.dict(os.environ, env, clear=True): + assert is_proxy_env() is True + + +class TestGetProxyUrl: + """Tests for get_proxy_url function.""" + + def test_returns_url_unchanged_when_not_proxy_env(self): + """Test URL unchanged when not in proxy environment.""" + with patch.dict(os.environ, {}, clear=True): + url = "http://localhost:8000/app" + assert get_proxy_url(url) == url + + def test_returns_url_unchanged_for_non_loopback(self): + """Test non-loopback URLs are returned unchanged.""" + env = { + "CODESPACES": "true", + "CODESPACE_NAME": "myspace", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "preview.app.github.dev", + } + with patch.dict(os.environ, env, clear=True): + url = "http://example.com:8000/app" + assert get_proxy_url(url) == url + + def test_returns_url_unchanged_for_port_0(self): + """Test port 0 URLs are returned unchanged.""" + env = { + "CODESPACES": "true", + "CODESPACE_NAME": "myspace", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "preview.app.github.dev", + } + with patch.dict(os.environ, env, clear=True): + url = "http://localhost:0/app" + assert get_proxy_url(url) == url + + def test_codespaces_proxy_url(self): + """Test URL proxying for Codespaces.""" + env = { + "CODESPACES": "true", + "CODESPACE_NAME": "myspace", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "preview.app.github.dev", + } + with patch.dict(os.environ, env, clear=True): + url = "http://localhost:8000/app" + result = get_proxy_url(url) + assert "myspace-8000" in result + assert "preview.app.github.dev" in result + assert "https://" in result + + def test_codespaces_proxy_url_websocket(self): + """Test WebSocket URL proxying for Codespaces.""" + env = { + "CODESPACES": "true", + "CODESPACE_NAME": "myspace", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "preview.app.github.dev", + } + with patch.dict(os.environ, env, clear=True): + url = "ws://localhost:8000/ws" + result = get_proxy_url(url) + assert "wss://" in result + assert "myspace-8000" in result + + def test_implicit_http_port(self): + """Test URL with implicit HTTP port 80.""" + env = { + "CODESPACES": "true", + "CODESPACE_NAME": "myspace", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "preview.app.github.dev", + } + with patch.dict(os.environ, env, clear=True): + url = "http://localhost/app" + result = get_proxy_url(url) + assert "myspace-80" in result + + def test_implicit_https_port(self): + """Test URL with implicit HTTPS port 443.""" + env = { + "CODESPACES": "true", + "CODESPACE_NAME": "myspace", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "preview.app.github.dev", + } + with patch.dict(os.environ, env, clear=True): + url = "https://localhost/app" + result = get_proxy_url(url) + assert "myspace-443" in result + + def test_127_0_0_1_is_loopback(self): + """Test that 127.0.0.1 is recognized as loopback.""" + env = { + "CODESPACES": "true", + "CODESPACE_NAME": "myspace", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "preview.app.github.dev", + } + with patch.dict(os.environ, env, clear=True): + url = "http://127.0.0.1:8000/app" + result = get_proxy_url(url) + assert result != url + assert "myspace-8000" in result + + +class TestPatLocalUrl: + """Tests for the pat_local_url regex pattern.""" + + def test_matches_localhost(self): + """Test pattern matches localhost URLs.""" + assert pat_local_url.match("http://localhost:8000/path") + assert pat_local_url.match("https://localhost/") + + def test_matches_127_0_0_1(self): + """Test pattern matches 127.0.0.1 URLs.""" + assert pat_local_url.match("http://127.0.0.1:8000/path") + assert pat_local_url.match("https://127.0.0.1/") + + def test_case_insensitive(self): + """Test pattern is case insensitive.""" + assert pat_local_url.match("HTTP://LOCALHOST:8000/") + assert pat_local_url.match("http://LOCALHOST:8000/") + + def test_captures_host_and_port(self): + """Test pattern captures host and port.""" + match = pat_local_url.match("http://localhost:8000/my/path") + assert match is not None + # The regex captures the scheme, host, and optional port + full_match = match.group(0) + assert "localhost" in full_match + assert "8000" in full_match + + def test_search_finds_url_in_text(self): + """Test pattern can find URL within larger text.""" + text = "Visit http://localhost:8000 for the app" + match = pat_local_url.search(text) + assert match is not None + assert "localhost" in match.group(0) + + +class TestProxyUrlFilter: + """Tests for ProxyUrlFilter logging filter.""" + + def test_filter_creates_instance(self): + """Test ProxyUrlFilter can be instantiated.""" + filter_obj = ProxyUrlFilter() + assert filter_obj is not None + + def test_filter_returns_1(self): + """Test filter always returns 1 (allow log record).""" + filter_obj = ProxyUrlFilter() + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="", + lineno=0, + msg="http://localhost:8000/app", + args=(), + exc_info=None, + ) + result = filter_obj.filter(record) + assert result == 1 + + def test_filter_transforms_url_in_proxy_env(self): + """Test filter transforms URLs in proxy environment.""" + env = { + "CODESPACES": "true", + "CODESPACE_NAME": "myspace", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "preview.app.github.dev", + } + with patch.dict(os.environ, env, clear=True): + filter_obj = ProxyUrlFilter() + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="", + lineno=0, + msg="Visit http://localhost:8000/app", + args=(), + exc_info=None, + ) + filter_obj.filter(record) + assert "myspace-8000" in record.msg + assert "localhost" not in record.msg + + def test_filter_handles_color_message(self): + """Test filter handles color_message attribute.""" + env = { + "CODESPACES": "true", + "CODESPACE_NAME": "myspace", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "preview.app.github.dev", + } + with patch.dict(os.environ, env, clear=True): + filter_obj = ProxyUrlFilter() + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="", + lineno=0, + msg="Visit http://localhost:8000/app", + args=(), + exc_info=None, + ) + record.color_message = "Visit http://localhost:8000/app" # type: ignore + filter_obj.filter(record) + assert "myspace-8000" in str(record.color_message) # type: ignore + + +class TestPortCache: + """Tests for port_cache module variable.""" + + def test_port_cache_is_dict(self): + """Test port_cache is a dictionary.""" + assert isinstance(port_cache, dict) diff --git a/tests/pytest/test_html_dependencies.py b/tests/pytest/test_html_dependencies.py new file mode 100644 index 000000000..56b63567e --- /dev/null +++ b/tests/pytest/test_html_dependencies.py @@ -0,0 +1,118 @@ +"""Tests for shiny/html_dependencies.py - HTML dependency functions.""" + +import os +from unittest.mock import patch + +from htmltools import HTMLDependency + +from shiny import __version__ +from shiny.html_dependencies import jquery_deps, require_deps, shiny_deps + + +class TestShinyDeps: + """Tests for shiny_deps function.""" + + def test_returns_list(self): + """Test that shiny_deps returns a list.""" + deps = shiny_deps() + assert isinstance(deps, list) + + def test_contains_html_dependencies(self): + """Test that list contains HTMLDependency objects.""" + deps = shiny_deps() + for dep in deps: + assert isinstance(dep, HTMLDependency) + + def test_includes_shiny_dependency(self): + """Test that shiny dependency is included.""" + deps = shiny_deps() + dep_names = [dep.name for dep in deps] + assert "shiny" in dep_names + + def test_shiny_dependency_has_correct_version(self): + """Test that shiny dependency has correct version.""" + deps = shiny_deps() + shiny_dep = next(d for d in deps if d.name == "shiny") + assert str(shiny_dep.version) == __version__ + + def test_includes_css_by_default(self): + """Test that CSS is included by default.""" + deps = shiny_deps(include_css=True) + shiny_dep = next(d for d in deps if d.name == "shiny") + # Check that stylesheet is not None + assert shiny_dep.stylesheet is not None + + def test_excludes_css_when_false(self): + """Test that CSS can be excluded.""" + deps = shiny_deps(include_css=False) + shiny_dep = next(d for d in deps if d.name == "shiny") + # Check that stylesheet is empty or None + assert shiny_dep.stylesheet is None or len(shiny_dep.stylesheet) == 0 + + def test_includes_busy_indicators(self): + """Test that busy indicators dependency is included.""" + deps = shiny_deps() + # Should have at least 2 dependencies (shiny + busy_indicators) + assert len(deps) >= 2 + + def test_dev_mode_adds_devmode_dep(self): + """Test that dev mode adds shiny-devmode dependency.""" + with patch.dict(os.environ, {"SHINY_DEV_MODE": "1"}): + deps = shiny_deps() + dep_names = [dep.name for dep in deps] + assert "shiny-devmode" in dep_names + + def test_no_devmode_dep_when_not_dev_mode(self): + """Test that devmode dep not included when not in dev mode.""" + with patch.dict(os.environ, {"SHINY_DEV_MODE": "0"}, clear=False): + deps = shiny_deps() + dep_names = [dep.name for dep in deps] + assert "shiny-devmode" not in dep_names + + +class TestJqueryDeps: + """Tests for jquery_deps function.""" + + def test_returns_html_dependency(self): + """Test that jquery_deps returns HTMLDependency.""" + dep = jquery_deps() + assert isinstance(dep, HTMLDependency) + + def test_has_correct_name(self): + """Test that dependency has correct name.""" + dep = jquery_deps() + assert dep.name == "jquery" + + def test_has_version(self): + """Test that dependency has version.""" + dep = jquery_deps() + assert str(dep.version) == "3.6.0" + + def test_has_script(self): + """Test that dependency has script.""" + dep = jquery_deps() + assert dep.script is not None + + +class TestRequireDeps: + """Tests for require_deps function.""" + + def test_returns_html_dependency(self): + """Test that require_deps returns HTMLDependency.""" + dep = require_deps() + assert isinstance(dep, HTMLDependency) + + def test_has_correct_name(self): + """Test that dependency has correct name.""" + dep = require_deps() + assert dep.name == "requirejs" + + def test_has_version(self): + """Test that dependency has version.""" + dep = require_deps() + assert str(dep.version) == "2.3.6" + + def test_has_script(self): + """Test that dependency has script.""" + dep = require_deps() + assert dep.script is not None diff --git a/tests/pytest/test_html_dependencies_funcs.py b/tests/pytest/test_html_dependencies_funcs.py new file mode 100644 index 000000000..18fae48f8 --- /dev/null +++ b/tests/pytest/test_html_dependencies_funcs.py @@ -0,0 +1,76 @@ +"""Tests for shiny.html_dependencies module.""" + +from htmltools import HTMLDependency + +from shiny.html_dependencies import jquery_deps, require_deps, shiny_deps + + +class TestShinyDeps: + """Tests for shiny_deps function.""" + + def test_shiny_deps_returns_list(self) -> None: + """Test shiny_deps returns a list.""" + result = shiny_deps() + assert isinstance(result, list) + assert len(result) >= 1 + + def test_shiny_deps_contains_html_dependencies(self) -> None: + """Test shiny_deps contains HTMLDependency objects.""" + result = shiny_deps() + assert all(isinstance(dep, HTMLDependency) for dep in result) + + def test_shiny_deps_with_css(self) -> None: + """Test shiny_deps with include_css=True (default).""" + result = shiny_deps(include_css=True) + assert len(result) >= 1 + + def test_shiny_deps_without_css(self) -> None: + """Test shiny_deps with include_css=False.""" + result = shiny_deps(include_css=False) + assert len(result) >= 1 + + def test_shiny_deps_has_shiny_dep(self) -> None: + """Test shiny_deps includes shiny dependency.""" + result = shiny_deps() + names = [dep.name for dep in result] + assert "shiny" in names + + +class TestJqueryDeps: + """Tests for jquery_deps function.""" + + def test_jquery_deps_returns_dependency(self) -> None: + """Test jquery_deps returns HTMLDependency.""" + result = jquery_deps() + assert isinstance(result, HTMLDependency) + + def test_jquery_deps_name(self) -> None: + """Test jquery_deps has correct name.""" + result = jquery_deps() + assert result.name == "jquery" + + def test_jquery_deps_has_version(self) -> None: + """Test jquery_deps has version.""" + result = jquery_deps() + assert result.version is not None + # Version is a Version object, convert to string to check + assert str(result.version).startswith("3") + + +class TestRequireDeps: + """Tests for require_deps function.""" + + def test_require_deps_returns_dependency(self) -> None: + """Test require_deps returns HTMLDependency.""" + result = require_deps() + assert isinstance(result, HTMLDependency) + + def test_require_deps_name(self) -> None: + """Test require_deps has correct name.""" + result = require_deps() + assert result.name == "requirejs" + + def test_require_deps_has_version(self) -> None: + """Test require_deps has version.""" + result = require_deps() + assert result.version is not None diff --git a/tests/pytest/test_html_deps.py b/tests/pytest/test_html_deps.py new file mode 100644 index 000000000..d8aaf70fb --- /dev/null +++ b/tests/pytest/test_html_deps.py @@ -0,0 +1,142 @@ +"""Tests for shiny.ui HTML dependencies.""" + +from htmltools import HTMLDependency + +from shiny.ui._html_deps_external import ( + bootstrap_deps, + datepicker_deps, + ionrangeslider_deps, + jqui_deps, + selectize_deps, +) +from shiny.ui._html_deps_shinyverse import ( + components_dependencies, + fill_dependency, +) + + +class TestBootstrapDeps: + """Tests for bootstrap_deps function.""" + + def test_bootstrap_deps_returns_list(self): + """Test bootstrap_deps returns a list.""" + deps = bootstrap_deps() + assert isinstance(deps, list) + assert len(deps) >= 2 # jQuery and Bootstrap + + def test_bootstrap_deps_with_css(self): + """Test bootstrap_deps includes CSS by default.""" + deps = bootstrap_deps(include_css=True) + bootstrap_dep = deps[1] # First is jQuery, second is Bootstrap + assert bootstrap_dep.stylesheet is not None + + def test_bootstrap_deps_without_css(self): + """Test bootstrap_deps can exclude CSS.""" + deps = bootstrap_deps(include_css=False) + bootstrap_dep = deps[1] + # Returns empty list when CSS is excluded + assert not bootstrap_dep.stylesheet + + +class TestIonRangeSliderDeps: + """Tests for ionrangeslider_deps function.""" + + def test_ionrangeslider_deps_returns_list(self): + """Test ionrangeslider_deps returns a list.""" + deps = ionrangeslider_deps() + assert isinstance(deps, list) + assert len(deps) == 2 # ionrangeslider and strftime + + def test_ionrangeslider_deps_with_css(self): + """Test ionrangeslider_deps includes CSS by default.""" + deps = ionrangeslider_deps(include_css=True) + assert deps[0].stylesheet is not None + + def test_ionrangeslider_deps_without_css(self): + """Test ionrangeslider_deps can exclude CSS.""" + deps = ionrangeslider_deps(include_css=False) + # Returns empty list when CSS is excluded + assert not deps[0].stylesheet + + +class TestDatepickerDeps: + """Tests for datepicker_deps function.""" + + def test_datepicker_deps_returns_dependency(self): + """Test datepicker_deps returns an HTMLDependency.""" + dep = datepicker_deps() + assert isinstance(dep, HTMLDependency) + assert dep.name == "bootstrap-datepicker" + + def test_datepicker_deps_with_css(self): + """Test datepicker_deps includes CSS by default.""" + dep = datepicker_deps(include_css=True) + assert dep.stylesheet is not None + + def test_datepicker_deps_without_css(self): + """Test datepicker_deps can exclude CSS.""" + dep = datepicker_deps(include_css=False) + # Returns empty list when CSS is excluded + assert not dep.stylesheet + + +class TestSelectizeDeps: + """Tests for selectize_deps function.""" + + def test_selectize_deps_returns_dependency(self): + """Test selectize_deps returns an HTMLDependency.""" + dep = selectize_deps() + assert isinstance(dep, HTMLDependency) + assert dep.name == "selectize" + + def test_selectize_deps_with_css(self): + """Test selectize_deps includes CSS by default.""" + dep = selectize_deps(include_css=True) + assert dep.stylesheet is not None + + def test_selectize_deps_without_css(self): + """Test selectize_deps can exclude CSS.""" + dep = selectize_deps(include_css=False) + # Returns empty list when CSS is excluded + assert not dep.stylesheet + + +class TestJquiDeps: + """Tests for jqui_deps function.""" + + def test_jqui_deps_returns_dependency(self): + """Test jqui_deps returns an HTMLDependency.""" + dep = jqui_deps() + assert isinstance(dep, HTMLDependency) + assert dep.name == "jquery-ui" + + +class TestFillDependency: + """Tests for fill_dependency function.""" + + def test_fill_dependency_returns_dependency(self): + """Test fill_dependency returns an HTMLDependency.""" + dep = fill_dependency() + assert isinstance(dep, HTMLDependency) + assert dep.name == "htmltools-fill" + + +class TestComponentsDependencies: + """Tests for components_dependencies function.""" + + def test_components_dependencies_returns_dependency(self): + """Test components_dependencies returns an HTMLDependency.""" + dep = components_dependencies() + assert isinstance(dep, HTMLDependency) + assert dep.name == "bslib-components" + + def test_components_dependencies_with_css(self): + """Test components_dependencies includes CSS by default.""" + dep = components_dependencies(include_css=True) + assert dep.stylesheet is not None + + def test_components_dependencies_without_css(self): + """Test components_dependencies can exclude CSS.""" + dep = components_dependencies(include_css=False) + # Returns empty list when CSS is excluded + assert not dep.stylesheet diff --git a/tests/pytest/test_html_deps_external_complete.py b/tests/pytest/test_html_deps_external_complete.py new file mode 100644 index 000000000..73a848dd4 --- /dev/null +++ b/tests/pytest/test_html_deps_external_complete.py @@ -0,0 +1,256 @@ +"""Comprehensive tests for shiny.ui._html_deps_external module.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from htmltools import HTMLDependency, Tag, TagList + +from shiny.ui._html_deps_external import ( + bootstrap_deps, + datepicker_deps, + ionrangeslider_deps, + jqui_deps, + selectize_deps, + shiny_page_theme_deps, +) +from shiny.ui._theme import Theme + + +class TestShinyPageThemeDeps: + """Tests for shiny_page_theme_deps function.""" + + def test_with_none_theme(self): + """Test shiny_page_theme_deps with None theme (default Bootstrap).""" + result = shiny_page_theme_deps(None) + assert isinstance(result, TagList) + # Should include Bootstrap CSS by default + assert result is not None + + def test_with_theme_object(self): + """Test shiny_page_theme_deps with Theme object.""" + theme = Theme() + result = shiny_page_theme_deps(theme) + assert isinstance(result, TagList) + + def test_with_http_url(self): + """Test shiny_page_theme_deps with HTTP URL.""" + result = shiny_page_theme_deps("http://example.com/theme.css") + assert isinstance(result, TagList) + + def test_with_https_url(self): + """Test shiny_page_theme_deps with HTTPS URL.""" + result = shiny_page_theme_deps("https://example.com/theme.css") + assert isinstance(result, TagList) + + def test_with_protocol_relative_url(self): + """Test shiny_page_theme_deps with protocol-relative URL.""" + result = shiny_page_theme_deps("//example.com/theme.css") + assert isinstance(result, TagList) + + def test_with_file_path(self, tmp_path: Path): + """Test shiny_page_theme_deps with file path.""" + path = tmp_path / "theme.css" + path.write_text("body { color: red; }", encoding="utf-8") + + result = shiny_page_theme_deps(str(path)) + assert isinstance(result, TagList) + + def test_with_path_object(self, tmp_path: Path): + """Test shiny_page_theme_deps with Path object.""" + path = tmp_path / "theme.css" + path.write_text("body { color: blue; }", encoding="utf-8") + + result = shiny_page_theme_deps(path) + assert isinstance(result, TagList) + + def test_with_tagifiable(self): + """Test shiny_page_theme_deps with Tagifiable object.""" + tag = Tag("style", "body { color: green; }") + result = shiny_page_theme_deps(tag) + assert isinstance(result, TagList) + + def test_with_html_dependency(self): + """Test shiny_page_theme_deps with single HTMLDependency.""" + dep = HTMLDependency( + name="test-theme", + version="1.0.0", + source={"package": "shiny", "subdir": "www"}, + ) + result = shiny_page_theme_deps(dep) + assert isinstance(result, TagList) + + def test_with_html_dependency_list(self): + """Test shiny_page_theme_deps with list of HTMLDependency.""" + deps = [ + HTMLDependency( + name="test-theme1", + version="1.0.0", + source={"package": "shiny", "subdir": "www"}, + ), + HTMLDependency( + name="test-theme2", + version="2.0.0", + source={"package": "shiny", "subdir": "www"}, + ), + ] + result = shiny_page_theme_deps(deps) + assert isinstance(result, TagList) + + def test_with_invalid_theme_type(self): + """Test shiny_page_theme_deps with invalid theme type.""" + with pytest.raises(ValueError, match="Invalid `theme`"): + shiny_page_theme_deps(12345) # type: ignore + + def test_with_invalid_path(self): + """Test shiny_page_theme_deps with non-existent file path.""" + with pytest.raises(RuntimeError, match="does not exist"): + shiny_page_theme_deps("/non/existent/path/theme.css") + + def test_includes_bootstrap_deps(self): + """Test that result includes bootstrap dependencies.""" + result = shiny_page_theme_deps(None) + # TagList should contain multiple dependencies + assert len(result) > 0 + + def test_includes_component_deps(self): + """Test that result includes component dependencies.""" + result = shiny_page_theme_deps(None) + # Should include various component dependencies + assert isinstance(result, TagList) + + +class TestJquiDeps: + """Tests for jqui_deps function.""" + + def test_jqui_deps_returns_dependency(self): + """jqui_deps should return an HTMLDependency.""" + result = jqui_deps() + assert isinstance(result, HTMLDependency) + + def test_jqui_deps_has_correct_name(self): + """jqui_deps should have name 'jquery-ui'.""" + result = jqui_deps() + assert result.name == "jquery-ui" + + def test_jqui_deps_has_correct_version(self): + """jqui_deps should have version '1.12.1'.""" + result = jqui_deps() + assert str(result.version) == "1.12.1" + + def test_jqui_deps_has_script(self): + """jqui_deps should include script.""" + result = jqui_deps() + assert result.script is not None + + def test_jqui_deps_has_stylesheet(self): + """jqui_deps should include stylesheet.""" + result = jqui_deps() + assert result.stylesheet is not None + + +class TestIonrangesliderDepsComplete: + """Additional tests for ionrangeslider_deps.""" + + def test_with_css_true(self): + """Test ionrangeslider_deps with include_css=True.""" + result = ionrangeslider_deps(include_css=True) + assert isinstance(result, list) + assert len(result) == 2 + + def test_with_css_false(self): + """Test ionrangeslider_deps with include_css=False.""" + result = ionrangeslider_deps(include_css=False) + assert isinstance(result, list) + assert len(result) == 2 + + def test_strftime_version(self): + """Test strftime dependency has correct version.""" + result = ionrangeslider_deps() + strftime_dep = next(dep for dep in result if dep.name == "strftime") + assert str(strftime_dep.version) == "0.9.2" + + +class TestDatepickerDepsComplete: + """Additional tests for datepicker_deps.""" + + def test_has_correct_name(self): + """datepicker_deps should have name 'bootstrap-datepicker'.""" + result = datepicker_deps() + assert result.name == "bootstrap-datepicker" + + def test_has_correct_version(self): + """datepicker_deps should have version '1.9.0'.""" + result = datepicker_deps() + assert str(result.version) == "1.9.0" + + def test_with_css_true(self): + """datepicker_deps should work with include_css=True.""" + result = datepicker_deps(include_css=True) + assert isinstance(result, HTMLDependency) + + def test_with_css_false(self): + """datepicker_deps should work with include_css=False.""" + result = datepicker_deps(include_css=False) + assert isinstance(result, HTMLDependency) + + def test_has_no_conflict_script(self): + """datepicker_deps should include noConflict script in head.""" + result = datepicker_deps() + assert result.head is not None + # head can be HTML or TagList, both are valid + + +class TestSelectizeDepsComplete: + """Additional tests for selectize_deps.""" + + def test_selectize_deps_has_correct_name(self): + """selectize_deps should have name 'selectize'.""" + result = selectize_deps() + assert result.name == "selectize" + + def test_selectize_deps_has_correct_version(self): + """selectize_deps should have version '0.12.6'.""" + result = selectize_deps() + assert str(result.version) == "0.12.6" + + def test_selectize_deps_with_css_true(self): + """selectize_deps should work with include_css=True.""" + result = selectize_deps(include_css=True) + assert isinstance(result, HTMLDependency) + + def test_selectize_deps_with_css_false(self): + """selectize_deps should work with include_css=False.""" + result = selectize_deps(include_css=False) + assert isinstance(result, HTMLDependency) + + def test_selectize_deps_has_multiple_scripts(self): + """selectize_deps should include multiple script files.""" + result = selectize_deps() + assert result.script is not None + # Should have main script and accessibility plugin + assert isinstance(result.script, list) + assert len(result.script) == 2 + + +class TestBootstrapDepsComplete: + """Additional tests for bootstrap_deps.""" + + def test_has_viewport_meta(self): + """bootstrap_deps should include viewport meta tag.""" + result = bootstrap_deps() + bootstrap_dep = next(dep for dep in result if dep.name == "bootstrap") + assert bootstrap_dep.meta is not None + + def test_all_files_included(self): + """bootstrap_deps should set all_files to True.""" + result = bootstrap_deps() + bootstrap_dep = next(dep for dep in result if dep.name == "bootstrap") + assert bootstrap_dep.all_files is True + + def test_bootstrap_has_script(self): + """bootstrap_deps should include bootstrap.bundle.min.js.""" + result = bootstrap_deps() + bootstrap_dep = next(dep for dep in result if dep.name == "bootstrap") + assert bootstrap_dep.script is not None diff --git a/tests/pytest/test_html_deps_external_funcs.py b/tests/pytest/test_html_deps_external_funcs.py new file mode 100644 index 000000000..d3051a88f --- /dev/null +++ b/tests/pytest/test_html_deps_external_funcs.py @@ -0,0 +1,168 @@ +"""Tests for shiny.ui._html_deps_external module.""" + +from htmltools import HTMLDependency + +from shiny._versions import bootstrap as bootstrap_version +from shiny.ui._html_deps_external import ( + bootstrap_deps, + datepicker_deps, + ionrangeslider_deps, + jqui_deps, + selectize_deps, +) + + +class TestBootstrapDeps: + """Tests for bootstrap_deps function.""" + + def test_bootstrap_deps_returns_list(self): + """bootstrap_deps should return a list.""" + result = bootstrap_deps() + assert isinstance(result, list) + + def test_bootstrap_deps_contains_dependencies(self): + """bootstrap_deps should contain HTMLDependency objects.""" + result = bootstrap_deps() + for dep in result: + assert isinstance(dep, HTMLDependency) + + def test_bootstrap_deps_includes_jquery(self): + """bootstrap_deps should include jQuery dependency.""" + result = bootstrap_deps() + dep_names = [dep.name for dep in result] + assert "jquery" in dep_names + + def test_bootstrap_deps_includes_bootstrap(self): + """bootstrap_deps should include bootstrap dependency.""" + result = bootstrap_deps() + dep_names = [dep.name for dep in result] + assert "bootstrap" in dep_names + + def test_bootstrap_deps_has_correct_version(self): + """bootstrap_deps should have correct bootstrap version.""" + result = bootstrap_deps() + bootstrap_dep = next(dep for dep in result if dep.name == "bootstrap") + assert str(bootstrap_dep.version) == bootstrap_version + + def test_bootstrap_deps_with_css(self): + """bootstrap_deps should include CSS by default.""" + result = bootstrap_deps(include_css=True) + bootstrap_dep = next(dep for dep in result if dep.name == "bootstrap") + # The dependency should have stylesheet + assert bootstrap_dep is not None + + def test_bootstrap_deps_without_css(self): + """bootstrap_deps should work without CSS.""" + result = bootstrap_deps(include_css=False) + # Should still have dependencies + assert len(result) >= 2 + + +class TestIonrangesliderDeps: + """Tests for ionrangeslider_deps function.""" + + def test_ionrangeslider_deps_returns_list(self): + """ionrangeslider_deps should return a list.""" + result = ionrangeslider_deps() + assert isinstance(result, list) + + def test_ionrangeslider_deps_contains_dependencies(self): + """ionrangeslider_deps should contain HTMLDependency objects.""" + result = ionrangeslider_deps() + for dep in result: + assert isinstance(dep, HTMLDependency) + + def test_ionrangeslider_deps_includes_ionrangeslider(self): + """ionrangeslider_deps should include ionrangeslider dependency.""" + result = ionrangeslider_deps() + dep_names = [dep.name for dep in result] + assert "ionrangeslider" in dep_names + + def test_ionrangeslider_deps_includes_strftime(self): + """ionrangeslider_deps should include strftime dependency.""" + result = ionrangeslider_deps() + dep_names = [dep.name for dep in result] + assert "strftime" in dep_names + + def test_ionrangeslider_deps_has_correct_version(self): + """ionrangeslider_deps should have correct version.""" + result = ionrangeslider_deps() + ion_dep = next(dep for dep in result if dep.name == "ionrangeslider") + assert str(ion_dep.version) == "2.3.1" + + +class TestDatepickerDeps: + """Tests for datepicker_deps function.""" + + def test_datepicker_deps_returns_dependency(self): + """datepicker_deps should return an HTMLDependency.""" + result = datepicker_deps() + assert isinstance(result, HTMLDependency) + + def test_datepicker_deps_has_correct_name(self): + """datepicker_deps should have correct name.""" + result = datepicker_deps() + assert result.name == "bootstrap-datepicker" + + def test_datepicker_deps_has_correct_version(self): + """datepicker_deps should have correct version.""" + result = datepicker_deps() + assert str(result.version) == "1.9.0" + + def test_datepicker_deps_with_css(self): + """datepicker_deps should include CSS by default.""" + result = datepicker_deps(include_css=True) + assert result is not None + + def test_datepicker_deps_without_css(self): + """datepicker_deps should work without CSS.""" + result = datepicker_deps(include_css=False) + assert result is not None + + +class TestSelectizeDeps: + """Tests for selectize_deps function.""" + + def test_selectize_deps_returns_dependency(self): + """selectize_deps should return an HTMLDependency.""" + result = selectize_deps() + assert isinstance(result, HTMLDependency) + + def test_selectize_deps_has_correct_name(self): + """selectize_deps should have correct name.""" + result = selectize_deps() + assert result.name == "selectize" + + def test_selectize_deps_has_correct_version(self): + """selectize_deps should have correct version.""" + result = selectize_deps() + assert str(result.version) == "0.12.6" + + def test_selectize_deps_with_css(self): + """selectize_deps should include CSS by default.""" + result = selectize_deps(include_css=True) + assert result is not None + + def test_selectize_deps_without_css(self): + """selectize_deps should work without CSS.""" + result = selectize_deps(include_css=False) + assert result is not None + + +class TestJquiDeps: + """Tests for jqui_deps function.""" + + def test_jqui_deps_returns_dependency(self): + """jqui_deps should return an HTMLDependency.""" + result = jqui_deps() + assert isinstance(result, HTMLDependency) + + def test_jqui_deps_has_correct_name(self): + """jqui_deps should have correct name.""" + result = jqui_deps() + assert result.name == "jquery-ui" + + def test_jqui_deps_has_correct_version(self): + """jqui_deps should have correct version.""" + result = jqui_deps() + assert str(result.version) == "1.12.1" diff --git a/tests/pytest/test_html_deps_funcs.py b/tests/pytest/test_html_deps_funcs.py new file mode 100644 index 000000000..1427568fd --- /dev/null +++ b/tests/pytest/test_html_deps_funcs.py @@ -0,0 +1,80 @@ +"""Tests for shiny.html_dependencies module.""" + +from htmltools import HTMLDependency + +from shiny.html_dependencies import jquery_deps, require_deps, shiny_deps + + +class TestShinyDeps: + """Tests for shiny_deps function.""" + + def test_shiny_deps_returns_list(self): + """Test shiny_deps returns a list of dependencies.""" + deps = shiny_deps() + assert isinstance(deps, list) + assert len(deps) >= 1 + + def test_shiny_deps_contains_html_dependency(self): + """Test shiny_deps contains HTMLDependency objects.""" + deps = shiny_deps() + for dep in deps: + assert isinstance(dep, HTMLDependency) + + def test_shiny_deps_has_shiny_dependency(self): + """Test shiny_deps includes the shiny dependency.""" + deps = shiny_deps() + names = [dep.name for dep in deps] + assert "shiny" in names + + def test_shiny_deps_with_css(self): + """Test shiny_deps includes CSS by default.""" + deps = shiny_deps(include_css=True) + shiny_dep = next(dep for dep in deps if dep.name == "shiny") + assert shiny_dep.stylesheet is not None + + def test_shiny_deps_without_css(self): + """Test shiny_deps can exclude CSS.""" + deps = shiny_deps(include_css=False) + shiny_dep = next(dep for dep in deps if dep.name == "shiny") + # Returns empty list when CSS is excluded + assert not shiny_dep.stylesheet + + +class TestJqueryDeps: + """Tests for jquery_deps function.""" + + def test_jquery_deps_returns_dependency(self): + """Test jquery_deps returns an HTMLDependency.""" + dep = jquery_deps() + assert isinstance(dep, HTMLDependency) + + def test_jquery_deps_name(self): + """Test jquery_deps has correct name.""" + dep = jquery_deps() + assert dep.name == "jquery" + + def test_jquery_deps_version(self): + """Test jquery_deps has a version.""" + dep = jquery_deps() + assert dep.version is not None + assert str(dep.version) != "" + + +class TestRequireDeps: + """Tests for require_deps function.""" + + def test_require_deps_returns_dependency(self): + """Test require_deps returns an HTMLDependency.""" + dep = require_deps() + assert isinstance(dep, HTMLDependency) + + def test_require_deps_name(self): + """Test require_deps has correct name.""" + dep = require_deps() + assert dep.name == "requirejs" + + def test_require_deps_version(self): + """Test require_deps has a version.""" + dep = require_deps() + assert dep.version is not None + assert str(dep.version) != "" diff --git a/tests/pytest/test_html_deps_py_shiny_funcs.py b/tests/pytest/test_html_deps_py_shiny_funcs.py new file mode 100644 index 000000000..f457c813a --- /dev/null +++ b/tests/pytest/test_html_deps_py_shiny_funcs.py @@ -0,0 +1,88 @@ +"""Tests for shiny.ui._html_deps_py_shiny module""" + +from htmltools import HTMLDependency + +from shiny import __version__ +from shiny.ui._html_deps_py_shiny import ( + busy_indicators_dep, + data_frame_deps, + page_output_dependency, + spin_dependency, +) + + +class TestDataFrameDeps: + """Test data_frame_deps function""" + + def test_returns_html_dependency(self): + """Test data_frame_deps returns HTMLDependency""" + result = data_frame_deps() + assert isinstance(result, HTMLDependency) + + def test_has_correct_name(self): + """Test data_frame_deps has correct name""" + result = data_frame_deps() + assert result.name == "shiny-data-frame-output" + + def test_uses_shiny_version(self): + """Test data_frame_deps uses shiny __version__""" + result = data_frame_deps() + # version might be a Version object + assert str(result.version) == __version__ + + +class TestPageOutputDependency: + """Test page_output_dependency function""" + + def test_returns_html_dependency(self): + """Test page_output_dependency returns HTMLDependency""" + result = page_output_dependency() + assert isinstance(result, HTMLDependency) + + def test_has_correct_name(self): + """Test page_output_dependency has correct name""" + result = page_output_dependency() + assert result.name == "shiny-page-output" + + def test_uses_shiny_version(self): + """Test page_output_dependency uses shiny __version__""" + result = page_output_dependency() + assert str(result.version) == __version__ + + +class TestSpinDependency: + """Test spin_dependency function""" + + def test_returns_html_dependency(self): + """Test spin_dependency returns HTMLDependency""" + result = spin_dependency() + assert isinstance(result, HTMLDependency) + + def test_has_correct_name(self): + """Test spin_dependency has correct name""" + result = spin_dependency() + assert result.name == "shiny-spin" + + def test_uses_shiny_version(self): + """Test spin_dependency uses shiny __version__""" + result = spin_dependency() + assert str(result.version) == __version__ + + +class TestBusyIndicatorsDep: + """Test busy_indicators_dep function""" + + def test_returns_html_dependency(self): + """Test busy_indicators_dep returns HTMLDependency""" + result = busy_indicators_dep() + assert isinstance(result, HTMLDependency) + + def test_has_correct_name(self): + """Test busy_indicators_dep has correct name""" + result = busy_indicators_dep() + assert result.name == "shiny-busy-indicators" + + def test_uses_shiny_version(self): + """Test busy_indicators_dep uses shiny __version__""" + result = busy_indicators_dep() + assert str(result.version) == __version__ diff --git a/tests/pytest/test_html_deps_shinyverse_complete.py b/tests/pytest/test_html_deps_shinyverse_complete.py new file mode 100644 index 000000000..51fd41f14 --- /dev/null +++ b/tests/pytest/test_html_deps_shinyverse_complete.py @@ -0,0 +1,184 @@ +"""Comprehensive tests for shiny.ui._html_deps_shinyverse module.""" + +from __future__ import annotations + +from htmltools import HTMLDependency + +from shiny._versions import bslib as bslib_version +from shiny._versions import htmltools as htmltools_version +from shiny.ui._html_deps_shinyverse import components_dependencies, fill_dependency + + +class TestFillDependencyComplete: + """Comprehensive tests for fill_dependency function.""" + + def test_returns_html_dependency(self): + """fill_dependency should return an HTMLDependency object.""" + result = fill_dependency() + assert isinstance(result, HTMLDependency) + + def test_has_correct_name(self): + """fill_dependency should have name 'htmltools-fill'.""" + result = fill_dependency() + assert result.name == "htmltools-fill" + + def test_has_correct_version(self): + """fill_dependency should use htmltools version.""" + result = fill_dependency() + assert str(result.version) == htmltools_version + + def test_has_source_package(self): + """fill_dependency should have source package 'shiny'.""" + result = fill_dependency() + assert result.source is not None + assert result.source.get("package") == "shiny" + + def test_has_source_subdir(self): + """fill_dependency should have correct source subdir.""" + result = fill_dependency() + assert result.source is not None + assert "htmltools" in result.source.get("subdir", "") + assert "fill" in result.source.get("subdir", "") + + def test_has_stylesheet(self): + """fill_dependency should include fill.css stylesheet.""" + result = fill_dependency() + assert result.stylesheet is not None + + def test_is_reusable(self): + """fill_dependency should be callable multiple times.""" + result1 = fill_dependency() + result2 = fill_dependency() + assert result1.name == result2.name + assert result1.version == result2.version + + +class TestComponentsDependenciesComplete: + """Comprehensive tests for components_dependencies function.""" + + def test_returns_html_dependency(self): + """components_dependencies should return an HTMLDependency object.""" + result = components_dependencies() + assert isinstance(result, HTMLDependency) + + def test_has_correct_name(self): + """components_dependencies should have name 'bslib-components'.""" + result = components_dependencies() + assert result.name == "bslib-components" + + def test_has_correct_version(self): + """components_dependencies should use bslib version.""" + result = components_dependencies() + assert str(result.version) == bslib_version + + def test_has_source_package(self): + """components_dependencies should have source package 'shiny'.""" + result = components_dependencies() + assert result.source is not None + assert result.source.get("package") == "shiny" + + def test_has_source_subdir(self): + """components_dependencies should have correct source subdir.""" + result = components_dependencies() + assert result.source is not None + assert "bslib" in result.source.get("subdir", "") + assert "components" in result.source.get("subdir", "") + + def test_has_scripts(self): + """components_dependencies should include JavaScript files.""" + result = components_dependencies() + assert result.script is not None + assert isinstance(result.script, list) + # Should have at least 2 scripts + assert len(result.script) >= 2 + + def test_has_components_script(self): + """components_dependencies should include components.min.js.""" + result = components_dependencies() + assert result.script is not None + script_sources = [ + s.get("src", "") if isinstance(s, dict) else s for s in result.script + ] + assert any("components.min.js" in src for src in script_sources) + + def test_has_web_components_script(self): + """components_dependencies should include web-components.min.js.""" + result = components_dependencies() + assert result.script is not None + script_sources = [ + s.get("src", "") if isinstance(s, dict) else s for s in result.script + ] + assert any("web-components.min.js" in src for src in script_sources) + + def test_web_components_is_module(self): + """web-components.min.js should be loaded as ES module.""" + result = components_dependencies() + assert result.script is not None + # Find web-components script and check if it has type="module" + web_comp_script = next( + ( + s + for s in result.script + if isinstance(s, dict) and "web-components.min.js" in s.get("src", "") + ), + None, + ) + assert web_comp_script is not None + assert web_comp_script.get("type") == "module" + + def test_include_css_default_true(self): + """components_dependencies should include CSS by default.""" + result = components_dependencies() + # Default behavior includes CSS + assert result is not None + + def test_include_css_explicit_true(self): + """components_dependencies should include CSS when explicitly True.""" + result = components_dependencies(include_css=True) + assert result is not None + # Should have stylesheet when include_css=True + + def test_include_css_false(self): + """components_dependencies should work without CSS.""" + result = components_dependencies(include_css=False) + assert result is not None + assert isinstance(result, HTMLDependency) + + def test_is_reusable(self): + """components_dependencies should be callable multiple times.""" + result1 = components_dependencies() + result2 = components_dependencies() + assert result1.name == result2.name + assert result1.version == result2.version + + def test_different_css_settings(self): + """components_dependencies with different CSS settings should work.""" + result_with_css = components_dependencies(include_css=True) + result_without_css = components_dependencies(include_css=False) + # Both should return valid dependencies + assert isinstance(result_with_css, HTMLDependency) + assert isinstance(result_without_css, HTMLDependency) + # Same name and version + assert result_with_css.name == result_without_css.name + assert result_with_css.version == result_without_css.version + + +class TestModuleConstants: + """Tests for module-level constants and paths.""" + + def test_module_imports_correctly(self): + """Module should import correctly.""" + from shiny.ui import _html_deps_shinyverse + + assert _html_deps_shinyverse is not None + # Module exists and is importable + + def test_all_functions_importable(self): + """All public functions should be importable.""" + from shiny.ui._html_deps_shinyverse import ( + components_dependencies, + fill_dependency, + ) + + assert callable(fill_dependency) + assert callable(components_dependencies) diff --git a/tests/pytest/test_html_deps_shinyverse_funcs.py b/tests/pytest/test_html_deps_shinyverse_funcs.py new file mode 100644 index 000000000..e68ddce78 --- /dev/null +++ b/tests/pytest/test_html_deps_shinyverse_funcs.py @@ -0,0 +1,62 @@ +"""Tests for shiny.ui._html_deps_shinyverse module""" + +from htmltools import HTMLDependency + +from shiny._versions import bslib as bslib_version +from shiny._versions import htmltools as htmltools_version +from shiny.ui._html_deps_shinyverse import components_dependencies, fill_dependency + + +class TestFillDependency: + """Test fill_dependency function""" + + def test_returns_html_dependency(self): + """Test fill_dependency returns HTMLDependency""" + result = fill_dependency() + assert isinstance(result, HTMLDependency) + + def test_has_correct_name(self): + """Test fill_dependency has correct name""" + result = fill_dependency() + assert result.name == "htmltools-fill" + + def test_uses_htmltools_version(self): + """Test fill_dependency uses htmltools version""" + result = fill_dependency() + assert str(result.version) == htmltools_version + + +class TestComponentsDependencies: + """Test components_dependencies function""" + + def test_returns_html_dependency(self): + """Test components_dependencies returns HTMLDependency""" + result = components_dependencies() + assert isinstance(result, HTMLDependency) + + def test_has_correct_name(self): + """Test components_dependencies has correct name""" + result = components_dependencies() + assert result.name == "bslib-components" + + def test_uses_bslib_version(self): + """Test components_dependencies uses bslib version""" + result = components_dependencies() + assert str(result.version) == bslib_version + + def test_include_css_default(self): + """Test components_dependencies includes CSS by default""" + result = components_dependencies() + # Should have stylesheet when include_css is True (default) + assert result is not None + + def test_include_css_false(self): + """Test components_dependencies without CSS""" + result = components_dependencies(include_css=False) + assert result is not None + # Should not include CSS stylesheet when include_css=False + + def test_include_css_true(self): + """Test components_dependencies with explicit include_css=True""" + result = components_dependencies(include_css=True) + assert result is not None diff --git a/tests/pytest/test_http_staticfiles.py b/tests/pytest/test_http_staticfiles.py new file mode 100644 index 000000000..205c7b631 --- /dev/null +++ b/tests/pytest/test_http_staticfiles.py @@ -0,0 +1,72 @@ +"""Tests for shiny.http_staticfiles module.""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path +from types import ModuleType + +import pytest +from starlette.responses import Response + + +def test_staticfiles_native_branch( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + if "pyodide" in sys.modules: + monkeypatch.delitem(sys.modules, "pyodide", raising=False) + + mod = importlib.reload(importlib.import_module("shiny.http_staticfiles")) + + class FakeResponse(Response): + def __init__(self): + super().__init__("ok") + self.headers["content-type"] = "text/plain" + self.media_type = "text/plain" + + def fake_file_response( + self: object, full_path: Path, *args: object, **kwargs: object + ) -> Response: + return FakeResponse() + + monkeypatch.setattr( + mod.starlette.staticfiles.StaticFiles, + "file_response", + fake_file_response, + ) + + def fake_guess_mime_type(*_: object) -> str: + return "text/javascript" + + monkeypatch.setattr( + "shiny.http_staticfiles._utils.guess_mime_type", fake_guess_mime_type + ) + + sf = mod.StaticFiles(directory=tmp_path) + resp = sf.file_response(tmp_path / "file.js") + assert resp.headers["content-type"].startswith("text/javascript") + + +def test_staticfiles_wasm_branch( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + sys.modules["pyodide"] = ModuleType("pyodide") + mod = importlib.reload(importlib.import_module("shiny.http_staticfiles")) + + file_path = tmp_path / "test.txt" + file_path.write_text("data") + + final, trailing = mod._traverse_url_path(tmp_path, ["test.txt"]) # type: ignore[attr-defined] + assert final == file_path + assert trailing is False + + bad_final, _ = mod._traverse_url_path(tmp_path, [".."]) + assert bad_final is None + + headers = mod._convert_headers({"X": "Y"}, "text/plain") + assert (b"X", b"Y") in headers + + # Cleanup + monkeypatch.delitem(sys.modules, "pyodide", raising=False) + importlib.reload(importlib.import_module("shiny.http_staticfiles")) diff --git a/tests/pytest/test_include_helpers.py b/tests/pytest/test_include_helpers.py new file mode 100644 index 000000000..fe735cd0f --- /dev/null +++ b/tests/pytest/test_include_helpers.py @@ -0,0 +1,272 @@ +"""Tests for shiny/ui/_include_helpers.py - Include helpers for JS/CSS.""" + +import os +import tempfile +from pathlib import Path + +import pytest + +from shiny.ui._include_helpers import ( + check_path, + get_file_key, + get_hash, + hash_deterministic, + include_css, + include_js, + read_utf8, +) + + +class TestIncludeJs: + """Tests for include_js function.""" + + def test_include_js_inline(self): + """Test include_js with inline method.""" + with tempfile.TemporaryDirectory() as tmpdir: + js_file = os.path.join(tmpdir, "test.js") + with open(js_file, "w") as f: + f.write('console.log("hello");') + + tag = include_js(js_file, method="inline") + html = str(tag) + assert "