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 @@
[](https://github.com/USERNAME/REPO/actions/workflows/lint.yml)
-[](https://github.com/USERNAME/REPO/actions/workflows/test.yml)
-
[](https://github.com/USERNAME/REPO/actions/workflows/security.yml)
-
+[](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(),
+ }