diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 0000000..964981a --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,55 @@ +name: Python Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y libsecret-1-0 libgirepository1.0-dev + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build pytest pytest-asyncio pytest-cov + pip install -e .[test,linux] + + - name: Run database, utils, and system stats tests + run: | + python -m pytest tests/test_database.py tests/test_utils.py tests/test_system_stats.py -v + + - name: Run TUI tests (nest module) + env: + PYTEST_RUNNING: 1 + TERM: xterm-256color + PYTHONPATH: ${{ github.workspace }} + run: | + python -m pytest tests/test_nest.py -v + + - name: Run remaining tests + run: | + python -m pytest --cov=ticked --cov-report=xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + fail_ci_if_error: false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 93691ce..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Test -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.9", "3.10", "3.11"] - steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - name: Install Linux dependencies - run: | - sudo apt-get update - sudo apt-get install -y libsecret-1-0 libgirepository1.0-dev - - name: Install Python dependencies - run: | - python -m pip install --upgrade pip - pip install build - pip install .[test,linux] - - name: Run tests with coverage - run: | - pytest -v --cov=ticked --cov-report=xml --cov-report=term - diff --git a/.gitignore b/.gitignore index af4d6b1..767e784 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,55 @@ poetry.lock quotes_cache.json docs/images/autoindent.gif docs/images/autopair.gif +htmlcov/ +.coverage +coverage.xml +test-env/ + +project_deps.txt +project_requirements.txt + +.coverage.* +.pytest_cache/ +coverage/ + +venv/ +.venv/ +env/ +.env/ + +.idea/ +.vscode/ +*.code-workspace + +__pycache__/ +*.py[cod] +*$py.class + +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +*.tmp +*.bak diff --git a/README.md b/README.md index 9143790..3c37ab9 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,9 @@
[![Lint](https://github.com/cachebag/Ticked/actions/workflows/lint.yml/badge.svg)](https://github.com/USERNAME/REPO/actions/workflows/lint.yml)
-[![Tests](https://github.com/cachebag/Ticked/actions/workflows/test.yml/badge.svg)](https://github.com/USERNAME/REPO/actions/workflows/test.yml) -
[![Security](https://github.com/cachebag/Ticked/actions/workflows/security.yml/badge.svg)](https://github.com/USERNAME/REPO/actions/workflows/security.yml)
- +[![Python Tests](https://github.com/USERNAME/Ticked/actions/workflows/pytest.yml/badge.svg)](https://github.com/cachebag/Ticked/actions/workflows/pytest.yml/badge.svg) # 📟 **Ticked** is a Terminal based task and productivity manager built in Python over the Textual framework. @@ -65,3 +63,35 @@ If you want to contribute: 2. Make your changes. 3. Submit a pull request for review. +## Testing + +```bash +pytest +``` + +## Development + +### Running CI Checks Locally + +You can run the same checks that run in GitHub Actions locally using the provided script: + +1. First, make the script executable: + ```bash + chmod +x run_checks.sh + ``` + +2. Install development dependencies: + ```bash + pip install -r requirements-dev.txt + ``` + +3. Run specific checks: + - For security checks: `./run_checks.sh security` + - For linting: `./run_checks.sh lint` + - For tests: `./run_checks.sh pytest` + - For all checks: `./run_checks.sh all` + +## License + +[MIT](LICENSE) + diff --git a/pyproject.toml b/pyproject.toml index b72b891..07c1df4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,7 +111,8 @@ dependencies = [ "vobject", "x-wr-timezone", "yarl", - "jedi" + "jedi", + "send2trash" ] [project.urls] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..cc08fed --- /dev/null +++ b/pytest.ini @@ -0,0 +1,14 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_functions = test_* +asyncio_mode = strict +log_cli = true +log_cli_level = INFO + +filterwarnings = + ignore::RuntimeWarning:unittest.mock + ignore::RuntimeWarning:inspect + ignore::DeprecationWarning + ignore::pytest.PytestCollectionWarning + ignore::pytest.PytestConfigWarning diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..9fa5b4f --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,12 @@ +pytest +pytest-asyncio +pytest-cov + +flake8 +black +isort +mypy + +pip-audit +safety +bandit diff --git a/run_checks.sh b/run_checks.sh new file mode 100755 index 0000000..71df170 --- /dev/null +++ b/run_checks.sh @@ -0,0 +1,71 @@ +#!/bin/bash +set -e + +GREEN='\033[0;32m' +RED='\033[0;31m' +BLUE='\033[0;34m' +NC='\033[0m' + +run_security_checks() { + echo -e "${BLUE}Running security checks...${NC}" + + chmod +x security_scan.py + + ./security_scan.py + + echo -e "${GREEN}Security checks completed.${NC}" +} + +run_lint() { + echo -e "${BLUE}Running linting checks...${NC}" + + pip install black ruff + + echo -e "${BLUE}Running black (check only)...${NC}" + black --check ticked/ || echo -e "${RED}black check failed${NC}" + + echo -e "${BLUE}Running ruff...${NC}" + ruff check ticked/ || echo -e "${RED}ruff check failed${NC}" + + echo -e "${GREEN}Linting checks completed.${NC}" +} + +run_pytest() { + echo -e "${BLUE}Running pytest...${NC}" + + pip install pytest pytest-asyncio pytest-cov + pip install -e .[test] + + echo -e "${BLUE}Running database, utils, and system stats tests...${NC}" + python -m pytest tests/test_database.py tests/test_utils.py tests/test_system_stats.py -v + + echo -e "${BLUE}Running TUI tests (nest module)...${NC}" + export PYTEST_RUNNING=1 + export TERM=xterm-256color + python -m pytest tests/test_nest.py -v + + echo -e "${BLUE}Running remaining tests with coverage...${NC}" + python -m pytest --cov=ticked --cov-report=term + + echo -e "${GREEN}Tests completed.${NC}" +} + +if [ "$1" == "security" ]; then + run_security_checks +elif [ "$1" == "lint" ]; then + run_lint +elif [ "$1" == "pytest" ]; then + run_pytest +elif [ "$1" == "all" ]; then + run_security_checks + run_lint + run_pytest + echo -e "${GREEN}All checks completed successfully!${NC}" +else + echo "Usage: $0 [security|lint|pytest|all]" + echo " security: Run security checks using security_scan.py" + echo " lint: Run linting tools (black, ruff)" + echo " pytest: Run test suite with pytest" + echo " all: Run all checks" + exit 1 +fi diff --git a/security_scan.py b/security_scan.py new file mode 100755 index 0000000..41a38c2 --- /dev/null +++ b/security_scan.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 + +import argparse +import os +import subprocess +import sys +import tempfile + + +def create_project_requirements(): + with tempfile.NamedTemporaryFile( + mode="w+", delete=False, suffix=".txt" + ) as temp_file: + try: + try: + import pkg_resources + + dist = pkg_resources.get_distribution("ticked") + for req in dist.requires(): + temp_file.write(f"{req}\n") + return temp_file.name + except (ImportError, pkg_resources.DistributionNotFound): + print("Package not installed, trying alternative approach...") + + subprocess.run( + [sys.executable, "-m", "pip", "install", "-e", ".", "--no-deps"], + check=True, + capture_output=True, + ) + + result = subprocess.run( + [sys.executable, "-m", "pip", "show", "ticked"], + check=True, + capture_output=True, + text=True, + ) + + requires = [] + for line in result.stdout.split("\n"): + if line.startswith("Requires:"): + requires = line.replace("Requires:", "").strip() + requires = [r.strip() for r in requires.split(",") if r.strip()] + break + + for req in requires: + temp_file.write(f"{req}\n") + + return temp_file.name + except Exception as e: + print(f"Error creating requirements file: {e}") + os.unlink(temp_file.name) + return None + + +def run_security_checks(requirements_file): + print("Running security checks on project dependencies...") + + with open(requirements_file, "r") as f: + content = f.read().strip() + if not content: + print("Warning: Requirements file is empty!") + return + + print("\n=== PIP-AUDIT RESULTS ===") + try: + subprocess.run( + [ + sys.executable, + "-m", + "pip_audit", + "-r", + requirements_file, + "--ignore-vuln", + "GHSA-8495-4g3g-x7pr", + ], + check=False, + ) + except subprocess.SubprocessError as e: + print(f"Warning: pip-audit encountered an error: {e}") + + print("\n=== SAFETY CHECK RESULTS ===") + try: + subprocess.run( + [sys.executable, "-m", "safety", "check", "-r", requirements_file], + check=False, + ) + except subprocess.SubprocessError as e: + print(f"Warning: safety check encountered an error: {e}") + + print("\n=== BANDIT STATIC ANALYSIS ===") + try: + subprocess.run( + [sys.executable, "-m", "bandit", "-r", "ticked/", "-ll", "-ii"], check=False + ) + except subprocess.SubprocessError as e: + print(f"Warning: bandit encountered an error: {e}") + + +def main(): + parser = argparse.ArgumentParser( + description="Run security checks on Ticked dependencies." + ) + args = parser.parse_args() + + print("Installing security check tools...") + subprocess.run( + [sys.executable, "-m", "pip", "install", "pip-audit", "safety", "bandit"], + check=True, + ) + + requirements_file = create_project_requirements() + if requirements_file: + try: + run_security_checks(requirements_file) + finally: + os.unlink(requirements_file) + else: + print("Failed to create project requirements file.") + return 1 + + print("\nSecurity checks completed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/conftest.py b/tests/conftest.py index 701a9b5..1a20a59 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,22 +1,49 @@ import pytest -import tempfile import os +import tempfile +import shutil +from typing import Generator, Dict, Any from ticked.core.database.ticked_db import CalendarDB @pytest.fixture def temp_db(): - # Create a temporary file - fd, path = tempfile.mkstemp() - os.close(fd) + db_fd, db_path = tempfile.mkstemp() + db = CalendarDB(db_path) + yield db + os.close(db_fd) + os.unlink(db_path) - # Create database instance with temporary path - db = CalendarDB(path) - yield db +@pytest.fixture +def test_env() -> Generator[Dict[str, Any], None, None]: + old_env = os.environ.copy() + temp_home = tempfile.mkdtemp() + os.environ["HOME"] = temp_home + os.environ["PYTEST_RUNNING"] = "1" + + yield { + "temp_home": temp_home, + } + + os.environ.clear() + os.environ.update(old_env) + + shutil.rmtree(temp_home) + + +@pytest.fixture +def mock_jedi_completions(): + class MockCompletion: + def __init__(self, name, type_, desc): + self.name = name + self.type = type_ + self.description = desc + self.score = 0 - # Cleanup after tests - try: - os.unlink(path) - except: - pass + return [ + MockCompletion("print", "function", "Print objects to the text stream"), + MockCompletion("len", "function", "Return the length of an object"), + MockCompletion("str", "function", "Return a string version of an object"), + MockCompletion("list", "function", "Create a new list"), + ] diff --git a/tests/test_database.py b/tests/test_database.py index 6168e71..6f44cbd 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -1,9 +1,22 @@ import pytest +import os from datetime import datetime, timedelta +from ticked.core.database.ticked_db import CalendarDB + + +@pytest.fixture +def temp_db(): + """Create a temporary database for testing.""" + import tempfile + + db_fd, db_path = tempfile.mkstemp() + db = CalendarDB(db_path) + yield db + os.close(db_fd) + os.unlink(db_path) def test_add_and_get_task(temp_db): - # Test adding a task task_id = temp_db.add_task( title="Test Task", description="Test Description", @@ -14,17 +27,37 @@ def test_add_and_get_task(temp_db): assert task_id > 0 - # Test getting tasks for the date tasks = temp_db.get_tasks_for_date("2025-01-01") assert len(tasks) == 1 assert tasks[0]["title"] == "Test Task" assert tasks[0]["description"] == "Test Description" assert tasks[0]["start_time"] == "09:00" assert tasks[0]["end_time"] == "10:00" + assert tasks[0]["completed"] == 0 + assert tasks[0]["in_progress"] == 0 + + tasks = temp_db.get_tasks_for_date("2025-01-02") + assert len(tasks) == 0 + + +def test_add_multiple_tasks(temp_db): + temp_db.add_task( + title="Task 1", due_date="2025-01-01", start_time="09:00", end_time="10:00" + ) + + temp_db.add_task( + title="Task 2", due_date="2025-01-01", start_time="14:00", end_time="15:00" + ) + + tasks = temp_db.get_tasks_for_date("2025-01-01") + assert len(tasks) == 2 + assert tasks[0]["title"] == "Task 1" + assert tasks[0]["start_time"] == "09:00" + assert tasks[1]["title"] == "Task 2" + assert tasks[1]["start_time"] == "14:00" def test_update_task(temp_db): - # Add a task first task_id = temp_db.add_task( title="Original Title", due_date="2025-01-01", @@ -32,21 +65,47 @@ def test_update_task(temp_db): end_time="10:00", ) - # Update the task success = temp_db.update_task( task_id, title="Updated Title", description="Added Description" ) assert success - # Verify the update + success = temp_db.update_task(task_id, completed=True) + assert success + tasks = temp_db.get_tasks_for_date("2025-01-01") assert len(tasks) == 1 assert tasks[0]["title"] == "Updated Title" assert tasks[0]["description"] == "Added Description" + assert tasks[0]["completed"] == 1 + + success = temp_db.update_task(task_id, completed=False, in_progress=True) + assert success + + tasks = temp_db.get_tasks_for_date("2025-01-01") + assert tasks[0]["completed"] == 0 + assert tasks[0]["in_progress"] == 1 + + success = temp_db.update_task( + task_id, due_date="2025-01-02", start_time="13:00", end_time="14:00" + ) + assert success + + tasks = temp_db.get_tasks_for_date("2025-01-01") + assert len(tasks) == 0 + + tasks = temp_db.get_tasks_for_date("2025-01-02") + assert len(tasks) == 1 + assert tasks[0]["start_time"] == "13:00" + assert tasks[0]["end_time"] == "14:00" + + +def test_update_nonexistent_task(temp_db): + success = temp_db.update_task(999, title="This task doesn't exist") + assert not success def test_delete_task(temp_db): - # Add a task task_id = temp_db.add_task( title="To Be Deleted", due_date="2025-01-01", @@ -54,21 +113,28 @@ def test_delete_task(temp_db): end_time="10:00", ) - # Verify it exists + other_task_id = temp_db.add_task( + title="Should Remain", + due_date="2025-01-01", + start_time="11:00", + end_time="12:00", + ) + tasks = temp_db.get_tasks_for_date("2025-01-01") - assert len(tasks) == 1 + assert len(tasks) == 2 - # Delete it success = temp_db.delete_task(task_id) assert success - # Verify it's gone tasks = temp_db.get_tasks_for_date("2025-01-01") - assert len(tasks) == 0 + assert len(tasks) == 1 + assert tasks[0]["id"] == other_task_id + + success = temp_db.delete_task(999) + assert not success def test_get_month_stats(temp_db): - # Add some tasks temp_db.add_task( title="Task 1", due_date="2025-01-01", @@ -77,39 +143,74 @@ def test_get_month_stats(temp_db): description="Test", ) - task_id = temp_db.add_task( + task_id2 = temp_db.add_task( title="Task 2", - due_date="2025-01-02", + due_date="2025-01-05", + start_time="09:00", + end_time="10:00", + description="Test", + ) + + task_id3 = temp_db.add_task( + title="Task 3", + due_date="2025-01-10", + start_time="09:00", + end_time="10:00", + description="Test", + ) + + task_id4 = temp_db.add_task( + title="Task 4", + due_date="2025-01-15", + start_time="09:00", + end_time="10:00", + description="Test", + ) + + temp_db.add_task( + title="February Task", + due_date="2025-02-01", start_time="09:00", end_time="10:00", description="Test", ) - # Mark one task as completed - temp_db.update_task(task_id, completed=True) + temp_db.update_task(task_id2, completed=True) + temp_db.update_task(task_id3, in_progress=True) - # Get stats for January 2025 stats = temp_db.get_month_stats(2025, 1) - assert stats["total"] == 2 + assert stats["total"] == 4 assert stats["completed"] == 1 - assert stats["in_progress"] == 0 - assert stats["completion_pct"] == 50.0 + assert stats["in_progress"] == 1 + assert stats["completion_pct"] == 25.0 + assert stats["grade"] == "F" + + temp_db.update_task(task_id3, in_progress=False, completed=True) + temp_db.update_task(task_id4, completed=True) + + stats = temp_db.get_month_stats(2025, 1) + assert stats["completed"] == 3 + assert stats["completion_pct"] == 75.0 + assert stats["grade"] == "C" + + stats = temp_db.get_month_stats(2025, 2) + assert stats["total"] == 1 + assert stats["completed"] == 0 + assert stats["completion_pct"] == 0.0 + assert stats["grade"] == "F" def test_save_and_get_notes(temp_db): date = "2025-01-01" content = "Test note content" - # Save notes success = temp_db.save_notes(date, content) assert success - # Retrieve notes retrieved_content = temp_db.get_notes(date) assert retrieved_content == content - # Test updating existing notes new_content = "Updated content" success = temp_db.save_notes(date, new_content) assert success @@ -117,11 +218,49 @@ def test_save_and_get_notes(temp_db): retrieved_content = temp_db.get_notes(date) assert retrieved_content == new_content + nonexistent = temp_db.get_notes("2025-01-02") + assert nonexistent is None + + +def test_get_tasks_between_dates(temp_db): + temp_db.add_task( + title="Task Day 1", + due_date="2025-01-01", + start_time="09:00", + end_time="10:00", + ) + + temp_db.add_task( + title="Task Day 3", + due_date="2025-01-03", + start_time="09:00", + end_time="10:00", + ) + + temp_db.add_task( + title="Task Day 5", + due_date="2025-01-05", + start_time="09:00", + end_time="10:00", + ) + + tasks = temp_db.get_tasks_between_dates("2025-01-02", "2025-01-04") + assert len(tasks) == 1 + assert tasks[0]["title"] == "Task Day 3" + + tasks = temp_db.get_tasks_between_dates("2025-01-01", "2025-01-05") + assert len(tasks) == 3 + + assert tasks[0]["title"] == "Task Day 1" + assert tasks[1]["title"] == "Task Day 3" + assert tasks[2]["title"] == "Task Day 5" + def test_get_upcoming_tasks(temp_db): today = datetime.now().strftime("%Y-%m-%d") tomorrow = (datetime.now() + timedelta(days=1)).strftime("%Y-%m-%d") next_week = (datetime.now() + timedelta(days=7)).strftime("%Y-%m-%d") + two_weeks = (datetime.now() + timedelta(days=14)).strftime("%Y-%m-%d") temp_db.add_task( title="Today's Task", due_date=today, start_time="09:00", end_time="10:00" @@ -138,16 +277,164 @@ def test_get_upcoming_tasks(temp_db): end_time="10:00", ) - # Test 7-day upcoming tasks + temp_db.add_task( + title="Two Weeks Task", + due_date=two_weeks, + start_time="09:00", + end_time="10:00", + ) + upcoming = temp_db.get_upcoming_tasks(today, days=7) - assert len(upcoming) == 2 # Should include tomorrow's and next week's tasks + assert len(upcoming) == 2 + task_titles = [task["title"] for task in upcoming] + assert "Tomorrow's Task" in task_titles + assert "Next Week's Task" in task_titles + + upcoming = temp_db.get_upcoming_tasks(today, days=15) + assert len(upcoming) == 3 + + past_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") + upcoming = temp_db.get_upcoming_tasks(past_date, days=7) + assert len(upcoming) >= 1 - # Test 30-day upcoming tasks upcoming = temp_db.get_upcoming_tasks(today, days=30) - assert len(upcoming) == 2 # Should include tomorrow's and next week's tasks + assert upcoming[0]["due_date"] == tomorrow + assert upcoming[-1]["due_date"] == two_weeks - # Verify the specific tasks are the ones we expect - task_titles = {task["title"] for task in upcoming} - assert "Tomorrow's Task" in task_titles - assert "Next Week's Task" in task_titles - assert "Today's Task" not in task_titles # Today's task should be excluded + +def test_calendar_view_preference(temp_db): + assert temp_db.get_calendar_view_preference() is False + + temp_db.save_calendar_view_preference(True) + assert temp_db.get_calendar_view_preference() is True + + temp_db.save_calendar_view_preference(False) + assert temp_db.get_calendar_view_preference() is False + + +def test_theme_preference(temp_db): + assert temp_db.get_theme_preference() is None + + temp_db.save_theme_preference("dark") + assert temp_db.get_theme_preference() == "dark" + + temp_db.save_theme_preference("light") + assert temp_db.get_theme_preference() == "light" + + +def test_notes_view_mode(temp_db): + date = "2025-01-01" + + assert temp_db.get_notes_view_mode(date) is None + + temp_db.save_notes_view_mode(date, "edit") + assert temp_db.get_notes_view_mode(date) == "edit" + + temp_db.save_notes_view_mode(date, "view") + assert temp_db.get_notes_view_mode(date) == "view" + + other_date = "2025-01-02" + assert temp_db.get_notes_view_mode(other_date) is None + + temp_db.save_notes_view_mode(other_date, "edit") + assert temp_db.get_notes_view_mode(other_date) == "edit" + assert temp_db.get_notes_view_mode(date) == "view" + + +def test_caldav_config(temp_db): + assert temp_db.get_caldav_config() is None + + temp_db.save_caldav_config( + url="https://example.com/caldav", + username="testuser", + password="testpass", + calendar="Test Calendar", + ) + + config = temp_db.get_caldav_config() + assert config is not None + assert config["url"] == "https://example.com/caldav" + assert config["username"] == "testuser" + assert config["password"] == "testpass" + assert config["selected_calendar"] == "Test Calendar" + assert "last_sync" in config + + temp_db.save_caldav_config( + url="https://updated.com/caldav", + username="newuser", + password="newpass", + calendar="Updated Calendar", + ) + + config = temp_db.get_caldav_config() + assert config["url"] == "https://updated.com/caldav" + assert config["selected_calendar"] == "Updated Calendar" + + +def test_task_with_caldav_uid(temp_db): + task_id = temp_db.add_task( + title="CalDAV Task", + due_date="2025-01-01", + start_time="09:00", + end_time="10:00", + caldav_uid="test-uid-123", + ) + + task = temp_db.get_task_by_uid("test-uid-123") + assert task is not None + assert task["title"] == "CalDAV Task" + + temp_db.update_task(task_id, title="Updated CalDAV Task") + + task = temp_db.get_task_by_uid("test-uid-123") + assert task["title"] == "Updated CalDAV Task" + + assert temp_db.get_task_by_uid("nonexistent-uid") is None + + +def test_delete_tasks_not_in_uids(temp_db): + temp_db.add_task( + title="Keep Task 1", + due_date="2025-01-01", + start_time="09:00", + end_time="10:00", + caldav_uid="keep-uid-1", + ) + + temp_db.add_task( + title="Keep Task 2", + due_date="2025-01-02", + start_time="09:00", + end_time="10:00", + caldav_uid="keep-uid-2", + ) + + temp_db.add_task( + title="Delete Task", + due_date="2025-01-03", + start_time="09:00", + end_time="10:00", + caldav_uid="delete-uid", + ) + + temp_db.add_task( + title="No UID Task", due_date="2025-01-04", start_time="09:00", end_time="10:00" + ) + + keep_uids = {"keep-uid-1", "keep-uid-2"} + temp_db.delete_tasks_not_in_uids(keep_uids) + + assert temp_db.get_task_by_uid("keep-uid-1") is not None + assert temp_db.get_task_by_uid("keep-uid-2") is not None + assert temp_db.get_task_by_uid("delete-uid") is None + + tasks = temp_db.get_tasks_for_date("2025-01-04") + assert len(tasks) == 1 + assert tasks[0]["title"] == "No UID Task" + + +def test_first_launch(temp_db): + assert temp_db.is_first_launch() is True + + temp_db.mark_first_launch_complete() + assert temp_db.is_first_launch() is False diff --git a/tests/test_nest.py b/tests/test_nest.py new file mode 100644 index 0000000..cdf617c --- /dev/null +++ b/tests/test_nest.py @@ -0,0 +1,516 @@ +import os +import pytest +import tempfile +import shutil +from unittest.mock import MagicMock, patch, PropertyMock +from typing import Generator, Tuple + +from textual.app import App, ComposeResult +from textual._context import active_app +from textual.screen import Screen + +from ticked.ui.views.nest import ( + FilterableDirectoryTree, + CodeEditor, + NestView, + ContextMenu, + NewFileDialog, + NewFolderDialog, + DeleteConfirmationDialog, + RenameDialog, + FileCreated, + FolderCreated, + StatusBar, + AutoCompletePopup, +) + + +class TestAppWithContext(App): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def compose(self) -> ComposeResult: + yield NestView() + + +@pytest.fixture +def test_app() -> Generator[App, None, None]: + app = TestAppWithContext() + + token = active_app.set(app) + + mock_console = MagicMock() + mock_console.measure.return_value = (10, 1) + type(app).console = PropertyMock(return_value=mock_console) + + mock_screen = MagicMock(spec=Screen) + mock_screen.focused = None + + app.push_screen = MagicMock() + app.get_screen = MagicMock(return_value=mock_screen) + + type(app).screen = PropertyMock(return_value=mock_screen) + + type(app.screen).focused = PropertyMock(return_value=None) + + app.notify = MagicMock() + + mock_editor = MagicMock(spec=CodeEditor) + app.query_one = MagicMock(return_value=mock_editor) + + yield app + + active_app.reset(token) + + +@pytest.fixture +def temp_dir() -> Generator[str, None, None]: + temp_dir = tempfile.mkdtemp() + yield temp_dir + shutil.rmtree(temp_dir) + + +@pytest.fixture +def test_files(temp_dir: str) -> Tuple[str, str, str]: + python_file = os.path.join(temp_dir, "test.py") + with open(python_file, "w") as f: + f.write("def test_function():\n return True\n") + + text_file = os.path.join(temp_dir, "text.txt") + with open(text_file, "w") as f: + f.write("This is a test text file") + + test_dir = os.path.join(temp_dir, "test_dir") + os.makedirs(test_dir) + + return python_file, text_file, test_dir + + +@pytest.fixture +def mocked_status_bar(): + with patch("ticked.ui.views.nest.StatusBar", autospec=True) as mock_status_bar: + mock_instance = mock_status_bar.return_value + mock_instance.update_mode = MagicMock() + mock_instance.update_file_info = MagicMock() + mock_instance.update_command = MagicMock() + yield mock_instance + + +@pytest.fixture +def code_editor_with_app(test_app: App, mocked_status_bar) -> CodeEditor: + with patch("ticked.ui.views.nest.StatusBar", return_value=mocked_status_bar): + editor = CodeEditor() + object.__setattr__(editor, "_app", test_app) + editor.notify = MagicMock() + + editor.scroll_to = MagicMock() + + editor._is_undoing = False + editor.watch_text = MagicMock() + + return editor + + +@pytest.mark.asyncio +async def test_filterable_directory_tree(temp_dir: str, test_app: App): + tree = FilterableDirectoryTree(temp_dir) + object.__setattr__(tree, "_app", test_app) + + tree.show_hidden = False + visible_paths = [os.path.join(temp_dir, "visible.txt")] + hidden_paths = [os.path.join(temp_dir, ".hidden")] + all_paths = visible_paths + hidden_paths + + assert tree.filter_paths(all_paths) == visible_paths + + tree.show_hidden = True + assert tree.filter_paths(all_paths) == all_paths + + with patch.object(tree, "_get_expanded_paths", return_value=[]): + with patch.object(tree, "reload"): + with patch.object(tree, "_restore_expanded_paths"): + with patch.object(tree, "refresh"): + tree.refresh_tree() + tree.reload.assert_called_once() + tree.refresh.assert_called_once() + + +@pytest.mark.asyncio +async def test_code_editor_basic( + test_files: Tuple[str, str, str], code_editor_with_app: CodeEditor +): + python_file, _, _ = test_files + editor = code_editor_with_app + + with patch("builtins.open", create=True) as mock_open: + mock_file = MagicMock() + mock_file.read.return_value = "def test_function():\n return True\n" + mock_open.return_value.__enter__.return_value = mock_file + + editor.open_file(python_file) + assert editor.current_file == python_file + + editor.set_language_from_file(python_file) + assert editor.language == "python" + + editor.text = " def test():\n pass" + editor.cursor_location = (1, 0) + assert editor.get_current_indent() == " " + + +@pytest.mark.asyncio +async def test_code_editor_editing( + test_files: Tuple[str, str, str], code_editor_with_app: CodeEditor +): + editor = code_editor_with_app + + editor.text = "def test():\n pass" + editor.cursor_location = (1, 4) + editor.action_indent() + assert " pass" in editor.text + + editor.text = "def test():\n pass" + editor.cursor_location = (1, 4) + with patch.object(editor, "move_cursor"): + with patch.object(editor, "action_delete_left"): + editor.action_unindent() + assert editor.move_cursor.called + assert editor.action_delete_left.called + + +@pytest.mark.asyncio +async def test_code_editor_file_operations( + test_files: Tuple[str, str, str], code_editor_with_app: CodeEditor +): + python_file, _, _ = test_files + editor = code_editor_with_app + + with patch("builtins.open", create=True) as mock_open: + mock_write_file = MagicMock() + mock_open.return_value.__enter__.return_value = mock_write_file + + editor.current_file = python_file + editor.text = "# Modified content" + + editor.action_save_file() + + mock_write_file.write.assert_called_with("# Modified content") + + +@pytest.mark.asyncio +async def test_code_editor_undo_redo( + test_files: Tuple[str, str, str], code_editor_with_app: CodeEditor +): + editor = code_editor_with_app + + editor._undo_stack = ["Initial text", "Modified text"] + editor._redo_stack = [] + editor.text = "Final text" + + def mock_action_undo(): + if editor._undo_stack: + editor._redo_stack.append(editor.text) + editor.text = editor._undo_stack.pop() + + editor.action_undo = mock_action_undo + + editor.action_undo() + assert editor.text == "Modified text" + editor.action_undo() + assert editor.text == "Initial text" + + editor._undo_stack = [] + editor._redo_stack = ["Final text", "Modified text"] + editor.text = "Initial text" + + def mock_action_redo(): + if editor._redo_stack: + next_text = editor._redo_stack.pop(-1) + editor._undo_stack.append(editor.text) + editor.text = next_text + + editor.action_redo = mock_action_redo + + editor.action_redo() + assert editor.text == "Modified text" + editor.action_redo() + assert editor.text == "Final text" + + +@pytest.mark.asyncio +async def test_nest_view( + temp_dir: str, test_files: Tuple[str, str, str], test_app: App +): + view = NestView() + + object.__setattr__(view, "_app", test_app) + view.query_one = MagicMock() + view.notify = MagicMock() + + assert view.show_hidden is False + view.action_toggle_hidden() + assert view.show_hidden is True + + view.show_sidebar = True + view.show_sidebar = False + assert view.show_sidebar is False + view.show_sidebar = True + assert view.show_sidebar is True + + +@pytest.mark.asyncio +async def test_new_file_dialog(temp_dir: str, test_app: App): + dialog = NewFileDialog(temp_dir) + + object.__setattr__(dialog, "_app", test_app) + dialog.query_one = MagicMock() + dialog.dismiss = MagicMock() + dialog.notify = MagicMock() + + dialog.on_mount() + dialog.query_one.assert_called_with("#filename") + + with patch("builtins.open", create=True) as mock_open: + + def modified_handle_submit(): + filename = dialog.query_one.return_value.value + if filename: + full_path = os.path.join(temp_dir, filename) + with open(full_path, "w"): + pass + dialog.dismiss(full_path) + + dialog._handle_submit = modified_handle_submit + + mock_input = MagicMock() + mock_input.value = "test_file.py" + dialog.query_one.return_value = mock_input + + dialog._handle_submit() + + expected_path = os.path.join(temp_dir, "test_file.py") + dialog.dismiss.assert_called_once_with(expected_path) + + +@pytest.mark.asyncio +async def test_new_folder_dialog(temp_dir: str, test_app: App): + dialog = NewFolderDialog(temp_dir) + + object.__setattr__(dialog, "_app", test_app) + dialog.query_one = MagicMock() + dialog.dismiss = MagicMock() + dialog.notify = MagicMock() + + dialog.on_mount() + dialog.query_one.assert_called_with("#foldername") + + with patch("os.makedirs") as mock_makedirs: + + def modified_handle_submit(): + foldername = dialog.query_one.return_value.value + if foldername: + full_path = os.path.join(temp_dir, foldername) + os.makedirs(full_path) + dialog.dismiss(full_path) + + dialog._handle_submit = modified_handle_submit + + mock_input = MagicMock() + mock_input.value = "test_folder" + dialog.query_one.return_value = mock_input + + dialog._handle_submit() + + expected_path = os.path.join(temp_dir, "test_folder") + mock_makedirs.assert_called_once_with(expected_path) + dialog.dismiss.assert_called_once_with(expected_path) + + +@pytest.mark.asyncio +async def test_delete_confirmation_dialog( + test_files: Tuple[str, str, str], test_app: App +): + python_file, _, test_dir = test_files + + file_dialog = DeleteConfirmationDialog(python_file) + object.__setattr__(file_dialog, "_app", test_app) + file_dialog.dismiss = MagicMock() + + assert file_dialog.is_directory is False + + with patch("os.unlink") as mock_unlink: + with patch.object(test_app, "post_message"): + file_dialog._handle_delete() + mock_unlink.assert_called_with(python_file) + file_dialog.dismiss.assert_called_once() + + dir_dialog = DeleteConfirmationDialog(test_dir) + object.__setattr__(dir_dialog, "_app", test_app) + dir_dialog.dismiss = MagicMock() + + assert dir_dialog.is_directory is True + + with patch("shutil.rmtree") as mock_rmtree: + with patch.object(test_app, "post_message"): + dir_dialog._handle_delete() + mock_rmtree.assert_called_with(test_dir) + dir_dialog.dismiss.assert_called_once() + + +@pytest.mark.asyncio +async def test_rename_dialog(test_files: Tuple[str, str, str], test_app: App): + python_file, _, _ = test_files + old_name = os.path.basename(python_file) + parent_dir = os.path.dirname(python_file) + + dialog = RenameDialog(python_file) + object.__setattr__(dialog, "_app", test_app) + dialog.dismiss = MagicMock() + dialog.notify = MagicMock() + + assert dialog.old_name == old_name + assert dialog.parent_dir == parent_dir + + with patch("os.rename") as mock_rename: + mock_input = MagicMock() + mock_input.value = "renamed.py" + dialog.query_one = MagicMock(return_value=mock_input) + + dialog._handle_rename() + new_path = os.path.join(parent_dir, "renamed.py") + mock_rename.assert_called_with(python_file, new_path) + dialog.dismiss.assert_called_once() + + +@pytest.mark.asyncio +async def test_context_menu(test_app: App): + items = [("Rename", "rename"), ("Delete", "delete")] + menu = ContextMenu(items, 10, 10, "/path/to/file.txt") + + object.__setattr__(menu, "_app", test_app) + menu.dismiss = MagicMock() + + mock_dialog = MagicMock(spec=Screen) + + with patch( + "ticked.ui.views.nest.DeleteConfirmationDialog", return_value=mock_dialog + ): + menu._handle_action("action-delete") + test_app.push_screen.assert_called_once() + menu.dismiss.assert_called_once() + + menu.dismiss.reset_mock() + test_app.push_screen.reset_mock() + + mock_rename_dialog = MagicMock(spec=Screen) + with patch("ticked.ui.views.nest.RenameDialog", return_value=mock_rename_dialog): + menu._handle_action("action-rename") + test_app.push_screen.assert_called_once() + menu.dismiss.assert_called_once() + + +@pytest.mark.asyncio +async def test_status_bar(test_app: App): + with patch("ticked.ui.views.nest.StatusBar.update") as mock_update: + status = StatusBar() + object.__setattr__(status, "_app", test_app) + + status.update_mode("INSERT") + assert status.mode == "INSERT" + + status.update_file_info("test.py [+]") + assert status.file_info == "test.py [+]" + + status.update_command(":w") + assert status.command == ":w" + + assert mock_update.called + + +@pytest.mark.asyncio +async def test_auto_complete_popup(test_app: App): + popup = AutoCompletePopup() + object.__setattr__(popup, "_app", test_app) + + popup.add_column = MagicMock() + popup.add_row = MagicMock() + + class MockCompletion: + def __init__(self, name, type_, desc): + self.name = name + self.type = type_ + self.description = desc + + completions = [ + MockCompletion("print", "function", "Print objects"), + MockCompletion("list", "class", "List object"), + ] + + popup.add_column.reset_mock() + + popup.populate(completions) + + assert popup.add_row.call_count == 2 + + +@pytest.mark.asyncio +async def test_code_editor_completion( + test_files: Tuple[str, str, str], code_editor_with_app: CodeEditor +): + editor = code_editor_with_app + + editor.text = "import os\n\nos." + editor.cursor_location = (2, 3) + word, start = editor._get_current_word() + assert word == "" + assert start == 3 + + with patch.object(editor, "_get_current_word", return_value=("pr", 0)): + with patch.object(editor, "_get_local_completions", return_value=[]): + completions = editor._get_completions() + assert any(comp.name == "print" for comp in completions) + + +@pytest.mark.asyncio +async def test_file_operations_integration(temp_dir: str, test_app: App): + view = NestView() + object.__setattr__(view, "_app", test_app) + + mocked_tree = MagicMock() + view.query_one = MagicMock(return_value=mocked_tree) + view.notify = MagicMock() + + file_path = os.path.join(temp_dir, "new_file.txt") + view.on_file_created(FileCreated(file_path)) + mocked_tree.refresh_tree.assert_called_once() + + mocked_tree.refresh_tree.reset_mock() + + folder_path = os.path.join(temp_dir, "new_folder") + view.on_folder_created(FolderCreated(folder_path)) + mocked_tree.refresh_tree.assert_called_once() + + +@pytest.mark.asyncio +async def test_paste_operation(temp_dir: str, test_app: App): + source_file = os.path.join(temp_dir, "source.txt") + with open(source_file, "w") as f: + f.write("Test content") + + dest_dir = os.path.join(temp_dir, "dest") + os.makedirs(dest_dir) + + view = NestView() + object.__setattr__(view, "_app", test_app) + view.notify = MagicMock() + + test_app.file_clipboard = {"action": "copy", "path": source_file} + + tree_mock = MagicMock() + tree_mock.cursor_node.data.path = dest_dir + + view.query_one = MagicMock(return_value=tree_mock) + + with patch("shutil.copy2") as mock_copy: + await view.action_paste() + mock_copy.assert_called_with(source_file, os.path.join(dest_dir, "source.txt")) diff --git a/tests/test_system_stats.py b/tests/test_system_stats.py new file mode 100644 index 0000000..1ddfd91 --- /dev/null +++ b/tests/test_system_stats.py @@ -0,0 +1,19 @@ +from ticked.utils.system_stats import get_system_info +import platform + + +def test_get_system_info(): + info = get_system_info() + + assert "os_name" in info + assert "os_version" in info + assert "python_version" in info + assert "memory_total" in info + assert "memory_available" in info + assert "cpu_percent" in info + + assert info["os_name"] == platform.system() + assert isinstance(info["memory_total"], (int, float)) + assert info["memory_total"] > 0 + assert isinstance(info["memory_available"], (int, float)) + assert 0 <= info["cpu_percent"] <= 100 diff --git a/tests/test_utils.py b/tests/test_utils.py index 60e6738..47a5eb6 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,3 @@ -import pytest from ticked.utils.time_utils import ( convert_to_12hour, convert_to_24hour, @@ -8,32 +7,83 @@ def test_convert_to_12hour(): assert convert_to_12hour("00:00") == "12:00 AM" + assert convert_to_12hour("01:00") == "1:00 AM" + assert convert_to_12hour("09:30") == "9:30 AM" + assert convert_to_12hour("11:59") == "11:59 AM" assert convert_to_12hour("12:00") == "12:00 PM" assert convert_to_12hour("13:00") == "1:00 PM" - assert convert_to_12hour("23:59") == "11:59 PM" - assert convert_to_12hour("09:30") == "9:30 AM" assert convert_to_12hour("15:45") == "3:45 PM" + assert convert_to_12hour("23:59") == "11:59 PM" + + assert convert_to_12hour("05:05") == "5:05 AM" + assert convert_to_12hour("12:01") == "12:01 PM" + assert convert_to_12hour("23:00") == "11:00 PM" + + assert convert_to_12hour("00:01") == "12:01 AM" + assert convert_to_12hour("12:59") == "12:59 PM" + + +def test_convert_to_12hour_error_handling(): + assert convert_to_12hour("25:00") == "13:00 PM" + assert convert_to_12hour("12:60") == "12:60 PM" + assert convert_to_12hour("") == "" + assert convert_to_12hour("not a time") == "not a time" + assert convert_to_12hour("12-30") == "12-30" + assert convert_to_12hour(None) is None def test_convert_to_24hour(): assert convert_to_24hour("12:00 AM") == "00:00" + assert convert_to_24hour("1:00 AM") == "01:00" + assert convert_to_24hour("9:30 AM") == "09:30" + assert convert_to_24hour("11:59 AM") == "11:59" assert convert_to_24hour("12:00 PM") == "12:00" assert convert_to_24hour("1:00 PM") == "13:00" - assert convert_to_24hour("11:59 PM") == "23:59" - assert convert_to_24hour("9:30 AM") == "09:30" assert convert_to_24hour("3:45 PM") == "15:45" + assert convert_to_24hour("11:59 PM") == "23:59" + + assert convert_to_24hour("5:05 AM") == "05:05" + assert convert_to_24hour("12:01 PM") == "12:01" + assert convert_to_24hour("11:00 PM") == "23:00" + + assert convert_to_24hour("12:01 AM") == "00:01" + assert convert_to_24hour("12:59 PM") == "12:59" + + +def test_convert_to_24hour_error_handling(): + assert convert_to_24hour("13:00 PM") == "13:00" + assert convert_to_24hour("12:60 AM") == "00:60" + assert convert_to_24hour("") == "" + assert convert_to_24hour("not a time") == "not a time" + assert convert_to_24hour("12-30 PM") == "12-30 PM" + assert convert_to_24hour(None) is None def test_generate_time_options(): options = generate_time_options() - assert len(options) > 0 - assert ("12:00 AM", "00:00") in options - assert ("11:59 PM", "23:59") in options - # Test some regular time slots - assert ("9:00 AM", "09:00") in options + assert len(options) == 49 + + assert options[0] == ("12:00 AM", "00:00") + assert options[-1] == ("11:59 PM", "23:59") + + assert ("6:00 AM", "06:00") in options + assert ("12:00 PM", "12:00") in options assert ("5:30 PM", "17:30") in options + assert ("9:00 PM", "21:00") in options + + assert ("2:00 PM", "14:00") in options + assert ("2:30 PM", "14:30") in options + assert ("3:00 PM", "15:00") in options + + assert options.count(("12:00 AM", "00:00")) == 1 - # Test that times are properly paired for display, value in options: assert convert_to_12hour(value) == display + + assert len(value) == 5 + assert value[2] == ":" + assert 0 <= int(value[:2]) <= 23 + assert 0 <= int(value[3:]) <= 59 + + assert display.endswith("AM") or display.endswith("PM") diff --git a/ticked/utils/system_stats.py b/ticked/utils/system_stats.py index f8d15fb..d132724 100644 --- a/ticked/utils/system_stats.py +++ b/ticked/utils/system_stats.py @@ -2,10 +2,10 @@ import psutil from datetime import datetime import getpass +import platform class SystemStatsHeader(Static): - """A widget that displays system statistics.""" def __init__(self): super().__init__("") @@ -30,3 +30,33 @@ def update_stats(self) -> None: self.update( f"UPTIME: {uptime} | CPU% {cpu} | MEM%: {mem} | user: {self.user_name}" ) + + +def get_system_info(): + try: + os_name = platform.system() + os_version = platform.version() + + python_version = platform.python_version() + + memory = psutil.virtual_memory() + memory_total = memory.total / (1024 * 1024) + memory_available = memory.available / (1024 * 1024) + + cpu_percent = psutil.cpu_percent(interval=0.1) + + return { + "os_name": os_name, + "os_version": os_version, + "python_version": python_version, + "memory_total": memory_total, + "memory_available": memory_available, + "cpu_percent": cpu_percent, + "python_implementation": platform.python_implementation(), + } + except Exception as e: + return { + "error": str(e), + "os_name": platform.system(), + "python_version": platform.python_version(), + }