diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c3ac7a..a34b215 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,107 +2,90 @@ name: Tests on: push: - branches: [ main, master ] + branches: [main] pull_request: - branches: [ main, master ] + branches: [main] jobs: - test-unit: + test: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v5 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e . - pip install -r requirements-test.txt - - - name: Run unit tests - run: | - pytest tests/test_plugin.py tests/test_utils.py tests/test_cli.py tests/test_integration.py tests/test_dash_simple.py - - test-browser: - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] - - steps: - - uses: actions/checkout@v5 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e . - pip install -r requirements-test.txt - - - name: Install browser for testing (Ubuntu) - if: matrix.os == 'ubuntu-latest' - run: | - sudo apt-get update - sudo apt-get install -y chromium-browser chromium-chromedriver xvfb - - - name: Install browser for testing (macOS) - if: matrix.os == 'macos-latest' - run: | - brew install --cask chromium chromedriver - - - name: Install browser for testing (Windows) - if: matrix.os == 'windows-latest' - run: | - choco install chromium chromedriver - - - name: Start virtual display (Ubuntu) - if: matrix.os == 'ubuntu-latest' - run: | - export DISPLAY=:99 - Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & - - - name: Run browser tests - run: | - pytest tests/test_dash_callbacks.py tests/test_dash_integration.py - env: - DISPLAY: :99 + - uses: actions/checkout@v5 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install -r requirements-dev.txt + + - name: Install browser for testing (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y chromium-browser chromium-chromedriver xvfb + + - name: Install browser for testing (macOS) + if: matrix.os == 'macos-latest' + run: | + brew install --cask chromium chromedriver + + - name: Install browser for testing (Windows) + if: matrix.os == 'windows-latest' + run: | + choco install chromium chromedriver + + - name: Start virtual display (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: | + export DISPLAY=:99 + Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & + + - name: Run tests + run: | + python -m pytest tests/ -v --cov=dash_tailwindcss_plugin --cov-report=xml + env: + DISPLAY: :99 + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella lint-format: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e . - pip install ruff - - - name: Run linting - run: | - ruff check . - - - name: Run format check - run: | - ruff format . --check \ No newline at end of file + - uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install ruff + + - name: Run linting + run: | + ruff check . + + - name: Run format check + run: | + ruff format . --check diff --git a/README-zh_CN.md b/README-zh_CN.md index 185a462..06edfa7 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -1,13 +1,16 @@ -# Dash TailwindCSS 插件 +# Dash TailwindCSS Plugin -[](https://github.com/HogaStack/dash-tailwindcss-plugin/blob/main/LICENSE) -[](https://pypi.org/project/dash-tailwindcss-plugin/) +一个用于将 TailwindCSS 集成到 Plotly Dash 应用程序中的插件,使用 Dash 3.x hooks。支持 Tailwind CSS v3 和 v4。 + +[](https://github.com/HogaStack/dash-tailwindcss-plugin/actions) +[](https://codecov.io/gh/HogaStack/dash-tailwindcss-plugin) +[](https://pypi.org/project/dash-tailwindcss-plugin/) +[](https://pypi.org/project/dash-tailwindcss-plugin/) [](https://github.com/astral-sh/ruff) +[](https://github.com/HogaStack/dash-tailwindcss-plugin/blob/main/LICENSE) 简体中文 | [English](./README.md) -一个用于将 TailwindCSS 集成到 Plotly Dash 应用程序中的插件,使用 Dash 3.x hooks。支持 Tailwind CSS v3 和 v4。 - ## 功能特性 1. **在线模式**:使用 Tailwind CSS CDN 快速设置 @@ -34,16 +37,10 @@ pip install dash-tailwindcss-plugin pip install -e . ``` -用于开发和测试依赖: +用于开发和所有依赖(包括测试依赖): ```bash -pip install -e .[test] -``` - -用于开发和所有依赖: - -```bash -pip install -e .[dev,test] +pip install -e .[dev] ``` ## 使用方法 @@ -231,23 +228,24 @@ if __name__ == "__main__": ```bash dash-tailwindcss-plugin/ +├── .github/ +│ └── workflows/ +│ └── test.yml # GitHub Actions 测试工作流 ├── dash_tailwindcss_plugin/ │ ├── __init__.py # 导出主插件函数 │ ├── plugin.py # 主插件实现,包含 _TailwindCSSPlugin 类 │ ├── cli.py # 命令行界面,包含 _TailwindCLI 类 │ └── utils.py # Node.js 管理、文件操作等实用函数 ├── tests/ -│ ├── test_plugin.py # 插件核心功能的单元测试 -│ ├── test_utils.py # 实用函数的单元测试 +│ ├── README.md # 英文测试文档 +│ ├── README-zh_CN.md # 中文测试文档 +│ ├── conftest.py # Pytest 配置夹具 │ ├── test_cli.py # CLI 接口的单元测试 -│ ├── test_integration.py # 构建过程的集成测试 -│ ├── test_dash_simple.py # 简单的 Dash 集成测试(无需浏览器) -│ ├── test_dash_integration.py # Dash 端到端集成测试(需要浏览器自动化) -│ └── test_dash_callbacks.py # Dash 回调和布局测试 +│ ├── test_dash_integration.py # Dash 端到端集成测试 +│ ├── test_plugin.py # 插件核心功能的单元测试 +│ └── test_utils.py # 实用函数的单元测试 ├── example_app.py # 示例 Dash 应用 -├── requirements.txt # 运行时依赖 -├── requirements-test.txt # 测试依赖 -├── setup.py # 安装脚本 +├── requirements-dev.txt # 开发和测试依赖 ├── pyproject.toml # 构建配置 ├── pytest.ini # Pytest 配置 ├── ruff.toml # Ruff 配置(代码检查) @@ -304,47 +302,24 @@ dash-tailwindcss-plugin/ ## 开发 1. 克隆仓库 -2. 安装依赖:`pip install -r requirements.txt` -3. 安装测试依赖:`pip install -r requirements-test.txt` -4. 以开发模式安装:`pip install -e .` -5. 运行示例:`python example_app.py` - -### 可选依赖 - -用于开发和测试依赖: - -```bash -pip install -e .[test] -``` - -用于开发和所有依赖: - -```bash -pip install -e .[dev,test] -``` +2. 安装开发依赖:`pip install -r requirements-dev.txt` +3. 以开发模式安装:`pip install -e .` +4. 运行示例:`python example_app.py` ## 运行测试 -该项目使用分层测试方法: - -1. **基础测试** - 无需浏览器自动化运行(推荐用于大多数情况) -2. **高级测试** - 需要浏览器自动化进行端到端测试 - ```bash -# 安装测试依赖 -pip install -r requirements-test.txt - -# 运行基础测试(无需浏览器自动化) -python -m pytest tests/test_plugin.py tests/test_utils.py tests/test_cli.py tests/test_integration.py tests/test_dash_simple.py +# 安装开发依赖(包含测试依赖) +pip install -r requirements-dev.txt -# 运行所有测试(包括需要浏览器自动化的测试) +# 运行所有测试 python -m pytest tests/ # 运行特定测试文件 python -m pytest tests/test_plugin.py python -m pytest tests/test_utils.py python -m pytest tests/test_cli.py -python -m pytest tests/test_dash_simple.py +python -m pytest tests/test_dash_integration.py # 运行带详细输出的测试 python -m pytest tests/ -v @@ -353,7 +328,7 @@ python -m pytest tests/ -v python -m pytest tests/ --cov=dash_tailwindcss_plugin --cov-report=html ``` -更多信息请参见 [tests/README.md](tests/README.md)。 +更多信息请参见 [tests/README-zh_CN.md](tests/README-zh_CN.md)。 ## 构建包 diff --git a/README.md b/README.md index 9bf11d6..b3ecb48 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,16 @@ # Dash TailwindCSS Plugin -[](https://github.com/HogaStack/dash-tailwindcss-plugin/blob/main/LICENSE) -[](https://pypi.org/project/dash-tailwindcss-plugin/) +A plugin for integrating TailwindCSS with Plotly Dash applications using Dash 3.x hooks. Supports both Tailwind CSS v3 and v4. + +[](https://github.com/HogaStack/dash-tailwindcss-plugin/actions) +[](https://codecov.io/gh/HogaStack/dash-tailwindcss-plugin) +[](https://pypi.org/project/dash-tailwindcss-plugin/) +[](https://pypi.org/project/dash-tailwindcss-plugin/) [](https://github.com/astral-sh/ruff) +[](https://github.com/HogaStack/dash-tailwindcss-plugin/blob/main/LICENSE) English | [简体中文](./README-zh_CN.md) -A plugin for integrating TailwindCSS with Plotly Dash applications using Dash 3.x hooks. Supports both Tailwind CSS v3 and v4. - ## Features 1. **Online Mode**: Uses Tailwind CSS CDN for quick setup @@ -34,16 +37,10 @@ Or for development: pip install -e . ``` -For development with test dependencies: +For development with all dependencies (including test dependencies): ```bash -pip install -e .[test] -``` - -For development with both development and test dependencies: - -```bash -pip install -e .[dev,test] +pip install -e .[dev] ``` ## Usage @@ -231,23 +228,24 @@ if __name__ == "__main__": ```bash dash-tailwindcss-plugin/ +├── .github/ +│ └── workflows/ +│ └── test.yml # GitHub Actions workflow for testing ├── dash_tailwindcss_plugin/ │ ├── __init__.py # Exports main plugin function │ ├── plugin.py # Main plugin implementation with _TailwindCSSPlugin class │ ├── cli.py # Command-line interface with _TailwindCLI class │ └── utils.py # Utility functions for Node.js management, file operations, etc. ├── tests/ -│ ├── test_plugin.py # Unit tests for plugin core functionality -│ ├── test_utils.py # Unit tests for utility functions +│ ├── README.md # English test documentation +│ ├── README-zh_CN.md # Chinese test documentation +│ ├── conftest.py # Pytest configuration fixtures │ ├── test_cli.py # Unit tests for CLI interface -│ ├── test_integration.py # Integration tests for build process -│ ├── test_dash_simple.py # Simple Dash integration tests (no browser required) -│ ├── test_dash_integration.py # Dash end-to-end integration tests (requires browser automation) -│ └── test_dash_callbacks.py # Dash callback and layout tests +│ ├── test_dash_integration.py # Dash end-to-end integration tests +│ ├── test_plugin.py # Unit tests for plugin core functionality +│ └── test_utils.py # Unit tests for utility functions ├── example_app.py # Example Dash application -├── requirements.txt # Runtime dependencies -├── requirements-test.txt # Test dependencies -├── setup.py # Setup script for installation +├── requirements-dev.txt # Development and test dependencies ├── pyproject.toml # Build configuration ├── pytest.ini # Pytest configuration ├── ruff.toml # Ruff configuration (linting) @@ -304,47 +302,24 @@ The plugin accepts the following parameters: ## Development 1. Clone the repository -2. Install dependencies: `pip install -r requirements.txt` -3. Install test dependencies: `pip install -r requirements-test.txt` -4. Install in development mode: `pip install -e .` -5. Run example: `python example_app.py` - -### Optional Dependencies - -For development with test dependencies: - -```bash -pip install -e .[test] -``` - -For development with both development and test dependencies: - -```bash -pip install -e .[dev,test] -``` +2. Install development dependencies: `pip install -r requirements-dev.txt` +3. Install in development mode: `pip install -e .` +4. Run example: `python example_app.py` ## Running Tests -This project uses a layered testing approach: - -1. **Basic tests** - Run without browser automation (recommended for most cases) -2. **Advanced tests** - Require browser automation for end-to-end testing - ```bash -# Install test dependencies -pip install -r requirements-test.txt - -# Run basic tests (no browser automation required) -python -m pytest tests/test_plugin.py tests/test_utils.py tests/test_cli.py tests/test_integration.py tests/test_dash_simple.py +# Install development dependencies (includes test dependencies) +pip install -r requirements-dev.txt -# Run all tests (including those requiring browser automation) +# Run all tests python -m pytest tests/ # Run specific test files python -m pytest tests/test_plugin.py python -m pytest tests/test_utils.py python -m pytest tests/test_cli.py -python -m pytest tests/test_dash_simple.py +python -m pytest tests/test_dash_integration.py # Run tests with verbose output python -m pytest tests/ -v diff --git a/dash_tailwindcss_plugin/cli.py b/dash_tailwindcss_plugin/cli.py index 52d1482..0c0e727 100644 --- a/dash_tailwindcss_plugin/cli.py +++ b/dash_tailwindcss_plugin/cli.py @@ -1,6 +1,11 @@ import argparse +import logging import json -from .utils import logger, NodeManager, TailwindCommand +from py_node_manager import get_logger +from .utils import TailwindCommand + + +logger = get_logger(logging.getLogger(__name__)) class _TailwindCLI: @@ -90,16 +95,7 @@ def run(self): logger.error(f'Invalid JSON for theme config: {e}') theme_config = None - node_manager = NodeManager( - download_node=args.download_node, - node_version=args.node_version, - is_cli=True, - ) self.tailwind_command = TailwindCommand( - node_path=node_manager.node_path, - node_env=node_manager.node_env, - npm_path=node_manager.npm_path, - npx_path=node_manager.npx_path, tailwind_version=args.tailwind_version, content_path=args.content_path if args.content_path else ['**/*.py'], plugin_tmp_dir=args.plugin_tmp_dir, @@ -107,6 +103,8 @@ def run(self): output_css_path=args.output_css_path, config_js_path=args.config_js_path, is_cli=True, + download_node=args.download_node, + node_version=args.node_version, theme_config=theme_config, ) @@ -176,7 +174,3 @@ def main(): """ cli = _TailwindCLI() cli.run() - - -if __name__ == '__main__': - main() diff --git a/dash_tailwindcss_plugin/plugin.py b/dash_tailwindcss_plugin/plugin.py index f3bb7e8..6456d40 100644 --- a/dash_tailwindcss_plugin/plugin.py +++ b/dash_tailwindcss_plugin/plugin.py @@ -1,10 +1,15 @@ +import logging import os import time import uuid from dash import Dash, hooks from flask import Response, send_file from typing import Any, Dict, List, Literal, Optional -from .utils import dict_to_js_object, logger, NodeManager, TailwindCommand +from py_node_manager import get_logger +from .utils import dict_to_js_object, TailwindCommand + + +logger = get_logger(logging.getLogger(__name__)) class _TailwindCSSPlugin: @@ -26,7 +31,7 @@ def __init__( clean_after: bool = True, skip_build_if_recent: bool = True, skip_build_time_threshold: int = 5, - ): + ) -> None: """ Initialize Tailwind CSS plugin with specified configuration. @@ -45,18 +50,12 @@ def __init__( clean_after (bool): Whether to clean up generated files after build skip_build_if_recent (bool): Whether to skip build if CSS file was recently generated skip_build_time_threshold (int): Time threshold in seconds to consider CSS file as recent + + Returns: + None """ if mode == 'offline': - node_manager = NodeManager( - download_node=download_node, - node_version=node_version, - is_cli=False, - ) self.tailwind_command = TailwindCommand( - node_path=node_manager.node_path, - node_env=node_manager.node_env, - npm_path=node_manager.npm_path, - npx_path=node_manager.npx_path, tailwind_version=tailwind_version, content_path=content_path, plugin_tmp_dir=plugin_tmp_dir, @@ -64,6 +63,8 @@ def __init__( output_css_path=output_css_path, config_js_path=config_js_path, is_cli=False, + download_node=download_node, + node_version=node_version, theme_config=tailwind_theme_config, ) self.mode = mode @@ -88,26 +89,26 @@ def __init__( ) self.cdn_url = new_cdn_url - def setup_online_mode(self): + def _process_online_html(self, index_string: str) -> str: """ - Setup Tailwind CSS using CDN + Process HTML string for online mode by adding Tailwind CSS CDN script. + + Args: + index_string (str): Original HTML string Returns: - None + str: Modified HTML string with Tailwind CSS CDN script """ + # Create Tailwind CSS CDN script with theme configuration + tailwind_script = f'\n' - @hooks.index() - def add_tailwindcss_cdn(index_string: str) -> str: - # Create Tailwind CSS CDN script with theme configuration - tailwind_script = f'\n' + # Add theme configuration script if provided + if self.tailwind_theme_config: + # Convert Python dict to JavaScript object using the utility function + theme_config_js = dict_to_js_object(self.tailwind_theme_config) - # Add theme configuration script if provided - if self.tailwind_theme_config: - # Convert Python dict to JavaScript object using the utility function - theme_config_js = dict_to_js_object(self.tailwind_theme_config) - - # Add configuration script - config_script = f""" """ - tailwind_script += config_script + tailwind_script += config_script + + # Look for the closing head tag and insert the script before it + if '' in index_string: + index_string = index_string.replace('', f'{tailwind_script}') + # If no head tag, look for opening body tag and insert before it + elif '
' in index_string: + index_string = index_string.replace('', f'\n{tailwind_script}\n') + # If neither head nor body tag, append to the beginning + else: + index_string = f'\n{tailwind_script}\n' + index_string - # Look for the closing head tag and insert the script before it - if '' in index_string: - index_string = index_string.replace('', f'{tailwind_script}') - # If no head tag, look for opening body tag and insert before it - elif '' in index_string: - index_string = index_string.replace('', f'\n{tailwind_script}\n') - # If neither head nor body tag, append to the beginning - else: - index_string = f'\n{tailwind_script}\n' + index_string + return index_string - return index_string + def _should_skip_build(self) -> bool: + """ + Check if the build should be skipped based on the skip_build_if_recent setting + + Returns: + bool: True if the build should be skipped, False otherwise + """ + # Check if CSS file exists and was generated recently (within threshold seconds) + if self.skip_build_if_recent and os.path.exists(self.output_css_path): + file_mod_time = os.path.getmtime(self.output_css_path) + current_time = time.time() + if current_time - file_mod_time < self.skip_build_time_threshold: + logger.info( + f'⚡ CSS file {self.output_css_path} was generated recently ' + f'({current_time - file_mod_time:.2f}s ago), skipping build...' + ) + return True + return False + + def _build_tailwindcss(self) -> None: + """ + Build Tailwind CSS using Tailwind CLI + + Returns: + None + """ + built = self.tailwind_command.init().install().build() + if self.clean_after: + built.clean() + + def _serve_tailwindcss(self) -> Response: + """ + Serve Tailwind CSS file. + + Returns: + Response: CSS file response or 404 if file not found + """ + # Check if the CSS file exists + if os.path.exists(self.output_css_path): + try: + # Return the CSS file + return send_file(self.output_css_path, mimetype='text/css') + except Exception: + # If there's an error return the file, return the content directly + with open(self.output_css_path, 'r', encoding='utf-8') as f: + css_content = f.read() + return Response(css_content, mimetype='text/css') + else: + # Return 404 if file not found + return Response('CSS file not found', status=404, mimetype='text/plain') + + def _process_offline_html(self, built_tailwindcss_link: str, index_string: str) -> str: + """ + Process HTML string for offline mode by adding Tailwind CSS link. + + Args: + built_tailwindcss_link (str): Link to the built Tailwind CSS file + index_string (str): Original HTML string + + Returns: + str: Modified HTML string with Tailwind CSS link + """ + # Insert Tailwind CSS link into the head section + tailwindcss_link = f'\n' + + # Look for the closing head tag and insert the link before it + if '' in index_string: + index_string = index_string.replace('', f'{tailwindcss_link}') + # If no head tag, look for opening body tag and insert before it + elif '' in index_string: + index_string = index_string.replace('', f'\n{tailwindcss_link}\n') + # If neither head nor body tag, append to the beginning + else: + index_string = f'\n{tailwindcss_link}\n' + index_string + + return index_string + + def setup_online_mode(self): + """ + Setup Tailwind CSS using CDN + + Returns: + None + """ + + @hooks.index() + def add_tailwindcss_cdn(index_string: str) -> str: + return self._process_online_html(index_string) def setup_offline_mode(self): """ @@ -146,62 +236,17 @@ def setup_offline_mode(self): # Generate Tailwind CSS on app startup @hooks.setup(priority=3) def generate_tailwindcss(app: Dash): - # Check if CSS file exists and was generated recently (within threshold seconds) - if self.skip_build_if_recent and os.path.exists(self.output_css_path): - file_mod_time = os.path.getmtime(self.output_css_path) - current_time = time.time() - if current_time - file_mod_time < self.skip_build_time_threshold: - logger.info( - f'⚡ CSS file {self.output_css_path} was generated recently ' - f'({current_time - file_mod_time:.2f}s ago), skipping build...' - ) - return - + if self._should_skip_build(): + return self._build_tailwindcss() @hooks.route(name=built_tailwindcss_link, methods=('GET',), priority=2) def serve_tailwindcss(): - # Check if the CSS file exists - if os.path.exists(self.output_css_path): - try: - # Return the CSS file - return send_file(self.output_css_path, mimetype='text/css') - except Exception: - # If there's an error return the file, return the content directly - with open(self.output_css_path, 'r', encoding='utf-8') as f: - css_content = f.read() - return Response(css_content, mimetype='text/css') - else: - # Return 404 if file not found - return Response('CSS file not found', status=404, mimetype='text/plain') + return self._serve_tailwindcss() @hooks.index(priority=1) def add_tailwindcss_link(index_string: str) -> str: - # Insert Tailwind CSS link into the head section - tailwindcss_link = f'\n' - - # Look for the closing head tag and insert the link before it - if '' in index_string: - index_string = index_string.replace('', f'{tailwindcss_link}') - # If no head tag, look for opening body tag and insert before it - elif '' in index_string: - index_string = index_string.replace('', f'\n{tailwindcss_link}\n') - # If neither head nor body tag, append to the beginning - else: - index_string = f'\n{tailwindcss_link}\n' + index_string - - return index_string - - def _build_tailwindcss(self): - """ - Build Tailwind CSS using Tailwind CLI - - Returns: - None - """ - built = self.tailwind_command.init().install().build() - if self.clean_after: - built.clean() + return self._process_offline_html(built_tailwindcss_link, index_string) def setup_tailwindcss_plugin( diff --git a/dash_tailwindcss_plugin/utils.py b/dash_tailwindcss_plugin/utils.py index d52690f..24541da 100644 --- a/dash_tailwindcss_plugin/utils.py +++ b/dash_tailwindcss_plugin/utils.py @@ -1,55 +1,13 @@ import logging import os -import platform import shutil import subprocess -import tarfile -import urllib.request -import zipfile -from typing import Any, Dict, List, Literal, Optional, Tuple +from py_node_manager import get_logger, NodeManager +from typing import Any, Dict, List, Literal, Optional from typing_extensions import Self -# Custom log formatter to add colors for different log levels -class ColoredFormatter(logging.Formatter): - """Custom log formatter to add colors for different log levels and emoji""" - - # ANSI颜色代码 - COLORS = { - 'DEBUG': '\033[36m', # cyan - 'INFO': '\033[32m', # green - 'WARNING': '\033[33m', # yellow - 'ERROR': '\033[31m', # red - 'CRITICAL': '\033[35m', # purple - 'RESET': '\033[0m', # reset - } - - def format(self, record): - # Obtain the color corresponding to the log level - log_color = self.COLORS.get(record.levelname, self.COLORS['RESET']) - reset_color = self.COLORS['RESET'] - - # Add color to log level - record.levelname = f'{log_color}{record.levelname}{reset_color}' - - # Call the format method of the parent class - return super().format(record) - - -# Configure the logger -logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) - -# Create console processor -console_handler = logging.StreamHandler() -console_handler.setLevel(logging.INFO) - -# Create formatter -formatter = ColoredFormatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') -console_handler.setFormatter(formatter) - -# Add handler to logger -logger.addHandler(console_handler) +logger = get_logger(logging.getLogger(__name__)) def dict_to_js_object(d: Dict[Any, Any], indent: int = 0) -> str: @@ -100,241 +58,9 @@ def dict_to_js_object(d: Dict[Any, Any], indent: int = 0) -> str: return '{\n' + ',\n'.join(items) + f'\n{indent_str}}}' -class NodeManager: - def __init__(self, download_node: bool, node_version: str, is_cli: bool = False): - """ - Node.js manager class - - Args: - download_node (bool): Whether to download Node.js if not found - node_version (str): Node.js version to download if download_node is True - is_cli (bool): Whether this is being called from CLI (affects error messages) - """ - self.download_node = download_node - self.node_version = node_version - self.is_cli = is_cli - self.node_path = self._node_path() - self.node_env = self._node_env() - self.npm_path = self._npm_path() - self.npx_path = self._npx_path() - - def check_nodejs_available(self) -> Tuple[bool, str]: - """ - Check if Node.js is available in PATH - - Returns: - Tuple[bool, str]: A tuple containing: - - bool: True if Node.js is available, False otherwise - - str: The version of Node.js if available, empty string otherwise - """ - try: - result = subprocess.run(['node', '--version'], capture_output=True, text=True) - if result.returncode == 0: - return True, result.stdout.strip() - except FileNotFoundError: - pass - - return False, '' - - def download_nodejs(self) -> str: - """ - Download Node.js for the current platform - - Returns: - str: Path to downloaded Node.js executable - """ - # Determine platform - system = platform.system().lower() - machine = platform.machine().lower() - - # Define download URLs for different platforms - if system == 'darwin': # macOS - if machine == 'arm64' or machine == 'aarch64': - node_url = f'https://nodejs.org/dist/v{self.node_version}/node-v{self.node_version}-darwin-arm64.tar.gz' - node_dir = f'node-v{self.node_version}-darwin-arm64' - else: - node_url = f'https://nodejs.org/dist/v{self.node_version}/node-v{self.node_version}-darwin-x64.tar.gz' - node_dir = f'node-v{self.node_version}-darwin-x64' - elif system == 'linux': - if machine == 'aarch64': - node_url = f'https://nodejs.org/dist/v{self.node_version}/node-v{self.node_version}-linux-arm64.tar.xz' - node_dir = f'node-v{self.node_version}-linux-arm64' - else: - node_url = f'https://nodejs.org/dist/v{self.node_version}/node-v{self.node_version}-linux-x64.tar.xz' - node_dir = f'node-v{self.node_version}-linux-x64' - elif system == 'windows': - node_url = f'https://nodejs.org/dist/v{self.node_version}/node-v{self.node_version}-win-x64.zip' - node_dir = f'node-v{self.node_version}-win-x64' - else: - raise RuntimeError(f'Unsupported platform: {system}') - - # Create directory for downloaded Node.js within the package directory - # Use the package directory instead of current working directory - # Get the directory of this utils.py file - package_dir = os.path.dirname(os.path.abspath(__file__)) - node_dir_path = os.path.join(package_dir, '.nodejs_cache') - if not os.path.exists(node_dir_path): - os.makedirs(node_dir_path) - - # Check if Node.js is already downloaded - if system == 'windows': - node_executable = os.path.join(node_dir_path, node_dir, 'node.exe') - else: - node_executable = os.path.join(node_dir_path, node_dir, 'bin', 'node') - - # If Node.js already exists, return the path without downloading - if os.path.exists(node_executable): - logger.info(f'📦 Using cached Node.js from {node_executable}') - return node_executable - - # Download Node.js - node_archive = os.path.join(node_dir_path, os.path.basename(node_url)) - logger.info('🌐 Node.js not found in PATH. Downloading Node.js...') - if self.is_cli: - logger.info(f'📥 Downloading Node.js from {node_url}...') - urllib.request.urlretrieve(node_url, node_archive) - - # Extract Node.js - if self.is_cli: - logger.info('🔧 Extracting Node.js...') - if node_archive.endswith('.tar.gz'): - with tarfile.open(node_archive, 'r:gz') as tar: - tar.extractall(node_dir_path) - elif node_archive.endswith('.tar.xz'): - with tarfile.open(node_archive, 'r:xz') as tar: - tar.extractall(node_dir_path) - elif node_archive.endswith('.zip'): - with zipfile.ZipFile(node_archive, 'r') as zip_ref: - zip_ref.extractall(node_dir_path) - - # Remove archive - os.remove(node_archive) - - # Make executable if not on Windows - if system != 'windows': - os.chmod(node_executable, 0o755) - - logger.info(f'✅ Node.js downloaded and extracted to {node_executable}') - return node_executable - - def check_or_download_nodejs(self) -> Optional[str]: - """ - Check if Node.js is available or download it if requested - - Returns: - Optional[str]: Path to Node.js executable or None if using system Node.js - """ - # First check if Node.js is available in PATH - is_available, version = self.check_nodejs_available() - if is_available: - logger.info(f'💻 Using System Default Node.js {version}') - - return None # Use system Node.js - - # If not found and download is not requested, raise error - if not self.download_node: - if self.is_cli: - raise RuntimeError( - 'Node.js is required but not found in PATH. ' - 'Install Node.js or use --download-node to automatically download it.' - ) - else: - raise RuntimeError( - 'Node.js is required for offline mode but not found. ' - 'Install Node.js or use download_node=True to automatically download it.' - ) - - # Download Node.js using the shared utility function - return self.download_nodejs() - - def get_command_alias_by_platform(self, command: str) -> str: - """ - Get the command alias for a given command on the current platform. - - Args: - command (str): Command to get alias for - Returns: - str: Command alias - """ - if platform.system().lower() == 'windows': - return command + '.cmd' - else: - return command - - def _node_path(self) -> Optional[str]: - """ - Get the path to the Node.js executable - - Returns: - Optional[str]: Path to Node.js executable or None if using system Node.js - """ - node_path = self.check_or_download_nodejs() - - return node_path - - def _node_env(self) -> Optional[Dict[str, str]]: - """ - Get the environment variables for the Node.js executable - - Returns: - Optional[Dict[str, str]]: Environment variables for Node.js executable or None if using system Node.js - """ - env = None - if self.node_path: - node_dir = os.path.dirname(self.node_path) - env = os.environ.copy() - env['PATH'] = node_dir + os.pathsep + env.get('PATH', '') - - return env - - def _npm_path(self) -> str: - """ - Get the path to the npm executable - - Returns: - str: Path to npm executable - """ - if self.node_path: - # When using downloaded Node.js, we need to use npm from the same directory - node_dir = os.path.dirname(self.node_path) - npm_path = os.path.join(node_dir, self.get_command_alias_by_platform('npm')) - # If npm doesn't exist in the same directory, check in bin subdirectory - if not os.path.exists(npm_path): - npm_path = os.path.join(node_dir, 'bin', self.get_command_alias_by_platform('npm')) - - else: - npm_path = self.get_command_alias_by_platform('npm') - - return npm_path - - def _npx_path(self) -> str: - """ - Get the path to the npx executable - - Returns: - str: Path to npx executable - """ - if self.node_path: - # When using downloaded Node.js, we need to use npx from the same directory - node_dir = os.path.dirname(self.node_path) - npx_path = os.path.join(node_dir, self.get_command_alias_by_platform('npx')) - # If npx doesn't exist in the same directory, check in bin subdirectory - if not os.path.exists(npx_path): - npx_path = os.path.join(node_dir, 'bin', self.get_command_alias_by_platform('npx')) - - else: - npx_path = self.get_command_alias_by_platform('npx') - - return npx_path - - class TailwindCommand: def __init__( self, - node_path: Optional[str], - node_env: Optional[Dict[str, str]], - npm_path: str, - npx_path: str, tailwind_version: Literal['3', '4'], content_path: List[str], plugin_tmp_dir: str, @@ -342,28 +68,29 @@ def __init__( output_css_path: str, config_js_path: str, is_cli: bool, + download_node: bool, + node_version: str, theme_config: Optional[Dict[Any, Any]] = None, ): """ Initialize the TailwindCommand class Args: - node_path (Optional[str]): Path to Node.js executable - node_env (Optional[Dict[str, str]]): Environment variables for Node.js executable - npm_path (str): Path to npm executable - npx_path (str): Path to npx executable tailwind_version (Literal['3', '4']): Version of Tailwind CSS content_path (List[str]): List of paths to content files input_css_path (str): Path to input CSS file output_css_path (str): Path to output CSS file config_js_path (str): Path to Tailwind config file is_cli (bool): Whether the command is being run from the CLI + download_node (bool): Whether to download Node.js if not found + node_version (str): Node.js version to download if download_node is True theme_config (Optional[Dict[Any, Any]]): Custom theme configuration for Tailwind CSS """ - self.node_path = node_path - self.node_env = node_env - self.npm_path = npm_path - self.npx_path = npx_path + node_manager = NodeManager(download_node=download_node, node_version=node_version, is_cli=is_cli) + self.node_path = node_manager.node_path + self.node_env = node_manager.node_env + self.npm_path = node_manager.npm_path + self.npx_path = node_manager.npx_path self.tailwind_version = tailwind_version self.content_path = content_path self.input_css_path = input_css_path diff --git a/pyproject.toml b/pyproject.toml index e9165eb..38bd122 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,10 +20,13 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Framework :: Dash", ] dependencies = [ "dash>=3.0.3", + "py-node-manager>=0.1.1", ] [project.urls] @@ -33,8 +36,7 @@ Repository = "https://github.com/HogaStack/dash-tailwindcss-plugin" Issues = "https://github.com/HogaStack/dash-tailwindcss-plugin/issues" [project.optional-dependencies] -dev = ["build", "twine"] -test = ["pytest>=6.0"] +dev = ["build", "dash[testing]>=3.0.3", "pytest", "pytest-cov", "twine"] [project.scripts] dash-tailwindcss-plugin = "dash_tailwindcss_plugin.cli:main" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..6aade84 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +dash[testing] +pytest +pytest-cov diff --git a/requirements-test.txt b/requirements-test.txt deleted file mode 100644 index 173af4c..0000000 --- a/requirements-test.txt +++ /dev/null @@ -1,4 +0,0 @@ -pytest>=6.0 -dash[testing] # For Dash integration testing with dash_duo -multiprocess # Required by dash[testing] -selenium # Required for browser-based Dash testing (optional) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index b4ea41a..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -dash>=3.0.3 \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 016b377..0000000 --- a/setup.py +++ /dev/null @@ -1,49 +0,0 @@ -from setuptools import setup, find_packages - -with open('README.md', 'r', encoding='utf-8') as fh: - long_description = fh.read() - -with open('requirements.txt', 'r', encoding='utf-8') as fh: - requirements = [line.strip() for line in fh if line.strip() and not line.startswith('#')] - -setup( - name='dash-tailwindcss-plugin', - version='0.1.5', - author='insistence', - author_email='3055204202@qq.com', - homepage='https://github.com/HogaStack/dash-tailwindcss-plugin', - description='A Dash plugin for integrating TailwindCSS using Dash 3.x hooks', - long_description=long_description, - long_description_content_type='text/markdown', - url='https://github.com/HogaStack/dash-tailwindcss-plugin', - packages=find_packages(), - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - 'Framework :: Dash', - ], - python_requires='>=3.8', - install_requires=requirements, - extras_require={ - 'dev': [ - 'build', - 'twine', - ], - 'test': [ - 'pytest>=6.0', - ], - }, - entry_points={ - 'console_scripts': [ - 'dash-tailwindcss-plugin=dash_tailwindcss_plugin.cli:main', - ], - }, -) diff --git a/tests/README-zh_CN.md b/tests/README-zh_CN.md new file mode 100644 index 0000000..1c77cff --- /dev/null +++ b/tests/README-zh_CN.md @@ -0,0 +1,65 @@ +# 测试套件说明 + +本项目的测试套件使用 pytest 框架编写,包含以下测试文件: + +## 测试文件结构 + +- `test_plugin.py` - 测试插件核心功能 +- `test_utils.py` - 测试工具函数 +- `test_cli.py` - 测试命令行接口 +- `test_dash_integration.py` - 测试插件与 Dash 应用的端到端集成(需要浏览器自动化) +- `conftest.py` - pytest 配置和 fixtures +- `README.md` - 英文测试文档 +- `README-zh_CN.md` - 中文测试文档 + +## 运行测试 + +### 安装测试依赖 + +```bash +pip install -r requirements-dev.txt +``` + +### 运行所有测试 + +```bash +python -m pytest tests/ +``` + +### 运行特定测试文件 + +```bash +python -m pytest tests/test_plugin.py +python -m pytest tests/test_utils.py +python -m pytest tests/test_cli.py +python -m pytest tests/test_dash_integration.py +``` + +### 运行测试并显示详细输出 + +```bash +python -m pytest tests/ -v +``` + +## 测试类型 + +### 单元测试 + +- 测试插件类的初始化和方法 +- 测试工具函数的功能 +- 测试CLI命令解析和执行 + +### 集成测试 + +- 测试插件与 Dash 应用的集成 +- 测试端到端的工作流程 + +## 测试覆盖率 + +要生成测试覆盖率报告,请运行: + +```bash +python -m pytest tests/ --cov=dash_tailwindcss_plugin --cov-report=html +``` + +这将生成一个 HTML 格式的覆盖率报告,可以在浏览器中查看。 diff --git a/tests/README.md b/tests/README.md index 82c253a..ede6ae0 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,86 +1,65 @@ -# 测试套件说明 +# Test Suite Documentation -本项目的测试套件使用 pytest 框架编写,包含以下测试文件: +This project's test suite is written using the pytest framework and includes the following test files: -## 测试文件结构 +## Test File Structure -- `test_plugin.py` - 测试插件核心功能 -- `test_utils.py` - 测试工具函数 -- `test_cli.py` - 测试命令行接口 -- `test_integration.py` - 测试插件与 Dash 应用的集成 -- `conftest.py` - pytest 配置和 fixtures -- `test_dash_integration.py` - 测试插件与 Dash 应用的端到端集成(使用 dash_duo) -- `test_dash_simple.py` - 测试插件与 Dash 应用的基本集成(无需浏览器) -- `test_dash_callbacks.py` - 测试插件与 Dash 回调函数的集成 +- `test_plugin.py` - Tests core plugin functionality +- `test_utils.py` - Tests utility functions +- `test_cli.py` - Tests command-line interface +- `test_dash_integration.py` - Tests end-to-end Dash application integration (requires browser automation) +- `conftest.py` - pytest configuration and fixtures +- `README.md` - English test documentation +- `README-zh_CN.md` - Chinese test documentation -## 运行测试 +## Running Tests -本项目采用分层测试策略: - -1. **基础测试** - 无需浏览器自动化,测试核心功能 -2. **高级测试** - 需要浏览器自动化进行端到端测试 - -### 安装测试依赖 - -```bash -pip install -r requirements-test.txt -``` - -### 运行基础测试(推荐) +### Install Test Dependencies ```bash -python -m pytest tests/test_plugin.py tests/test_utils.py tests/test_cli.py tests/test_integration.py tests/test_dash_simple.py +pip install -r requirements-dev.txt ``` -### 运行所有测试(包括需要浏览器自动化的测试) +### Run All Tests ```bash python -m pytest tests/ ``` -### 运行特定测试文件 +### Run Specific Test Files ```bash python -m pytest tests/test_plugin.py python -m pytest tests/test_utils.py python -m pytest tests/test_cli.py -python -m pytest tests/test_integration.py -python -m pytest tests/test_dash_simple.py -python -m pytest tests/test_dash_callbacks.py python -m pytest tests/test_dash_integration.py ``` -### 运行测试并显示详细输出 +### Run Tests with Verbose Output ```bash python -m pytest tests/ -v ``` -## 测试类型 - -### 单元测试 - -- 测试插件类的初始化和方法 -- 测试工具函数的功能 -- 测试CLI命令解析和执行 +## Test Types -### 集成测试 +### Unit Tests -- 测试插件与 Dash 应用的集成 -- 测试端到端的工作流程 +- Tests plugin class initialization and methods +- Tests utility function functionality +- Tests CLI command parsing and execution -## 测试覆盖率 +### Integration Tests -要生成测试覆盖率报告,需要安装额外的依赖: +- Tests plugin integration with Dash applications +- Tests end-to-end workflows -```bash -pip install pytest-cov -``` +## Test Coverage -然后运行: +To generate a test coverage report, run: ```bash python -m pytest tests/ --cov=dash_tailwindcss_plugin --cov-report=html ``` -这将生成一个 HTML 格式的覆盖率报告,可以在浏览器中查看。 +This will generate an HTML coverage report that can be viewed in a browser. diff --git a/tests/test_cli.py b/tests/test_cli.py index c1ec2da..4156386 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,8 +1,8 @@ -import pytest import os -import tempfile +import pytest import shutil -from unittest.mock import patch, MagicMock +import tempfile +from unittest.mock import MagicMock, patch from dash_tailwindcss_plugin.cli import _TailwindCLI @@ -26,8 +26,9 @@ def test_cli_initialization(self): assert cli is not None @patch('argparse.ArgumentParser.parse_args') - def test_run_with_init_command(self, mock_parse_args): - """Test run method with init command.""" + def test_run_with_different_commands(self, mock_parse_args): + """Test run method with different commands.""" + # Test with init command mock_args = MagicMock() mock_args.command = 'init' mock_args.content_path = None @@ -48,63 +49,27 @@ def test_run_with_init_command(self, mock_parse_args): input_css_path='./_tailwind/tailwind_input.css', config_js_path='./_tailwind/tailwind.config.js' ) - @patch('argparse.ArgumentParser.parse_args') - def test_run_with_build_command(self, mock_parse_args): - """Test run method with build command.""" - mock_args = MagicMock() + # Test with build command mock_args.command = 'build' - mock_args.content_path = None - mock_args.input_css_path = './_tailwind/tailwind_input.css' - mock_args.output_css_path = './_tailwind/tailwind.css' - mock_args.config_js_path = './_tailwind/tailwind.config.js' mock_args.clean_after = False - mock_args.download_node = False - mock_args.node_version = '18.17.0' - mock_args.tailwind_theme_config = None - mock_args.tailwind_version = '3' - mock_args.plugin_tmp_dir = './_tailwind' mock_parse_args.return_value = mock_args - cli = _TailwindCLI() with patch.object(cli, 'build_tailwindcss') as mock_build: cli.run() mock_build.assert_called_once_with(clean_after=False) - @patch('argparse.ArgumentParser.parse_args') - def test_run_with_watch_command(self, mock_parse_args): - """Test run method with watch command.""" - mock_args = MagicMock() + # Test with watch command mock_args.command = 'watch' - mock_args.content_path = None - mock_args.input_css_path = './_tailwind/tailwind_input.css' - mock_args.output_css_path = './_tailwind/tailwind.css' - mock_args.config_js_path = './_tailwind/tailwind.config.js' - mock_args.download_node = False - mock_args.node_version = '18.17.0' - mock_args.tailwind_theme_config = None - mock_args.tailwind_version = '3' - mock_args.plugin_tmp_dir = './_tailwind' mock_parse_args.return_value = mock_args - cli = _TailwindCLI() with patch.object(cli, 'watch_tailwindcss') as mock_watch: cli.run() mock_watch.assert_called_once() - @patch('argparse.ArgumentParser.parse_args') - def test_run_with_clean_command(self, mock_parse_args): - """Test run method with clean command.""" - mock_args = MagicMock() + # Test with clean command mock_args.command = 'clean' - mock_args.input_css_path = './_tailwind/tailwind_input.css' - mock_args.config_js_path = './_tailwind/tailwind.config.js' - mock_args.tailwind_theme_config = None - mock_args.tailwind_version = '3' - mock_args.plugin_tmp_dir = './_tailwind' - mock_args.output_css_path = './_tailwind/tailwind.css' mock_parse_args.return_value = mock_args - cli = _TailwindCLI() with patch.object(cli, 'clean_tailwindcss') as mock_clean: cli.run() mock_clean.assert_called_once() @@ -112,6 +77,7 @@ def test_run_with_clean_command(self, mock_parse_args): @patch('argparse.ArgumentParser.parse_args') def test_run_with_theme_config(self, mock_parse_args): """Test run method with theme configuration.""" + # Test with valid theme config theme_config_json = '{"colors": {"primary": "#ff0000"}}' mock_args = MagicMock() mock_args.command = 'init' @@ -133,24 +99,11 @@ def test_run_with_theme_config(self, mock_parse_args): input_css_path='./_tailwind/tailwind_input.css', config_js_path='./_tailwind/tailwind.config.js' ) - @patch('argparse.ArgumentParser.parse_args') - def test_run_with_invalid_theme_config(self, mock_parse_args): - """Test run method with invalid theme configuration.""" + # Test with invalid theme config theme_config_json = '{"colors": {"primary": "#ff0000"' # Invalid JSON - mock_args = MagicMock() - mock_args.command = 'init' - mock_args.content_path = None - mock_args.input_css_path = './_tailwind/tailwind_input.css' - mock_args.config_js_path = './_tailwind/tailwind.config.js' - mock_args.download_node = False - mock_args.node_version = '18.17.0' mock_args.tailwind_theme_config = theme_config_json - mock_args.tailwind_version = '3' - mock_args.plugin_tmp_dir = './_tailwind' - mock_args.output_css_path = './_tailwind/tailwind.css' mock_parse_args.return_value = mock_args - cli = _TailwindCLI() with patch.object(cli, 'init_tailwindcss') as mock_init: cli.run() mock_init.assert_called_once_with( @@ -181,8 +134,9 @@ def test_run_with_tailwind_v4(self, mock_parse_args): ) @patch('argparse.ArgumentParser.parse_args') - def test_run_with_multiple_content_paths(self, mock_parse_args): - """Test run method with multiple content paths.""" + def test_run_with_custom_parameters(self, mock_parse_args): + """Test run method with custom parameters.""" + # Test with multiple content paths mock_args = MagicMock() mock_args.command = 'build' mock_args.content_path = ['*.html', '*.js'] @@ -199,51 +153,15 @@ def test_run_with_multiple_content_paths(self, mock_parse_args): cli = _TailwindCLI() with patch.object(cli, 'build_tailwindcss'): - with patch('dash_tailwindcss_plugin.cli.NodeManager'), patch( - 'dash_tailwindcss_plugin.cli.TailwindCommand' - ) as mock_tailwind_command: + with patch('dash_tailwindcss_plugin.cli.TailwindCommand') as mock_tailwind_command: mock_tailwind_instance = MagicMock() mock_tailwind_command.return_value = mock_tailwind_instance cli.run() - # Verify that the multiple content paths are passed correctly mock_tailwind_command.assert_called() - call_args = mock_tailwind_command.call_args[1] # Get keyword arguments + call_args = mock_tailwind_command.call_args[1] assert call_args['content_path'] == ['*.html', '*.js'] - @patch('argparse.ArgumentParser.parse_args') - def test_cli_with_multiple_content_paths(self, mock_parse_args): - """Test CLI with multiple content paths.""" - with patch('argparse.ArgumentParser.parse_args') as mock_parse_args: - mock_args = MagicMock() - mock_args.command = 'build' - mock_args.content_path = ['*.html', '*.js', '*.py'] - mock_args.input_css_path = './_tailwind/tailwind_input.css' - mock_args.output_css_path = './_tailwind/tailwind.css' - mock_args.config_js_path = './_tailwind/tailwind.config.js' - mock_args.clean_after = False - mock_args.download_node = False - mock_args.node_version = '18.17.0' - mock_args.tailwind_theme_config = None - mock_args.tailwind_version = '3' - mock_args.plugin_tmp_dir = './_tailwind' - mock_parse_args.return_value = mock_args - - cli = _TailwindCLI() - with patch.object(cli, 'build_tailwindcss'): - with patch('dash_tailwindcss_plugin.cli.NodeManager'), patch( - 'dash_tailwindcss_plugin.cli.TailwindCommand' - ) as mock_tailwind_command: - mock_tailwind_instance = MagicMock() - mock_tailwind_command.return_value = mock_tailwind_instance - cli.run() - # Verify that the multiple content paths are passed correctly - mock_tailwind_command.assert_called() - call_args = mock_tailwind_command.call_args[1] # Get keyword arguments - assert call_args['content_path'] == ['*.html', '*.js', '*.py'] - - @patch('argparse.ArgumentParser.parse_args') - def test_run_with_custom_plugin_tmp_dir(self, mock_parse_args): - """Test run method with custom plugin temporary directory.""" + # Test with custom plugin temporary directory mock_args = MagicMock() mock_args.command = 'build' mock_args.content_path = None @@ -258,19 +176,158 @@ def test_run_with_custom_plugin_tmp_dir(self, mock_parse_args): mock_args.plugin_tmp_dir = './custom' mock_parse_args.return_value = mock_args - cli = _TailwindCLI() with patch.object(cli, 'build_tailwindcss'): - with patch('dash_tailwindcss_plugin.cli.NodeManager'), patch( - 'dash_tailwindcss_plugin.cli.TailwindCommand' - ) as mock_tailwind_command: + with patch('dash_tailwindcss_plugin.cli.TailwindCommand') as mock_tailwind_command: mock_tailwind_instance = MagicMock() mock_tailwind_command.return_value = mock_tailwind_instance cli.run() - # Verify that the custom plugin temporary directory is passed correctly mock_tailwind_command.assert_called() - call_args = mock_tailwind_command.call_args[1] # Get keyword arguments + call_args = mock_tailwind_command.call_args[1] assert call_args['plugin_tmp_dir'] == './custom' + @patch('argparse.ArgumentParser.parse_args') + def test_tailwindcss_commands(self, mock_parse_args): + """Test various TailwindCSS commands.""" + # Test init_tailwindcss method + mock_args = MagicMock() + mock_args.command = 'init' + mock_args.content_path = None + mock_args.input_css_path = './_tailwind/tailwind_input.css' + mock_args.config_js_path = './_tailwind/tailwind.config.js' + mock_args.download_node = False + mock_args.node_version = '18.17.0' + mock_args.tailwind_theme_config = None + mock_args.tailwind_version = '3' + mock_args.plugin_tmp_dir = './_tailwind' + mock_args.output_css_path = './_tailwind/tailwind.css' + mock_args.clean_after = False + mock_parse_args.return_value = mock_args + + cli = _TailwindCLI() + + # Test init_tailwindcss with logging + with patch('dash_tailwindcss_plugin.cli.TailwindCommand') as mock_tailwind_command: + mock_tailwind_instance = MagicMock() + mock_init_instance = MagicMock() + mock_install_instance = MagicMock() + + mock_tailwind_command.return_value = mock_tailwind_instance + mock_tailwind_instance.init.return_value = mock_init_instance + mock_init_instance.install.return_value = mock_install_instance + + cli.run() + + mock_tailwind_instance.init.assert_called_once() + mock_init_instance.install.assert_called_once() + + # Test build_tailwindcss with different cleanup settings + mock_args.command = 'build' + mock_args.clean_after = True + mock_parse_args.return_value = mock_args + + with patch('dash_tailwindcss_plugin.cli.TailwindCommand') as mock_tailwind_command: + mock_tailwind_instance = MagicMock() + mock_init_instance = MagicMock() + mock_install_instance = MagicMock() + mock_build_instance = MagicMock() + mock_clean_instance = MagicMock() + + mock_tailwind_command.return_value = mock_tailwind_instance + mock_tailwind_instance.init.return_value = mock_init_instance + mock_init_instance.install.return_value = mock_install_instance + mock_install_instance.build.return_value = mock_build_instance + mock_build_instance.clean.return_value = mock_clean_instance + + cli.run() + + mock_tailwind_instance.init.assert_called_once() + mock_init_instance.install.assert_called_once() + mock_install_instance.build.assert_called_once() + mock_build_instance.clean.assert_called_once() + + # Test build_tailwindcss without cleanup + mock_args.clean_after = False + mock_parse_args.return_value = mock_args + + with patch('dash_tailwindcss_plugin.cli.TailwindCommand') as mock_tailwind_command: + mock_tailwind_instance = MagicMock() + mock_init_instance = MagicMock() + mock_install_instance = MagicMock() + mock_build_instance = MagicMock() + + mock_tailwind_command.return_value = mock_tailwind_instance + mock_tailwind_instance.init.return_value = mock_init_instance + mock_init_instance.install.return_value = mock_install_instance + mock_install_instance.build.return_value = mock_build_instance + + cli.run() + + mock_tailwind_instance.init.assert_called_once() + mock_init_instance.install.assert_called_once() + mock_install_instance.build.assert_called_once() + + # Test watch_tailwindcss + mock_args.command = 'watch' + mock_parse_args.return_value = mock_args + + with patch('dash_tailwindcss_plugin.cli.TailwindCommand') as mock_tailwind_command: + mock_tailwind_instance = MagicMock() + mock_init_instance = MagicMock() + mock_install_instance = MagicMock() + mock_watch_instance = MagicMock() + + mock_tailwind_command.return_value = mock_tailwind_instance + mock_tailwind_instance.init.return_value = mock_init_instance + mock_init_instance.install.return_value = mock_install_instance + mock_install_instance.watch.return_value = mock_watch_instance + + cli.run() + + mock_tailwind_instance.init.assert_called_once() + mock_init_instance.install.assert_called_once() + mock_install_instance.watch.assert_called_once() + + # Test clean_tailwindcss + mock_args.command = 'clean' + mock_parse_args.return_value = mock_args + + with patch('dash_tailwindcss_plugin.cli.TailwindCommand') as mock_tailwind_command: + mock_tailwind_instance = MagicMock() + mock_clean_instance = MagicMock() + + mock_tailwind_command.return_value = mock_tailwind_instance + mock_tailwind_instance.clean.return_value = mock_clean_instance + + cli.run() + + mock_tailwind_instance.clean.assert_called_once() + + def test_main_and_module_execution(self): + """Test the main function and module execution.""" + # Test the main function + with patch('dash_tailwindcss_plugin.cli._TailwindCLI') as mock_cli: + mock_cli_instance = MagicMock() + mock_cli.return_value = mock_cli_instance + + from dash_tailwindcss_plugin.cli import main + + main() + + mock_cli.assert_called_once() + mock_cli_instance.run.assert_called_once() + + # Test the __main__ execution of the CLI module + with patch('dash_tailwindcss_plugin.cli._TailwindCLI') as mock_cli: + mock_cli_instance = MagicMock() + mock_cli.return_value = mock_cli_instance + + # Import module and execute + import dash_tailwindcss_plugin.cli as cli_module + + # Simulate the case where __name__ == '__main__' + with patch.object(cli_module, '__name__', '__main__'): + pass # Call main() when actually running + if __name__ == '__main__': pytest.main([__file__]) diff --git a/tests/test_dash_callbacks.py b/tests/test_dash_callbacks.py deleted file mode 100644 index 95df159..0000000 --- a/tests/test_dash_callbacks.py +++ /dev/null @@ -1,577 +0,0 @@ -#!/usr/bin/env python3 -""" -Callback tests for the TailwindCSS plugin. - -These tests use the dash_duo pytest fixture to test the plugin's callbacks -with a real Dash application. -""" - -import pytest -import tempfile -import shutil -import os -import uuid -from dash import Dash, html, dcc, Input, Output, callback -from dash.testing.composite import DashComposite -from dash_tailwindcss_plugin import setup_tailwindcss_plugin - - -class TestDashCallbacks: - """Test cases for Dash callbacks with the TailwindCSS plugin.""" - - def setup_method(self): - """Set up test fixtures before each test method.""" - self.test_dir = tempfile.mkdtemp() - self.original_cwd = os.getcwd() - os.chdir(self.test_dir) - - def teardown_method(self): - """Tear down test fixtures after each test method.""" - os.chdir(self.original_cwd) - shutil.rmtree(self.test_dir, ignore_errors=True) - - def test_callback_with_tailwind_classes(self, dash_duo: DashComposite): - """Test that callbacks work correctly with TailwindCSS classes.""" - # Setup TailwindCSS plugin - setup_tailwindcss_plugin(mode='online') - - # Create a Dash app - app = Dash(__name__) - - # Define app layout with interactive elements - app.layout = html.Div( - [ - html.H1('Callback Test', className='text-3xl font-bold text-blue-600'), - html.Button( - 'Click Me', - id='click-button', - className='bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded', - ), - html.Div(id='output-div', className='mt-4 p-4 bg-gray-100 rounded'), - ] - ) - - @callback(Output('output-div', 'children'), Input('click-button', 'n_clicks')) - def update_output(n_clicks): - if n_clicks is None: - n_clicks = 0 - return f'Button clicked {n_clicks} times' - - # Start the app - dash_duo.start_server(app) - - # Wait for the app to load - dash_duo.wait_for_text_to_equal('h1', 'Callback Test') - - # Check initial state - dash_duo.wait_for_text_to_equal('#output-div', 'Button clicked 0 times') - - # Click the button - dash_duo.find_element('#click-button').click() - - # Check updated state - dash_duo.wait_for_text_to_equal('#output-div', 'Button clicked 1 times') - - # Click the button again - dash_duo.find_element('#click-button').click() - - # Check updated state - dash_duo.wait_for_text_to_equal('#output-div', 'Button clicked 2 times') - - # Check that there are no console errors - assert dash_duo.get_logs() == [], 'Browser console should contain no errors' - - def test_form_callback_with_tailwind(self, dash_duo: DashComposite): - """Test form callbacks with TailwindCSS styling.""" - # Setup TailwindCSS plugin - setup_tailwindcss_plugin(mode='online') - - # Create a Dash app - app = Dash(__name__) - - # Define app layout with form elements - app.layout = html.Div( - [ - html.H1('Form Callback Test', className='text-3xl font-bold text-center mb-4'), - html.Div( - [ - html.Label('Name:', className='block text-gray-700 text-sm font-bold mb-2'), - dcc.Input( - id='name-input', - type='text', - placeholder='Enter your name', - className='shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline', - ), - ], - className='mb-4', - ), - html.Div( - [ - html.Label('Email:', className='block text-gray-700 text-sm font-bold mb-2'), - dcc.Input( - id='email-input', - type='email', - placeholder='Enter your email', - className='shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline', - ), - ], - className='mb-4', - ), - html.Button( - 'Submit', - id='submit-button', - className='bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline', - ), - html.Div(id='form-output', className='mt-4 p-4 bg-gray-100 rounded'), - ], - className='container mx-auto p-4', - ) - - @callback( - Output('form-output', 'children'), - Input('submit-button', 'n_clicks'), - Input('name-input', 'value'), - Input('email-input', 'value'), - prevent_initial_call=False, - ) - def update_form_output(n_clicks, name, email): - if n_clicks is None: - return 'Please fill in the form and click submit' - - if not name and not email: - return 'Please provide at least a name or email' - - return f'Submitted: Name={name or "N/A"}, Email={email or "N/A"}' - - # Start the app - dash_duo.start_server(app) - - # Wait for the app to load - dash_duo.wait_for_text_to_equal('h1', 'Form Callback Test') - - # Check initial state - dash_duo.wait_for_text_to_equal('#form-output', 'Please fill in the form and click submit') - - # Fill in the form - dash_duo.find_element('#name-input').send_keys('John Doe') - dash_duo.find_element('#email-input').send_keys('john@example.com') - - # Click submit - dash_duo.find_element('#submit-button').click() - - # Check updated state - dash_duo.wait_for_text_to_equal('#form-output', 'Submitted: Name=John Doe, Email=john@example.com') - - # Check that there are no console errors - assert dash_duo.get_logs() == [], 'Browser console should contain no errors' - - def test_dynamic_content_callback(self, dash_duo: DashComposite): - """Test dynamic content updates with TailwindCSS.""" - # Setup TailwindCSS plugin - setup_tailwindcss_plugin(mode='online') - - # Create a Dash app - app = Dash(__name__) - - # Define app layout with dynamic content - app.layout = html.Div( - [ - html.H1('Dynamic Content Test', className='text-3xl font-bold text-center mb-4'), - html.Div( - [ - html.Button( - 'Show Content', - id='show-button', - className='bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded mr-2', - ), - html.Button( - 'Hide Content', - id='hide-button', - className='bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded', - ), - ], - className='mb-4', - ), - html.Div(id='dynamic-content', className='mt-4 p-4 bg-blue-100 rounded'), - ], - className='container mx-auto p-4', - ) - - @callback( - Output('dynamic-content', 'children'), Input('show-button', 'n_clicks'), Input('hide-button', 'n_clicks') - ) - def update_content(show_clicks, hide_clicks): - if show_clicks and (not hide_clicks or show_clicks > hide_clicks): - return html.Div( - [ - html.H2('Dynamic Content', className='text-xl font-semibold'), - html.P('This content was dynamically added!', className='text-gray-700'), - html.Ul( - [ - html.Li('Item 1', className='text-gray-600'), - html.Li('Item 2', className='text-gray-600'), - html.Li('Item 3', className='text-gray-600'), - ], - className='list-disc pl-5 mt-2', - ), - ], - className='p-4 bg-white rounded shadow', - ) - else: - return '' - - # Start the app - dash_duo.start_server(app) - - # Wait for the app to load - dash_duo.wait_for_text_to_equal('h1', 'Dynamic Content Test') - - # Check initial state (empty content) - dynamic_content = dash_duo.find_element('#dynamic-content') - assert dynamic_content.text == '' - - # Click show button - dash_duo.find_element('#show-button').click() - - # Check that content is shown - dash_duo.wait_for_text_to_equal('#dynamic-content h2', 'Dynamic Content') - - # Click hide button - dash_duo.find_element('#hide-button').click() - - # Check that content is hidden (empty) - dash_duo.wait_for_text_to_equal('#dynamic-content', '') - - # Check that there are no console errors - assert dash_duo.get_logs() == [], 'Browser console should contain no errors' - - def test_offline_mode_callback(self, dash_duo: DashComposite): - """Test callbacks work in offline mode with TailwindCSS.""" - # Setup TailwindCSS plugin in offline mode - output_css_path = f'_tailwind/offline_callback_test_{str(uuid.uuid4())[:8]}.css' - setup_tailwindcss_plugin( - mode='offline', - output_css_path=output_css_path, - clean_after=False, # Don't clean up so we can check the generated files - ) - - # Create a Dash app - app = Dash(__name__) - - # Define app layout with interactive elements - app.layout = html.Div( - [ - html.H1('Offline Mode Callback Test', className='text-3xl font-bold text-center mb-4 text-green-600'), - html.Div( - [ - html.Label('Select an option:', className='block text-gray-700 text-sm font-bold mb-2'), - dcc.Dropdown( - id='option-dropdown', - options=[ - {'label': 'Option 1', 'value': '1'}, - {'label': 'Option 2', 'value': '2'}, - {'label': 'Option 3', 'value': '3'}, - ], - value='1', - className='block appearance-none w-full bg-white border border-gray-300 text-gray-700 py-2 px-3 pr-8 rounded leading-tight focus:outline-none focus:shadow-outline', - ), - ], - className='mb-4', - ), - html.Div(id='dropdown-output', className='mt-4 p-4 bg-gray-100 rounded'), - ], - className='container mx-auto p-4', - ) - - @callback(Output('dropdown-output', 'children'), Input('option-dropdown', 'value')) - def update_dropdown_output(value): - option_labels = {'1': 'Option 1', '2': 'Option 2', '3': 'Option 3'} - selected_label = option_labels.get(value, 'Unknown') - return html.Div( - [ - html.H3(f'You selected: {selected_label}', className='text-lg font-semibold'), - html.P(f'Value: {value}', className='text-gray-700'), - ] - ) - - # Start the app - dash_duo.start_server(app) - - # Wait for the app to load - dash_duo.wait_for_text_to_equal('h1', 'Offline Mode Callback Test') - - # Check initial state - dash_duo.wait_for_text_to_equal('#dropdown-output h3', 'You selected: Option 1') - - # Check that the CSS file was generated - # Use absolute path to ensure we're checking the correct file - css_file_path = os.path.join(os.getcwd(), output_css_path) - assert os.path.exists(css_file_path), f'CSS file {css_file_path} was not generated' - - # Check that the CSS file is not empty - assert os.path.getsize(css_file_path) > 0, f'CSS file {css_file_path} is empty' - - # Check that there are no console errors - assert dash_duo.get_logs() == [], 'Browser console should contain no errors' - - def test_multiple_callbacks_with_tailwind_styles(self, dash_duo: DashComposite): - """Test multiple callbacks working together with Tailwind CSS styles.""" - # Setup TailwindCSS plugin - setup_tailwindcss_plugin(mode='online') - - # Create a Dash app - app = Dash(__name__) - - # Define app layout with multiple interactive elements - app.layout = html.Div( - [ - html.H1('Multiple Callbacks Test', className='text-3xl font-bold text-center mb-6'), - # Counter section - html.Div( - [ - html.H2('Counter', className='text-xl font-semibold mb-2'), - html.Button( - '+', - id='increment-btn', - className='bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-3 rounded-l', - ), - html.Button( - '-', - id='decrement-btn', - className='bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-3 rounded-r', - ), - html.Span(id='counter-display', className='mx-4 text-2xl font-bold'), - ], - className='mb-6 p-4 bg-gray-100 rounded', - ), - # Color changer section - html.Div( - [ - html.H2('Color Changer', className='text-xl font-semibold mb-2'), - html.Button( - 'Red', - id='red-btn', - className='bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded mr-2', - ), - html.Button( - 'Green', - id='green-btn', - className='bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded mr-2', - ), - html.Button( - 'Blue', - id='blue-btn', - className='bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded', - ), - html.Div(id='color-box', className='mt-4 w-32 h-32 border-2 border-gray-300'), - ], - className='mb-6 p-4 bg-gray-100 rounded', - ), - # Text updater section - html.Div( - [ - html.H2('Text Updater', className='text-xl font-semibold mb-2'), - dcc.Input( - id='text-input', - type='text', - placeholder='Enter text', - className='shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline', - ), - html.Div(id='text-display', className='mt-4 p-4 bg-white border rounded'), - ], - className='mb-6 p-4 bg-gray-100 rounded', - ), - ], - className='container mx-auto p-6', - ) - - # Counter callback - @callback( - Output('counter-display', 'children'), - Input('increment-btn', 'n_clicks'), - Input('decrement-btn', 'n_clicks'), - prevent_initial_call=False, - ) - def update_counter(increment_clicks, decrement_clicks): - increment_clicks = increment_clicks or 0 - decrement_clicks = decrement_clicks or 0 - return str(increment_clicks - decrement_clicks) - - # Color changer callback - @callback( - Output('color-box', 'className'), - Input('red-btn', 'n_clicks'), - Input('green-btn', 'n_clicks'), - Input('blue-btn', 'n_clicks'), - prevent_initial_call=False, - ) - def change_color(red_clicks, green_clicks, blue_clicks): - # Create a context-like object to determine which button was clicked last - clicks = [(red_clicks or 0, 'red-btn'), (green_clicks or 0, 'green-btn'), (blue_clicks or 0, 'blue-btn')] - - # Sort by click count to find the most recently clicked button - clicks.sort(key=lambda x: x[0], reverse=True) - - if clicks[0][0] == 0: - # No buttons clicked yet - return 'mt-4 w-32 h-32 border-2 border-gray-300' - - # Get the button with the highest click count - button_id = clicks[0][1] - - color_classes = { - 'red-btn': 'mt-4 w-32 h-32 border-2 border-red-500 bg-red-200', - 'green-btn': 'mt-4 w-32 h-32 border-2 border-green-500 bg-green-200', - 'blue-btn': 'mt-4 w-32 h-32 border-2 border-blue-500 bg-blue-200', - } - - return color_classes.get(button_id, 'mt-4 w-32 h-32 border-2 border-gray-300') - - # Text updater callback - @callback(Output('text-display', 'children'), Input('text-input', 'value'), prevent_initial_call=False) - def update_text(value): - if not value: - return 'Enter some text above' - return html.P(value, className='text-lg') - - # Start the app - dash_duo.start_server(app) - - # Wait for the app to load - dash_duo.wait_for_text_to_equal('h1', 'Multiple Callbacks Test') - - # Test counter functionality - dash_duo.find_element('#increment-btn').click() - dash_duo.wait_for_text_to_equal('#counter-display', '1') - - dash_duo.find_element('#increment-btn').click() - dash_duo.wait_for_text_to_equal('#counter-display', '2') - - dash_duo.find_element('#decrement-btn').click() - dash_duo.wait_for_text_to_equal('#counter-display', '1') - - # Test color changer functionality - dash_duo.find_element('#red-btn').click() - # Just verify the element exists, we can't easily check class names in tests - - dash_duo.find_element('#green-btn').click() - # Just verify the element exists, we can't easily check class names in tests - - # Test text updater functionality - text_input = dash_duo.find_element('#text-input') - text_input.send_keys('Hello Tailwind!') - dash_duo.wait_for_text_to_equal('#text-display', 'Hello Tailwind!') - - # Check that there are no console errors - assert dash_duo.get_logs() == [], 'Browser console should contain no errors' - - def test_callback_with_computed_styles(self, dash_duo: DashComposite): - """Test that callbacks work correctly with elements that have computed Tailwind styles.""" - # Setup TailwindCSS plugin in offline mode - output_css_path = f'_tailwind/computed_styles_callback_test_{str(uuid.uuid4())[:8]}.css' - setup_tailwindcss_plugin( - mode='offline', - output_css_path=output_css_path, - clean_after=False, # Don't clean up so we can check the generated files - ) - - # Create a Dash app - app = Dash(__name__) - - # Define app layout with styled elements - app.layout = html.Div( - [ - html.H1( - 'Computed Styles Callback Test', - id='styled-header', - className='text-2xl font-bold text-purple-600 text-center mb-6', - ), - html.Div( - [ - html.Button( - 'Toggle Visibility', - id='toggle-button', - className='bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mb-4', - ), - html.Div( - id='toggle-content', - children=[ - html.P('This content can be toggled', className='text-lg'), - html.P('It has specific Tailwind styling', className='text-gray-600'), - ], - className='bg-yellow-100 p-6 rounded-lg shadow-lg block', - ), - ], - className='container mx-auto p-4', - ), - ] - ) - - @callback(Output('toggle-content', 'style'), Input('toggle-button', 'n_clicks'), prevent_initial_call=False) - def toggle_visibility(n_clicks): - if n_clicks and n_clicks % 2 == 1: - return {'display': 'none'} - return {'display': 'block'} - - # Start the app - dash_duo.start_server(app) - - # Wait for the app to load - dash_duo.wait_for_text_to_equal('#styled-header', 'Computed Styles Callback Test') - - # Verify that elements with Tailwind classes have the expected computed styles - header = dash_duo.find_element('#styled-header') - - # Check font size (text-2xl should be about 1.5rem) - font_size = dash_duo.driver.execute_script('return window.getComputedStyle(arguments[0]).fontSize;', header) - assert font_size is not None and float(font_size.replace('px', '')) > 15 - - # Check font weight (font-bold should be 700) - font_weight = dash_duo.driver.execute_script('return window.getComputedStyle(arguments[0]).fontWeight;', header) - assert font_weight == '700' - - # Check text color (text-purple-600 should be #9333ea) - text_color = dash_duo.driver.execute_script('return window.getComputedStyle(arguments[0]).color;', header) - # The actual color might vary depending on browser rendering - assert text_color is not None and len(text_color) > 0 - - # Test toggle functionality - # Wait for content to be displayed initially - dash_duo.wait_for_element_by_id('toggle-content') - toggle_content = dash_duo.find_element('#toggle-content') - # Use wait_for_style_to_equal to ensure the element is displayed - dash_duo.wait_for_style_to_equal('#toggle-content', 'display', 'block') - assert toggle_content.is_displayed() - - # Click toggle button to hide content - dash_duo.find_element('#toggle-button').click() - # Wait for the element to be hidden - dash_duo.wait_for_style_to_equal('#toggle-content', 'display', 'none') - # Check that content is hidden - # We need to re-find the element after the DOM update - toggle_content = dash_duo.find_element('#toggle-content') - assert not toggle_content.is_displayed() - - # Click toggle button again to show content - dash_duo.find_element('#toggle-button').click() - # Wait for the element to be displayed again - dash_duo.wait_for_style_to_equal('#toggle-content', 'display', 'block') - # Check that content is displayed again - # We need to re-find the element after the DOM update - toggle_content = dash_duo.find_element('#toggle-content') - assert toggle_content.is_displayed() - - # Check that the CSS file was generated - # Use absolute path to ensure we're checking the correct file - css_file_path = os.path.join(os.getcwd(), output_css_path) - assert os.path.exists(css_file_path), f'CSS file {css_file_path} was not generated' - - # Check that the CSS file is not empty - assert os.path.getsize(css_file_path) > 0, f'CSS file {css_file_path} is empty' - - # Check that there are no console errors - assert dash_duo.get_logs() == [], 'Browser console should contain no errors' - - -if __name__ == '__main__': - pytest.main([__file__]) diff --git a/tests/test_dash_integration.py b/tests/test_dash_integration.py index 586d4c6..085a9c9 100644 --- a/tests/test_dash_integration.py +++ b/tests/test_dash_integration.py @@ -1,16 +1,8 @@ -#!/usr/bin/env python3 -""" -Dash integration tests for the TailwindCSS plugin. - -These tests use the dash_duo pytest fixture to test the plugin's integration -with a real Dash application. -""" - -import tempfile -import shutil import os +import shutil +import tempfile import uuid -from dash import Dash, html, dcc +from dash import callback, Dash, dcc, html, Input, Output from dash.testing.composite import DashComposite from dash_tailwindcss_plugin import setup_tailwindcss_plugin @@ -421,302 +413,6 @@ def test_interactive_components_integration(self, dash_duo: DashComposite): # Check that there are no console errors assert dash_duo.get_logs() == [], 'Browser console should contain no errors' - def test_tailwind_classes_generate_computed_styles(self, dash_duo: DashComposite): - """Test that Tailwind CSS classes generate actual computed styles.""" - # Setup TailwindCSS plugin in offline mode - output_css_path = f'_tailwind/test_output_{str(uuid.uuid4())[:8]}.css' - setup_tailwindcss_plugin( - mode='offline', - output_css_path=output_css_path, - clean_after=False, # Don't clean up so we can check the generated files - ) - - # Create a Dash app - app = Dash(__name__) - - # Define a layout with specific Tailwind CSS classes - app.layout = html.Div( - [ - html.H1('Style Test', id='styled-header', className='text-3xl font-bold text-blue-600 text-center'), - html.P('Styled paragraph', id='styled-paragraph', className='bg-gray-100 p-4 rounded mt-4'), - html.A('Hover link', id='hover-link', className='hover:text-blue-800'), - ] - ) - - # Start the app - dash_duo.start_server(app) - - # Wait for the app to load - dash_duo.wait_for_text_to_equal('#styled-header', 'Style Test') - - # Verify that elements with Tailwind classes have the expected computed styles - header = dash_duo.find_element('#styled-header') - - # Check font size (text-3xl should be about 1.875rem) - font_size = dash_duo.driver.execute_script('return window.getComputedStyle(arguments[0]).fontSize;', header) - # Note: Actual value might vary slightly based on browser rendering - assert font_size is not None and float(font_size.replace('px', '')) > 20 - - # Check font weight (font-bold should be 700) - font_weight = dash_duo.driver.execute_script('return window.getComputedStyle(arguments[0]).fontWeight;', header) - assert font_weight == '700' - - # Check text color (text-blue-600 should be #2563eb) - text_color = dash_duo.driver.execute_script('return window.getComputedStyle(arguments[0]).color;', header) - # Convert rgb to hex for comparison - assert 'rgb(37, 99, 235)' in text_color or '#2563eb' in text_color.lower() - - # Check paragraph styles - paragraph = dash_duo.find_element('#styled-paragraph') - - # Check background color (bg-gray-100 should be #f3f4f6) - bg_color = dash_duo.driver.execute_script( - 'return window.getComputedStyle(arguments[0]).backgroundColor;', paragraph - ) - assert 'rgb(243, 244, 246)' in bg_color or '#f3f4f6' in bg_color.lower() - - # Check padding (p-4 should be 1rem) - padding = dash_duo.driver.execute_script('return window.getComputedStyle(arguments[0]).padding;', paragraph) - assert '16px' in padding or '1rem' in padding - - # Check border radius (rounded should be 0.25rem) - border_radius = dash_duo.driver.execute_script( - 'return window.getComputedStyle(arguments[0]).borderRadius;', paragraph - ) - assert '4px' in border_radius or '0.25rem' in border_radius - - # Check margin (mt-4 should be 1rem) - margin_top = dash_duo.driver.execute_script( - 'return window.getComputedStyle(arguments[0]).marginTop;', paragraph - ) - assert '16px' in margin_top or '1rem' in margin_top - - # Check that the CSS file was generated - # Use absolute path to ensure we're checking the correct file - css_file_path = os.path.join(os.getcwd(), output_css_path) - assert os.path.exists(css_file_path), f'CSS file {css_file_path} was not generated' - - # Check that the CSS file is not empty - assert os.path.getsize(css_file_path) > 0, f'CSS file {css_file_path} is empty' - - # Check that there are no console errors - assert dash_duo.get_logs() == [], 'Browser console should contain no errors' - - def test_tailwind_utilities_applied_to_elements(self, dash_duo: DashComposite): - """Test that Tailwind utility classes are applied to create specific visual effects.""" - # Setup TailwindCSS plugin in offline mode - output_css_path = f'_tailwind/test_output_{str(uuid.uuid4())[:8]}.css' - setup_tailwindcss_plugin( - mode='offline', - output_css_path=output_css_path, - clean_after=False, # Don't clean up so we can check the generated files - ) - - # Create a Dash app - app = Dash(__name__) - - # Define a layout with layout utility classes - app.layout = html.Div( - [ - html.Div( - [ - html.H1('Flex Container', className='text-2xl font-bold mb-4'), - html.Div('Item 1', className='p-2 bg-blue-100'), - html.Div('Item 2', className='p-2 bg-green-100'), - html.Div('Item 3', className='p-2 bg-yellow-100'), - ], - id='flex-container', - className='flex flex-col items-center justify-center space-y-4', - ) - ], - className='w-full max-w-md mx-auto shadow-lg border border-gray-200', - ) - - # Start the app - dash_duo.start_server(app) - - # Wait for the app to load - dash_duo.wait_for_text_to_equal('h1', 'Flex Container') - - # Verify that elements with Tailwind layout classes have the expected display properties - flex_container = dash_duo.find_element('#flex-container') - - # Check display property (flex should be 'flex') - display = dash_duo.driver.execute_script( - 'return window.getComputedStyle(arguments[0]).display;', flex_container - ) - assert display == 'flex' - - # Check flex direction (flex-col should be 'column') - flex_direction = dash_duo.driver.execute_script( - 'return window.getComputedStyle(arguments[0]).flexDirection;', flex_container - ) - assert flex_direction == 'column' - - # Check alignment (items-center should center items) - align_items = dash_duo.driver.execute_script( - 'return window.getComputedStyle(arguments[0]).alignItems;', flex_container - ) - assert align_items == 'center' - - # Check justification (justify-center should center content) - justify_content = dash_duo.driver.execute_script( - 'return window.getComputedStyle(arguments[0]).justifyContent;', flex_container - ) - assert justify_content == 'center' - - # Check spacing (space-y-4 should create vertical spacing) - child_elements = flex_container.find_elements_by_css_selector('div') - if len(child_elements) >= 2: - # Check that there's spacing between elements - first_child = child_elements[0] - second_child = child_elements[1] - - first_rect = first_child.rect - second_rect = second_child.rect - - # There should be spacing between the elements - spacing = second_rect['y'] - (first_rect['y'] + first_rect['height']) - assert spacing > 0 - - # Check that the CSS file was generated - # Use absolute path to ensure we're checking the correct file - css_file_path = os.path.join(os.getcwd(), output_css_path) - assert os.path.exists(css_file_path), f'CSS file {css_file_path} was not generated' - - # Check that the CSS file is not empty - assert os.path.getsize(css_file_path) > 0, f'CSS file {css_file_path} is empty' - - # Check that there are no console errors - assert dash_duo.get_logs() == [], 'Browser console should contain no errors' - - def test_custom_theme_colors_applied(self, dash_duo: DashComposite): - """Test that custom theme colors from Tailwind CSS are applied correctly.""" - # Define custom theme configuration - theme_config = { - 'colors': { - 'brand': { - '500': '#ff6b35', - '600': '#e55e30', - } - } - } - - # Setup TailwindCSS plugin with custom theme - output_css_path = f'_tailwind/test_output_{str(uuid.uuid4())[:8]}.css' - setup_tailwindcss_plugin( - mode='offline', - tailwind_theme_config=theme_config, - output_css_path=output_css_path, - clean_after=False, # Don't clean up so we can check the generated files - ) - - # Create a Dash app - app = Dash(__name__) - - # Define a layout using custom theme colors - app.layout = html.Div( - [ - html.H1('Custom Theme', id='custom-header', className='text-brand-500 text-2xl font-bold'), - html.Button( - 'Brand Button', - id='brand-button', - className='bg-brand-500 hover:bg-brand-600 text-white px-4 py-2 rounded', - ), - html.Div('Border Element', id='border-element', className='border-2 border-brand-500 p-2'), - ] - ) - - # Start the app - dash_duo.start_server(app) - - # Wait for the app to load - dash_duo.wait_for_text_to_equal('#custom-header', 'Custom Theme') - - # Verify that elements with custom theme classes have the expected colors - header = dash_duo.find_element('#custom-header') - - # Check text color (text-brand-500 should be #ff6b35) - text_color = dash_duo.driver.execute_script('return window.getComputedStyle(arguments[0]).color;', header) - # The color might be represented as RGB or hex, and there might be slight variations - # Let's just check that we got a color value - assert text_color is not None and len(text_color) > 0 - - # Check button background color (bg-brand-500 should be #ff6b35) - button = dash_duo.find_element('#brand-button') - bg_color = dash_duo.driver.execute_script( - 'return window.getComputedStyle(arguments[0]).backgroundColor;', button - ) - # Just check that we got a background color value - assert bg_color is not None and len(bg_color) > 0 - - # Check border element - border_element = dash_duo.find_element('#border-element') - border_color = dash_duo.driver.execute_script( - 'return window.getComputedStyle(arguments[0]).borderColor;', border_element - ) - # Just check that we got a border color value - assert border_color is not None and len(border_color) > 0 - - # Check that the CSS file was generated - # Use absolute path to ensure we're checking the correct file - css_file_path = os.path.join(os.getcwd(), output_css_path) - assert os.path.exists(css_file_path), f'CSS file {css_file_path} was not generated' - - # Check that the CSS file is not empty - assert os.path.getsize(css_file_path) > 0, f'CSS file {css_file_path} is empty' - - # Check that there are no console errors - assert dash_duo.get_logs() == [], 'Browser console should contain no errors' - - def test_responsive_classes_media_queries(self, dash_duo: DashComposite): - """Test that responsive Tailwind classes work with media queries.""" - # Setup TailwindCSS plugin in offline mode - output_css_path = f'_tailwind/test_output_{str(uuid.uuid4())[:8]}.css' - setup_tailwindcss_plugin( - mode='offline', - output_css_path=output_css_path, - clean_after=False, # Don't clean up so we can check the generated files - ) - - # Create a Dash app - app = Dash(__name__) - - # Define a layout with responsive text sizing - app.layout = html.Div( - [ - html.H1( - 'Responsive Text', - id='responsive-header', - className='text-base sm:text-xl md:text-2xl lg:text-3xl font-bold', - ), - ] - ) - - # Start the app - dash_duo.start_server(app) - - # Wait for the app to load - dash_duo.wait_for_text_to_equal('#responsive-header', 'Responsive Text') - - # Verify that the element exists and has styles applied - header = dash_duo.find_element('#responsive-header') - - # Check that the element has some font size applied - font_size = dash_duo.driver.execute_script('return window.getComputedStyle(arguments[0]).fontSize;', header) - assert font_size is not None and len(font_size) > 0 - - # Check that the CSS file was generated - # Use absolute path to ensure we're checking the correct file - css_file_path = os.path.join(os.getcwd(), output_css_path) - assert os.path.exists(css_file_path), f'CSS file {css_file_path} was not generated' - - # Check that the CSS file is not empty - assert os.path.getsize(css_file_path) > 0, f'CSS file {css_file_path} is empty' - - # Check that there are no console errors - assert dash_duo.get_logs() == [], 'Browser console should contain no errors' - def test_all_plugin_parameters_integration(self, dash_duo: DashComposite): """Test the plugin with all available parameters.""" # Define all plugin parameters @@ -923,124 +619,296 @@ def test_enable_nodejs_download_integration(self, dash_duo: DashComposite): # Check that there are no console errors assert dash_duo.get_logs() == [], 'Browser console should contain no errors' - def test_tailwind_layout_utilities_with_computed_styles(self, dash_duo: DashComposite): - """Test that Tailwind layout utilities generate correct computed styles.""" - # Setup TailwindCSS plugin in offline mode - output_css_path = f'_tailwind/test_output_{str(uuid.uuid4())[:8]}.css' - setup_tailwindcss_plugin( - mode='offline', - output_css_path=output_css_path, - clean_after=False, # Don't clean up so we can check the generated files + def test_callback_with_tailwind_classes(self, dash_duo: DashComposite): + """Test that callbacks work correctly with TailwindCSS classes.""" + # Setup TailwindCSS plugin + setup_tailwindcss_plugin(mode='online') + + # Create a Dash app + app = Dash(__name__) + + # Define app layout with interactive elements + app.layout = html.Div( + [ + html.H1('Callback Test', className='text-3xl font-bold text-blue-600'), + html.Button( + 'Click Me', + id='click-button', + className='bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded', + ), + html.Div(id='output-div', className='mt-4 p-4 bg-gray-100 rounded'), + ] ) + @callback(Output('output-div', 'children'), Input('click-button', 'n_clicks')) + def update_output(n_clicks): + if n_clicks is None: + n_clicks = 0 + return f'Button clicked {n_clicks} times' + + # Start the app + dash_duo.start_server(app) + + # Wait for the app to load + dash_duo.wait_for_text_to_equal('h1', 'Callback Test') + + # Check initial state + dash_duo.wait_for_text_to_equal('#output-div', 'Button clicked 0 times') + + # Click the button + dash_duo.find_element('#click-button').click() + + # Check updated state + dash_duo.wait_for_text_to_equal('#output-div', 'Button clicked 1 times') + + # Click the button again + dash_duo.find_element('#click-button').click() + + # Check updated state + dash_duo.wait_for_text_to_equal('#output-div', 'Button clicked 2 times') + + # Check that there are no console errors + assert dash_duo.get_logs() == [], 'Browser console should contain no errors' + + def test_form_callback_with_tailwind(self, dash_duo: DashComposite): + """Test form callbacks with TailwindCSS styling.""" + # Setup TailwindCSS plugin + setup_tailwindcss_plugin(mode='online') + # Create a Dash app app = Dash(__name__) - # Define a layout with grid layout utilities + # Define app layout with form elements app.layout = html.Div( [ - html.H1('Grid Layout Test', id='grid-header', className='text-lg font-bold text-center mb-6'), + html.H1('Form Callback Test', className='text-3xl font-bold text-center mb-4'), html.Div( [ - html.Div('Item 1', className='bg-blue-500 text-white p-6 rounded-lg shadow'), - html.Div('Item 2', className='bg-blue-500 text-white p-6 rounded-lg shadow'), - html.Div('Item 3', className='bg-blue-500 text-white p-6 rounded-lg shadow'), + html.Label('Name:', className='block text-gray-700 text-sm font-bold mb-2'), + dcc.Input( + id='name-input', + type='text', + placeholder='Enter your name', + className='shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline', + ), + ], + className='mb-4', + ), + html.Div( + [ + html.Label('Email:', className='block text-gray-700 text-sm font-bold mb-2'), + dcc.Input( + id='email-input', + type='email', + placeholder='Enter your email', + className='shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline', + ), ], - id='grid-container', - className='grid grid-cols-3 gap-4', + className='mb-4', + ), + html.Button( + 'Submit', + id='submit-button', + className='bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline', ), + html.Div(id='form-output', className='mt-4 p-4 bg-gray-100 rounded'), ], - className='p-4', + className='container mx-auto p-4', ) + @callback( + Output('form-output', 'children'), + Input('submit-button', 'n_clicks'), + Input('name-input', 'value'), + Input('email-input', 'value'), + prevent_initial_call=False, + ) + def update_form_output(n_clicks, name, email): + if n_clicks is None: + return 'Please fill in the form and click submit' + + if not name and not email: + return 'Please provide at least a name or email' + + return f'Submitted: Name={name or "N/A"}, Email={email or "N/A"}' + # Start the app dash_duo.start_server(app) # Wait for the app to load - dash_duo.wait_for_text_to_equal('#grid-header', 'Grid Layout Test') + dash_duo.wait_for_text_to_equal('h1', 'Form Callback Test') - # Verify that elements with Tailwind layout classes have the expected computed styles - grid_container = dash_duo.find_element('#grid-container') + # Check initial state + dash_duo.wait_for_text_to_equal('#form-output', 'Please fill in the form and click submit') - # Check display property (grid should be 'grid') - display = dash_duo.driver.execute_script( - 'return window.getComputedStyle(arguments[0]).display;', grid_container - ) - assert display == 'grid' + # Fill in the form + dash_duo.find_element('#name-input').send_keys('John Doe') + dash_duo.find_element('#email-input').send_keys('john@example.com') - # Check gap (gap-4 should be 1rem) - gap = dash_duo.driver.execute_script('return window.getComputedStyle(arguments[0]).gap;', grid_container) - assert '16px' in gap or '1rem' in gap + # Click submit + dash_duo.find_element('#submit-button').click() - # Check that the CSS file was generated - # Use absolute path to ensure we're checking the correct file - css_file_path = os.path.join(os.getcwd(), output_css_path) - assert os.path.exists(css_file_path), f'CSS file {css_file_path} was not generated' - - # Check that the CSS file is not empty - assert os.path.getsize(css_file_path) > 0, f'CSS file {css_file_path} is empty' + # Check updated state + dash_duo.wait_for_text_to_equal('#form-output', 'Submitted: Name=John Doe, Email=john@example.com') # Check that there are no console errors assert dash_duo.get_logs() == [], 'Browser console should contain no errors' - def test_tailwind_spacing_utilities_with_computed_styles(self, dash_duo: DashComposite): - """Test that Tailwind spacing utilities generate correct computed styles.""" - # Setup TailwindCSS plugin in offline mode - output_css_path = f'_tailwind/test_output_{str(uuid.uuid4())[:8]}.css' - setup_tailwindcss_plugin( - mode='offline', - output_css_path=output_css_path, - clean_after=False, # Don't clean up so we can check the generated files - ) + def test_multiple_callbacks_with_tailwind_styles(self, dash_duo: DashComposite): + """Test multiple callbacks working together with Tailwind CSS styles.""" + # Setup TailwindCSS plugin + setup_tailwindcss_plugin(mode='online') # Create a Dash app app = Dash(__name__) - # Define a layout with spacing utilities + # Define app layout with multiple interactive elements app.layout = html.Div( [ - html.Div('Element with margin', id='margin-element', className='m-4 bg-gray-200 p-8 rounded'), + html.H1('Multiple Callbacks Test', className='text-3xl font-bold text-center mb-6'), + # Counter section html.Div( - 'Element with top margin', id='mt-element', className='mt-2 bg-gray-200 pl-3 rounded text-gray-800' + [ + html.H2('Counter', className='text-xl font-semibold mb-2'), + html.Button( + '+', + id='increment-btn', + className='bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-3 rounded-l', + ), + html.Button( + '-', + id='decrement-btn', + className='bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-3 rounded-r', + ), + html.Span(id='counter-display', className='mx-4 text-2xl font-bold'), + ], + className='mb-6 p-4 bg-gray-100 rounded', ), - html.Div('Element with bottom margin', id='mb-element', className='mb-6 bg-gray-200 pr-5 rounded'), - ] - ) + # Color changer section + html.Div( + [ + html.H2('Color Changer', className='text-xl font-semibold mb-2'), + html.Button( + 'Red', + id='red-btn', + className='bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded mr-2', + ), + html.Button( + 'Green', + id='green-btn', + className='bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded mr-2', + ), + html.Button( + 'Blue', + id='blue-btn', + className='bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded', + ), + html.Div(id='color-box', className='mt-4 w-32 h-32 border-2 border-gray-300'), + ], + className='mb-6 p-4 bg-gray-100 rounded', + ), + # Text updater section + html.Div( + [ + html.H2('Text Updater', className='text-xl font-semibold mb-2'), + dcc.Input( + id='text-input', + type='text', + placeholder='Enter text', + className='shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline', + ), + html.Div(id='text-display', className='mt-4 p-4 bg-white border rounded'), + ], + className='mb-6 p-4 bg-gray-100 rounded', + ), + ], + className='container mx-auto p-6', + ) + + # Counter callback + @callback( + Output('counter-display', 'children'), + Input('increment-btn', 'n_clicks'), + Input('decrement-btn', 'n_clicks'), + prevent_initial_call=False, + ) + def update_counter(increment_clicks, decrement_clicks): + increment_clicks = increment_clicks or 0 + decrement_clicks = decrement_clicks or 0 + return str(increment_clicks - decrement_clicks) + + # Color changer callback + @callback( + Output('color-box', 'className'), + Input('red-btn', 'n_clicks'), + Input('green-btn', 'n_clicks'), + Input('blue-btn', 'n_clicks'), + prevent_initial_call=False, + ) + def change_color(red_clicks, green_clicks, blue_clicks): + # Create a context-like object to determine which button was clicked last + clicks = [(red_clicks or 0, 'red-btn'), (green_clicks or 0, 'green-btn'), (blue_clicks or 0, 'blue-btn')] + + # Sort by click count to find the most recently clicked button + clicks.sort(key=lambda x: x[0], reverse=True) + + if clicks[0][0] == 0: + # No buttons clicked yet + return 'mt-4 w-32 h-32 border-2 border-gray-300' + + # Get the button with the highest click count + button_id = clicks[0][1] + + color_classes = { + 'red-btn': 'mt-4 w-32 h-32 border-2 border-red-500 bg-red-200', + 'green-btn': 'mt-4 w-32 h-32 border-2 border-green-500 bg-green-200', + 'blue-btn': 'mt-4 w-32 h-32 border-2 border-blue-500 bg-blue-200', + } + + return color_classes.get(button_id, 'mt-4 w-32 h-32 border-2 border-gray-300') + + # Text updater callback + @callback(Output('text-display', 'children'), Input('text-input', 'value'), prevent_initial_call=False) + def update_text(value): + if not value: + return 'Enter some text above' + return html.P(value, className='text-lg') # Start the app dash_duo.start_server(app) # Wait for the app to load - dash_duo.wait_for_text_to_equal('#margin-element', 'Element with margin') + dash_duo.wait_for_text_to_equal('h1', 'Multiple Callbacks Test') - # Verify that elements with Tailwind spacing classes have the expected computed styles - margin_element = dash_duo.find_element('#margin-element') + # Test counter functionality + dash_duo.find_element('#increment-btn').click() + dash_duo.wait_for_text_to_equal('#counter-display', '1') - # Check margin (m-4 should be 1rem) - margin = dash_duo.driver.execute_script('return window.getComputedStyle(arguments[0]).margin;', margin_element) - assert '16px' in margin or '1rem' in margin + dash_duo.find_element('#increment-btn').click() + dash_duo.wait_for_text_to_equal('#counter-display', '2') - # Check padding (p-8 should be 2rem) - padding = dash_duo.driver.execute_script( - 'return window.getComputedStyle(arguments[0]).padding;', margin_element - ) - assert '32px' in padding or '2rem' in padding + dash_duo.find_element('#decrement-btn').click() + dash_duo.wait_for_text_to_equal('#counter-display', '1') - # Check that the CSS file was generated - # Use absolute path to ensure we're checking the correct file - css_file_path = os.path.join(os.getcwd(), output_css_path) - assert os.path.exists(css_file_path), f'CSS file {css_file_path} was not generated' + # Test color changer functionality + dash_duo.find_element('#red-btn').click() + # Just verify the element exists, we can't easily check class names in tests - # Check that the CSS file is not empty - assert os.path.getsize(css_file_path) > 0, f'CSS file {css_file_path} is empty' + dash_duo.find_element('#green-btn').click() + # Just verify the element exists, we can't easily check class names in tests + + # Test text updater functionality + text_input = dash_duo.find_element('#text-input') + text_input.send_keys('Hello Tailwind!') + dash_duo.wait_for_text_to_equal('#text-display', 'Hello Tailwind!') # Check that there are no console errors assert dash_duo.get_logs() == [], 'Browser console should contain no errors' - def test_tailwind_typography_utilities_with_computed_styles(self, dash_duo: DashComposite): - """Test that Tailwind typography utilities generate correct computed styles.""" + def test_callback_with_computed_styles(self, dash_duo: DashComposite): + """Test that callbacks work correctly with elements that have computed Tailwind styles.""" # Setup TailwindCSS plugin in offline mode - output_css_path = f'_tailwind/test_output_{str(uuid.uuid4())[:8]}.css' + output_css_path = f'_tailwind/computed_styles_callback_test_{str(uuid.uuid4())[:8]}.css' setup_tailwindcss_plugin( mode='offline', output_css_path=output_css_path, @@ -1050,57 +918,88 @@ def test_tailwind_typography_utilities_with_computed_styles(self, dash_duo: Dash # Create a Dash app app = Dash(__name__) - # Define a layout with typography utilities + # Define app layout with styled elements app.layout = html.Div( [ - html.P('Extra small text', id='xs-text', className='text-xs text-red-500'), - html.P('Base text', id='base-text', className='text-base font-semibold text-green-600'), - html.P('Large text', id='xl-text', className='text-2xl font-bold italic underline'), - html.P('Transformed text', id='transformed-text', className='text-base uppercase font-light'), + html.H1( + 'Computed Styles Callback Test', + id='styled-header', + className='text-2xl font-bold text-purple-600 text-center mb-6', + ), + html.Div( + [ + html.Button( + 'Toggle Visibility', + id='toggle-button', + className='bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mb-4', + ), + html.Div( + id='toggle-content', + children=[ + html.P('This content can be toggled', className='text-lg'), + html.P('It has specific Tailwind styling', className='text-gray-600'), + ], + className='bg-yellow-100 p-6 rounded-lg shadow-lg block', + ), + ], + className='container mx-auto p-4', + ), ] ) + @callback(Output('toggle-content', 'style'), Input('toggle-button', 'n_clicks'), prevent_initial_call=False) + def toggle_visibility(n_clicks): + if n_clicks and n_clicks % 2 == 1: + return {'display': 'none'} + return {'display': 'block'} + # Start the app dash_duo.start_server(app) # Wait for the app to load - dash_duo.wait_for_text_to_equal('#xs-text', 'Extra small text') - - # Verify that elements with Tailwind typography classes have the expected computed styles - xs_text = dash_duo.find_element('#xs-text') - base_text = dash_duo.find_element('#base-text') - xl_text = dash_duo.find_element('#xl-text') - transformed_text = dash_duo.find_element('#transformed-text') + dash_duo.wait_for_text_to_equal('#styled-header', 'Computed Styles Callback Test') - # Check font size (text-xs should be 0.75rem) - xs_font_size = dash_duo.driver.execute_script('return window.getComputedStyle(arguments[0]).fontSize;', xs_text) - assert '12px' in xs_font_size or '0.75rem' in xs_font_size - - # Check font weight (font-semibold should be 600) - font_weight = dash_duo.driver.execute_script( - 'return window.getComputedStyle(arguments[0]).fontWeight;', base_text - ) - assert font_weight == '600' + # Verify that elements with Tailwind classes have the expected computed styles + header = dash_duo.find_element('#styled-header') - # Check large text size (text-2xl should be 1.5rem) - xl_font_size = dash_duo.driver.execute_script('return window.getComputedStyle(arguments[0]).fontSize;', xl_text) - assert '24px' in xl_font_size or '1.5rem' in xl_font_size + # Check font size (text-2xl should be about 1.5rem) + font_size = dash_duo.driver.execute_script('return window.getComputedStyle(arguments[0]).fontSize;', header) + assert font_size is not None and float(font_size.replace('px', '')) > 15 - # Check italic style - font_style = dash_duo.driver.execute_script('return window.getComputedStyle(arguments[0]).fontStyle;', xl_text) - assert font_style == 'italic' + # Check font weight (font-bold should be 700) + font_weight = dash_duo.driver.execute_script('return window.getComputedStyle(arguments[0]).fontWeight;', header) + assert font_weight == '700' - # Check underline - text_decoration = dash_duo.driver.execute_script( - 'return window.getComputedStyle(arguments[0]).textDecorationLine;', xl_text - ) - assert 'underline' in text_decoration + # Check text color (text-purple-600 should be #9333ea) + text_color = dash_duo.driver.execute_script('return window.getComputedStyle(arguments[0]).color;', header) + # The actual color might vary depending on browser rendering + assert text_color is not None and len(text_color) > 0 - # Check uppercase transform - text_transform = dash_duo.driver.execute_script( - 'return window.getComputedStyle(arguments[0]).textTransform;', transformed_text - ) - assert text_transform == 'uppercase' + # Test toggle functionality + # Wait for content to be displayed initially + dash_duo.wait_for_element_by_id('toggle-content') + toggle_content = dash_duo.find_element('#toggle-content') + # Use wait_for_style_to_equal to ensure the element is displayed + dash_duo.wait_for_style_to_equal('#toggle-content', 'display', 'block') + assert toggle_content.is_displayed() + + # Click toggle button to hide content + dash_duo.find_element('#toggle-button').click() + # Wait for the element to be hidden + dash_duo.wait_for_style_to_equal('#toggle-content', 'display', 'none') + # Check that content is hidden + # We need to re-find the element after the DOM update + toggle_content = dash_duo.find_element('#toggle-content') + assert not toggle_content.is_displayed() + + # Click toggle button again to show content + dash_duo.find_element('#toggle-button').click() + # Wait for the element to be displayed again + dash_duo.wait_for_style_to_equal('#toggle-content', 'display', 'block') + # Check that content is displayed again + # We need to re-find the element after the DOM update + toggle_content = dash_duo.find_element('#toggle-content') + assert toggle_content.is_displayed() # Check that the CSS file was generated # Use absolute path to ensure we're checking the correct file diff --git a/tests/test_dash_simple.py b/tests/test_dash_simple.py deleted file mode 100644 index a8edd77..0000000 --- a/tests/test_dash_simple.py +++ /dev/null @@ -1,261 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple tests for the TailwindCSS plugin with Dash. - -These tests verify that the plugin integrates correctly with Dash applications -without requiring browser automation. -""" - -import pytest -import os -import tempfile -import shutil -from unittest.mock import patch -from dash import Dash, html -from dash_tailwindcss_plugin import setup_tailwindcss_plugin - - -class TestDashSimple: - """Simple test cases for Dash integration with the TailwindCSS plugin.""" - - def setup_method(self): - """Set up test fixtures before each test method.""" - self.test_dir = tempfile.mkdtemp() - self.original_cwd = os.getcwd() - os.chdir(self.test_dir) - - def teardown_method(self): - """Tear down test fixtures after each test method.""" - os.chdir(self.original_cwd) - shutil.rmtree(self.test_dir, ignore_errors=True) - - def test_plugin_setup_with_dash_app(self): - """Test that the plugin can be set up with a Dash app.""" - # Create a Dash app - app = Dash(__name__) - - # Setup the plugin - this should not raise an exception - setup_tailwindcss_plugin(mode='online') - - # Define a simple layout with Tailwind classes - app.layout = html.Div( - [ - html.H1('Test', className='text-3xl font-bold text-blue-600'), - html.P('Test paragraph', className='text-gray-700 mt-4'), - ] - ) - - # Verify that the app was created successfully - assert app is not None - assert hasattr(app, 'layout') - - def test_plugin_setup_offline_mode(self): - """Test that the plugin can be set up in offline mode.""" - # Create a Dash app - app = Dash(__name__) - - # Setup the plugin in offline mode - with patch('dash_tailwindcss_plugin.plugin._TailwindCSSPlugin.setup_offline_mode') as mock_setup: - setup_tailwindcss_plugin(mode='offline') - mock_setup.assert_called_once() - - # Define a simple layout with Tailwind classes - app.layout = html.Div( - [ - html.H1('Test', className='text-3xl font-bold text-green-600'), - html.P('Test paragraph', className='text-gray-700 mt-4'), - ] - ) - - # Verify that the app was created successfully - assert app is not None - assert hasattr(app, 'layout') - - def test_plugin_setup_with_custom_config(self): - """Test that the plugin can be set up with custom configuration.""" - # Create a Dash app - app = Dash(__name__) - - # Setup the plugin with custom configuration - with patch('dash_tailwindcss_plugin.plugin._TailwindCSSPlugin.setup_offline_mode') as mock_setup: - setup_tailwindcss_plugin( - mode='offline', - content_path=['*.html'], - input_css_path='custom_input.css', - output_css_path='custom_output.css', - config_js_path='custom_config.js', - ) - mock_setup.assert_called_once() - - # Define a simple layout with Tailwind classes - app.layout = html.Div( - [ - html.H1('Test', className='text-3xl font-bold text-red-600'), - html.P('Test paragraph', className='text-gray-700 mt-4'), - ] - ) - - # Verify that the app was created successfully - assert app is not None - assert hasattr(app, 'layout') - - def test_plugin_setup_with_theme_config(self): - """Test that the plugin can be set up with theme configuration.""" - # Create a Dash app - app = Dash(__name__) - - # Define custom theme configuration - theme_config = {'colors': {'brand': {'500': '#3b82f6'}}} - - # Setup the plugin with theme configuration - with patch('dash_tailwindcss_plugin.plugin._TailwindCSSPlugin.setup_online_mode') as mock_setup: - setup_tailwindcss_plugin(mode='online', tailwind_theme_config=theme_config) - mock_setup.assert_called_once() - - # Define a simple layout with Tailwind classes - app.layout = html.Div( - [ - html.H1('Test', className='text-3xl font-bold text-brand-500'), - html.P('Test paragraph', className='text-gray-700 mt-4'), - ] - ) - - # Verify that the app was created successfully - assert app is not None - assert hasattr(app, 'layout') - - def test_plugin_with_complex_layout(self): - """Test that the plugin works with complex layouts.""" - # Create a Dash app - app = Dash(__name__) - - # Setup the plugin - with patch('dash_tailwindcss_plugin.plugin._TailwindCSSPlugin.setup_online_mode'): - setup_tailwindcss_plugin(mode='online') - - # Define a complex layout with various Tailwind classes - app.layout = html.Div( - [ - # Header - html.Header( - [ - html.Nav( - [ - html.A('Home', href='#', className='text-blue-600 hover:text-blue-800'), - html.A('About', href='#', className='ml-4 text-blue-600 hover:text-blue-800'), - html.A('Contact', href='#', className='ml-4 text-blue-600 hover:text-blue-800'), - ], - className='flex items-center', - ) - ], - className='bg-white shadow p-4', - ), - # Main content - html.Main( - [ - html.H1('Welcome', className='text-3xl font-bold text-center mb-4'), - html.P( - 'This is a test with complex Tailwind CSS classes.', className='text-gray-600 text-center' - ), - # Cards - html.Div( - [ - html.Div( - [ - html.H3('Card 1', className='text-xl font-semibold mb-2'), - html.P('Card content', className='text-gray-600'), - ], - className='bg-white p-4 rounded-lg shadow', - ), - html.Div( - [ - html.H3('Card 2', className='text-xl font-semibold mb-2'), - html.P('Card content', className='text-gray-600'), - ], - className='bg-white p-4 rounded-lg shadow mt-4', - ), - ], - className='mt-8', - ), - ], - className='container mx-auto px-4', - ), - # Footer - html.Footer( - [html.P('Footer content', className='text-center text-gray-500')], className='bg-gray-100 p-4 mt-8' - ), - ], - className='min-h-screen bg-gray-50', - ) - - # Verify that the app was created successfully - assert app is not None - assert hasattr(app, 'layout') - - def test_plugin_with_tailwind_v4(self): - """Test that the plugin works with Tailwind CSS v4.""" - # Create a Dash app - app = Dash(__name__) - - # Setup the plugin with Tailwind v4 - with patch('dash_tailwindcss_plugin.plugin._TailwindCSSPlugin.setup_online_mode') as mock_setup: - setup_tailwindcss_plugin(mode='online', tailwind_version='4') - mock_setup.assert_called_once() - - # Define layout with Tailwind classes - app.layout = html.Div( - [ - html.H1('Test with Tailwind v4', className='text-3xl font-bold text-blue-600'), - html.P('Testing Tailwind CSS v4 support', className='text-gray-700 mt-4'), - ] - ) - - # Verify that the app was created successfully - assert app is not None - assert hasattr(app, 'layout') - - def test_plugin_with_multiple_content_paths(self): - """Test that the plugin works with multiple content paths.""" - # Create a Dash app - app = Dash(__name__) - - # Setup the plugin with multiple content paths - with patch('dash_tailwindcss_plugin.plugin._TailwindCSSPlugin.setup_offline_mode') as mock_setup: - setup_tailwindcss_plugin(mode='offline', content_path=['*.html', '*.js', '*.py']) - mock_setup.assert_called_once() - - # Define layout - app.layout = html.Div( - [ - html.H1('Test', className='text-3xl font-bold text-blue-600'), - ] - ) - - # Verify that the app was created successfully - assert app is not None - assert hasattr(app, 'layout') - - def test_plugin_with_custom_cdn_url(self): - """Test that the plugin works with custom CDN URL.""" - # Create a Dash app - app = Dash(__name__) - - # Setup the plugin with custom CDN URL - with patch('dash_tailwindcss_plugin.plugin._TailwindCSSPlugin.setup_online_mode') as mock_setup: - setup_tailwindcss_plugin(mode='online', cdn_url='https://custom.cdn.com/tailwindcss/3.3.0/tailwind.min.css') - mock_setup.assert_called_once() - - # Define layout - app.layout = html.Div( - [ - html.H1('Test', className='text-3xl font-bold text-blue-600'), - ] - ) - - # Verify that the app was created successfully - assert app is not None - assert hasattr(app, 'layout') - - -if __name__ == '__main__': - pytest.main([__file__]) diff --git a/tests/test_integration.py b/tests/test_integration.py deleted file mode 100644 index 7798669..0000000 --- a/tests/test_integration.py +++ /dev/null @@ -1,65 +0,0 @@ -import pytest -import os -import tempfile -import shutil -from unittest.mock import patch -from dash import Dash -from dash_tailwindcss_plugin import setup_tailwindcss_plugin - - -class TestIntegration: - """Integration test cases for the plugin.""" - - def setup_method(self): - """Set up test fixtures before each test method.""" - self.test_dir = tempfile.mkdtemp() - self.original_cwd = os.getcwd() - os.chdir(self.test_dir) - - def teardown_method(self): - """Tear down test fixtures after each test method.""" - os.chdir(self.original_cwd) - shutil.rmtree(self.test_dir, ignore_errors=True) - - @patch('dash_tailwindcss_plugin.plugin._TailwindCSSPlugin.setup_online_mode') - def test_setup_tailwindcss_plugin_online_mode(self, mock_setup_online): - """Test setup_tailwindcss_plugin with online mode integration.""" - # This test verifies that the plugin can be set up with online mode - setup_tailwindcss_plugin(mode='online') - mock_setup_online.assert_called_once() - - @patch('dash_tailwindcss_plugin.plugin._TailwindCSSPlugin.setup_offline_mode') - def test_setup_tailwindcss_plugin_offline_mode(self, mock_setup_offline): - """Test setup_tailwindcss_plugin with offline mode integration.""" - # This test verifies that the plugin can be set up with offline mode - setup_tailwindcss_plugin(mode='offline') - mock_setup_offline.assert_called_once() - - @patch('dash_tailwindcss_plugin.plugin._TailwindCSSPlugin.setup_offline_mode') - def test_setup_tailwindcss_plugin_with_custom_config(self, mock_setup_offline): - """Test setup_tailwindcss_plugin with custom configuration.""" - # This test verifies that the plugin can be set up with custom configuration - setup_tailwindcss_plugin( - mode='offline', - content_path=['*.html'], - input_css_path='custom_input.css', - output_css_path='custom_output.css', - config_js_path='custom_config.js', - ) - mock_setup_offline.assert_called_once() - - def test_setup_tailwindcss_plugin_with_dash_app(self): - """Test setup_tailwindcss_plugin with actual Dash app creation.""" - # Create a Dash app - app = Dash(__name__) - - # Setup the plugin - setup_tailwindcss_plugin(mode='online') - - # Verify that the app was created successfully - assert app is not None - assert hasattr(app, 'layout') or hasattr(app, '_layout') or hasattr(app, '_layout_value') - - -if __name__ == '__main__': - pytest.main([__file__]) diff --git a/tests/test_online_theme.py b/tests/test_online_theme.py deleted file mode 100644 index af9787c..0000000 --- a/tests/test_online_theme.py +++ /dev/null @@ -1,241 +0,0 @@ -import pytest -import os -import tempfile -import shutil -from unittest.mock import patch -from dash_tailwindcss_plugin.plugin import _TailwindCSSPlugin - - -class TestOnlineThemeConfig: - """Test cases for online mode theme configuration.""" - - def setup_method(self): - """Set up test fixtures before each test method.""" - self.test_dir = tempfile.mkdtemp() - self.original_cwd = os.getcwd() - os.chdir(self.test_dir) - - def teardown_method(self): - """Tear down test fixtures after each test method.""" - os.chdir(self.original_cwd) - shutil.rmtree(self.test_dir, ignore_errors=True) - - @patch('dash_tailwindcss_plugin.plugin.hooks') - def test_online_mode_with_theme_config(self, mock_hooks): - """Test online mode with theme configuration.""" - # Define a custom theme configuration - theme_config = { - 'colors': { - 'brand': { - '50': '#eff6ff', - '500': '#3b82f6', - } - } - } - - # Create plugin with online mode and theme config - plugin = _TailwindCSSPlugin(mode='online', tailwind_theme_config=theme_config) - - # Verify plugin properties - assert plugin.mode == 'online' - assert plugin.tailwind_theme_config == theme_config - - # Call setup_online_mode - plugin.setup_online_mode() - - # Verify that hooks.index() decorator was called - mock_hooks.index.assert_called_once() - - @patch('dash_tailwindcss_plugin.plugin.hooks') - def test_online_mode_without_theme_config(self, mock_hooks): - """Test online mode without theme configuration.""" - # Create plugin with online mode but no theme config - plugin = _TailwindCSSPlugin(mode='online') - - # Verify plugin properties - assert plugin.mode == 'online' - assert plugin.tailwind_theme_config == {} - - # Call setup_online_mode - plugin.setup_online_mode() - - # Verify that hooks.index() decorator was called - mock_hooks.index.assert_called_once() - - def test_theme_config_conversion(self): - """Test that theme config is properly converted to JavaScript.""" - # Define a custom theme configuration with nested structure - theme_config = { - 'colors': { - 'primary': '#ff0000', - 'secondary': '#00ff00', - 'brand': {'100': '#dbeafe', '500': '#3b82f6', '900': '#1e3a8a'}, - }, - 'borderRadius': {'none': '0px', 'sm': '2px', 'DEFAULT': '4px', 'md': '8px'}, - } - - # Create plugin with online mode and theme config - _TailwindCSSPlugin(mode='online', tailwind_theme_config=theme_config) - - # Test the _dict_to_js_object function directly - from dash_tailwindcss_plugin.utils import dict_to_js_object - - js_object = dict_to_js_object(theme_config) - - # Verify the structure - assert 'colors' in js_object - assert 'primary: "#ff0000"' in js_object - assert 'secondary: "#00ff00"' in js_object - assert 'brand' in js_object - assert '100: "#dbeafe"' in js_object - assert '500: "#3b82f6"' in js_object - assert 'borderRadius' in js_object - assert 'none: "0px"' in js_object - assert 'sm: "2px"' in js_object - - def test_complex_theme_config(self): - """Test complex theme configuration with arrays and nested objects.""" - # Define a complex theme configuration - theme_config = { - 'colors': { - 'brand': { - '50': '#eff6ff', - '100': '#dbeafe', - '200': '#bfdbfe', - '300': '#93c5fd', - '400': '#60a5fa', - '500': '#3b82f6', - '600': '#2563eb', - '700': '#1d4ed8', - '800': '#1e40af', - '900': '#1e3a8a', - } - }, - 'extend': { - 'spacing': { - '128': '32rem', - '144': '36rem', - }, - 'borderRadius': { - 'xl': '1rem', - '2xl': '2rem', - }, - }, - } - - # Create plugin with online mode and theme config - _TailwindCSSPlugin(mode='online', tailwind_theme_config=theme_config) - - # Test the _dict_to_js_object function directly - from dash_tailwindcss_plugin.utils import dict_to_js_object - - js_object = dict_to_js_object(theme_config) - - # Verify the structure contains all expected elements - assert 'brand' in js_object - assert '50: "#eff6ff"' in js_object - assert '500: "#3b82f6"' in js_object - assert '900: "#1e3a8a"' in js_object - assert 'extend' in js_object - assert 'spacing' in js_object - assert '128: "32rem"' in js_object - assert 'borderRadius' in js_object - assert 'xl: "1rem"' in js_object - - def test_theme_config_with_v4(self): - """Test theme config with Tailwind CSS v4.""" - # Define a custom theme configuration - theme_config = { - 'colors': { - 'brand': { - '50': '#eff6ff', - '500': '#3b82f6', - } - } - } - - # Create plugin with online mode, v4, and theme config - plugin = _TailwindCSSPlugin(mode='online', tailwind_version='4', tailwind_theme_config=theme_config) - - # Verify plugin properties - assert plugin.mode == 'online' - assert plugin.tailwind_version == '4' - assert plugin.tailwind_theme_config == theme_config - - def test_theme_config_with_empty_values(self): - """Test theme config with empty values.""" - # Define a theme configuration with empty values - theme_config = { - 'colors': { - 'primary': '', - 'secondary': None, - }, - 'spacing': {}, - } - - # Create plugin with online mode and theme config - plugin = _TailwindCSSPlugin(mode='online', tailwind_theme_config=theme_config) - - # Verify plugin properties - assert plugin.mode == 'online' - assert plugin.tailwind_theme_config == theme_config - - def test_theme_config_with_complex_structure(self): - """Test theme config with complex structure.""" - # Define a complex theme configuration - theme_config = { - 'colors': { - 'brand': { - '50': '#eff6ff', - '100': '#dbeafe', - '200': '#bfdbfe', - '300': '#93c5fd', - '400': '#60a5fa', - '500': '#3b82f6', - '600': '#2563eb', - '700': '#1d4ed8', - '800': '#1e40af', - '900': '#1e3a8a', - } - }, - 'extend': { - 'spacing': { - '128': '32rem', - '144': '36rem', - }, - 'borderRadius': { - 'xl': '1rem', - '2xl': '2rem', - }, - 'keyframes': {'spin': {'from': {'transform': 'rotate(0deg)'}, 'to': {'transform': 'rotate(360deg)'}}}, - 'animation': {'spin': 'spin 1s linear infinite'}, - }, - } - - # Create plugin with online mode and theme config - plugin = _TailwindCSSPlugin(mode='online', tailwind_theme_config=theme_config) - - # Verify plugin properties - assert plugin.mode == 'online' - assert plugin.tailwind_theme_config == theme_config - - # Test the dict_to_js_object function directly - from dash_tailwindcss_plugin.utils import dict_to_js_object - - js_object = dict_to_js_object(theme_config) - - # Verify the structure contains all expected elements - assert 'brand' in js_object - assert '500: "#3b82f6"' in js_object - assert 'extend' in js_object - assert 'spacing' in js_object - assert '128: "32rem"' in js_object - assert 'borderRadius' in js_object - assert 'xl: "1rem"' in js_object - assert 'keyframes' in js_object - assert 'spin' in js_object - assert 'from: {' in str(js_object) # Check that the keyframes structure is present - - -if __name__ == '__main__': - pytest.main([__file__]) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 3be6d3f..c45996f 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,10 +1,10 @@ -import pytest import os -import tempfile +import pytest import shutil +import tempfile import time -from unittest.mock import patch, MagicMock -from dash_tailwindcss_plugin.plugin import _TailwindCSSPlugin, setup_tailwindcss_plugin +from unittest.mock import MagicMock, patch +from dash_tailwindcss_plugin.plugin import _TailwindCSSPlugin class TestTailwindCSSPlugin: @@ -22,9 +22,9 @@ def teardown_method(self): shutil.rmtree(self.test_dir, ignore_errors=True) def test_plugin_initialization(self): - """Test plugin initialization with default parameters.""" + """Test plugin initialization with default and custom parameters.""" + # Test with default parameters plugin = _TailwindCSSPlugin() - assert plugin.mode == 'offline' assert plugin.content_path == ['**/*.py'] assert plugin.plugin_tmp_dir == '_tailwind' @@ -39,8 +39,7 @@ def test_plugin_initialization(self): assert plugin.skip_build_if_recent is True assert plugin.skip_build_time_threshold == 5 - def test_plugin_initialization_with_custom_parameters(self): - """Test plugin initialization with custom parameters.""" + # Test with custom parameters custom_content_path = ['*.html', '*.js'] plugin = _TailwindCSSPlugin( mode='online', @@ -57,7 +56,6 @@ def test_plugin_initialization_with_custom_parameters(self): skip_build_if_recent=False, skip_build_time_threshold=10, ) - assert plugin.mode == 'online' assert plugin.content_path == custom_content_path assert plugin.plugin_tmp_dir == '_custom_tailwind' @@ -72,295 +70,223 @@ def test_plugin_initialization_with_custom_parameters(self): assert plugin.skip_build_if_recent is False assert plugin.skip_build_time_threshold == 10 - def test_plugin_initialization_with_clean_after_disabled(self): - """Test plugin initialization with clean_after disabled.""" - plugin = _TailwindCSSPlugin(mode='offline', clean_after=False) - - assert plugin.clean_after is False - - def test_plugin_initialization_with_tailwind_v4_offline(self): - """Test plugin initialization with Tailwind CSS v4 in offline mode.""" - plugin = _TailwindCSSPlugin(tailwind_version='4') - - assert plugin.tailwind_version == '4' - # In offline mode, CDN URL should remain default since it's not used - assert plugin.cdn_url == 'https://cdn.tailwindcss.com' - - def test_plugin_initialization_with_tailwind_v4_online(self): - """Test plugin initialization with Tailwind CSS v4 in online mode.""" - plugin = _TailwindCSSPlugin(mode='online', tailwind_version='4') - - assert plugin.tailwind_version == '4' - # In online mode with v4, CDN URL should be updated - assert plugin.cdn_url == 'https://registry.npmmirror.com/@tailwindcss/browser/4/files/dist/index.global.js' - - def test_plugin_initialization_with_custom_cdn_and_v4(self): - """Test plugin initialization with custom CDN URL and Tailwind CSS v4.""" - custom_cdn = 'https://custom.cdn.com/tailwind.v4.js' - plugin = _TailwindCSSPlugin(mode='online', tailwind_version='4', cdn_url=custom_cdn) - - assert plugin.tailwind_version == '4' - # Custom CDN URL should be preserved - assert plugin.cdn_url == custom_cdn - - def test_plugin_with_all_parameters(self): - """Test plugin initialization with all parameters.""" - plugin = _TailwindCSSPlugin( - mode='offline', - tailwind_version='4', - content_path=['*.html', '*.js'], - plugin_tmp_dir='_custom_tailwind', - input_css_path='custom_input.css', - output_css_path='custom_output.css', - config_js_path='custom_config.js', - cdn_url='https://custom.cdn.com', - download_node=True, - node_version='20.0.0', - tailwind_theme_config={'colors': {'primary': '#ff0000'}}, - clean_after=False, - skip_build_if_recent=False, - skip_build_time_threshold=30, - ) - - assert plugin.mode == 'offline' - assert plugin.tailwind_version == '4' - assert plugin.content_path == ['*.html', '*.js'] - assert plugin.plugin_tmp_dir == '_custom_tailwind' - assert plugin.input_css_path == 'custom_input.css' - assert plugin.output_css_path == 'custom_output.css' - assert plugin.config_js_path == 'custom_config.js' - assert plugin.cdn_url == 'https://custom.cdn.com' - assert plugin.download_node is True - assert plugin.node_version == '20.0.0' - assert plugin.tailwind_theme_config == {'colors': {'primary': '#ff0000'}} - assert plugin.clean_after is False - assert plugin.skip_build_if_recent is False - assert plugin.skip_build_time_threshold == 30 - - @patch('dash_tailwindcss_plugin.plugin.hooks') - def test_setup_online_mode(self, mock_hooks): - """Test setup_online_mode method.""" - plugin = _TailwindCSSPlugin(mode='online') - plugin.setup_online_mode() - - # Verify that hooks.index() decorator was called - mock_hooks.index.assert_called_once() - - @patch('dash_tailwindcss_plugin.plugin.hooks') - @patch('dash_tailwindcss_plugin.plugin._TailwindCSSPlugin._build_tailwindcss') - def test_setup_offline_mode(self, mock_build, mock_hooks): - """Test setup_offline_mode method.""" - plugin = _TailwindCSSPlugin(mode='offline') - plugin.setup_offline_mode() - - # Verify that hooks.setup() decorator was called - mock_hooks.setup.assert_called_once() - - def test_setup_offline_mode_skips_build_when_css_recent(self): - """Test that setup_offline_mode skips build when CSS file is recent.""" - # Create a temporary directory for testing - test_dir = tempfile.mkdtemp() - css_file = os.path.join(test_dir, 'test.css') - - # Create a CSS file with recent modification time + def test_setup_methods(self): + """Test setup methods for online and offline modes.""" + # Test online mode setup + with patch('dash_tailwindcss_plugin.plugin.hooks') as mock_hooks: + plugin = _TailwindCSSPlugin(mode='online') + plugin.setup_online_mode() + mock_hooks.index.assert_called_once() + + # Test offline mode setup + with patch('dash_tailwindcss_plugin.plugin.hooks') as mock_hooks: + plugin = _TailwindCSSPlugin(mode='offline') + plugin.setup_offline_mode() + mock_hooks.setup.assert_called_once() + mock_hooks.index.assert_called_once() + mock_hooks.route.assert_called_once() + + def test_should_skip_build(self): + """Test _should_skip_build method with different scenarios.""" + # Create temporary file + css_file = os.path.join(self.test_dir, 'test.css') with open(css_file, 'w') as f: f.write('/* test css */') - # Create plugin with the test CSS file - plugin = _TailwindCSSPlugin(mode='offline') + plugin = _TailwindCSSPlugin(mode='offline', skip_build_if_recent=True, skip_build_time_threshold=5) plugin.output_css_path = css_file - # Mock the _build_tailwindcss method - with patch.object(plugin, '_build_tailwindcss'): - # Call the setup function - plugin.setup_offline_mode() + # Test case where build should be skipped (recent file) + result = plugin._should_skip_build() + assert result is True - # Create a mock Dash app - MagicMock() - - # Manually invoke the generate_tailwindcss function - # We need to access it through the hooks decorator - # Since we can't easily access the decorated function, we'll test the logic directly - - # Check if CSS file exists and was generated recently (within 3 seconds) - if os.path.exists(css_file): - file_mod_time = os.path.getmtime(css_file) - current_time = time.time() - if current_time - file_mod_time < 3: - # In the actual implementation, _build_tailwindcss should not be called - # But in this test, we can't easily verify that without accessing the decorated function - pass - - # Clean up - os.remove(css_file) - os.rmdir(test_dir) - - def test_setup_offline_mode_builds_when_css_old(self): - """Test that setup_offline_mode builds when CSS file is old.""" - # Create a temporary directory for testing - test_dir = tempfile.mkdtemp() - css_file = os.path.join(test_dir, 'test.css') - - # Create a CSS file with old modification time (more than 3 seconds ago) - with open(css_file, 'w') as f: - f.write('/* test css */') - - # Set the modification time to 10 seconds ago + # Test case where build should not be skipped (old file) old_time = time.time() - 10 os.utime(css_file, (old_time, old_time)) + result = plugin._should_skip_build() + assert result is False + + # Test case where build should not be skipped (skip_build_if_recent=False) + plugin.skip_build_if_recent = False + result = plugin._should_skip_build() + assert result is False + + # Test case where build should not be skipped (file doesn't exist) + plugin.output_css_path = 'nonexistent.css' + result = plugin._should_skip_build() + assert result is False + + def test_process_html(self): + """Test HTML processing for both online and offline modes.""" + # Test online mode HTML processing + plugin = _TailwindCSSPlugin(mode='online') - # Create plugin with the test CSS file + # Test with head tag + html_with_head = '