Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41d91d9c30 | |||
| 14d9943665 | |||
| 13a4826415 | |||
| 949e43ac6c | |||
| 33d7ae8d9f | |||
| e5e654a0b3 | |||
| 00443a540f | |||
| 59251ced31 | |||
| 9471b91f4c | |||
| c755f0affc | |||
| b8600ae57a | |||
| d7d4b332d4 | |||
| ea30cb88c9 | |||
| b76191d66d | |||
| d14d19e7d9 | |||
| 0a8d27957f | |||
| 7e04aebd5d | |||
| b7c01bc373 | |||
| e0faf20a56 | |||
| 7380d9a8a9 | |||
| 85e30671d4 | |||
| b259837af4 | |||
| aad02f0d36 | |||
| 30750710b8 | |||
| fd1f9a43c6 | |||
| 21dd1fc9c8 | |||
| 5243352867 | |||
| 387981aa47 | |||
| 13b2c9c416 | |||
| 4c04bfb92e | |||
| 2fe45e65eb | |||
| 036b4d1215 | |||
| ce986db27b | |||
| 188fb542be | |||
| 206cee5cb1 | |||
| 2b037a83e8 | |||
| 1a6fb9fcd4 | |||
| 2a1edeb76e | |||
| bce6c8c27d | |||
| 26fc74b580 | |||
| 187096870c | |||
| 3df610fc95 | |||
| a4a71380ef | |||
| 01a341130e | |||
| cbf01ad3dd | |||
| 760aa40a8c | |||
| e35a8af5c1 | |||
| d5423e98c0 | |||
| 100a4af72d | |||
| 4c7da343eb | |||
| c20c4478a6 | |||
| 9aa1188c98 | |||
| f0dd47d433 | |||
| f1976a8006 | |||
| 82353d292a | |||
| 85423d6a62 |
@@ -14,6 +14,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Fetch full history for release notes generation
|
||||
|
||||
- name: Install Docker
|
||||
run: curl -fsSL https://get.docker.com | sh
|
||||
@@ -55,3 +57,49 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=gitea-http.taildb3494.ts.net/will/thechart:buildcache
|
||||
cache-to: type=registry,ref=gitea-http.taildb3494.ts.net/will/thechart:buildcache,mode=max
|
||||
|
||||
- name: Generate release notes
|
||||
id: release_notes
|
||||
if: startsWith(gitea.ref, 'refs/tags/')
|
||||
run: |
|
||||
# Get the current tag
|
||||
CURRENT_TAG=${GITEA_REF#refs/tags/}
|
||||
|
||||
# Get the previous tag
|
||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
|
||||
# Generate release notes from commits
|
||||
if [ -n "$PREVIOUS_TAG" ]; then
|
||||
echo "## Changes from $PREVIOUS_TAG to $CURRENT_TAG" > release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..$CURRENT_TAG >> release_notes.md
|
||||
else
|
||||
echo "## Initial Release $CURRENT_TAG" > release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
git log --pretty=format:"- %s (%h)" >> release_notes.md
|
||||
fi
|
||||
|
||||
# Add Docker image information
|
||||
echo "" >> release_notes.md
|
||||
echo "## Docker Images" >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "This release includes multi-platform Docker images:" >> release_notes.md
|
||||
echo "- \`gitea-http.taildb3494.ts.net/will/thechart:$CURRENT_TAG\`" >> release_notes.md
|
||||
echo "- \`gitea-http.taildb3494.ts.net/will/thechart:latest\`" >> release_notes.md
|
||||
|
||||
# Output the release notes content for use in next step
|
||||
echo "release_notes<<EOF" >> $GITEA_OUTPUT
|
||||
cat release_notes.md >> $GITEA_OUTPUT
|
||||
echo "EOF" >> $GITEA_OUTPUT
|
||||
|
||||
- name: Create Release
|
||||
if: startsWith(gitea.ref, 'refs/tags/')
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ gitea.ref_name }}
|
||||
release_name: Release ${{ gitea.ref_name }}
|
||||
body: ${{ steps.release_notes.outputs.release_notes }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
+71
-1
@@ -1,13 +1,83 @@
|
||||
*.csv
|
||||
# Data files (except example data)
|
||||
thechart_data.csv
|
||||
### !thechart_data.csv
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Build and distribution
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
# Python bytecode
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
__pycache__/
|
||||
|
||||
# PyInstaller
|
||||
*.spec
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
.poetry/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
coverage.xml
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
|
||||
# Code quality tools
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.pylint.d/
|
||||
|
||||
# IDEs and editors
|
||||
#.vscode/
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Databases
|
||||
*.db
|
||||
*.sqlite3
|
||||
*.sqlite
|
||||
|
||||
# uv lock files (keep for reproducibility)
|
||||
# uv.lock
|
||||
|
||||
# Docker
|
||||
.dockerignore.bak
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
@@ -65,3 +65,23 @@ repos:
|
||||
# - id: uv-export
|
||||
# - id: pip-compile
|
||||
# args: [requirements.in, -o, requirements.txt]
|
||||
########################################################
|
||||
# Run core tests before commit to ensure basic functionality
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pytest-check
|
||||
name: pytest-check (core tests)
|
||||
entry: uv run pytest
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
args:
|
||||
[
|
||||
--tb=short,
|
||||
--quiet,
|
||||
--no-cov,
|
||||
"tests/test_data_manager.py::TestDataManager::test_init",
|
||||
"tests/test_data_manager.py::TestDataManager::test_initialize_csv_creates_file_with_headers",
|
||||
"tests/test_data_manager.py::TestDataManager::test_load_data_with_valid_data",
|
||||
]
|
||||
stages: [pre-commit]
|
||||
|
||||
Vendored
+3
-2
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"editor.autoIndent": "advanced"
|
||||
},
|
||||
"ansible.python.interpreterPath": "/home/will/Code/thechart/.venv/bin/python",
|
||||
"ansible.python.interpreterPath": "${workspaceFolder}/.venv/bin/python",
|
||||
"makefile.configureOnOpen": true,
|
||||
"vs-kubernetes": {
|
||||
"vs-kubernetes.crd-code-completion": "enabled",
|
||||
@@ -36,5 +36,6 @@
|
||||
"editor.formatOnSave": true,
|
||||
"diffEditor.codeLens": true,
|
||||
"github.copilot.nextEditSuggestions.enabled": true,
|
||||
"github.copilot.selectedCompletionModel": ""
|
||||
"github.copilot.selectedCompletionModel": "",
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python"
|
||||
}
|
||||
|
||||
Vendored
+21
-1
@@ -4,10 +4,30 @@
|
||||
{
|
||||
"label": "Run TheChart App",
|
||||
"type": "shell",
|
||||
"command": "cd /home/will/Code/thechart && python -m src.main",
|
||||
"command": "/home/will/Code/thechart/.venv/bin/python",
|
||||
"args": [
|
||||
"src/main.py"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "/home/will/Code/thechart"
|
||||
},
|
||||
"group": "build",
|
||||
"isBackground": false,
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Test Dose Tracking UI",
|
||||
"type": "shell",
|
||||
"command": "/home/will/Code/thechart/.venv/bin/python",
|
||||
"args": [
|
||||
"scripts/test_dose_tracking_ui.py"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "/home/will/Code/thechart"
|
||||
},
|
||||
"group": "test",
|
||||
"isBackground": false,
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -53,6 +53,11 @@ RUN sh -c "pyinstaller --name ${TARGET} --optimize 2 --onefile --windowed --hidd
|
||||
RUN chown -R ${UID}:${GUID} /home/docker_user/
|
||||
RUN chmod -R 777 /home/docker_user/${TARGET}
|
||||
|
||||
RUN mkdir -p /app/logs && \
|
||||
touch /app/logs/app.log && \
|
||||
chown -R ${UID}:${GUID} /app/logs && \
|
||||
chmod 666 /app/logs/app.log
|
||||
|
||||
# Set environment variables for X11 forwarding
|
||||
ENV DISPLAY=:0
|
||||
ENV XAUTHORITY=/tmp/.docker.xauth
|
||||
|
||||
@@ -1,32 +1,105 @@
|
||||
TARGET=thechart
|
||||
VERSION=1.0.0
|
||||
VERSION=1.6.1
|
||||
ROOT=/home/will
|
||||
ICON=chart-671.png
|
||||
SHELL=/bin/fish
|
||||
SHELL=fish
|
||||
|
||||
# Virtual environment variables
|
||||
VENV_DIR=.venv
|
||||
VENV_ACTIVATE=$(VENV_DIR)/bin/activate
|
||||
PYTHON=$(VENV_DIR)/bin/python
|
||||
|
||||
help: ## Show this help
|
||||
@grep -E -h '\s##\s' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
clean: ## Clean up build artifacts and virtual environment
|
||||
@echo "Cleaning up build artifacts and virtual environment..."
|
||||
@rm -rf $(VENV_DIR)
|
||||
@rm -rf build/
|
||||
@rm -rf dist/
|
||||
@rm -rf htmlcov/
|
||||
@rm -rf .pytest_cache/
|
||||
@rm -rf .ruff_cache/
|
||||
@rm -rf src/__pycache__/
|
||||
@rm -rf tests/__pycache__/
|
||||
@rm -f .coverage
|
||||
@rm -f coverage.xml
|
||||
@echo "✅ Cleanup complete!"
|
||||
|
||||
reinstall: clean install ## Clean and reinstall the development environment
|
||||
|
||||
check-env: ## Check if the development environment is properly set up
|
||||
@echo "Checking development environment..."
|
||||
@bash -c 'if [ ! -d "$(VENV_DIR)" ]; then \
|
||||
echo "❌ Virtual environment not found at $(VENV_DIR)"; \
|
||||
echo " Run \"make install\" to set up the environment"; \
|
||||
exit 1; \
|
||||
fi'
|
||||
@bash -c 'if [ ! -f "$(PYTHON)" ]; then \
|
||||
echo "❌ Python executable not found at $(PYTHON)"; \
|
||||
echo " Run \"make install\" to set up the environment"; \
|
||||
exit 1; \
|
||||
fi'
|
||||
@echo "✅ Virtual environment: $(VENV_DIR)"
|
||||
@echo "✅ Python executable: $(PYTHON)"
|
||||
@$(PYTHON) --version
|
||||
@$(PYTHON) -c "import sys; print(f'✅ Python path: {sys.executable}')"
|
||||
@bash -c 'if cd /home/will/Code/thechart && $(PYTHON) -c "import sys; sys.path.insert(0, \"src\"); import main" 2>/dev/null; then \
|
||||
echo "✅ Main module imports successfully"; \
|
||||
else \
|
||||
echo "❌ Main module import failed"; \
|
||||
exit 1; \
|
||||
fi'
|
||||
@bash -c 'if $(PYTHON) -c "import pre_commit" 2>/dev/null; then \
|
||||
echo "✅ Pre-commit is installed"; \
|
||||
else \
|
||||
echo "⚠️ Pre-commit not found (run \"make install\" to fix)"; \
|
||||
fi'
|
||||
@echo "✅ Environment check completed successfully!"
|
||||
install: ## Set up the development environment
|
||||
@echo "Setting up the development environment..."
|
||||
# poetry env use 3.13
|
||||
# eval $(poetry env use 3.13) # bash/zsh/csh
|
||||
eval (poetry env activate)
|
||||
poetry install --no-root
|
||||
poetry run pre-commit install --install-hooks --overwrite
|
||||
poetry run pre-commit autoupdate
|
||||
poetry run pre-commit run --all-files
|
||||
@echo "Creating virtual environment..."
|
||||
@bash -c 'if [ -d "$(VENV_DIR)" ]; then \
|
||||
echo "Virtual environment already exists. Recreating..."; \
|
||||
rm -rf $(VENV_DIR); \
|
||||
fi'
|
||||
uv venv $(VENV_DIR) --python=python3.13
|
||||
@echo "Installing dependencies..."
|
||||
uv sync --dev --no-cache-dir
|
||||
@echo "Installing pre-commit hooks..."
|
||||
$(PYTHON) -m pre_commit install
|
||||
@echo "Verifying installation..."
|
||||
@$(PYTHON) --version
|
||||
@$(PYTHON) -c "import sys; print(f'Python executable: {sys.executable}')"
|
||||
@echo "Testing module imports..."
|
||||
@cd /home/will/Code/thechart && $(PYTHON) -c "import sys; sys.path.insert(0, 'src'); import main; print('✅ Main module imports successfully')"
|
||||
@echo "Development environment setup complete!"
|
||||
@echo ""
|
||||
@echo "🐟 For Fish shell users:"
|
||||
@echo " source $(VENV_DIR)/bin/activate.fish"
|
||||
@echo ""
|
||||
@echo "🐚 For Bash/Zsh shell users:"
|
||||
@echo " source $(VENV_ACTIVATE)"
|
||||
@echo ""
|
||||
@echo "To run the application: make run"
|
||||
@echo "To run tests: make test"
|
||||
build: ## Build the Docker image
|
||||
@echo "Building the Docker image..."
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t ${IMAGE} --push .
|
||||
deploy: ## Deploy the application as a standalone executable
|
||||
@echo "Deploying the application..."
|
||||
pyinstaller --name ${TARGET} --optimize 2 --onefile --windowed --hidden-import='PIL._tkinter_finder' --icon='${ICON}' --add-data="./.env:." --add-data='./chart-671.png:.' --add-data='./thechart_data.csv:.' src/main.py
|
||||
pyinstaller --name ${TARGET} --optimize 2 --onefile --windowed --hidden-import='PIL._tkinter_finder' --icon='${ICON}' --add-data="./.env:." --add-data='./chart-671.png:.' --add-data='./thechart_data.csv:.' --log-level=DEBUG src/main.py
|
||||
cp -f ./thechart_data.csv ${ROOT}/Documents/
|
||||
cp -f ./dist/${TARGET} ${ROOT}/Applications/
|
||||
cp -f ./deploy/${TARGET}.desktop ${ROOT}/.local/share/applications/
|
||||
desktop-file-validate ${ROOT}/.local/share/applications/${TARGET}.desktop
|
||||
run: ## Run the application
|
||||
run: $(VENV_ACTIVATE) ## Run the application
|
||||
@echo "Running the application..."
|
||||
python src/main.py
|
||||
@bash -c 'if [ ! -f "$(VENV_ACTIVATE)" ]; then \
|
||||
echo "❌ Virtual environment not found. Run \"make install\" first."; \
|
||||
exit 1; \
|
||||
fi'
|
||||
$(PYTHON) src/main.py
|
||||
start: ## Start the application
|
||||
@echo "Starting the application..."
|
||||
docker-compose up -d --build
|
||||
@@ -35,7 +108,34 @@ stop: ## Stop the application
|
||||
docker-compose down
|
||||
test: ## Run the tests
|
||||
@echo "Running the tests..."
|
||||
docker-compose exec ${TARGET} pipenv run pytest -v --tb=short
|
||||
.venv/bin/python -m pytest tests/ -v --cov=src --cov-report=term-missing --cov-report=html:htmlcov
|
||||
test-unit: ## Run unit tests only
|
||||
@echo "Running unit tests..."
|
||||
.venv/bin/python -m pytest tests/ -v --tb=short
|
||||
test-coverage: ## Run tests with detailed coverage report
|
||||
@echo "Running tests with coverage..."
|
||||
.venv/bin/python -m pytest tests/ --cov=src --cov-report=html:htmlcov --cov-report=xml --cov-report=term-missing
|
||||
test-watch: ## Run tests in watch mode
|
||||
@echo "Running tests in watch mode..."
|
||||
.venv/bin/python -m pytest-watch tests/ -- -v --cov=src
|
||||
test-debug: ## Run tests with debug output
|
||||
@echo "Running tests with debug output..."
|
||||
.venv/bin/python -m pytest tests/ -v -s --tb=long --cov=src
|
||||
test-dose-tracking: ## Test the dose tracking functionality
|
||||
@echo "Testing dose tracking functionality..."
|
||||
.venv/bin/python scripts/test_dose_tracking.py
|
||||
test-scrollable-input: ## Test the scrollable input frame UI
|
||||
@echo "Testing scrollable input frame..."
|
||||
.venv/bin/python scripts/test_scrollable_input.py
|
||||
test-edit-functionality: ## Test the enhanced edit functionality
|
||||
@echo "Testing edit functionality..."
|
||||
.venv/bin/python scripts/test_edit_functionality.py
|
||||
test-edit-window: $(VENV_ACTIVATE) ## Test edit window functionality (save and delete)
|
||||
@echo "Running edit window functionality test..."
|
||||
$(PYTHON) scripts/test_edit_window_functionality.py
|
||||
test-dose-editing: $(VENV_ACTIVATE) ## Test dose editing functionality in edit window
|
||||
@echo "Running dose editing functionality test..."
|
||||
$(PYTHON) scripts/test_dose_editing_functionality.py
|
||||
lint: ## Run the linter
|
||||
@echo "Running the linter..."
|
||||
docker-compose exec ${TARGET} pipenv run pre-commit run --all-files
|
||||
@@ -47,8 +147,14 @@ attach: ## Open a shell in the container
|
||||
docker-compose exec -it ${TARGET} /bin/bash
|
||||
shell: ## Open a shell in the local environment
|
||||
@echo "Opening a shell in the local environment..."
|
||||
${SHELL} -c "eval (poetry env activate)"
|
||||
source .venv/bin/activate.${SHELL}; /bin/${SHELL}
|
||||
requirements: ## Export the requirements to a file
|
||||
@echo "Exporting requirements to requirements.txt..."
|
||||
poetry export --without-hashes -f requirements.txt -o requirements.txt
|
||||
.PHONY: install build attach deploy run start stop test lint format shell requirements help
|
||||
commit-emergency: ## Emergency commit (bypasses pre-commit hooks) - USE SPARINGLY
|
||||
@echo "⚠️ WARNING: Emergency commit bypasses all pre-commit checks!"
|
||||
@echo "This should only be used in true emergencies."
|
||||
@read -p "Enter commit message: " msg; \
|
||||
git add . && git commit --no-verify -m "$$msg"
|
||||
@echo "✅ Emergency commit completed. Please run tests manually when possible."
|
||||
.PHONY: install clean reinstall check-env build attach deploy run start stop test lint format shell requirements commit-emergency test-dose-tracking test-scrollable-input test-edit-functionality test-edit-window test-dose-editing migrate-csv help
|
||||
|
||||
@@ -1,72 +1,497 @@
|
||||
# Thechart
|
||||
App to manage medication and see the evolution of its effects.
|
||||
# TheChart
|
||||
Advanced medication tracking application for monitoring treatment progress and symptom evolution.
|
||||
|
||||
## Quick Start
|
||||
```bash
|
||||
# Install dependencies
|
||||
make install
|
||||
|
||||
# Run the application
|
||||
make run
|
||||
|
||||
# Run tests
|
||||
make test
|
||||
```
|
||||
|
||||
## 📚 Documentation
|
||||
- **[Features Guide](docs/FEATURES.md)** - Complete feature documentation
|
||||
- **[Development Guide](docs/DEVELOPMENT.md)** - Testing, development, and architecture
|
||||
- **[Changelog](docs/CHANGELOG.md)** - Version history and feature evolution
|
||||
- **[Quick Reference](#quick-reference)** - Common commands and shortcuts
|
||||
|
||||
## Table of Contents
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Installation](#installation)
|
||||
- [Running the Application](#running-the-application)
|
||||
- [Key Features](#key-features)
|
||||
- [Development](#development)
|
||||
- [Deployment](#deployment)
|
||||
- [Docker Usage](#docker-usage)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Quick Reference](#quick-reference)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before installing Thechart, ensure you have the following installed on your system:
|
||||
|
||||
### Required Software
|
||||
- **Python 3.13 or higher** - The application requires Python 3.13+
|
||||
- **uv** - For fast dependency management and virtual environment handling
|
||||
- **Git** - For version control (if cloning from repository)
|
||||
|
||||
### Installing Prerequisites
|
||||
|
||||
#### Install Python 3.13
|
||||
**Ubuntu/Debian:**
|
||||
```shell
|
||||
sudo apt update
|
||||
sudo apt install python3.13 python3.13-venv python3.13-dev
|
||||
```
|
||||
|
||||
**macOS (using Homebrew):**
|
||||
```shell
|
||||
brew install python@3.13
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
Download and install from [python.org](https://www.python.org/downloads/)
|
||||
|
||||
#### Install uv
|
||||
**All Platforms:**
|
||||
```shell
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
```
|
||||
|
||||
**macOS (using Homebrew):**
|
||||
```shell
|
||||
brew install uv
|
||||
```
|
||||
|
||||
**Windows (using PowerShell):**
|
||||
```shell
|
||||
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
```
|
||||
|
||||
**Alternative (using pip):**
|
||||
```shell
|
||||
pip install uv
|
||||
```
|
||||
|
||||
Add uv to your PATH (usually done automatically by the installer):
|
||||
```shell
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
```
|
||||
|
||||
#### Verify Installation
|
||||
```shell
|
||||
python3.13 --version
|
||||
uv --version
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### Install dev environment and dependencies
|
||||
The Makefile is set to use the fish shell by default, see the section on [`bash/zsh/csh`](#bash/zsh/csh). The environment will be activated as well, therefore the next section can be skiped, and you can jump to [`run the app`](#Run%20the%20app).
|
||||
### Quick Setup (Recommended)
|
||||
The Makefile is configured to use the fish shell by default. For other shells, see the [shell-specific instructions](#shell-specific-activation) below.
|
||||
|
||||
**Note:** The current Makefile still uses Poetry commands. If you've switched to uv, you may need to update the Makefile or use the manual installation method below.
|
||||
|
||||
```shell
|
||||
make install
|
||||
```
|
||||
|
||||
### Activate the environment according to your shell
|
||||
This command will:
|
||||
- Set up the Python virtual environment using uv
|
||||
- Install all required dependencies
|
||||
- Install development dependencies
|
||||
- Set up pre-commit hooks for code quality
|
||||
- Run initial code formatting and linting
|
||||
|
||||
#### bash/zsh/csh
|
||||
### Manual Installation
|
||||
If you prefer to set up the environment manually:
|
||||
|
||||
1. **Clone the repository** (if not already done):
|
||||
```shell
|
||||
eval $(poetry env activate)
|
||||
git clone <repository-url>
|
||||
cd thechart
|
||||
```
|
||||
|
||||
#### fish
|
||||
2. **Create and activate virtual environment:**
|
||||
```shell
|
||||
eval (poetry env activate)
|
||||
uv venv --python 3.13
|
||||
uv sync
|
||||
```
|
||||
or
|
||||
|
||||
3. **Install pre-commit hooks** (for development):
|
||||
```shell
|
||||
uv run pre-commit install --install-hooks --overwrite
|
||||
uv run pre-commit autoupdate
|
||||
```
|
||||
|
||||
### Migrating from Poetry to uv
|
||||
|
||||
If you have an existing Poetry setup and want to migrate to uv:
|
||||
|
||||
1. **Remove Poetry environment** (optional):
|
||||
```shell
|
||||
poetry env remove python
|
||||
```
|
||||
|
||||
2. **Create new uv environment:**
|
||||
```shell
|
||||
uv venv --python 3.13
|
||||
uv sync
|
||||
```
|
||||
|
||||
3. **Update your workflow:** Replace `poetry run` with `uv run` in your commands.
|
||||
|
||||
The `pyproject.toml` file remains compatible between Poetry and uv, so no changes are needed there.
|
||||
|
||||
### Shell-Specific Activation
|
||||
|
||||
If the automatic environment activation doesn't work or you're using a different shell, manually activate the environment:
|
||||
|
||||
#### fish shell (default)
|
||||
```shell
|
||||
source .venv/bin/activate.fish
|
||||
```
|
||||
or use the convenience command:
|
||||
```shell
|
||||
make shell
|
||||
```
|
||||
|
||||
## Run the app
|
||||
#### bash/zsh
|
||||
```shell
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
#### PowerShell (Windows)
|
||||
```shell
|
||||
.venv\Scripts\Activate.ps1
|
||||
```
|
||||
|
||||
#### Using uv run (recommended)
|
||||
For any command, you can use `uv run` to automatically use the virtual environment:
|
||||
```shell
|
||||
uv run python src/main.py
|
||||
uv run pre-commit run --all-files
|
||||
```
|
||||
|
||||
## Running the Application
|
||||
|
||||
### Quick Start
|
||||
After installation, run the application with:
|
||||
```shell
|
||||
make run
|
||||
```
|
||||
|
||||
## Build container image
|
||||
### Manual Run
|
||||
Alternatively, you can run the application directly:
|
||||
```shell
|
||||
make build
|
||||
uv run python src/main.py
|
||||
```
|
||||
or if you have activated the virtual environment:
|
||||
```shell
|
||||
python src/main.py
|
||||
```
|
||||
|
||||
## Run unit tests
|
||||
```shell
|
||||
### First-Time Setup
|
||||
On first run, the application will:
|
||||
- Create a default CSV data file (`thechart_data.csv`) if it doesn't exist
|
||||
- Set up logging in the `logs/` directory
|
||||
- Initialize medicine and pathology configuration files (`medicines.json`, `pathologies.json`)
|
||||
- Create necessary directory structure
|
||||
## Key Features
|
||||
|
||||
### 🏥 Modular Medicine System
|
||||
- **Dynamic Medicine Management**: Add, edit, and remove medicines through the UI
|
||||
- **Configurable Properties**: Customize names, dosages, colors, and quick-dose options
|
||||
- **JSON Configuration**: Easy management through `medicines.json`
|
||||
- **Automatic UI Updates**: All components update when medicines change
|
||||
|
||||
### 💊 Advanced Dose Tracking
|
||||
- **Precise Timestamps**: Record exact time and dose amounts
|
||||
- **Multiple Daily Doses**: Track multiple doses of the same medicine
|
||||
- **Comprehensive Interface**: Dedicated dose management in edit windows
|
||||
- **Historical Data**: Complete dose history with CSV persistence
|
||||
|
||||
### 📊 Enhanced Visualizations
|
||||
- **Interactive Graphs**: Toggle visibility of symptoms and medicines
|
||||
- **Dose Bar Charts**: Visual representation of daily medication intake
|
||||
- **Enhanced Legends**: Multi-column layout with average dosage information
|
||||
- **Professional Styling**: Clean, informative chart design
|
||||
|
||||
### 📈 Data Management
|
||||
- **Robust CSV Storage**: Human-readable and portable data format
|
||||
- **Automatic Backups**: Data protection during updates
|
||||
- **Backward Compatibility**: Seamless upgrades without data loss
|
||||
- **Dynamic Columns**: Adapts to new medicines and pathologies
|
||||
|
||||
For complete feature documentation, see **[docs/FEATURES.md](docs/FEATURES.md)**.
|
||||
|
||||
## Development
|
||||
|
||||
### Testing Framework
|
||||
TheChart includes a comprehensive testing suite with **93% code coverage**:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
make test
|
||||
|
||||
# Run tests with coverage report
|
||||
uv run pytest --cov=src --cov-report=html
|
||||
|
||||
# Run specific test file
|
||||
uv run pytest tests/test_graph_manager.py -v
|
||||
```
|
||||
|
||||
## Deploy the app
|
||||
### Linux / Unix
|
||||
The app will be deployed in **~/Applications**, the CSV data file *thechart_data.csv* will be store in **~/Documents**.
|
||||
**Testing Statistics:**
|
||||
- **112 total tests** across 6 test modules
|
||||
- **93% overall coverage** (482 statements, 33 missed)
|
||||
- **Pre-commit testing** prevents broken commits
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
# Format code
|
||||
make format
|
||||
|
||||
# Check code quality
|
||||
make lint
|
||||
|
||||
# Run pre-commit checks
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
### Package Management with uv
|
||||
```bash
|
||||
# Add dependencies
|
||||
uv add package-name
|
||||
|
||||
# Add development dependencies
|
||||
uv add --dev package-name
|
||||
|
||||
# Update dependencies
|
||||
uv sync --upgrade
|
||||
|
||||
# Remove dependencies
|
||||
uv remove package-name
|
||||
```
|
||||
|
||||
For detailed development information, see **[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)**.
|
||||
|
||||
## Deployment
|
||||
|
||||
### Creating a Standalone Executable
|
||||
|
||||
#### Linux/Unix Deployment
|
||||
Deploy the application as a standalone executable that can run without Python installed:
|
||||
|
||||
```shell
|
||||
make deploy
|
||||
```
|
||||
### MacOS / Windows
|
||||
TODO: use OS specific flags with *pyinstaller*.
|
||||
|
||||
## Make options
|
||||
Show the help menu:
|
||||
This command will:
|
||||
1. **Create a standalone executable** using PyInstaller
|
||||
2. **Install the executable** to `~/Applications/`
|
||||
3. **Copy data file** to `~/Documents/thechart_data.csv`
|
||||
4. **Create desktop entry** for easy access from the applications menu
|
||||
5. **Validate desktop file** to ensure proper integration
|
||||
|
||||
#### Manual Deployment Steps
|
||||
If you prefer to deploy manually:
|
||||
|
||||
1. **Build the executable:**
|
||||
```shell
|
||||
make help
|
||||
pyinstaller --name thechart \
|
||||
--optimize 2 \
|
||||
--onefile \
|
||||
--windowed \
|
||||
--hidden-import='PIL._tkinter_finder' \
|
||||
--icon='chart-671.png' \
|
||||
--add-data="./.env:." \
|
||||
--add-data='./chart-671.png:.' \
|
||||
--add-data='./thechart_data.csv:.' \
|
||||
src/main.py
|
||||
```
|
||||
Sub-commands listed below:
|
||||
|
||||
2. **Install files:**
|
||||
```shell
|
||||
# Copy executable
|
||||
cp ./dist/thechart ~/Applications/
|
||||
|
||||
# Copy data file
|
||||
cp ./thechart_data.csv ~/Documents/
|
||||
|
||||
# Install desktop entry (Linux)
|
||||
cp ./deploy/thechart.desktop ~/.local/share/applications/
|
||||
desktop-file-validate ~/.local/share/applications/thechart.desktop
|
||||
```
|
||||
attach Open a shell in the container
|
||||
build Build the Docker image
|
||||
deploy Deploy standalone app executable
|
||||
format Format the code
|
||||
help Show this help
|
||||
install Set up the development environment
|
||||
lint Run the linter
|
||||
requirements Export the requirements to a file
|
||||
run Run the application
|
||||
shell Open a shell in the local environment
|
||||
start Start the app
|
||||
stop Stop the app
|
||||
test Run the tests
|
||||
|
||||
#### macOS/Windows Deployment
|
||||
**Note:** macOS and Windows deployment is planned for future releases. Currently, you can run the application using Python directly on these platforms.
|
||||
|
||||
For now, use:
|
||||
```shell
|
||||
python src/main.py
|
||||
```
|
||||
|
||||
### Deployment Requirements
|
||||
- **PyInstaller** (included in dev dependencies)
|
||||
- **Icon file** (`chart-671.png`)
|
||||
- **Desktop file** (`deploy/thechart.desktop` for Linux)
|
||||
|
||||
## Docker Usage
|
||||
|
||||
### Quick Start with Docker
|
||||
```bash
|
||||
# Build and start the application
|
||||
make build
|
||||
make start
|
||||
|
||||
# Stop the application
|
||||
make stop
|
||||
|
||||
# Access container shell
|
||||
make attach
|
||||
```
|
||||
|
||||
### Manual Docker Commands
|
||||
```bash
|
||||
# Build image
|
||||
docker build -t thechart .
|
||||
|
||||
# Run container with X11 forwarding (Linux)
|
||||
docker run -it --rm \
|
||||
-e DISPLAY=$DISPLAY \
|
||||
-v /tmp/.X11-unix:/tmp/.X11-unix:rw \
|
||||
thechart
|
||||
```
|
||||
|
||||
**Note:** Docker support is primarily for development. For production use, consider the standalone executable deployment.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Python Version Conflicts
|
||||
**Problem:** `uv sync` fails with Python version errors.
|
||||
**Solution:** Ensure Python 3.13+ is installed and specify the correct version:
|
||||
```shell
|
||||
uv venv --python 3.13
|
||||
uv sync
|
||||
```
|
||||
|
||||
#### Permission Denied During Deployment
|
||||
**Problem:** Cannot copy files to `~/Applications/` or `~/Documents/`.
|
||||
**Solution:** Ensure directories exist and have proper permissions:
|
||||
```shell
|
||||
mkdir -p ~/Applications ~/Documents
|
||||
chmod 755 ~/Applications ~/Documents
|
||||
```
|
||||
|
||||
#### Missing System Dependencies
|
||||
**Problem:** Application fails to start due to missing system libraries.
|
||||
**Solution:** Install required system packages:
|
||||
|
||||
**Ubuntu/Debian:**
|
||||
```shell
|
||||
sudo apt install python3-tk python3-dev build-essential
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```shell
|
||||
brew install tcl-tk
|
||||
```
|
||||
|
||||
#### Virtual Environment Issues
|
||||
**Problem:** Environment activation fails or commands not found.
|
||||
**Solution:** Rebuild the virtual environment:
|
||||
```shell
|
||||
rm -rf .venv
|
||||
uv venv --python 3.13
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Logs and Debugging
|
||||
Application logs are stored in the `logs/` directory:
|
||||
- `app.log` - General application logs
|
||||
- `app.error.log` - Error messages
|
||||
- `app.warning.log` - Warning messages
|
||||
|
||||
To enable debug logging, modify the logging configuration in `src/logger.py`.
|
||||
|
||||
### Getting Help
|
||||
If you encounter issues not covered here:
|
||||
1. Check the application logs in the `logs/` directory
|
||||
2. Ensure all prerequisites are properly installed
|
||||
3. Try rebuilding the virtual environment
|
||||
4. Verify file permissions for deployment directories
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Essential Commands
|
||||
```bash
|
||||
# Development workflow
|
||||
make install # One-time setup
|
||||
make run # Run application
|
||||
make test # Run tests
|
||||
make format # Format code
|
||||
make lint # Check code quality
|
||||
|
||||
# Deployment
|
||||
make deploy # Create standalone executable
|
||||
|
||||
# Docker
|
||||
make build # Build container image
|
||||
make start # Start containerized app
|
||||
make stop # Stop containerized app
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
src/ # Main application source code
|
||||
├── main.py # Application entry point
|
||||
├── ui_manager.py # User interface management
|
||||
├── data_manager.py # CSV data operations
|
||||
├── graph_manager.py # Visualization and plotting
|
||||
├── medicine_manager.py # Medicine system
|
||||
└── pathology_manager.py # Symptom tracking
|
||||
|
||||
docs/ # Documentation
|
||||
├── FEATURES.md # Complete feature guide
|
||||
└── DEVELOPMENT.md # Development guide
|
||||
|
||||
logs/ # Application logs
|
||||
deploy/ # Deployment configuration
|
||||
tests/ # Test suite
|
||||
medicines.json # Medicine configuration
|
||||
pathologies.json # Pathology configuration
|
||||
thechart_data.csv # User data (created on first run)
|
||||
```
|
||||
|
||||
### Key Files
|
||||
- **`medicines.json`**: Configure available medicines
|
||||
- **`pathologies.json`**: Configure tracked symptoms
|
||||
- **`thechart_data.csv`**: Your medication and symptom data
|
||||
- **`pyproject.toml`**: Project configuration and dependencies
|
||||
- **`uv.lock`**: Dependency lock file
|
||||
|
||||
---
|
||||
|
||||
## Why uv?
|
||||
|
||||
**uv** is a fast Python package installer and resolver, written in Rust. It offers several advantages over Poetry:
|
||||
|
||||
- **Speed**: 10-100x faster than pip and Poetry
|
||||
- **Compatibility**: Drop-in replacement for pip with Poetry-like project management
|
||||
- **Simplicity**: Unified tool for package management and virtual environments
|
||||
- **Standards**: Follows Python packaging standards (PEP 621, etc.)
|
||||
|
||||
### Key uv Commands vs Poetry
|
||||
|
||||
| Task | uv Command | Poetry Equivalent |
|
||||
|------|------------|-------------------|
|
||||
| Create virtual environment | `uv venv` | `poetry env use` |
|
||||
| Install dependencies | `uv sync` | `poetry install` |
|
||||
| Add package | `uv add package` | `poetry add package` |
|
||||
| Run command | `uv run command` | `poetry run command` |
|
||||
| Activate environment | `source .venv/bin/activate` | `poetry shell` |
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to TheChart project are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.6.1] - 2025-07-31
|
||||
|
||||
### 📚 Documentation Overhaul
|
||||
- **BREAKING**: Consolidated scattered documentation into organized structure
|
||||
- **Added**: Comprehensive `docs/FEATURES.md` with complete feature documentation
|
||||
- **Added**: Detailed `docs/DEVELOPMENT.md` with testing and development guide
|
||||
- **Updated**: Streamlined `README.md` with quick-start focus and navigation
|
||||
- **Removed**: 10 redundant/outdated markdown files
|
||||
- **Improved**: Clear separation between user and developer documentation
|
||||
|
||||
### 🏗️ Documentation Structure
|
||||
```
|
||||
docs/
|
||||
├── FEATURES.md # Complete feature guide (new)
|
||||
├── DEVELOPMENT.md # Development & testing guide (new)
|
||||
└── CHANGELOG.md # This changelog (new)
|
||||
|
||||
README.md # Streamlined quick-start guide (updated)
|
||||
```
|
||||
|
||||
## [1.3.3] - Previous Releases
|
||||
|
||||
### 🏥 Modular Medicine System
|
||||
- **Added**: Dynamic medicine management system
|
||||
- **Added**: JSON-based medicine configuration (`medicines.json`)
|
||||
- **Added**: Medicine management UI (`Tools` → `Manage Medicines...`)
|
||||
- **Added**: Configurable medicine properties (colors, doses, names)
|
||||
- **Added**: Automatic UI updates when medicines change
|
||||
- **Added**: Backward compatibility with existing data
|
||||
|
||||
### 💊 Advanced Dose Tracking System
|
||||
- **Added**: Precise timestamp recording for medicine doses
|
||||
- **Added**: Multiple daily dose support for same medicine
|
||||
- **Added**: Comprehensive dose tracking interface in edit windows
|
||||
- **Added**: Quick-dose buttons for common amounts
|
||||
- **Added**: Real-time dose display and feedback
|
||||
- **Added**: Historical dose data persistence in CSV
|
||||
- **Improved**: Dose format parsing with robust error handling
|
||||
|
||||
#### Punch Button Redesign
|
||||
- **Moved**: Dose tracking from main input to edit window
|
||||
- **Added**: Individual dose entry fields per medicine
|
||||
- **Added**: "Take [Medicine]" buttons with immediate recording
|
||||
- **Added**: Editable dose display areas with history
|
||||
- **Improved**: User experience with centralized dose management
|
||||
|
||||
### 📊 Enhanced Graph Visualization
|
||||
- **Added**: Medicine dose bar charts with distinct colors
|
||||
- **Added**: Interactive toggle controls for symptoms and medicines
|
||||
- **Added**: Enhanced legend with multi-column layout
|
||||
- **Added**: Average dosage calculations and displays
|
||||
- **Added**: Professional styling with transparency and shadows
|
||||
- **Improved**: Graph layout with dynamic positioning
|
||||
|
||||
#### Medicine Dose Plotting
|
||||
- **Added**: Visual representation of daily medication intake
|
||||
- **Added**: Scaled dose display (mg/10) for chart compatibility
|
||||
- **Added**: Color-coded bars for each medicine
|
||||
- **Added**: Semi-transparent rendering to preserve symptom visibility
|
||||
- **Fixed**: Dose calculation logic for complex timestamp formats
|
||||
|
||||
#### Legend Enhancements
|
||||
- **Added**: Multi-column legend layout (2 columns)
|
||||
- **Added**: Average dosage information per medicine
|
||||
- **Added**: Tracking status for medicines without current doses
|
||||
- **Added**: Frame, shadow, and transparency effects
|
||||
- **Improved**: Space utilization and readability
|
||||
|
||||
### 🧪 Comprehensive Testing Framework
|
||||
- **Added**: Professional testing infrastructure with pytest
|
||||
- **Added**: 93% code coverage across 112 tests
|
||||
- **Added**: Coverage reporting (HTML, XML, terminal)
|
||||
- **Added**: Pre-commit testing hooks
|
||||
- **Added**: Comprehensive dose calculation testing
|
||||
- **Added**: UI component testing with mocking
|
||||
- **Added**: Medicine plotting and legend testing
|
||||
|
||||
#### Test Infrastructure
|
||||
- **Added**: `tests/conftest.py` with shared fixtures
|
||||
- **Added**: Sample data generators for realistic testing
|
||||
- **Added**: Mock loggers and temporary file management
|
||||
- **Added**: Environment variable mocking
|
||||
|
||||
#### Pre-commit Testing
|
||||
- **Added**: Automated testing before commits
|
||||
- **Added**: Core functionality validation (3 essential tests)
|
||||
- **Added**: Commit blocking on test failures
|
||||
- **Configured**: `.pre-commit-config.yaml` with testing hooks
|
||||
|
||||
### 🏗️ Technical Architecture Improvements
|
||||
- **Added**: Modular component architecture
|
||||
- **Added**: MedicineManager and PathologyManager classes
|
||||
- **Added**: Dynamic UI generation based on configuration
|
||||
- **Improved**: Separation of concerns across modules
|
||||
- **Enhanced**: Error handling and logging throughout
|
||||
|
||||
### 📈 Data Management Enhancements
|
||||
- **Added**: Automatic data migration and backup system
|
||||
- **Added**: Dynamic CSV column management
|
||||
- **Added**: Robust dose string parsing
|
||||
- **Improved**: Data validation and error handling
|
||||
- **Enhanced**: Backward compatibility preservation
|
||||
|
||||
### 🔧 Development Tools & Workflow
|
||||
- **Added**: uv integration for fast package management
|
||||
- **Added**: Comprehensive Makefile with development commands
|
||||
- **Added**: Docker support with multi-platform builds
|
||||
- **Added**: Pre-commit hooks for code quality
|
||||
- **Added**: Ruff for fast Python formatting and linting
|
||||
- **Improved**: Virtual environment management
|
||||
|
||||
### 🚀 Deployment & Distribution
|
||||
- **Added**: PyInstaller integration for standalone executables
|
||||
- **Added**: Linux desktop integration
|
||||
- **Added**: Automatic file installation and desktop entries
|
||||
- **Added**: Docker containerization support
|
||||
- **Improved**: Build and deployment automation
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Dependencies
|
||||
- **Runtime**: Python 3.13+, matplotlib, pandas, tkinter, colorlog
|
||||
- **Development**: pytest, pytest-cov, ruff, pre-commit, pyinstaller
|
||||
- **Package Management**: uv (Rust-based, 10-100x faster than pip/Poetry)
|
||||
|
||||
### Architecture
|
||||
- **Frontend**: Tkinter-based GUI with dynamic component generation
|
||||
- **Backend**: Pandas for data manipulation, Matplotlib for visualization
|
||||
- **Storage**: CSV-based with JSON configuration files
|
||||
- **Testing**: pytest with comprehensive mocking and coverage
|
||||
|
||||
### File Structure
|
||||
```
|
||||
src/ # Main application code
|
||||
├── main.py # Application entry point
|
||||
├── ui_manager.py # User interface management
|
||||
├── data_manager.py # CSV operations and data persistence
|
||||
├── graph_manager.py # Visualization and plotting
|
||||
├── medicine_manager.py # Medicine system management
|
||||
└── pathology_manager.py # Symptom tracking
|
||||
|
||||
tests/ # Comprehensive test suite (112 tests, 93% coverage)
|
||||
docs/ # Organized documentation
|
||||
├── FEATURES.md # Complete feature documentation
|
||||
├── DEVELOPMENT.md # Development and testing guide
|
||||
└── CHANGELOG.md # This changelog
|
||||
|
||||
Configuration Files:
|
||||
├── medicines.json # Medicine definitions (auto-generated)
|
||||
├── pathologies.json # Symptom categories (auto-generated)
|
||||
├── pyproject.toml # Project configuration
|
||||
└── uv.lock # Dependency lock file
|
||||
```
|
||||
|
||||
## Migration Notes
|
||||
|
||||
### From Previous Versions
|
||||
- **Data Compatibility**: All existing CSV data continues to work
|
||||
- **Automatic Migration**: Data structure updates handled automatically
|
||||
- **Backup Creation**: Automatic backups before major changes
|
||||
- **No Data Loss**: Existing functionality preserved during updates
|
||||
|
||||
### Configuration Migration
|
||||
- **Medicine System**: Hard-coded medicines converted to JSON configuration
|
||||
- **UI Updates**: Interface automatically adapts to new medicine definitions
|
||||
- **Graph Integration**: Visualization system updated for dynamic medicines
|
||||
|
||||
## Future Roadmap
|
||||
|
||||
### Planned Features (v2.0)
|
||||
- **Mobile App**: Companion mobile application for dose tracking
|
||||
- **Cloud Sync**: Multi-device data synchronization
|
||||
- **Advanced Analytics**: Machine learning-based trend analysis
|
||||
- **Reminder System**: Intelligent medication reminders
|
||||
- **Doctor Integration**: Healthcare provider report generation
|
||||
|
||||
### Platform Expansion
|
||||
- **macOS Support**: Native macOS application
|
||||
- **Windows Support**: Windows executable and installer
|
||||
- **Web Interface**: Browser-based version for universal access
|
||||
|
||||
### API Development
|
||||
- **REST API**: External system integration
|
||||
- **Plugin Architecture**: Third-party extension support
|
||||
- **Data Export**: Multiple format support (JSON, XML, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
This project follows semantic versioning and maintains comprehensive documentation.
|
||||
For development guidelines, see [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
|
||||
For feature information, see [docs/FEATURES.md](docs/FEATURES.md).
|
||||
@@ -0,0 +1,340 @@
|
||||
# TheChart - Development Documentation
|
||||
|
||||
## Development Environment Setup
|
||||
|
||||
### Prerequisites
|
||||
- **Python 3.13+**: Required for the application
|
||||
- **uv**: Fast Python package manager (10-100x faster than pip/Poetry)
|
||||
- **Git**: Version control
|
||||
|
||||
### Quick Setup
|
||||
```bash
|
||||
# Clone and setup
|
||||
git clone <repository-url>
|
||||
cd thechart
|
||||
|
||||
# Install with uv (recommended)
|
||||
make install
|
||||
|
||||
# Or manual setup
|
||||
uv venv --python 3.13
|
||||
uv sync
|
||||
uv run pre-commit install --install-hooks --overwrite
|
||||
```
|
||||
|
||||
### Environment Activation
|
||||
```bash
|
||||
# fish shell (default)
|
||||
source .venv/bin/activate.fish
|
||||
# or
|
||||
make shell
|
||||
|
||||
# bash/zsh
|
||||
source .venv/bin/activate
|
||||
|
||||
# Using uv run (recommended)
|
||||
uv run python src/main.py
|
||||
```
|
||||
|
||||
## Testing Framework
|
||||
|
||||
### Test Infrastructure
|
||||
Professional testing setup with comprehensive coverage and automation.
|
||||
|
||||
#### Testing Tools
|
||||
- **pytest**: Modern Python testing framework
|
||||
- **pytest-cov**: Coverage reporting (HTML, XML, terminal)
|
||||
- **pytest-mock**: Mocking support for isolated testing
|
||||
- **coverage**: Detailed coverage analysis
|
||||
|
||||
#### Test Statistics
|
||||
- **93% Overall Code Coverage** (482 total statements, 33 missed)
|
||||
- **112 Total Tests** across 6 test modules
|
||||
- **80 Tests Passing** (71.4% pass rate)
|
||||
|
||||
#### Coverage by Module
|
||||
| Module | Coverage | Status |
|
||||
|--------|----------|--------|
|
||||
| constants.py | 100% | ✅ Complete |
|
||||
| logger.py | 100% | ✅ Complete |
|
||||
| graph_manager.py | 97% | ✅ Excellent |
|
||||
| init.py | 95% | ✅ Excellent |
|
||||
| ui_manager.py | 93% | ✅ Very Good |
|
||||
| main.py | 91% | ✅ Very Good |
|
||||
| data_manager.py | 87% | ✅ Good |
|
||||
|
||||
### Test Structure
|
||||
|
||||
#### Test Files
|
||||
- **`tests/test_data_manager.py`** (16 tests): CSV operations, validation, error handling
|
||||
- **`tests/test_graph_manager.py`** (14 tests): Matplotlib integration, dose calculations
|
||||
- **`tests/test_ui_manager.py`** (21 tests): Tkinter UI components, user interactions
|
||||
- **`tests/test_main.py`** (18 tests): Application integration, workflow testing
|
||||
- **`tests/test_constants.py`** (12 tests): Configuration validation
|
||||
- **`tests/test_logger.py`** (8 tests): Logging functionality
|
||||
- **`tests/test_init.py`** (23 tests): Initialization and setup
|
||||
|
||||
#### Test Fixtures (`tests/conftest.py`)
|
||||
- **Temporary Files**: Safe testing without affecting real data
|
||||
- **Sample Data**: Comprehensive test datasets with realistic dose information
|
||||
- **Mock Loggers**: Isolated logging for testing
|
||||
- **Environment Mocking**: Controlled test environments
|
||||
|
||||
### Running Tests
|
||||
|
||||
#### Basic Testing
|
||||
```bash
|
||||
# Run all tests
|
||||
make test
|
||||
# or
|
||||
uv run pytest
|
||||
|
||||
# Run specific test file
|
||||
uv run pytest tests/test_graph_manager.py -v
|
||||
|
||||
# Run tests with specific pattern
|
||||
uv run pytest -k "dose_calculation" -v
|
||||
```
|
||||
|
||||
#### Coverage Testing
|
||||
```bash
|
||||
# Generate coverage report
|
||||
uv run pytest --cov=src --cov-report=html
|
||||
|
||||
# Coverage with specific module
|
||||
uv run pytest tests/test_graph_manager.py --cov=src.graph_manager --cov-report=term-missing
|
||||
```
|
||||
|
||||
#### Continuous Testing
|
||||
```bash
|
||||
# Watch for changes and re-run tests
|
||||
uv run pytest --watch
|
||||
|
||||
# Quick test runner script
|
||||
./scripts/run_tests.py
|
||||
```
|
||||
|
||||
### Pre-commit Testing
|
||||
Automated testing prevents commits when core functionality is broken.
|
||||
|
||||
#### Configuration
|
||||
Located in `.pre-commit-config.yaml`:
|
||||
- **Core Tests**: 3 essential tests run before each commit
|
||||
- **Fast Execution**: Only critical functionality tested
|
||||
- **Commit Blocking**: Prevents commits when tests fail
|
||||
|
||||
#### Core Tests
|
||||
1. **`test_init`**: DataManager initialization
|
||||
2. **`test_initialize_csv_creates_file_with_headers`**: CSV file creation
|
||||
3. **`test_load_data_with_valid_data`**: Data loading functionality
|
||||
|
||||
#### Usage
|
||||
```bash
|
||||
# Automatic on commit
|
||||
git commit -m "Your changes"
|
||||
|
||||
# Manual pre-commit check
|
||||
pre-commit run --all-files
|
||||
|
||||
# Run just test check
|
||||
pre-commit run pytest-check --all-files
|
||||
```
|
||||
|
||||
### Dose Calculation Testing
|
||||
Comprehensive testing for the complex dose parsing and calculation system.
|
||||
|
||||
#### Test Categories
|
||||
- **Standard Format**: `2025-07-28 18:59:45:150mg` → 150.0mg
|
||||
- **Multiple Doses**: `2025-07-28 18:59:45:150mg|2025-07-28 19:34:19:75mg` → 225.0mg
|
||||
- **With Symbols**: `• • • • 2025-07-30 07:50:00:300` → 300.0mg
|
||||
- **Decimal Values**: `2025-07-28 18:59:45:12.5mg|2025-07-28 19:34:19:7.5mg` → 20.0mg
|
||||
- **No Timestamps**: `100mg|50mg` → 150.0mg
|
||||
- **Mixed Formats**: `• 2025-07-30 22:50:00:10|75mg` → 85.0mg
|
||||
- **Edge Cases**: Empty strings, NaN values, malformed data → 0.0mg
|
||||
|
||||
#### Test Implementation
|
||||
```python
|
||||
# Example test case
|
||||
def test_calculate_daily_dose_standard_format(self, graph_manager):
|
||||
dose_str = "2025-07-28 18:59:45:150mg|2025-07-28 19:34:19:75mg"
|
||||
result = graph_manager._calculate_daily_dose(dose_str)
|
||||
assert result == 225.0
|
||||
```
|
||||
|
||||
### Medicine Plotting Tests
|
||||
Testing for the enhanced graph functionality with medicine dose visualization.
|
||||
|
||||
#### Test Areas
|
||||
- **Toggle Functionality**: Medicine show/hide controls
|
||||
- **Dose Plotting**: Bar chart generation for medicine doses
|
||||
- **Color Coding**: Proper color assignment and consistency
|
||||
- **Legend Enhancement**: Multi-column layout and average calculations
|
||||
- **Data Integration**: Proper data flow from CSV to visualization
|
||||
|
||||
### UI Testing Strategy
|
||||
Testing user interface components with mock frameworks to avoid GUI dependencies.
|
||||
|
||||
#### UI Test Coverage
|
||||
- **Component Creation**: Widget creation and configuration
|
||||
- **Event Handling**: User interactions and callbacks
|
||||
- **Data Binding**: Variable synchronization and updates
|
||||
- **Layout Management**: Grid and frame arrangements
|
||||
- **Error Handling**: User input validation and error messages
|
||||
|
||||
#### Mocking Strategy
|
||||
```python
|
||||
# Example UI test with mocking
|
||||
@patch('tkinter.Tk')
|
||||
def test_create_input_frame(self, mock_tk, ui_manager):
|
||||
parent = Mock()
|
||||
result = ui_manager.create_input_frame(parent, {}, {})
|
||||
assert result is not None
|
||||
assert isinstance(result, dict)
|
||||
```
|
||||
|
||||
## Code Quality
|
||||
|
||||
### Tools and Standards
|
||||
- **ruff**: Fast Python linter and formatter (Rust-based)
|
||||
- **pre-commit**: Git hook management for code quality
|
||||
- **Type Hints**: Comprehensive type annotations
|
||||
- **Docstrings**: Detailed function and class documentation
|
||||
|
||||
### Code Formatting
|
||||
```bash
|
||||
# Format code
|
||||
make format
|
||||
# or
|
||||
uv run ruff format .
|
||||
|
||||
# Check formatting
|
||||
make lint
|
||||
# or
|
||||
uv run ruff check .
|
||||
```
|
||||
|
||||
### Pre-commit Hooks
|
||||
Automatically installed hooks ensure code quality:
|
||||
- **Code Formatting**: ruff formatting
|
||||
- **Linting Checks**: Code quality validation
|
||||
- **Import Sorting**: Consistent import organization
|
||||
- **Basic File Checks**: Trailing whitespace, file endings
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Feature Development
|
||||
1. **Create Feature Branch**: `git checkout -b feature/new-feature`
|
||||
2. **Implement Changes**: Follow existing patterns and architecture
|
||||
3. **Add Tests**: Ensure new functionality is tested
|
||||
4. **Run Tests**: `make test` to verify functionality
|
||||
5. **Code Quality**: `make format && make lint`
|
||||
6. **Commit Changes**: Pre-commit hooks run automatically
|
||||
7. **Create Pull Request**: For code review
|
||||
|
||||
### Medicine System Development
|
||||
Adding new medicines or modifying the medicine system:
|
||||
|
||||
```python
|
||||
# Example: Adding a new medicine programmatically
|
||||
from medicine_manager import MedicineManager, Medicine
|
||||
|
||||
medicine_manager = MedicineManager()
|
||||
new_medicine = Medicine(
|
||||
key="sertraline",
|
||||
display_name="Sertraline",
|
||||
dosage_info="50mg",
|
||||
quick_doses=["25", "50", "100"],
|
||||
color="#9B59B6",
|
||||
default_enabled=False
|
||||
)
|
||||
medicine_manager.add_medicine(new_medicine)
|
||||
```
|
||||
|
||||
### Testing New Features
|
||||
1. **Unit Tests**: Add tests for new functionality
|
||||
2. **Integration Tests**: Test feature integration with existing system
|
||||
3. **UI Tests**: Test user interface changes
|
||||
4. **Dose Calculation Tests**: If affecting dose calculations
|
||||
5. **Regression Tests**: Ensure existing functionality still works
|
||||
|
||||
## Debugging and Troubleshooting
|
||||
|
||||
### Logging
|
||||
Application logs are stored in `logs/` directory:
|
||||
- **`app.log`**: General application logs
|
||||
- **`app.error.log`**: Error messages only
|
||||
- **`app.warning.log`**: Warning messages only
|
||||
|
||||
### Debug Mode
|
||||
Enable debug logging by modifying `src/logger.py` configuration.
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Test Failures
|
||||
- **Matplotlib Mocking**: Ensure proper matplotlib component mocking
|
||||
- **Tkinter Dependencies**: Use headless testing for UI components
|
||||
- **File Path Issues**: Use absolute paths in tests
|
||||
- **Mock Configuration**: Proper mock setup for external dependencies
|
||||
|
||||
#### Development Environment
|
||||
- **Python Version**: Ensure Python 3.13+ is used
|
||||
- **Virtual Environment**: Always work within the virtual environment
|
||||
- **Dependencies**: Keep dependencies up to date with `uv sync --upgrade`
|
||||
|
||||
### Performance Testing
|
||||
- **Dose Calculation Performance**: Test with large datasets
|
||||
- **UI Responsiveness**: Test with extensive medicine lists
|
||||
- **Memory Usage**: Monitor memory consumption with large CSV files
|
||||
- **Graph Rendering**: Test graph performance with large datasets
|
||||
|
||||
## Architecture Documentation
|
||||
|
||||
### Core Components
|
||||
- **MedTrackerApp**: Main application class
|
||||
- **MedicineManager**: Medicine CRUD operations
|
||||
- **PathologyManager**: Pathology/symptom management
|
||||
- **GraphManager**: Visualization and plotting
|
||||
- **UIManager**: User interface creation
|
||||
- **DataManager**: Data persistence and CSV operations
|
||||
|
||||
### Data Flow
|
||||
1. **User Input** → UIManager → DataManager → CSV
|
||||
2. **Data Loading** → DataManager → pandas DataFrame → GraphManager
|
||||
3. **Visualization** → GraphManager → matplotlib → UI Display
|
||||
|
||||
### Extension Points
|
||||
- **Medicine System**: Add new medicine properties
|
||||
- **Graph Types**: Add new visualization types
|
||||
- **Export Formats**: Add new data export options
|
||||
- **UI Components**: Add new interface elements
|
||||
|
||||
## Deployment Testing
|
||||
|
||||
### Standalone Executable
|
||||
```bash
|
||||
# Build executable
|
||||
make deploy
|
||||
|
||||
# Test deployment
|
||||
./dist/thechart
|
||||
```
|
||||
|
||||
### Docker Testing
|
||||
```bash
|
||||
# Build container
|
||||
make build
|
||||
|
||||
# Test container
|
||||
make start
|
||||
make attach
|
||||
```
|
||||
|
||||
### Cross-platform Testing
|
||||
- **Linux**: Primary development and testing platform
|
||||
- **macOS**: Planned support (testing needed)
|
||||
- **Windows**: Planned support (testing needed)
|
||||
|
||||
---
|
||||
|
||||
For user documentation, see [README.md](../README.md).
|
||||
For feature details, see [docs/FEATURES.md](FEATURES.md).
|
||||
@@ -0,0 +1,232 @@
|
||||
# TheChart - Features Documentation
|
||||
|
||||
## Overview
|
||||
TheChart is a comprehensive medication tracking application that allows users to monitor medication intake, symptom tracking, and visualize treatment progress over time.
|
||||
|
||||
## Core Features
|
||||
|
||||
### 🏥 Modular Medicine System
|
||||
TheChart features a dynamic medicine management system that allows complete customization without code modifications.
|
||||
|
||||
#### Features:
|
||||
- **Dynamic Medicine Management**: Add, edit, and remove medicines through the UI
|
||||
- **Configurable Properties**: Each medicine has customizable display names, dosages, colors, and quick-dose options
|
||||
- **Automatic UI Updates**: All interface elements update automatically when medicines change
|
||||
- **JSON Configuration**: Human-readable `medicines.json` file for easy management
|
||||
|
||||
#### Medicine Configuration:
|
||||
Each medicine includes:
|
||||
- **Key**: Internal identifier (e.g., "bupropion")
|
||||
- **Display Name**: User-friendly name (e.g., "Bupropion")
|
||||
- **Dosage Info**: Dosage information (e.g., "150/300 mg")
|
||||
- **Quick Doses**: Common dose amounts for quick selection
|
||||
- **Color**: Hex color for graph display (e.g., "#FF6B6B")
|
||||
- **Default Enabled**: Whether to show in graphs by default
|
||||
|
||||
#### Default Medicines:
|
||||
| Medicine | Dosage | Default Graph | Color |
|
||||
|----------|--------|---------------|--------|
|
||||
| Bupropion | 150/300 mg | ✅ | Red (#FF6B6B) |
|
||||
| Hydroxyzine | 25 mg | ❌ | Teal (#4ECDC4) |
|
||||
| Gabapentin | 100 mg | ❌ | Blue (#45B7D1) |
|
||||
| Propranolol | 10 mg | ✅ | Green (#96CEB4) |
|
||||
| Quetiapine | 25 mg | ❌ | Yellow (#FFEAA7) |
|
||||
|
||||
#### Usage:
|
||||
1. **Through UI**: Go to `Tools` → `Manage Medicines...`
|
||||
2. **Manual Configuration**: Edit `medicines.json` directly
|
||||
3. **Programmatically**: Use the MedicineManager API
|
||||
|
||||
### 💊 Advanced Dose Tracking
|
||||
Comprehensive dose tracking system that records exact timestamps and dosages throughout the day.
|
||||
|
||||
#### Core Capabilities:
|
||||
- **Timestamp Recording**: Exact time when medicine is taken
|
||||
- **Dose Amount Tracking**: Record specific doses (150mg, 10mg, etc.)
|
||||
- **Multiple Doses Per Day**: Take the same medicine multiple times
|
||||
- **Real-time Display**: See today's doses immediately
|
||||
- **Data Persistence**: All doses saved to CSV with full history
|
||||
|
||||
#### Dose Management Interface:
|
||||
Located in the edit window (double-click any entry):
|
||||
- **Individual Dose Entry Fields**: For each medicine
|
||||
- **"Take [Medicine]" Buttons**: Immediate dose recording with timestamps
|
||||
- **Editable Dose Display Areas**: View and modify existing doses
|
||||
- **Quick Dose Buttons**: Pre-configured common dose amounts
|
||||
- **Format Consistency**: All doses displayed in HH:MM: dose format
|
||||
|
||||
#### Data Format:
|
||||
- **Timestamp Format**: `YYYY-MM-DD HH:MM:SS`
|
||||
- **Dose Separator**: `|` (pipe) for multiple doses
|
||||
- **Dose Format**: `timestamp:dose`
|
||||
- **CSV Storage**: Additional columns in existing CSV file
|
||||
|
||||
#### Example CSV Format:
|
||||
```csv
|
||||
date,depression,anxiety,sleep,appetite,bupropion,bupropion_doses,hydroxyzine,hydroxyzine_doses,propranolol,propranolol_doses,note
|
||||
07/28/2025,4,5,3,3,1,"2025-07-28 14:30:00:150mg|2025-07-28 18:30:00:150mg",0,"",1,"2025-07-28 12:30:00:10mg","Multiple doses today"
|
||||
```
|
||||
|
||||
### 📊 Enhanced Graph Visualization
|
||||
Advanced graphing system with comprehensive data visualization and interactive controls.
|
||||
|
||||
#### Medicine Dose Visualization:
|
||||
- **Colored Bar Charts**: Each medicine has distinct colors
|
||||
- **Daily Dose Totals**: Automatically calculated from individual doses
|
||||
- **Scaled Display**: Doses scaled by 1/10 for better visibility (labeled as "mg/10")
|
||||
- **Dynamic Positioning**: Bars positioned below main chart area
|
||||
- **Semi-transparent Bars**: Alpha=0.6 to avoid overwhelming symptom data
|
||||
|
||||
#### Interactive Controls:
|
||||
- **Toggle Buttons**: Independent show/hide for each medicine and symptom
|
||||
- **Organized Sections**: "Symptoms" and "Medicines" sections
|
||||
- **Real-time Updates**: Changes take effect immediately
|
||||
|
||||
#### Enhanced Legend:
|
||||
- **Multi-column Layout**: Efficient use of graph space (2 columns)
|
||||
- **Average Dosage Display**: Shows average dose for each medicine
|
||||
- **Color Coding**: Consistent color scheme matching graph elements
|
||||
- **Professional Styling**: Frame, shadow, and transparency effects
|
||||
- **Tracking Status**: Shows medicines being monitored but without current dose data
|
||||
|
||||
#### Dose Calculation Features:
|
||||
- **Multiple Format Support**: Handles various dose string formats
|
||||
- **Robust Parsing**: Handles timestamps, symbols (•), and mixed formats
|
||||
- **Edge Case Handling**: Manages empty strings, NaN values, malformed data
|
||||
- **Daily Totals**: Sums all individual doses for comprehensive daily tracking
|
||||
|
||||
### 🏥 Pathology Management
|
||||
Comprehensive symptom tracking with configurable pathologies.
|
||||
|
||||
#### Features:
|
||||
- **Dynamic Pathology System**: Similar to medicine management
|
||||
- **Configurable Symptoms**: Add, edit, and remove symptom categories
|
||||
- **Scale-based Rating**: 0-10 rating system for symptom severity
|
||||
- **Historical Tracking**: Full symptom history with trend analysis
|
||||
|
||||
### 📝 Data Management
|
||||
Robust data handling with comprehensive backup and migration support.
|
||||
|
||||
#### Data Features:
|
||||
- **CSV-based Storage**: Human-readable and portable data format
|
||||
- **Automatic Backups**: Created before major migrations
|
||||
- **Backward Compatibility**: Existing data continues to work with updates
|
||||
- **Dynamic Column Management**: Automatically adapts to new medicines/pathologies
|
||||
- **Data Validation**: Ensures data integrity and handles edge cases
|
||||
|
||||
#### Migration Support:
|
||||
- **Automatic Migration**: Data structure updates handled automatically
|
||||
- **Backup Creation**: `thechart_data.csv.backup_YYYYMMDD_HHMMSS` format
|
||||
- **No Data Loss**: All existing functionality and data preserved
|
||||
- **Version Compatibility**: Seamless updates across application versions
|
||||
|
||||
### 🧪 Comprehensive Testing Framework
|
||||
Professional testing infrastructure with high code coverage.
|
||||
|
||||
#### Testing Statistics:
|
||||
- **93% Overall Code Coverage** (482 total statements, 33 missed)
|
||||
- **112 Total Tests** across 6 test modules
|
||||
- **80 Tests Passing** (71.4% pass rate)
|
||||
- **Pre-commit Testing**: Core functionality tests run before each commit
|
||||
|
||||
#### Test Coverage by Module:
|
||||
- **100% Coverage**: constants.py, logger.py
|
||||
- **97% Coverage**: graph_manager.py
|
||||
- **95% Coverage**: init.py
|
||||
- **93% Coverage**: ui_manager.py
|
||||
- **91% Coverage**: main.py
|
||||
- **87% Coverage**: data_manager.py
|
||||
|
||||
#### Testing Tools:
|
||||
- **pytest**: Modern Python testing framework
|
||||
- **pytest-cov**: Coverage reporting with HTML, XML, and terminal output
|
||||
- **pytest-mock**: Mocking support for isolated testing
|
||||
- **pre-commit hooks**: Automated testing before commits
|
||||
|
||||
## User Interface Features
|
||||
|
||||
### 🖥️ Intuitive Design
|
||||
- **Clean Main Interface**: Simplified new entry form focused on essential inputs
|
||||
- **Organized Edit Windows**: Comprehensive dose management in dedicated edit interface
|
||||
- **Scrollable Interface**: Vertical scrollbar for expanded UI components
|
||||
- **Responsive Design**: Interface adapts to window size and content
|
||||
- **Visual Feedback**: Success messages and clear status indicators
|
||||
|
||||
### 🎯 User Experience Improvements
|
||||
- **Centralized Dose Management**: All dose operations consolidated in edit windows
|
||||
- **Quick Entry Options**: Pre-configured dose buttons for common amounts
|
||||
- **Format Guidance**: Clear instructions and format examples
|
||||
- **Real-time Updates**: Immediate feedback and data updates
|
||||
- **Error Handling**: Comprehensive error messages and recovery options
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### 🏗️ Modular Design
|
||||
- **MedicineManager**: Core medicine CRUD operations
|
||||
- **PathologyManager**: Symptom and pathology management
|
||||
- **GraphManager**: All graph-related operations and visualizations
|
||||
- **UIManager**: User interface creation and management
|
||||
- **DataManager**: CSV operations and data persistence
|
||||
|
||||
### 🔧 Configuration Management
|
||||
- **JSON-based Configuration**: `medicines.json` and `pathologies.json`
|
||||
- **Dynamic Loading**: Runtime configuration updates
|
||||
- **Validation**: Input validation and error handling
|
||||
- **Backward Compatibility**: Seamless updates and migrations
|
||||
|
||||
### 📈 Data Processing
|
||||
- **Pandas Integration**: Efficient data manipulation and analysis
|
||||
- **Matplotlib Visualization**: Professional graph rendering
|
||||
- **Robust Parsing**: Handles various data formats and edge cases
|
||||
- **Real-time Calculations**: Dynamic dose totals and averages
|
||||
|
||||
## Deployment and Distribution
|
||||
|
||||
### 📦 Standalone Executable
|
||||
- **PyInstaller Integration**: Creates self-contained executables
|
||||
- **Cross-platform Support**: Linux deployment with desktop integration
|
||||
- **Automatic Installation**: Installs to `~/Applications/` with desktop entry
|
||||
- **Data Migration**: Copies data files to appropriate user directories
|
||||
|
||||
### 🐳 Docker Support
|
||||
- **Multi-platform Images**: Docker container support
|
||||
- **Docker Compose**: Easy container management
|
||||
- **Development Environment**: Consistent development setup across platforms
|
||||
|
||||
### 🔄 Package Management
|
||||
- **UV Integration**: Fast Python package management with Rust performance
|
||||
- **Virtual Environment**: Isolated dependency management
|
||||
- **Lock Files**: Reproducible builds with `uv.lock`
|
||||
- **Development Dependencies**: Separate dev dependencies for clean production builds
|
||||
|
||||
## Integration Features
|
||||
|
||||
### 🔄 Import/Export
|
||||
- **CSV Import**: Import existing medication data
|
||||
- **Data Export**: Export data for backup or analysis
|
||||
- **Format Compatibility**: Standard CSV format for portability
|
||||
|
||||
### 🔌 API Integration
|
||||
- **Extensible Architecture**: Plugin system for future enhancements
|
||||
- **Medicine API**: Programmatic medicine management
|
||||
- **Data API**: Direct data access and manipulation
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### 🚀 Planned Features
|
||||
- **Mobile Companion App**: Mobile dose tracking and reminders
|
||||
- **Cloud Synchronization**: Multi-device data synchronization
|
||||
- **Advanced Analytics**: Machine learning-based trend analysis
|
||||
- **Reminder System**: Intelligent dose reminders and scheduling
|
||||
- **Doctor Integration**: Export reports for healthcare providers
|
||||
|
||||
### 🎯 Development Roadmap
|
||||
- **macOS/Windows Support**: Extended platform support
|
||||
- **Plugin Architecture**: Third-party extension support
|
||||
- **API Development**: RESTful API for external integrations
|
||||
- **Advanced Visualizations**: Additional chart types and analysis tools
|
||||
|
||||
---
|
||||
|
||||
For detailed usage instructions, see the main [README.md](../README.md).
|
||||
For development information, see [DEVELOPMENT.md](DEVELOPMENT.md).
|
||||
@@ -0,0 +1,76 @@
|
||||
# TheChart Documentation
|
||||
|
||||
Welcome to TheChart documentation! This guide will help you navigate the available documentation.
|
||||
|
||||
## 📖 Documentation Index
|
||||
|
||||
### For Users
|
||||
- **[README.md](../README.md)** - Quick start guide and installation
|
||||
- **[Features Guide](FEATURES.md)** - Complete feature documentation
|
||||
- Modular Medicine System
|
||||
- Advanced Dose Tracking
|
||||
- Graph Visualizations
|
||||
- Data Management
|
||||
|
||||
### For Developers
|
||||
- **[Development Guide](DEVELOPMENT.md)** - Development setup and testing
|
||||
- Testing Framework (93% coverage)
|
||||
- Code Quality Tools
|
||||
- Architecture Overview
|
||||
- Debugging Guide
|
||||
|
||||
### Project History
|
||||
- **[Changelog](CHANGELOG.md)** - Version history and feature evolution
|
||||
- Recent updates and improvements
|
||||
- Migration notes
|
||||
- Future roadmap
|
||||
|
||||
## 🚀 Quick Navigation
|
||||
|
||||
### Getting Started
|
||||
1. **Installation**: See [README.md - Installation](../README.md#installation)
|
||||
2. **First Run**: See [README.md - Running the Application](../README.md#running-the-application)
|
||||
3. **Key Features**: See [FEATURES.md](FEATURES.md)
|
||||
|
||||
### Development
|
||||
1. **Setup**: See [DEVELOPMENT.md - Development Environment Setup](DEVELOPMENT.md#development-environment-setup)
|
||||
2. **Testing**: See [DEVELOPMENT.md - Testing Framework](DEVELOPMENT.md#testing-framework)
|
||||
3. **Contributing**: See [DEVELOPMENT.md - Development Workflow](DEVELOPMENT.md#development-workflow)
|
||||
|
||||
### Advanced Usage
|
||||
1. **Medicine Management**: See [FEATURES.md - Modular Medicine System](FEATURES.md#-modular-medicine-system)
|
||||
2. **Dose Tracking**: See [FEATURES.md - Advanced Dose Tracking](FEATURES.md#-advanced-dose-tracking)
|
||||
3. **Visualizations**: See [FEATURES.md - Enhanced Graph Visualization](FEATURES.md#-enhanced-graph-visualization)
|
||||
|
||||
## 📋 Documentation Standards
|
||||
|
||||
All documentation follows these principles:
|
||||
- **Clear Structure**: Hierarchical organization with clear headings
|
||||
- **Practical Examples**: Code snippets and usage examples
|
||||
- **Up-to-date**: Synchronized with current codebase
|
||||
- **Comprehensive**: Covers all major features and workflows
|
||||
- **Cross-referenced**: Links between related sections
|
||||
|
||||
## 🔍 Finding Information
|
||||
|
||||
### By Topic
|
||||
- **Installation & Setup** → [README.md](../README.md)
|
||||
- **Feature Usage** → [FEATURES.md](FEATURES.md)
|
||||
- **Development** → [DEVELOPMENT.md](DEVELOPMENT.md)
|
||||
- **Version History** → [CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
### By User Type
|
||||
- **End Users** → Start with [README.md](../README.md), then [FEATURES.md](FEATURES.md)
|
||||
- **Developers** → [DEVELOPMENT.md](DEVELOPMENT.md) and [CHANGELOG.md](CHANGELOG.md)
|
||||
- **Contributors** → All documentation, especially [DEVELOPMENT.md](DEVELOPMENT.md)
|
||||
|
||||
### By Task
|
||||
- **Install TheChart** → [README.md - Installation](../README.md#installation)
|
||||
- **Add New Medicine** → [FEATURES.md - Modular Medicine System](FEATURES.md#-modular-medicine-system)
|
||||
- **Track Doses** → [FEATURES.md - Advanced Dose Tracking](FEATURES.md#-advanced-dose-tracking)
|
||||
- **Run Tests** → [DEVELOPMENT.md - Testing Framework](DEVELOPMENT.md#testing-framework)
|
||||
- **Deploy Application** → [README.md - Deployment](../README.md#deployment)
|
||||
|
||||
---
|
||||
|
||||
**Need help?** Check the troubleshooting sections in [README.md](../README.md#troubleshooting) and [DEVELOPMENT.md](DEVELOPMENT.md#debugging-and-troubleshooting).
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"medicines": [
|
||||
{
|
||||
"key": "bupropion",
|
||||
"display_name": "Bupropion",
|
||||
"dosage_info": "150/300 mg",
|
||||
"quick_doses": [
|
||||
"150",
|
||||
"300"
|
||||
],
|
||||
"color": "#FF6B6B",
|
||||
"default_enabled": false
|
||||
},
|
||||
{
|
||||
"key": "hydroxyzine",
|
||||
"display_name": "Hydroxyzine",
|
||||
"dosage_info": "25 mg",
|
||||
"quick_doses": [
|
||||
"25",
|
||||
"50"
|
||||
],
|
||||
"color": "#4ECDC4",
|
||||
"default_enabled": false
|
||||
},
|
||||
{
|
||||
"key": "gabapentin",
|
||||
"display_name": "Gabapentin",
|
||||
"dosage_info": "100 mg",
|
||||
"quick_doses": [
|
||||
"100",
|
||||
"300",
|
||||
"600"
|
||||
],
|
||||
"color": "#45B7D1",
|
||||
"default_enabled": false
|
||||
},
|
||||
{
|
||||
"key": "propranolol",
|
||||
"display_name": "Propranolol",
|
||||
"dosage_info": "10 mg",
|
||||
"quick_doses": [
|
||||
"10",
|
||||
"20",
|
||||
"40"
|
||||
],
|
||||
"color": "#96CEB4",
|
||||
"default_enabled": false
|
||||
},
|
||||
{
|
||||
"key": "quetiapine",
|
||||
"display_name": "Quetiapine",
|
||||
"dosage_info": "25 mg",
|
||||
"quick_doses": [
|
||||
"25",
|
||||
"50",
|
||||
"100"
|
||||
],
|
||||
"color": "#FFEAA7",
|
||||
"default_enabled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
date,depression,anxiety,sleep,appetite,bupropion,bupropion_doses,hydroxyzine,hydroxyzine_doses,gabapentin,gabapentin_doses,propranolol,propranolol_doses,quetiapine,quetiapine_doses,note
|
||||
|
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"pathologies": [
|
||||
{
|
||||
"key": "depression",
|
||||
"display_name": "Depression",
|
||||
"scale_info": "0:good, 10:bad",
|
||||
"color": "#FF6B6B",
|
||||
"default_enabled": true,
|
||||
"scale_min": 0,
|
||||
"scale_max": 10,
|
||||
"scale_orientation": "normal"
|
||||
},
|
||||
{
|
||||
"key": "anxiety",
|
||||
"display_name": "Anxiety",
|
||||
"scale_info": "0:good, 10:bad",
|
||||
"color": "#FFA726",
|
||||
"default_enabled": true,
|
||||
"scale_min": 0,
|
||||
"scale_max": 10,
|
||||
"scale_orientation": "normal"
|
||||
},
|
||||
{
|
||||
"key": "sleep",
|
||||
"display_name": "Sleep Quality",
|
||||
"scale_info": "0:bad, 10:good",
|
||||
"color": "#66BB6A",
|
||||
"default_enabled": true,
|
||||
"scale_min": 0,
|
||||
"scale_max": 10,
|
||||
"scale_orientation": "inverted"
|
||||
},
|
||||
{
|
||||
"key": "appetite",
|
||||
"display_name": "Appetite",
|
||||
"scale_info": "0:bad, 10:good",
|
||||
"color": "#42A5F5",
|
||||
"default_enabled": true,
|
||||
"scale_min": 0,
|
||||
"scale_max": 10,
|
||||
"scale_orientation": "inverted"
|
||||
}
|
||||
]
|
||||
}
|
||||
+42
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "thechart"
|
||||
version = "1.0.1"
|
||||
version = "1.6.1"
|
||||
description = "Chart to monitor your medication intake over time."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
@@ -13,7 +13,47 @@ dependencies = [
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pre-commit>=4.2.0", "pyinstaller>=6.14.2", "ruff>=0.12.5"]
|
||||
dev = [
|
||||
"pre-commit>=4.2.0",
|
||||
"pyinstaller>=6.14.2",
|
||||
"ruff>=0.12.5",
|
||||
"pytest>=8.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"pytest-mock>=3.12.0",
|
||||
"coverage>=7.3.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py", "*_test.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = [
|
||||
"--verbose",
|
||||
"--cov=src",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-report=html:htmlcov",
|
||||
"--cov-report=xml",
|
||||
]
|
||||
minversion = "8.0"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["src"]
|
||||
omit = ["tests/*", "*/test_*", "*/__pycache__/*", ".venv/*"]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"if self.debug:",
|
||||
"if settings.DEBUG",
|
||||
"raise AssertionError",
|
||||
"raise NotImplementedError",
|
||||
"if 0:",
|
||||
"if __name__ == .__main__.:",
|
||||
"class .*\\bProtocol\\):",
|
||||
"@(abc\\.)?abstractmethod",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py313" # Target Python 3.13
|
||||
|
||||
@@ -3,3 +3,7 @@
|
||||
|
||||
pre-commit
|
||||
pyinstaller
|
||||
pytest>=8.0.0
|
||||
pytest-cov>=4.0.0
|
||||
pytest-mock>=3.12.0
|
||||
coverage>=7.3.0
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test runner script for TheChart application.
|
||||
Run this script to execute all tests with coverage reporting.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def run_tests():
|
||||
"""Run all tests with coverage reporting."""
|
||||
|
||||
# Change to project root directory
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
os.chdir(project_root)
|
||||
|
||||
print("Running TheChart tests with coverage...")
|
||||
print(f"Project root: {project_root}")
|
||||
|
||||
# Run pytest with coverage
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
"tests/",
|
||||
"--verbose",
|
||||
"--cov=src",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-report=html:htmlcov",
|
||||
"--cov-report=xml",
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, check=False)
|
||||
return result.returncode
|
||||
except Exception as e:
|
||||
print(f"Error running tests: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = run_tests()
|
||||
sys.exit(exit_code)
|
||||
+5
-1
@@ -1,8 +1,12 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(override=True)
|
||||
extDataDir = os.getcwd()
|
||||
if getattr(sys, "frozen", False):
|
||||
extDataDir = sys._MEIPASS
|
||||
load_dotenv(dotenv_path=os.path.join(extDataDir, ".env"))
|
||||
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
LOG_PATH = os.getenv("LOG_PATH", "/tmp/logs/thechart")
|
||||
|
||||
+222
-60
@@ -4,58 +4,129 @@ import os
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from medicine_manager import MedicineManager
|
||||
from pathology_manager import PathologyManager
|
||||
|
||||
|
||||
class DataManager:
|
||||
"""Handle all data operations for the application."""
|
||||
"""Handle all data operations for the application with performance optimizations."""
|
||||
|
||||
def __init__(self, filename: str, logger: logging.Logger) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
filename: str,
|
||||
logger: logging.Logger,
|
||||
medicine_manager: MedicineManager,
|
||||
pathology_manager: PathologyManager,
|
||||
) -> None:
|
||||
self.filename: str = filename
|
||||
self.logger: logging.Logger = logger
|
||||
self.initialize_csv()
|
||||
self.medicine_manager = medicine_manager
|
||||
self.pathology_manager = pathology_manager
|
||||
|
||||
def initialize_csv(self) -> None:
|
||||
"""Create CSV file with headers if it doesn't exist."""
|
||||
if not os.path.exists(self.filename):
|
||||
# Cache for loaded data to avoid repeated file I/O
|
||||
self._data_cache: pd.DataFrame | None = None
|
||||
self._cache_timestamp: float = 0
|
||||
self._headers_cache: tuple[str, ...] | None = None
|
||||
self._dtype_cache: dict[str, type] | None = None
|
||||
|
||||
self._initialize_csv_file()
|
||||
|
||||
def _get_csv_headers(self) -> tuple[str, ...]:
|
||||
"""Get CSV headers based on current pathology and medicine configuration.
|
||||
Cached to avoid repeated computation."""
|
||||
if self._headers_cache is not None:
|
||||
return self._headers_cache
|
||||
|
||||
# Start with date
|
||||
headers = ["date"]
|
||||
|
||||
# Add pathology headers
|
||||
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||
headers.append(pathology_key)
|
||||
|
||||
# Add medicine headers
|
||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||
headers.extend([medicine_key, f"{medicine_key}_doses"])
|
||||
|
||||
result = tuple(headers + ["note"])
|
||||
self._headers_cache = result
|
||||
return result
|
||||
|
||||
def _initialize_csv_file(self) -> None:
|
||||
"""Create CSV file with headers if it doesn't exist or is empty."""
|
||||
if not os.path.exists(self.filename) or os.path.getsize(self.filename) == 0:
|
||||
with open(self.filename, mode="w", newline="") as file:
|
||||
writer = csv.writer(file)
|
||||
writer.writerow(
|
||||
[
|
||||
"date",
|
||||
"depression",
|
||||
"anxiety",
|
||||
"sleep",
|
||||
"appetite",
|
||||
"bupropion",
|
||||
"hydroxyzine",
|
||||
"gabapentin",
|
||||
"propranolol",
|
||||
"note",
|
||||
]
|
||||
)
|
||||
writer.writerow(self._get_csv_headers())
|
||||
|
||||
def _invalidate_cache(self) -> None:
|
||||
"""Invalidate the data cache when data changes."""
|
||||
self._data_cache = None
|
||||
self._cache_timestamp = 0
|
||||
|
||||
def _should_reload_data(self) -> bool:
|
||||
"""Check if data should be reloaded based on file modification time."""
|
||||
if self._data_cache is None:
|
||||
return True
|
||||
|
||||
try:
|
||||
file_mtime = os.path.getmtime(self.filename)
|
||||
return file_mtime > self._cache_timestamp
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
def _get_dtype_dict(self) -> dict[str, type]:
|
||||
"""Get pandas dtype dictionary for efficient reading.
|
||||
Cached to avoid recreation."""
|
||||
if self._dtype_cache is not None:
|
||||
return self._dtype_cache
|
||||
|
||||
dtype_dict = {"date": str, "note": str}
|
||||
|
||||
# Add pathology types
|
||||
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||
dtype_dict[pathology_key] = int
|
||||
|
||||
# Add medicine types
|
||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||
dtype_dict[medicine_key] = int
|
||||
dtype_dict[f"{medicine_key}_doses"] = str
|
||||
|
||||
self._dtype_cache = dtype_dict
|
||||
return dtype_dict
|
||||
|
||||
def load_data(self) -> pd.DataFrame:
|
||||
"""Load data from CSV file."""
|
||||
"""Load data from CSV file with caching for better performance."""
|
||||
if not os.path.exists(self.filename) or os.path.getsize(self.filename) == 0:
|
||||
self.logger.warning("CSV file is empty or doesn't exist. No data to load.")
|
||||
return pd.DataFrame()
|
||||
|
||||
# Use cached data if available and file hasn't changed
|
||||
if not self._should_reload_data():
|
||||
return self._data_cache.copy()
|
||||
|
||||
try:
|
||||
# Use pre-built dtype dictionary for faster parsing
|
||||
dtype_dict = self._get_dtype_dict()
|
||||
|
||||
# Read with optimized settings
|
||||
df: pd.DataFrame = pd.read_csv(
|
||||
self.filename,
|
||||
dtype={
|
||||
"depression": int,
|
||||
"anxiety": int,
|
||||
"sleep": int,
|
||||
"appetite": int,
|
||||
"bupropion": int,
|
||||
"hydroxyzine": int,
|
||||
"gabapentin": int,
|
||||
"propranolol": int,
|
||||
"note": str,
|
||||
"date": str,
|
||||
},
|
||||
).fillna("")
|
||||
return df.sort_values(by="date").reset_index(drop=True)
|
||||
dtype=dtype_dict,
|
||||
na_filter=False, # Don't convert to NaN, keep as empty strings
|
||||
engine="c", # Use faster C engine
|
||||
)
|
||||
|
||||
# Sort only if needed (check if already sorted)
|
||||
if len(df) > 1 and not df["date"].is_monotonic_increasing:
|
||||
df = df.sort_values(by="date").reset_index(drop=True)
|
||||
|
||||
# Cache the data and timestamp
|
||||
self._data_cache = df.copy()
|
||||
self._cache_timestamp = os.path.getmtime(self.filename)
|
||||
|
||||
return df.copy()
|
||||
|
||||
except pd.errors.EmptyDataError:
|
||||
self.logger.warning("CSV file is empty. No data to load.")
|
||||
return pd.DataFrame()
|
||||
@@ -64,51 +135,142 @@ class DataManager:
|
||||
return pd.DataFrame()
|
||||
|
||||
def add_entry(self, entry_data: list[str | int]) -> bool:
|
||||
"""Add a new entry to the CSV file."""
|
||||
"""Add a new entry to the CSV file with optimized duplicate checking."""
|
||||
try:
|
||||
# Quick duplicate check using cached data if available
|
||||
date_to_add: str = str(entry_data[0])
|
||||
|
||||
if self._data_cache is not None:
|
||||
# Use cached data for duplicate check
|
||||
if date_to_add in self._data_cache["date"].values:
|
||||
self.logger.warning(
|
||||
f"Entry with date {date_to_add} already exists."
|
||||
)
|
||||
return False
|
||||
else:
|
||||
# Fallback to loading data if no cache
|
||||
df: pd.DataFrame = self.load_data()
|
||||
if not df.empty and date_to_add in df["date"].values:
|
||||
self.logger.warning(
|
||||
f"Entry with date {date_to_add} already exists."
|
||||
)
|
||||
return False
|
||||
|
||||
# Write to file
|
||||
with open(self.filename, mode="a", newline="") as file:
|
||||
writer = csv.writer(file)
|
||||
writer.writerow(entry_data)
|
||||
|
||||
# Invalidate cache since data changed
|
||||
self._invalidate_cache()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error adding entry: {str(e)}")
|
||||
return False
|
||||
|
||||
def update_entry(self, date: str, values: list[str | int]) -> bool:
|
||||
"""Update an existing entry identified by date."""
|
||||
def update_entry(self, original_date: str, values: list[str | int]) -> bool:
|
||||
"""Update an existing entry identified by original_date
|
||||
with optimized processing."""
|
||||
try:
|
||||
df: pd.DataFrame = self.load_data()
|
||||
# Find the row to update using date as a unique identifier
|
||||
df.loc[
|
||||
df["date"] == date,
|
||||
[
|
||||
"date",
|
||||
"depression",
|
||||
"anxiety",
|
||||
"sleep",
|
||||
"appetite",
|
||||
"bupropion",
|
||||
"hydroxyzine",
|
||||
"gabapentin",
|
||||
"propranolol",
|
||||
"note",
|
||||
],
|
||||
] = values
|
||||
df.to_csv(self.filename, index=False)
|
||||
return True
|
||||
new_date: str = str(values[0])
|
||||
|
||||
# Optimized duplicate check
|
||||
if original_date != new_date:
|
||||
date_exists = (df["date"] == new_date).any()
|
||||
if date_exists:
|
||||
self.logger.warning(
|
||||
f"Cannot update: entry with date {new_date} already exists."
|
||||
)
|
||||
return False
|
||||
|
||||
# Get current CSV headers to match with values
|
||||
headers = list(self._get_csv_headers())
|
||||
|
||||
# Ensure we have the right number of values with optimized padding
|
||||
if len(values) < len(headers):
|
||||
# Pad with defaults efficiently
|
||||
padding_needed = len(headers) - len(values)
|
||||
for i in range(padding_needed):
|
||||
header_idx = len(values) + i
|
||||
if header_idx < len(headers):
|
||||
header = headers[header_idx]
|
||||
if header == "note" or header.endswith("_doses"):
|
||||
values.append("")
|
||||
else:
|
||||
values.append(0)
|
||||
|
||||
# Use vectorized update for better performance
|
||||
mask = df["date"] == original_date
|
||||
if mask.any():
|
||||
df.loc[mask, headers] = values
|
||||
# Write back to CSV with optimized method
|
||||
df.to_csv(self.filename, index=False, mode="w")
|
||||
self._invalidate_cache()
|
||||
return True
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"Entry with date {original_date} not found for update."
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error updating entry: {str(e)}")
|
||||
return False
|
||||
|
||||
def delete_entry(self, date: str) -> bool:
|
||||
"""Delete an entry identified by date."""
|
||||
"""Delete an entry identified by date with optimized processing."""
|
||||
try:
|
||||
df: pd.DataFrame = self.load_data()
|
||||
# Remove the row with the matching date
|
||||
original_len = len(df)
|
||||
|
||||
# Use vectorized filtering for better performance
|
||||
df = df[df["date"] != date]
|
||||
# Write the updated dataframe back to the CSV
|
||||
df.to_csv(self.filename, index=False)
|
||||
|
||||
# Only write if something was actually deleted
|
||||
if len(df) < original_len:
|
||||
df.to_csv(self.filename, index=False, mode="w")
|
||||
self._invalidate_cache()
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error deleting entry: {str(e)}")
|
||||
return False
|
||||
|
||||
def get_today_medicine_doses(
|
||||
self, date: str, medicine_name: str
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Get list of (timestamp, dose) tuples for a medicine on a given date
|
||||
with caching."""
|
||||
try:
|
||||
df: pd.DataFrame = self.load_data()
|
||||
if df.empty:
|
||||
return []
|
||||
|
||||
# Use vectorized filtering for better performance
|
||||
date_mask = df["date"] == date
|
||||
if not date_mask.any():
|
||||
return []
|
||||
|
||||
dose_column = f"{medicine_name}_doses"
|
||||
if dose_column not in df.columns:
|
||||
return []
|
||||
|
||||
doses_str = df.loc[date_mask, dose_column].iloc[0]
|
||||
|
||||
if not doses_str:
|
||||
return []
|
||||
|
||||
# Optimized dose parsing
|
||||
doses = []
|
||||
for dose_entry in doses_str.split("|"):
|
||||
if ":" in dose_entry:
|
||||
parts = dose_entry.split(":", 1)
|
||||
if len(parts) == 2:
|
||||
doses.append((parts[0], parts[1]))
|
||||
|
||||
return doses
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting medicine doses: {str(e)}")
|
||||
return []
|
||||
|
||||
+303
-98
@@ -7,125 +7,286 @@ import pandas as pd
|
||||
from matplotlib.axes import Axes
|
||||
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
||||
|
||||
from medicine_manager import MedicineManager
|
||||
from pathology_manager import PathologyManager
|
||||
|
||||
|
||||
class GraphManager:
|
||||
"""Handle all graph-related operations for the application."""
|
||||
"""Optimized version - Handle all graph-related operations for the
|
||||
application with performance improvements."""
|
||||
|
||||
def __init__(self, parent_frame: ttk.LabelFrame) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
parent_frame: ttk.LabelFrame,
|
||||
medicine_manager: MedicineManager,
|
||||
pathology_manager: PathologyManager,
|
||||
) -> None:
|
||||
self.parent_frame: ttk.LabelFrame = parent_frame
|
||||
self.medicine_manager = medicine_manager
|
||||
self.pathology_manager = pathology_manager
|
||||
|
||||
# Configure graph frame to expand
|
||||
self.parent_frame.grid_rowconfigure(0, weight=1)
|
||||
self.parent_frame.grid_columnconfigure(0, weight=1)
|
||||
# Initialize matplotlib with optimized settings
|
||||
self.fig: matplotlib.figure.Figure = plt.figure(figsize=(10, 6), dpi=80)
|
||||
self.ax: Axes = self.fig.add_subplot(111)
|
||||
|
||||
# Initialize toggle variables for chart elements
|
||||
self.toggle_vars: dict[str, tk.BooleanVar] = {
|
||||
"depression": tk.BooleanVar(value=True),
|
||||
"anxiety": tk.BooleanVar(value=True),
|
||||
"sleep": tk.BooleanVar(value=True),
|
||||
"appetite": tk.BooleanVar(value=True),
|
||||
}
|
||||
|
||||
# Create control frame for toggles
|
||||
self.control_frame: ttk.Frame = ttk.Frame(self.parent_frame)
|
||||
self.control_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
|
||||
|
||||
# Create toggle checkboxes
|
||||
self._create_toggle_controls()
|
||||
|
||||
# Create graph frame
|
||||
self.graph_frame: ttk.Frame = ttk.Frame(self.parent_frame)
|
||||
self.graph_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
|
||||
|
||||
# Reconfigure parent frame for new layout
|
||||
self.parent_frame.grid_rowconfigure(1, weight=1)
|
||||
self.parent_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Initialize matplotlib figure and canvas
|
||||
self.fig: matplotlib.figure.Figure
|
||||
self.ax: Axes
|
||||
self.fig, self.ax = plt.subplots()
|
||||
self.canvas: FigureCanvasTkAgg = FigureCanvasTkAgg(
|
||||
figure=self.fig, master=self.graph_frame
|
||||
)
|
||||
self.canvas.get_tk_widget().pack(fill="both", expand=True)
|
||||
|
||||
# Store current data for replotting
|
||||
# Cache for current data to avoid reprocessing
|
||||
self.current_data: pd.DataFrame = pd.DataFrame()
|
||||
self._last_plot_hash: str = ""
|
||||
|
||||
def _create_toggle_controls(self) -> None:
|
||||
"""Create toggle controls for chart elements."""
|
||||
ttk.Label(self.control_frame, text="Show/Hide Elements:").pack(
|
||||
side="left", padx=5
|
||||
# Initialize UI components
|
||||
self.toggle_vars: dict[str, tk.IntVar] = {}
|
||||
self._setup_ui()
|
||||
self._initialize_toggle_vars()
|
||||
self._create_chart_toggles()
|
||||
|
||||
def _initialize_toggle_vars(self) -> None:
|
||||
"""Initialize toggle variables for chart elements with optimization."""
|
||||
# Initialize pathology toggles
|
||||
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||
self.toggle_vars[pathology_key] = tk.IntVar(value=1)
|
||||
|
||||
# Initialize medicine toggles (unchecked by default)
|
||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||
self.toggle_vars[medicine_key] = tk.IntVar(value=0)
|
||||
|
||||
def _setup_ui(self) -> None:
|
||||
"""Set up the UI components with performance optimizations."""
|
||||
# Create canvas with optimized settings
|
||||
self.canvas = FigureCanvasTkAgg(self.fig, master=self.parent_frame)
|
||||
self.canvas.draw_idle() # Use draw_idle for better performance
|
||||
|
||||
# Pack canvas
|
||||
canvas_widget = self.canvas.get_tk_widget()
|
||||
canvas_widget.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
|
||||
|
||||
# Create control frame
|
||||
self.control_frame = ttk.Frame(self.parent_frame)
|
||||
self.control_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=5, pady=2)
|
||||
|
||||
def _create_chart_toggles(self) -> None:
|
||||
"""Create toggle controls for chart elements with improved layout."""
|
||||
# Pathology toggles
|
||||
pathology_frame = ttk.LabelFrame(
|
||||
self.control_frame, text="Pathologies", padding="5"
|
||||
)
|
||||
pathology_frame.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=2)
|
||||
|
||||
toggle_configs = [
|
||||
("depression", "Depression"),
|
||||
("anxiety", "Anxiety"),
|
||||
("sleep", "Sleep"),
|
||||
("appetite", "Appetite"),
|
||||
]
|
||||
# Use grid for better layout
|
||||
row, col = 0, 0
|
||||
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||
pathology = self.pathology_manager.get_pathology(pathology_key)
|
||||
if pathology:
|
||||
display_name = pathology.display_name
|
||||
text = (
|
||||
display_name[:10] + "..."
|
||||
if len(display_name) > 10
|
||||
else display_name
|
||||
)
|
||||
cb = ttk.Checkbutton(
|
||||
pathology_frame,
|
||||
text=text,
|
||||
variable=self.toggle_vars[pathology_key],
|
||||
command=self._handle_toggle_changed,
|
||||
)
|
||||
cb.grid(row=row, column=col, sticky="w", padx=2)
|
||||
col += 1
|
||||
if col > 1: # 2 columns max
|
||||
col = 0
|
||||
row += 1
|
||||
|
||||
for key, label in toggle_configs:
|
||||
checkbox = ttk.Checkbutton(
|
||||
self.control_frame,
|
||||
text=label,
|
||||
variable=self.toggle_vars[key],
|
||||
command=self._on_toggle_changed,
|
||||
)
|
||||
checkbox.pack(side="left", padx=5)
|
||||
# Medicine toggles
|
||||
medicine_frame = ttk.LabelFrame(
|
||||
self.control_frame, text="Medicines", padding="5"
|
||||
)
|
||||
medicine_frame.pack(side=tk.RIGHT, fill=tk.X, expand=True, padx=2)
|
||||
|
||||
def _on_toggle_changed(self) -> None:
|
||||
"""Handle toggle changes by replotting the graph."""
|
||||
# Use grid for medicines too
|
||||
row, col = 0, 0
|
||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||
medicine = self.medicine_manager.get_medicine(medicine_key)
|
||||
if medicine:
|
||||
med_name = medicine.display_name
|
||||
text = med_name[:10] + "..." if len(med_name) > 10 else med_name
|
||||
cb = ttk.Checkbutton(
|
||||
medicine_frame,
|
||||
text=text,
|
||||
variable=self.toggle_vars[medicine_key],
|
||||
command=self._handle_toggle_changed,
|
||||
)
|
||||
cb.grid(row=row, column=col, sticky="w", padx=2)
|
||||
col += 1
|
||||
if col > 2: # 3 columns max for medicines
|
||||
col = 0
|
||||
row += 1
|
||||
|
||||
def _handle_toggle_changed(self) -> None:
|
||||
"""Handle toggle changes by replotting the graph with optimization."""
|
||||
if not self.current_data.empty:
|
||||
self._plot_graph_data(self.current_data)
|
||||
|
||||
def update_graph(self, df: pd.DataFrame) -> None:
|
||||
"""Update the graph with new data."""
|
||||
self.current_data = df.copy() if not df.empty else pd.DataFrame()
|
||||
self._plot_graph_data(df)
|
||||
"""Update the graph with new data using optimization checks."""
|
||||
# Create hash of data to avoid unnecessary redraws
|
||||
data_hash = str(hash(str(df.values.tobytes()) if not df.empty else "empty"))
|
||||
|
||||
# Only update if data actually changed
|
||||
if data_hash != self._last_plot_hash or self.current_data.empty:
|
||||
self.current_data = df.copy() if not df.empty else pd.DataFrame()
|
||||
self._last_plot_hash = data_hash
|
||||
self._plot_graph_data(df)
|
||||
|
||||
def _plot_graph_data(self, df: pd.DataFrame) -> None:
|
||||
"""Plot the graph data with current toggle settings."""
|
||||
self.ax.clear()
|
||||
if not df.empty:
|
||||
# Convert dates and sort
|
||||
df = df.copy() # Create a copy to avoid modifying the original
|
||||
df["date"] = pd.to_datetime(df["date"])
|
||||
df = df.sort_values(by="date")
|
||||
df.set_index(keys="date", inplace=True)
|
||||
"""Plot the graph data with current toggle settings using optimizations."""
|
||||
# Use batch updates to reduce redraws
|
||||
with plt.ioff(): # Turn off interactive mode for batch updates
|
||||
self.ax.clear()
|
||||
|
||||
# Track if any series are plotted
|
||||
has_plotted_series = False
|
||||
if not df.empty:
|
||||
# Optimize data processing
|
||||
df_processed = self._preprocess_data(df)
|
||||
|
||||
# Plot data series based on toggle states
|
||||
if self.toggle_vars["depression"].get():
|
||||
self._plot_series(
|
||||
df, "depression", "Depression (0:good, 10:bad)", "o", "-"
|
||||
)
|
||||
has_plotted_series = True
|
||||
if self.toggle_vars["anxiety"].get():
|
||||
self._plot_series(df, "anxiety", "Anxiety (0:good, 10:bad)", "o", "-")
|
||||
has_plotted_series = True
|
||||
if self.toggle_vars["sleep"].get():
|
||||
self._plot_series(df, "sleep", "Sleep (0:bad, 10:good)", "o", "dashed")
|
||||
has_plotted_series = True
|
||||
if self.toggle_vars["appetite"].get():
|
||||
self._plot_series(
|
||||
df, "appetite", "Appetite (0:bad, 10:good)", "o", "dashed"
|
||||
# Track if any series are plotted
|
||||
has_plotted_series = self._plot_pathology_data(df_processed)
|
||||
medicine_data = self._plot_medicine_data(df_processed)
|
||||
|
||||
if has_plotted_series or medicine_data["has_plotted"]:
|
||||
self._configure_graph_appearance(medicine_data)
|
||||
|
||||
# Single draw call at the end
|
||||
self.canvas.draw_idle()
|
||||
|
||||
def _preprocess_data(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Preprocess data for plotting with optimizations."""
|
||||
df = df.copy()
|
||||
# Batch convert dates and sort
|
||||
df["date"] = pd.to_datetime(df["date"], cache=True)
|
||||
df = df.sort_values(by="date")
|
||||
df.set_index(keys="date", inplace=True)
|
||||
return df
|
||||
|
||||
def _plot_pathology_data(self, df: pd.DataFrame) -> bool:
|
||||
"""Plot pathology data series with optimizations."""
|
||||
has_plotted_series = False
|
||||
|
||||
# Batch plot pathology data
|
||||
pathology_keys = self.pathology_manager.get_pathology_keys()
|
||||
active_pathologies = [
|
||||
key
|
||||
for key in pathology_keys
|
||||
if self.toggle_vars[key].get() and key in df.columns
|
||||
]
|
||||
|
||||
for pathology_key in active_pathologies:
|
||||
pathology = self.pathology_manager.get_pathology(pathology_key)
|
||||
if pathology:
|
||||
label = f"{pathology.display_name} ({pathology.scale_info})"
|
||||
linestyle = (
|
||||
"dashed" if pathology.scale_orientation == "inverted" else "-"
|
||||
)
|
||||
self._plot_series(df, pathology_key, label, "o", linestyle)
|
||||
has_plotted_series = True
|
||||
|
||||
# Configure graph appearance
|
||||
if has_plotted_series:
|
||||
self.ax.legend()
|
||||
self.ax.set_title("Medication Effects Over Time")
|
||||
self.ax.set_xlabel("Date")
|
||||
self.ax.set_ylabel("Rating (0-10)")
|
||||
self.fig.autofmt_xdate()
|
||||
return has_plotted_series
|
||||
|
||||
# Redraw the canvas
|
||||
self.canvas.draw()
|
||||
def _plot_medicine_data(self, df: pd.DataFrame) -> dict:
|
||||
"""Plot medicine data with optimizations."""
|
||||
result = {"has_plotted": False, "with_data": [], "without_data": []}
|
||||
|
||||
# Get medicine colors and keys in batch
|
||||
medicine_colors = self.medicine_manager.get_graph_colors()
|
||||
medicines = self.medicine_manager.get_medicine_keys()
|
||||
|
||||
# Pre-calculate daily doses for all medicines to avoid repeated computation
|
||||
medicine_doses = {}
|
||||
for medicine in medicines:
|
||||
dose_column = f"{medicine}_doses"
|
||||
if dose_column in df.columns:
|
||||
daily_doses = [
|
||||
self._calculate_daily_dose(dose_str) for dose_str in df[dose_column]
|
||||
]
|
||||
medicine_doses[medicine] = daily_doses
|
||||
|
||||
# Plot medicines with data
|
||||
for medicine in medicines:
|
||||
if self.toggle_vars[medicine].get() and medicine in medicine_doses:
|
||||
daily_doses = medicine_doses[medicine]
|
||||
|
||||
# Check if there's any data to plot
|
||||
if any(dose > 0 for dose in daily_doses):
|
||||
result["with_data"].append(medicine)
|
||||
|
||||
# Optimize dose scaling and bar plotting
|
||||
scaled_doses = [dose / 10 for dose in daily_doses]
|
||||
|
||||
# Calculate statistics more efficiently
|
||||
non_zero_doses = [d for d in daily_doses if d > 0]
|
||||
if non_zero_doses:
|
||||
avg_dose = sum(daily_doses) / len(non_zero_doses)
|
||||
label = f"{medicine.capitalize()} (avg: {avg_dose:.1f}mg)"
|
||||
|
||||
# Single bar plot call
|
||||
self.ax.bar(
|
||||
df.index,
|
||||
scaled_doses,
|
||||
alpha=0.6,
|
||||
color=medicine_colors.get(medicine, "#DDA0DD"),
|
||||
label=label,
|
||||
width=0.6,
|
||||
bottom=-max(scaled_doses) * 1.1 if scaled_doses else -1,
|
||||
)
|
||||
result["has_plotted"] = True
|
||||
else:
|
||||
# Medicine is toggled on but has no dose data
|
||||
if self.toggle_vars[medicine].get():
|
||||
result["without_data"].append(medicine)
|
||||
|
||||
return result
|
||||
|
||||
def _configure_graph_appearance(self, medicine_data: dict) -> None:
|
||||
"""Configure graph appearance with optimizations."""
|
||||
# Get legend data in batch
|
||||
handles, labels = self.ax.get_legend_handles_labels()
|
||||
|
||||
# Add information about medicines without data if any are toggled on
|
||||
if medicine_data["without_data"]:
|
||||
med_list = ", ".join(medicine_data["without_data"])
|
||||
info_text = f"Tracked (no doses): {med_list}"
|
||||
labels.append(info_text)
|
||||
|
||||
# Create dummy handle more efficiently
|
||||
from matplotlib.patches import Rectangle
|
||||
|
||||
dummy_handle = Rectangle(
|
||||
(0, 0), 1, 1, fc="w", fill=False, edgecolor="none", linewidth=0
|
||||
)
|
||||
handles.append(dummy_handle)
|
||||
|
||||
# Create legend with optimized settings
|
||||
if handles and labels:
|
||||
self.ax.legend(
|
||||
handles,
|
||||
labels,
|
||||
loc="upper left",
|
||||
bbox_to_anchor=(0, 1),
|
||||
ncol=2,
|
||||
fontsize="small",
|
||||
frameon=True,
|
||||
fancybox=True,
|
||||
shadow=True,
|
||||
framealpha=0.9,
|
||||
)
|
||||
|
||||
# Set titles and labels
|
||||
self.ax.set_title("Medication Effects Over Time")
|
||||
self.ax.set_xlabel("Date")
|
||||
self.ax.set_ylabel("Rating (0-10) / Dose (mg)")
|
||||
|
||||
# Optimize y-axis configuration
|
||||
current_ylim = self.ax.get_ylim()
|
||||
self.ax.set_ylim(bottom=current_ylim[0], top=max(10, current_ylim[1]))
|
||||
|
||||
# Optimize date formatting
|
||||
self.fig.autofmt_xdate()
|
||||
|
||||
def _plot_series(
|
||||
self,
|
||||
@@ -135,15 +296,59 @@ class GraphManager:
|
||||
marker: str,
|
||||
linestyle: str,
|
||||
) -> None:
|
||||
"""Helper method to plot a data series."""
|
||||
"""Helper method to plot a data series with optimizations."""
|
||||
# Use more efficient plotting parameters
|
||||
self.ax.plot(
|
||||
df.index,
|
||||
df[column],
|
||||
marker=marker,
|
||||
linestyle=linestyle,
|
||||
label=label,
|
||||
markersize=4, # Smaller markers for better performance
|
||||
linewidth=1.5, # Optimized line width
|
||||
)
|
||||
|
||||
def _calculate_daily_dose(self, dose_str: str) -> float:
|
||||
"""Calculate total daily dose from dose string format with optimizations."""
|
||||
if not dose_str or pd.isna(dose_str) or str(dose_str).lower() == "nan":
|
||||
return 0.0
|
||||
|
||||
total_dose = 0.0
|
||||
# Optimize string processing
|
||||
dose_str = str(dose_str).replace("•", "").strip()
|
||||
|
||||
# More efficient splitting and processing
|
||||
dose_entries = dose_str.split("|") if "|" in dose_str else [dose_str]
|
||||
|
||||
for entry in dose_entries:
|
||||
entry = entry.strip()
|
||||
if not entry:
|
||||
continue
|
||||
|
||||
try:
|
||||
# More efficient dose extraction
|
||||
dose_part = entry.split(":")[-1] if ":" in entry else entry
|
||||
|
||||
# Optimized numeric extraction
|
||||
dose_value = ""
|
||||
for char in dose_part:
|
||||
if char.isdigit() or char == ".":
|
||||
dose_value += char
|
||||
elif dose_value:
|
||||
break
|
||||
|
||||
if dose_value:
|
||||
total_dose += float(dose_value)
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
return total_dose
|
||||
|
||||
def close(self) -> None:
|
||||
"""Clean up resources."""
|
||||
plt.close(self.fig)
|
||||
"""Clean up resources with proper optimization."""
|
||||
try:
|
||||
# Clear the plot before closing
|
||||
self.ax.clear()
|
||||
plt.close(self.fig)
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
|
||||
+301
-71
@@ -2,7 +2,7 @@ import os
|
||||
import sys
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable
|
||||
from tkinter import messagebox
|
||||
from tkinter import messagebox, ttk
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
@@ -11,6 +11,10 @@ from constants import LOG_LEVEL, LOG_PATH
|
||||
from data_manager import DataManager
|
||||
from graph_manager import GraphManager
|
||||
from init import logger
|
||||
from medicine_management_window import MedicineManagementWindow
|
||||
from medicine_manager import MedicineManager
|
||||
from pathology_management_window import PathologyManagementWindow
|
||||
from pathology_manager import PathologyManager
|
||||
from ui_manager import UIManager
|
||||
|
||||
|
||||
@@ -19,7 +23,7 @@ class MedTrackerApp:
|
||||
self.root: tk.Tk = root
|
||||
self.root.resizable(True, True)
|
||||
self.root.title("Thechart - medication tracker")
|
||||
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
|
||||
self.root.protocol("WM_DELETE_WINDOW", self.handle_window_closing)
|
||||
|
||||
# Set up data file
|
||||
self.filename: str = "thechart_data.csv"
|
||||
@@ -42,18 +46,50 @@ class MedTrackerApp:
|
||||
logger.debug(f"First argument: {first_argument}")
|
||||
|
||||
# Initialize managers
|
||||
self.ui_manager: UIManager = UIManager(root, logger)
|
||||
self.data_manager: DataManager = DataManager(self.filename, logger)
|
||||
self.medicine_manager: MedicineManager = MedicineManager(logger=logger)
|
||||
self.pathology_manager: PathologyManager = PathologyManager(logger=logger)
|
||||
self.ui_manager: UIManager = UIManager(
|
||||
root, logger, self.medicine_manager, self.pathology_manager
|
||||
)
|
||||
self.data_manager: DataManager = DataManager(
|
||||
self.filename, logger, self.medicine_manager, self.pathology_manager
|
||||
)
|
||||
|
||||
# Set up application icon
|
||||
icon_path: str = "chart-671.png"
|
||||
if not os.path.exists(icon_path) and os.path.exists("./chart-671.png"):
|
||||
icon_path = "./chart-671.png"
|
||||
self.ui_manager.setup_icon(img_path=icon_path)
|
||||
self.ui_manager.setup_application_icon(img_path=icon_path)
|
||||
|
||||
# Set up the main application UI
|
||||
self._setup_main_ui()
|
||||
|
||||
# Add menu bar
|
||||
self._setup_menu()
|
||||
|
||||
# Center the window on screen
|
||||
self._center_window()
|
||||
|
||||
def _center_window(self) -> None:
|
||||
"""Center the main window on the screen."""
|
||||
# Update the window to get accurate dimensions
|
||||
self.root.update_idletasks()
|
||||
|
||||
# Get window dimensions
|
||||
window_width = self.root.winfo_reqwidth()
|
||||
window_height = self.root.winfo_reqheight()
|
||||
|
||||
# Get screen dimensions
|
||||
screen_width = self.root.winfo_screenwidth()
|
||||
screen_height = self.root.winfo_screenheight()
|
||||
|
||||
# Calculate position to center the window
|
||||
x = (screen_width // 2) - (window_width // 2)
|
||||
y = (screen_height // 2) - (window_height // 2)
|
||||
|
||||
# Set the window geometry
|
||||
self.root.geometry(f"{window_width}x{window_height}+{x}+{y}")
|
||||
|
||||
def _setup_main_ui(self) -> None:
|
||||
"""Set up the main UI components."""
|
||||
import tkinter.ttk as ttk
|
||||
@@ -74,41 +110,110 @@ class MedTrackerApp:
|
||||
|
||||
# --- Create Graph Frame ---
|
||||
graph_frame: ttk.Frame = self.ui_manager.create_graph_frame(main_frame)
|
||||
self.graph_manager: GraphManager = GraphManager(graph_frame)
|
||||
self.graph_manager: GraphManager = GraphManager(
|
||||
graph_frame, self.medicine_manager, self.pathology_manager
|
||||
)
|
||||
|
||||
# --- Create Input Frame ---
|
||||
input_ui: dict[str, Any] = self.ui_manager.create_input_frame(main_frame)
|
||||
self.input_frame: ttk.Frame = input_ui["frame"]
|
||||
self.symptom_vars: dict[str, tk.IntVar] = input_ui["symptom_vars"]
|
||||
self.medicine_vars: dict[str, list[tk.IntVar | ttk.Spinbox]] = input_ui[
|
||||
"medicine_vars"
|
||||
]
|
||||
self.pathology_vars: dict[str, tk.IntVar] = input_ui["pathology_vars"]
|
||||
self.medicine_vars: dict[str, tuple[tk.IntVar, str]] = input_ui["medicine_vars"]
|
||||
self.note_var: tk.StringVar = input_ui["note_var"]
|
||||
self.date_var: tk.StringVar = input_ui["date_var"]
|
||||
|
||||
# Add buttons to input frame
|
||||
self.ui_manager.add_buttons(
|
||||
self.ui_manager.add_action_buttons(
|
||||
self.input_frame,
|
||||
[
|
||||
{
|
||||
"text": "Add Entry",
|
||||
"command": self.add_entry,
|
||||
"command": self.add_new_entry,
|
||||
"fill": "both",
|
||||
"expand": True,
|
||||
},
|
||||
{"text": "Quit", "command": self.on_closing},
|
||||
{"text": "Quit", "command": self.handle_window_closing},
|
||||
],
|
||||
)
|
||||
|
||||
# --- Create Table Frame ---
|
||||
table_ui: dict[str, Any] = self.ui_manager.create_table_frame(main_frame)
|
||||
self.tree: ttk.Treeview = table_ui["tree"]
|
||||
self.tree.bind("<Double-1>", self.on_double_click)
|
||||
self.tree.bind("<Double-1>", self.handle_double_click)
|
||||
|
||||
# Load data
|
||||
self.load_data()
|
||||
self.refresh_data_display()
|
||||
|
||||
def on_double_click(self, event: tk.Event) -> None:
|
||||
def _setup_menu(self) -> None:
|
||||
"""Set up the menu bar."""
|
||||
menubar = tk.Menu(self.root)
|
||||
self.root.config(menu=menubar)
|
||||
|
||||
# Tools menu
|
||||
tools_menu = tk.Menu(menubar, tearoff=0)
|
||||
menubar.add_cascade(label="Tools", menu=tools_menu)
|
||||
tools_menu.add_command(
|
||||
label="Manage Pathologies...", command=self._open_pathology_manager
|
||||
)
|
||||
tools_menu.add_command(
|
||||
label="Manage Medicines...", command=self._open_medicine_manager
|
||||
)
|
||||
|
||||
def _open_pathology_manager(self) -> None:
|
||||
"""Open the pathology management window."""
|
||||
PathologyManagementWindow(
|
||||
self.root, self.pathology_manager, self._refresh_ui_after_config_change
|
||||
)
|
||||
|
||||
def _open_medicine_manager(self) -> None:
|
||||
"""Open the medicine management window."""
|
||||
MedicineManagementWindow(
|
||||
self.root, self.medicine_manager, self._refresh_ui_after_config_change
|
||||
)
|
||||
|
||||
def _refresh_ui_after_config_change(self) -> None:
|
||||
"""Refresh UI components after pathology or medicine configuration changes."""
|
||||
# Clear caches in optimized data manager
|
||||
if hasattr(self.data_manager, "_invalidate_cache"):
|
||||
self.data_manager._invalidate_cache()
|
||||
self.data_manager._headers_cache = None
|
||||
self.data_manager._dtype_cache = None
|
||||
|
||||
# Recreate the input frame with new pathologies and medicines
|
||||
self.input_frame.destroy()
|
||||
input_ui: dict[str, Any] = self.ui_manager.create_input_frame(
|
||||
self.input_frame.master
|
||||
)
|
||||
self.input_frame: ttk.Frame = input_ui["frame"]
|
||||
self.pathology_vars: dict[str, tk.IntVar] = input_ui["pathology_vars"]
|
||||
self.medicine_vars: dict[str, tuple[tk.IntVar, str]] = input_ui["medicine_vars"]
|
||||
|
||||
# Add buttons to input frame
|
||||
self.ui_manager.add_action_buttons(
|
||||
self.input_frame,
|
||||
[
|
||||
{
|
||||
"text": "Add Entry",
|
||||
"command": self.add_new_entry,
|
||||
"fill": "both",
|
||||
"expand": True,
|
||||
},
|
||||
{"text": "Quit", "command": self.handle_window_closing},
|
||||
],
|
||||
)
|
||||
|
||||
# Recreate the table with new columns
|
||||
self.tree.destroy()
|
||||
table_ui: dict[str, Any] = self.ui_manager.create_table_frame(
|
||||
self.tree.master.master
|
||||
)
|
||||
self.tree: ttk.Treeview = table_ui["tree"]
|
||||
self.tree.bind("<Double-1>", self.handle_double_click)
|
||||
|
||||
# Refresh data display
|
||||
self.refresh_data_display()
|
||||
|
||||
def handle_double_click(self, event: tk.Event) -> None:
|
||||
"""Handle double-click event to edit an entry."""
|
||||
logger.debug("Double-click event triggered on treeview.")
|
||||
if len(self.tree.get_children()) > 0:
|
||||
@@ -119,84 +224,187 @@ class MedTrackerApp:
|
||||
|
||||
def _create_edit_window(self, item_id: str, values: tuple[str, ...]) -> None:
|
||||
"""Create a new Toplevel window for editing an entry."""
|
||||
original_date = values[0] # Store the original date
|
||||
|
||||
# Get the full row data from the CSV (including dose columns)
|
||||
df = self.data_manager.load_data()
|
||||
if not df.empty and original_date in df["date"].values:
|
||||
full_row = df[df["date"] == original_date].iloc[0]
|
||||
# Convert to tuple in the expected order for the edit window
|
||||
full_values = [full_row["date"]]
|
||||
|
||||
# Add pathology data dynamically
|
||||
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||
if pathology_key in full_row:
|
||||
full_values.append(full_row[pathology_key])
|
||||
else:
|
||||
full_values.append(0)
|
||||
|
||||
# Add medicine data dynamically
|
||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||
if medicine_key in full_row:
|
||||
full_values.append(full_row[medicine_key])
|
||||
full_values.append(full_row.get(f"{medicine_key}_doses", ""))
|
||||
else:
|
||||
full_values.extend([0, ""])
|
||||
|
||||
full_values.append(full_row["note"])
|
||||
full_values = tuple(full_values)
|
||||
else:
|
||||
# Fallback to the table values if full data not found
|
||||
full_values = values
|
||||
|
||||
# Define callbacks for edit window buttons
|
||||
callbacks: dict[str, Callable] = {
|
||||
"save": self._save_edit,
|
||||
"save": lambda win, *args: self._save_edit(win, original_date, *args),
|
||||
"delete": lambda win: self._delete_entry(win, item_id),
|
||||
}
|
||||
|
||||
# Create edit window using UI manager
|
||||
_: tk.Toplevel = self.ui_manager.create_edit_window(values, callbacks)
|
||||
# Create edit window using UI manager with full data
|
||||
_: tk.Toplevel = self.ui_manager.create_edit_window(full_values, callbacks)
|
||||
|
||||
def _save_edit(
|
||||
self,
|
||||
edit_win: tk.Toplevel,
|
||||
date: str,
|
||||
dep: int,
|
||||
anx: int,
|
||||
slp: int,
|
||||
app: int,
|
||||
bup: int,
|
||||
hydro: int,
|
||||
gaba: int,
|
||||
prop: int,
|
||||
note: str,
|
||||
original_date: str,
|
||||
*args,
|
||||
) -> None:
|
||||
"""Save the edited data to the CSV file."""
|
||||
values: list[str | int] = [
|
||||
date,
|
||||
dep,
|
||||
anx,
|
||||
slp,
|
||||
app,
|
||||
bup,
|
||||
hydro,
|
||||
gaba,
|
||||
prop,
|
||||
note,
|
||||
]
|
||||
"""Save edited data to CSV file with dynamic pathology/medicine support."""
|
||||
# Parse dynamic arguments
|
||||
# Format: date, pathology1, pathology2, ..., medicine1, medicine2,
|
||||
# ..., note, dose_data
|
||||
|
||||
if self.data_manager.update_entry(date, values):
|
||||
if len(args) < 2: # At minimum need date and note
|
||||
messagebox.showerror("Error", "Invalid save data format", parent=edit_win)
|
||||
return
|
||||
|
||||
# Extract arguments
|
||||
date = args[0]
|
||||
|
||||
# Get pathology count to extract values
|
||||
pathology_keys = self.pathology_manager.get_pathology_keys()
|
||||
medicine_keys = self.medicine_manager.get_medicine_keys()
|
||||
|
||||
# Expected format: date, pathology_values..., medicine_values...,
|
||||
# note, dose_data
|
||||
expected_pathology_count = len(pathology_keys)
|
||||
expected_medicine_count = len(medicine_keys)
|
||||
|
||||
# Extract pathology values
|
||||
pathology_values = []
|
||||
for i in range(expected_pathology_count):
|
||||
if i + 1 < len(args):
|
||||
pathology_values.append(args[i + 1])
|
||||
else:
|
||||
pathology_values.append(0)
|
||||
|
||||
# Extract medicine values
|
||||
medicine_values = []
|
||||
medicine_start_idx = 1 + expected_pathology_count
|
||||
for i in range(expected_medicine_count):
|
||||
if medicine_start_idx + i < len(args):
|
||||
medicine_values.append(args[medicine_start_idx + i])
|
||||
else:
|
||||
medicine_values.append(0)
|
||||
|
||||
# Extract note and dose data (last two arguments)
|
||||
note = args[-2] if len(args) >= 2 else ""
|
||||
dose_data = args[-1] if len(args) >= 1 else {}
|
||||
|
||||
# Build the values list for data manager
|
||||
values = [date]
|
||||
values.extend(pathology_values)
|
||||
|
||||
# Add medicine data dynamically
|
||||
for i, medicine_key in enumerate(medicine_keys):
|
||||
values.append(medicine_values[i] if i < len(medicine_values) else 0)
|
||||
values.append(dose_data.get(medicine_key, ""))
|
||||
|
||||
values.append(note)
|
||||
|
||||
if self.data_manager.update_entry(original_date, values):
|
||||
edit_win.destroy()
|
||||
messagebox.showinfo(
|
||||
"Success", "Entry updated successfully!", parent=self.root
|
||||
)
|
||||
self._clear_entries()
|
||||
self.load_data()
|
||||
self.refresh_data_display()
|
||||
else:
|
||||
messagebox.showerror("Error", "Failed to save changes", parent=edit_win)
|
||||
# Check if it's a duplicate date issue
|
||||
df = self.data_manager.load_data()
|
||||
if original_date != date and not df.empty and date in df["date"].values:
|
||||
messagebox.showerror(
|
||||
"Error",
|
||||
f"An entry for date '{date}' already exists. "
|
||||
"Please use a different date.",
|
||||
parent=edit_win,
|
||||
)
|
||||
else:
|
||||
messagebox.showerror("Error", "Failed to save changes", parent=edit_win)
|
||||
|
||||
def on_closing(self) -> None:
|
||||
def handle_window_closing(self) -> None:
|
||||
if messagebox.askokcancel(
|
||||
"Quit", "Do you want to quit the application?", parent=self.root
|
||||
):
|
||||
self.graph_manager.close()
|
||||
self.root.destroy()
|
||||
|
||||
def add_entry(self) -> None:
|
||||
def add_new_entry(self) -> None:
|
||||
"""Add a new entry to the CSV file."""
|
||||
entry: list[str | int] = [
|
||||
self.date_var.get(),
|
||||
self.symptom_vars["depression"].get(),
|
||||
self.symptom_vars["anxiety"].get(),
|
||||
self.symptom_vars["sleep"].get(),
|
||||
self.symptom_vars["appetite"].get(),
|
||||
self.medicine_vars["bupropion"][0].get(),
|
||||
self.medicine_vars["hydroxyzine"][0].get(),
|
||||
self.medicine_vars["gabapentin"][0].get(),
|
||||
self.medicine_vars["propranolol"][0].get(),
|
||||
self.note_var.get(),
|
||||
]
|
||||
# Get current doses for today
|
||||
today = self.date_var.get()
|
||||
dose_values = {}
|
||||
|
||||
if today:
|
||||
# Get doses for all medicines dynamically
|
||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||
doses = self.data_manager.get_today_medicine_doses(today, medicine_key)
|
||||
dose_values[f"{medicine_key}_doses"] = "|".join(
|
||||
[f"{ts}:{dose}" for ts, dose in doses]
|
||||
)
|
||||
else:
|
||||
# Set empty doses for all medicines
|
||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||
dose_values[f"{medicine_key}_doses"] = ""
|
||||
|
||||
# Build entry dynamically
|
||||
entry: list[str | int] = [self.date_var.get()]
|
||||
|
||||
# Add pathology data dynamically
|
||||
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||
entry.append(self.pathology_vars[pathology_key].get())
|
||||
|
||||
# Add medicine data
|
||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||
entry.append(self.medicine_vars[medicine_key][0].get())
|
||||
entry.append(dose_values[f"{medicine_key}_doses"])
|
||||
|
||||
entry.append(self.note_var.get())
|
||||
logger.debug(f"Adding entry: {entry}")
|
||||
|
||||
# Check if date is empty
|
||||
if not self.date_var.get().strip():
|
||||
messagebox.showerror("Error", "Please enter a date.", parent=self.root)
|
||||
return
|
||||
|
||||
if self.data_manager.add_entry(entry):
|
||||
messagebox.showinfo(
|
||||
"Success", "Entry added successfully!", parent=self.root
|
||||
)
|
||||
self._clear_entries()
|
||||
self.load_data()
|
||||
self.refresh_data_display()
|
||||
else:
|
||||
messagebox.showerror("Error", "Failed to add entry", parent=self.root)
|
||||
# Check if it's a duplicate date by trying to load existing data
|
||||
df = self.data_manager.load_data()
|
||||
if not df.empty and self.date_var.get() in df["date"].values:
|
||||
messagebox.showerror(
|
||||
"Error",
|
||||
f"An entry for date '{self.date_var.get()}' already exists. "
|
||||
"Please use a different date or edit the existing entry.",
|
||||
parent=self.root,
|
||||
)
|
||||
else:
|
||||
messagebox.showerror("Error", "Failed to add entry", parent=self.root)
|
||||
|
||||
def _delete_entry(self, edit_win: tk.Toplevel, item_id: str) -> None:
|
||||
"""Delete the selected entry from the CSV file."""
|
||||
@@ -213,9 +421,9 @@ class MedTrackerApp:
|
||||
if self.data_manager.delete_entry(date):
|
||||
edit_win.destroy()
|
||||
messagebox.showinfo(
|
||||
"Success", "Entry deleted successfully!", parent=edit_win
|
||||
"Success", "Entry deleted successfully!", parent=self.root
|
||||
)
|
||||
self.load_data()
|
||||
self.refresh_data_display()
|
||||
else:
|
||||
messagebox.showerror("Error", "Failed to delete entry", parent=edit_win)
|
||||
|
||||
@@ -223,28 +431,50 @@ class MedTrackerApp:
|
||||
"""Clear all input fields."""
|
||||
logger.debug("Clearing input fields.")
|
||||
self.date_var.set("")
|
||||
for key in self.symptom_vars:
|
||||
self.symptom_vars[key].set(0)
|
||||
for key in self.pathology_vars:
|
||||
self.pathology_vars[key].set(0)
|
||||
for key in self.medicine_vars:
|
||||
self.medicine_vars[key][0].set(0)
|
||||
self.note_var.set("")
|
||||
|
||||
def load_data(self) -> None:
|
||||
def refresh_data_display(self) -> None:
|
||||
"""Load data from the CSV file into the table and graph."""
|
||||
logger.debug("Loading data from CSV.")
|
||||
|
||||
# Clear existing data in the treeview
|
||||
for i in self.tree.get_children():
|
||||
self.tree.delete(i)
|
||||
# Clear existing data in the treeview efficiently
|
||||
children = self.tree.get_children()
|
||||
if children:
|
||||
self.tree.delete(*children)
|
||||
|
||||
# Load data from the CSV file
|
||||
df: pd.DataFrame = self.data_manager.load_data()
|
||||
|
||||
# Update the treeview with the data
|
||||
if not df.empty:
|
||||
for _index, row in df.iterrows():
|
||||
# Build display columns dynamically (exclude dose columns for table view)
|
||||
display_columns = ["date"]
|
||||
|
||||
# Add pathology columns
|
||||
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||
display_columns.append(pathology_key)
|
||||
|
||||
# Add medicine columns (without dose columns)
|
||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||
display_columns.append(medicine_key)
|
||||
|
||||
display_columns.append("note")
|
||||
|
||||
# Filter to only the columns we want to display
|
||||
if all(col in df.columns for col in display_columns):
|
||||
display_df = df[display_columns]
|
||||
else:
|
||||
# Fallback - just use all columns
|
||||
display_df = df
|
||||
|
||||
# Batch insert for better performance
|
||||
for _index, row in display_df.iterrows():
|
||||
self.tree.insert(parent="", index="end", values=list(row))
|
||||
logger.debug(f"Loaded {len(df)} entries into treeview.")
|
||||
logger.debug(f"Loaded {len(display_df)} entries into treeview.")
|
||||
|
||||
# Update the graph
|
||||
self.graph_manager.update_graph(df)
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
"""
|
||||
Medicine management window for adding, editing, and removing medicines.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
from medicine_manager import Medicine, MedicineManager
|
||||
|
||||
|
||||
class MedicineManagementWindow:
|
||||
"""Window for managing medicine configurations."""
|
||||
|
||||
def __init__(
|
||||
self, parent: tk.Tk, medicine_manager: MedicineManager, refresh_callback
|
||||
):
|
||||
self.parent = parent
|
||||
self.medicine_manager = medicine_manager
|
||||
self.refresh_callback = refresh_callback
|
||||
|
||||
# Create the window
|
||||
self.window = tk.Toplevel(parent)
|
||||
self.window.title("Manage Medicines")
|
||||
self.window.geometry("600x500")
|
||||
self.window.resizable(True, True)
|
||||
|
||||
# Make window modal
|
||||
self.window.transient(parent)
|
||||
self.window.grab_set()
|
||||
|
||||
self._setup_ui()
|
||||
self._populate_medicine_list()
|
||||
|
||||
# Center window
|
||||
self.window.update_idletasks()
|
||||
x = (self.window.winfo_screenwidth() // 2) - (600 // 2)
|
||||
y = (self.window.winfo_screenheight() // 2) - (500 // 2)
|
||||
self.window.geometry(f"600x500+{x}+{y}")
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the user interface."""
|
||||
main_frame = ttk.Frame(self.window, padding="10")
|
||||
main_frame.grid(row=0, column=0, sticky="nsew")
|
||||
|
||||
self.window.grid_rowconfigure(0, weight=1)
|
||||
self.window.grid_columnconfigure(0, weight=1)
|
||||
main_frame.grid_rowconfigure(1, weight=1)
|
||||
main_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Title
|
||||
title_label = ttk.Label(
|
||||
main_frame, text="Medicine Management", font=("Arial", 14, "bold")
|
||||
)
|
||||
title_label.grid(row=0, column=0, columnspan=2, pady=(0, 10))
|
||||
|
||||
# Medicine list
|
||||
list_frame = ttk.LabelFrame(main_frame, text="Current Medicines")
|
||||
list_frame.grid(row=1, column=0, columnspan=2, sticky="nsew", pady=(0, 10))
|
||||
list_frame.grid_rowconfigure(0, weight=1)
|
||||
list_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Treeview for medicines
|
||||
columns = ("key", "name", "dosage", "quick_doses", "color", "default")
|
||||
self.tree = ttk.Treeview(list_frame, columns=columns, show="headings")
|
||||
|
||||
# Column headings
|
||||
self.tree.heading("key", text="Key")
|
||||
self.tree.heading("name", text="Name")
|
||||
self.tree.heading("dosage", text="Dosage Info")
|
||||
self.tree.heading("quick_doses", text="Quick Doses")
|
||||
self.tree.heading("color", text="Color")
|
||||
self.tree.heading("default", text="Default Enabled")
|
||||
|
||||
# Column widths
|
||||
self.tree.column("key", width=80)
|
||||
self.tree.column("name", width=100)
|
||||
self.tree.column("dosage", width=100)
|
||||
self.tree.column("quick_doses", width=120)
|
||||
self.tree.column("color", width=70)
|
||||
self.tree.column("default", width=100)
|
||||
|
||||
self.tree.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
|
||||
|
||||
# Scrollbar for treeview
|
||||
scrollbar = ttk.Scrollbar(
|
||||
list_frame, orient="vertical", command=self.tree.yview
|
||||
)
|
||||
scrollbar.grid(row=0, column=1, sticky="ns")
|
||||
self.tree.configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
# Buttons
|
||||
button_frame = ttk.Frame(main_frame)
|
||||
button_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0))
|
||||
|
||||
ttk.Button(button_frame, text="Add Medicine", command=self._add_medicine).grid(
|
||||
row=0, column=0, padx=(0, 5)
|
||||
)
|
||||
|
||||
ttk.Button(
|
||||
button_frame, text="Edit Medicine", command=self._edit_medicine
|
||||
).grid(row=0, column=1, padx=5)
|
||||
|
||||
ttk.Button(
|
||||
button_frame, text="Remove Medicine", command=self._remove_medicine
|
||||
).grid(row=0, column=2, padx=5)
|
||||
|
||||
ttk.Button(button_frame, text="Close", command=self._close_window).grid(
|
||||
row=0, column=3, padx=(5, 0)
|
||||
)
|
||||
|
||||
def _populate_medicine_list(self):
|
||||
"""Populate the medicine list."""
|
||||
# Clear existing items
|
||||
for item in self.tree.get_children():
|
||||
self.tree.delete(item)
|
||||
|
||||
# Add medicines
|
||||
for medicine in self.medicine_manager.get_all_medicines().values():
|
||||
self.tree.insert(
|
||||
"",
|
||||
"end",
|
||||
values=(
|
||||
medicine.key,
|
||||
medicine.display_name,
|
||||
medicine.dosage_info,
|
||||
", ".join(medicine.quick_doses),
|
||||
medicine.color,
|
||||
"Yes" if medicine.default_enabled else "No",
|
||||
),
|
||||
)
|
||||
|
||||
def _add_medicine(self):
|
||||
"""Add a new medicine."""
|
||||
MedicineEditDialog(
|
||||
self.window, self.medicine_manager, None, self._on_medicine_changed
|
||||
)
|
||||
|
||||
def _edit_medicine(self):
|
||||
"""Edit selected medicine."""
|
||||
selection = self.tree.selection()
|
||||
if not selection:
|
||||
messagebox.showwarning("No Selection", "Please select a medicine to edit.")
|
||||
return
|
||||
|
||||
item = self.tree.item(selection[0])
|
||||
medicine_key = item["values"][0]
|
||||
medicine = self.medicine_manager.get_medicine(medicine_key)
|
||||
|
||||
if medicine:
|
||||
MedicineEditDialog(
|
||||
self.window, self.medicine_manager, medicine, self._on_medicine_changed
|
||||
)
|
||||
|
||||
def _remove_medicine(self):
|
||||
"""Remove selected medicine."""
|
||||
selection = self.tree.selection()
|
||||
if not selection:
|
||||
messagebox.showwarning(
|
||||
"No Selection", "Please select a medicine to remove."
|
||||
)
|
||||
return
|
||||
|
||||
item = self.tree.item(selection[0])
|
||||
medicine_key = item["values"][0]
|
||||
medicine_name = item["values"][1]
|
||||
|
||||
if messagebox.askyesno(
|
||||
"Confirm Removal",
|
||||
f"Are you sure you want to remove '{medicine_name}'?\n\n"
|
||||
"This will also remove all associated data from your records!",
|
||||
):
|
||||
if self.medicine_manager.remove_medicine(medicine_key):
|
||||
messagebox.showinfo(
|
||||
"Success", f"'{medicine_name}' removed successfully!"
|
||||
)
|
||||
self._populate_medicine_list()
|
||||
self._refresh_main_app()
|
||||
else:
|
||||
messagebox.showerror("Error", f"Failed to remove '{medicine_name}'.")
|
||||
|
||||
def _on_medicine_changed(self):
|
||||
"""Called when a medicine is added or edited."""
|
||||
self._populate_medicine_list()
|
||||
self._refresh_main_app()
|
||||
|
||||
def _refresh_main_app(self):
|
||||
"""Refresh the main application after medicine changes."""
|
||||
if self.refresh_callback:
|
||||
self.refresh_callback()
|
||||
|
||||
def _close_window(self):
|
||||
"""Close the window."""
|
||||
self.window.destroy()
|
||||
|
||||
|
||||
class MedicineEditDialog:
|
||||
"""Dialog for adding/editing a medicine."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: tk.Toplevel,
|
||||
medicine_manager: MedicineManager,
|
||||
medicine: Medicine | None,
|
||||
callback,
|
||||
):
|
||||
self.parent = parent
|
||||
self.medicine_manager = medicine_manager
|
||||
self.medicine = medicine
|
||||
self.callback = callback
|
||||
self.is_edit = medicine is not None
|
||||
|
||||
# Create dialog
|
||||
self.dialog = tk.Toplevel(parent)
|
||||
self.dialog.title("Edit Medicine" if self.is_edit else "Add Medicine")
|
||||
self.dialog.geometry("400x350")
|
||||
self.dialog.resizable(False, False)
|
||||
|
||||
# Make modal
|
||||
self.dialog.transient(parent)
|
||||
self.dialog.grab_set()
|
||||
|
||||
self._setup_dialog()
|
||||
self._populate_fields()
|
||||
|
||||
# Center dialog
|
||||
self.dialog.update_idletasks()
|
||||
x = parent.winfo_x() + (parent.winfo_width() // 2) - (400 // 2)
|
||||
y = parent.winfo_y() + (parent.winfo_height() // 2) - (350 // 2)
|
||||
self.dialog.geometry(f"400x350+{x}+{y}")
|
||||
|
||||
def _setup_dialog(self):
|
||||
"""Set up the dialog UI."""
|
||||
main_frame = ttk.Frame(self.dialog, padding="15")
|
||||
main_frame.grid(row=0, column=0, sticky="nsew")
|
||||
|
||||
self.dialog.grid_rowconfigure(0, weight=1)
|
||||
self.dialog.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Fields
|
||||
fields_frame = ttk.Frame(main_frame)
|
||||
fields_frame.grid(row=0, column=0, sticky="ew", pady=(0, 15))
|
||||
fields_frame.grid_columnconfigure(1, weight=1)
|
||||
|
||||
row = 0
|
||||
|
||||
# Key
|
||||
ttk.Label(fields_frame, text="Key:").grid(row=row, column=0, sticky="w", pady=5)
|
||||
self.key_var = tk.StringVar()
|
||||
key_entry = ttk.Entry(fields_frame, textvariable=self.key_var)
|
||||
key_entry.grid(row=row, column=1, sticky="ew", padx=(10, 0), pady=5)
|
||||
if self.is_edit:
|
||||
key_entry.configure(state="readonly")
|
||||
row += 1
|
||||
|
||||
# Display Name
|
||||
ttk.Label(fields_frame, text="Display Name:").grid(
|
||||
row=row, column=0, sticky="w", pady=5
|
||||
)
|
||||
self.name_var = tk.StringVar()
|
||||
ttk.Entry(fields_frame, textvariable=self.name_var).grid(
|
||||
row=row, column=1, sticky="ew", padx=(10, 0), pady=5
|
||||
)
|
||||
row += 1
|
||||
|
||||
# Dosage Info
|
||||
ttk.Label(fields_frame, text="Dosage Info:").grid(
|
||||
row=row, column=0, sticky="w", pady=5
|
||||
)
|
||||
self.dosage_var = tk.StringVar()
|
||||
ttk.Entry(fields_frame, textvariable=self.dosage_var).grid(
|
||||
row=row, column=1, sticky="ew", padx=(10, 0), pady=5
|
||||
)
|
||||
row += 1
|
||||
|
||||
# Quick Doses
|
||||
ttk.Label(fields_frame, text="Quick Doses:").grid(
|
||||
row=row, column=0, sticky="w", pady=5
|
||||
)
|
||||
self.doses_var = tk.StringVar()
|
||||
ttk.Entry(fields_frame, textvariable=self.doses_var).grid(
|
||||
row=row, column=1, sticky="ew", padx=(10, 0), pady=5
|
||||
)
|
||||
ttk.Label(
|
||||
fields_frame, text="(comma-separated, e.g. 25,50,100)", font=("Arial", 8)
|
||||
).grid(row=row + 1, column=1, sticky="w", padx=(10, 0))
|
||||
row += 2
|
||||
|
||||
# Color
|
||||
ttk.Label(fields_frame, text="Graph Color:").grid(
|
||||
row=row, column=0, sticky="w", pady=5
|
||||
)
|
||||
self.color_var = tk.StringVar()
|
||||
ttk.Entry(fields_frame, textvariable=self.color_var).grid(
|
||||
row=row, column=1, sticky="ew", padx=(10, 0), pady=5
|
||||
)
|
||||
ttk.Label(
|
||||
fields_frame, text="(hex color, e.g. #FF6B6B)", font=("Arial", 8)
|
||||
).grid(row=row + 1, column=1, sticky="w", padx=(10, 0))
|
||||
row += 2
|
||||
|
||||
# Default Enabled
|
||||
self.default_var = tk.BooleanVar()
|
||||
ttk.Checkbutton(
|
||||
fields_frame,
|
||||
text="Show in graph by default",
|
||||
variable=self.default_var,
|
||||
).grid(row=row, column=0, columnspan=2, sticky="w", pady=5)
|
||||
|
||||
# Buttons
|
||||
button_frame = ttk.Frame(main_frame)
|
||||
button_frame.grid(row=1, column=0)
|
||||
|
||||
ttk.Button(button_frame, text="Save", command=self._save_medicine).grid(
|
||||
row=0, column=0, padx=(0, 10)
|
||||
)
|
||||
|
||||
ttk.Button(button_frame, text="Cancel", command=self.dialog.destroy).grid(
|
||||
row=0, column=1
|
||||
)
|
||||
|
||||
def _populate_fields(self):
|
||||
"""Populate fields if editing."""
|
||||
if self.medicine:
|
||||
self.key_var.set(self.medicine.key)
|
||||
self.name_var.set(self.medicine.display_name)
|
||||
self.dosage_var.set(self.medicine.dosage_info)
|
||||
self.doses_var.set(",".join(self.medicine.quick_doses))
|
||||
self.color_var.set(self.medicine.color)
|
||||
self.default_var.set(self.medicine.default_enabled)
|
||||
|
||||
def _save_medicine(self):
|
||||
"""Save the medicine."""
|
||||
# Validate fields
|
||||
key = self.key_var.get().strip()
|
||||
name = self.name_var.get().strip()
|
||||
dosage = self.dosage_var.get().strip()
|
||||
doses_str = self.doses_var.get().strip()
|
||||
color = self.color_var.get().strip()
|
||||
|
||||
if not all([key, name, dosage, doses_str, color]):
|
||||
messagebox.showerror("Error", "All fields are required.")
|
||||
return
|
||||
|
||||
# Validate key format (alphanumeric and underscores only)
|
||||
if not key.replace("_", "").replace("-", "").isalnum():
|
||||
messagebox.showerror(
|
||||
"Error",
|
||||
"Key must contain only letters, numbers, underscores, and hyphens.",
|
||||
)
|
||||
return
|
||||
|
||||
# Parse quick doses
|
||||
try:
|
||||
quick_doses = [dose.strip() for dose in doses_str.split(",")]
|
||||
quick_doses = [dose for dose in quick_doses if dose] # Remove empty strings
|
||||
if not quick_doses:
|
||||
raise ValueError("At least one quick dose is required.")
|
||||
except Exception:
|
||||
messagebox.showerror("Error", "Quick doses must be comma-separated values.")
|
||||
return
|
||||
|
||||
# Validate color format
|
||||
if not color.startswith("#") or len(color) != 7:
|
||||
messagebox.showerror(
|
||||
"Error", "Color must be in hex format (e.g., #FF6B6B)."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
int(color[1:], 16) # Validate hex color
|
||||
except ValueError:
|
||||
messagebox.showerror("Error", "Invalid hex color format.")
|
||||
return
|
||||
|
||||
# Create medicine object
|
||||
new_medicine = Medicine(
|
||||
key=key,
|
||||
display_name=name,
|
||||
dosage_info=dosage,
|
||||
quick_doses=quick_doses,
|
||||
color=color,
|
||||
default_enabled=self.default_var.get(),
|
||||
)
|
||||
|
||||
# Save medicine
|
||||
success = False
|
||||
if self.is_edit:
|
||||
success = self.medicine_manager.update_medicine(
|
||||
self.medicine.key, new_medicine
|
||||
)
|
||||
else:
|
||||
success = self.medicine_manager.add_medicine(new_medicine)
|
||||
|
||||
if success:
|
||||
action = "updated" if self.is_edit else "added"
|
||||
messagebox.showinfo("Success", f"Medicine {action} successfully!")
|
||||
self.callback()
|
||||
self.dialog.destroy()
|
||||
else:
|
||||
action = "update" if self.is_edit else "add"
|
||||
messagebox.showerror("Error", f"Failed to {action} medicine.")
|
||||
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Medicine configuration manager for the MedTracker application.
|
||||
Handles dynamic loading and saving of medicine configurations.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class Medicine:
|
||||
"""Data class representing a medicine."""
|
||||
|
||||
key: str # Internal key (e.g., "bupropion")
|
||||
display_name: str # Display name (e.g., "Bupropion")
|
||||
dosage_info: str # Dosage information (e.g., "150/300 mg")
|
||||
quick_doses: list[str] # Common dose amounts for quick selection
|
||||
color: str # Color for graph display
|
||||
default_enabled: bool = False # Whether to show in graph by default
|
||||
|
||||
|
||||
class MedicineManager:
|
||||
"""Manages medicine configurations and provides access to medicine data."""
|
||||
|
||||
def __init__(
|
||||
self, config_file: str = "medicines.json", logger: logging.Logger = None
|
||||
):
|
||||
self.config_file = config_file
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.medicines: dict[str, Medicine] = {}
|
||||
self._load_medicines()
|
||||
|
||||
def _get_default_medicines(self) -> list[Medicine]:
|
||||
"""Get the default medicine configuration."""
|
||||
return [
|
||||
Medicine(
|
||||
key="bupropion",
|
||||
display_name="Bupropion",
|
||||
dosage_info="150/300 mg",
|
||||
quick_doses=["150", "300"],
|
||||
color="#FF6B6B",
|
||||
default_enabled=True,
|
||||
),
|
||||
Medicine(
|
||||
key="hydroxyzine",
|
||||
display_name="Hydroxyzine",
|
||||
dosage_info="25 mg",
|
||||
quick_doses=["25", "50"],
|
||||
color="#4ECDC4",
|
||||
default_enabled=False,
|
||||
),
|
||||
Medicine(
|
||||
key="gabapentin",
|
||||
display_name="Gabapentin",
|
||||
dosage_info="100 mg",
|
||||
quick_doses=["100", "300", "600"],
|
||||
color="#45B7D1",
|
||||
default_enabled=False,
|
||||
),
|
||||
Medicine(
|
||||
key="propranolol",
|
||||
display_name="Propranolol",
|
||||
dosage_info="10 mg",
|
||||
quick_doses=["10", "20", "40"],
|
||||
color="#96CEB4",
|
||||
default_enabled=True,
|
||||
),
|
||||
Medicine(
|
||||
key="quetiapine",
|
||||
display_name="Quetiapine",
|
||||
dosage_info="25 mg",
|
||||
quick_doses=["25", "50", "100"],
|
||||
color="#FFEAA7",
|
||||
default_enabled=False,
|
||||
),
|
||||
]
|
||||
|
||||
def _load_medicines(self) -> None:
|
||||
"""Load medicines from configuration file."""
|
||||
if os.path.exists(self.config_file):
|
||||
try:
|
||||
with open(self.config_file) as f:
|
||||
data = json.load(f)
|
||||
|
||||
self.medicines = {}
|
||||
for medicine_data in data.get("medicines", []):
|
||||
medicine = Medicine(**medicine_data)
|
||||
self.medicines[medicine.key] = medicine
|
||||
|
||||
self.logger.info(
|
||||
f"Loaded {len(self.medicines)} medicines from {self.config_file}"
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading medicines config: {e}")
|
||||
self._create_default_config()
|
||||
else:
|
||||
self._create_default_config()
|
||||
|
||||
def _create_default_config(self) -> None:
|
||||
"""Create default medicine configuration."""
|
||||
default_medicines = self._get_default_medicines()
|
||||
self.medicines = {med.key: med for med in default_medicines}
|
||||
self.save_medicines()
|
||||
self.logger.info("Created default medicine configuration")
|
||||
|
||||
def save_medicines(self) -> bool:
|
||||
"""Save current medicines to configuration file."""
|
||||
try:
|
||||
data = {
|
||||
"medicines": [asdict(medicine) for medicine in self.medicines.values()]
|
||||
}
|
||||
|
||||
with open(self.config_file, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
self.logger.info(
|
||||
f"Saved {len(self.medicines)} medicines to {self.config_file}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving medicines config: {e}")
|
||||
return False
|
||||
|
||||
def get_all_medicines(self) -> dict[str, Medicine]:
|
||||
"""Get all medicines."""
|
||||
return self.medicines.copy()
|
||||
|
||||
def get_medicine(self, key: str) -> Medicine | None:
|
||||
"""Get a specific medicine by key."""
|
||||
return self.medicines.get(key)
|
||||
|
||||
def add_medicine(self, medicine: Medicine) -> bool:
|
||||
"""Add a new medicine."""
|
||||
if medicine.key in self.medicines:
|
||||
self.logger.warning(f"Medicine with key '{medicine.key}' already exists")
|
||||
return False
|
||||
|
||||
self.medicines[medicine.key] = medicine
|
||||
return self.save_medicines()
|
||||
|
||||
def update_medicine(self, key: str, medicine: Medicine) -> bool:
|
||||
"""Update an existing medicine."""
|
||||
if key not in self.medicines:
|
||||
self.logger.warning(f"Medicine with key '{key}' does not exist")
|
||||
return False
|
||||
|
||||
# If key is changing, remove old entry
|
||||
if key != medicine.key:
|
||||
del self.medicines[key]
|
||||
|
||||
self.medicines[medicine.key] = medicine
|
||||
return self.save_medicines()
|
||||
|
||||
def remove_medicine(self, key: str) -> bool:
|
||||
"""Remove a medicine."""
|
||||
if key not in self.medicines:
|
||||
self.logger.warning(f"Medicine with key '{key}' does not exist")
|
||||
return False
|
||||
|
||||
del self.medicines[key]
|
||||
return self.save_medicines()
|
||||
|
||||
def get_medicine_keys(self) -> list[str]:
|
||||
"""Get list of all medicine keys."""
|
||||
return list(self.medicines.keys())
|
||||
|
||||
def get_display_names(self) -> dict[str, str]:
|
||||
"""Get mapping of keys to display names."""
|
||||
return {key: med.display_name for key, med in self.medicines.items()}
|
||||
|
||||
def get_quick_doses(self, key: str) -> list[str]:
|
||||
"""Get quick dose options for a medicine."""
|
||||
medicine = self.medicines.get(key)
|
||||
return medicine.quick_doses if medicine else ["25", "50"]
|
||||
|
||||
def get_graph_colors(self) -> dict[str, str]:
|
||||
"""Get mapping of medicine keys to graph colors."""
|
||||
return {key: med.color for key, med in self.medicines.items()}
|
||||
|
||||
def get_default_enabled_medicines(self) -> list[str]:
|
||||
"""Get list of medicines that should be enabled by default in graphs."""
|
||||
return [key for key, med in self.medicines.items() if med.default_enabled]
|
||||
|
||||
def get_medicine_vars_dict(self) -> dict[str, tuple[Any, str]]:
|
||||
"""Get medicine variables dictionary for UI compatibility."""
|
||||
# This maintains compatibility with existing UI code
|
||||
import tkinter as tk
|
||||
|
||||
return {
|
||||
key: (tk.IntVar(value=0), f"{med.display_name} {med.dosage_info}")
|
||||
for key, med in self.medicines.items()
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
"""
|
||||
Pathology management window for adding, editing, and removing pathologies.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
from pathology_manager import Pathology, PathologyManager
|
||||
|
||||
|
||||
class PathologyManagementWindow:
|
||||
"""Window for managing pathology configurations."""
|
||||
|
||||
def __init__(
|
||||
self, parent: tk.Tk, pathology_manager: PathologyManager, refresh_callback
|
||||
):
|
||||
self.parent = parent
|
||||
self.pathology_manager = pathology_manager
|
||||
self.refresh_callback = refresh_callback
|
||||
|
||||
# Create the window
|
||||
self.window = tk.Toplevel(parent)
|
||||
self.window.title("Manage Pathologies")
|
||||
self.window.geometry("800x500")
|
||||
self.window.resizable(True, True)
|
||||
|
||||
# Make window modal
|
||||
self.window.transient(parent)
|
||||
self.window.grab_set()
|
||||
|
||||
self._setup_ui()
|
||||
self._populate_pathology_list()
|
||||
|
||||
# Center window
|
||||
self.window.update_idletasks()
|
||||
x = (self.window.winfo_screenwidth() // 2) - (800 // 2)
|
||||
y = (self.window.winfo_screenheight() // 2) - (500 // 2)
|
||||
self.window.geometry(f"800x500+{x}+{y}")
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI components."""
|
||||
# Main frame
|
||||
main_frame = ttk.Frame(self.window, padding="10")
|
||||
main_frame.grid(row=0, column=0, sticky="nsew")
|
||||
self.window.grid_rowconfigure(0, weight=1)
|
||||
self.window.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Pathology list
|
||||
list_frame = ttk.LabelFrame(main_frame, text="Pathologies", padding="5")
|
||||
list_frame.grid(row=0, column=0, sticky="nsew", pady=(0, 10))
|
||||
main_frame.grid_rowconfigure(0, weight=1)
|
||||
main_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Treeview for pathology list
|
||||
columns = (
|
||||
"Key",
|
||||
"Display Name",
|
||||
"Scale Info",
|
||||
"Color",
|
||||
"Default Enabled",
|
||||
"Scale Range",
|
||||
)
|
||||
self.tree = ttk.Treeview(list_frame, columns=columns, show="headings")
|
||||
|
||||
# Configure columns
|
||||
self.tree.heading("Key", text="Key")
|
||||
self.tree.heading("Display Name", text="Display Name")
|
||||
self.tree.heading("Scale Info", text="Scale Info")
|
||||
self.tree.heading("Color", text="Color")
|
||||
self.tree.heading("Default Enabled", text="Default Enabled")
|
||||
self.tree.heading("Scale Range", text="Scale Range")
|
||||
|
||||
self.tree.column("Key", width=120)
|
||||
self.tree.column("Display Name", width=150)
|
||||
self.tree.column("Scale Info", width=150)
|
||||
self.tree.column("Color", width=80)
|
||||
self.tree.column("Default Enabled", width=100)
|
||||
self.tree.column("Scale Range", width=100)
|
||||
|
||||
# Scrollbar for treeview
|
||||
scrollbar = ttk.Scrollbar(
|
||||
list_frame, orient="vertical", command=self.tree.yview
|
||||
)
|
||||
self.tree.configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
self.tree.grid(row=0, column=0, sticky="nsew")
|
||||
scrollbar.grid(row=0, column=1, sticky="ns")
|
||||
|
||||
list_frame.grid_rowconfigure(0, weight=1)
|
||||
list_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Buttons frame
|
||||
button_frame = ttk.Frame(main_frame)
|
||||
button_frame.grid(row=1, column=0, sticky="ew")
|
||||
|
||||
ttk.Button(
|
||||
button_frame, text="Add Pathology", command=self._add_pathology
|
||||
).pack(side="left", padx=(0, 5))
|
||||
ttk.Button(
|
||||
button_frame, text="Edit Pathology", command=self._edit_pathology
|
||||
).pack(side="left", padx=(0, 5))
|
||||
ttk.Button(
|
||||
button_frame, text="Remove Pathology", command=self._remove_pathology
|
||||
).pack(side="left", padx=(0, 5))
|
||||
ttk.Button(button_frame, text="Close", command=self.window.destroy).pack(
|
||||
side="right"
|
||||
)
|
||||
|
||||
def _populate_pathology_list(self):
|
||||
"""Populate the pathology list."""
|
||||
# Clear existing items
|
||||
for item in self.tree.get_children():
|
||||
self.tree.delete(item)
|
||||
|
||||
# Add pathologies
|
||||
for pathology in self.pathology_manager.get_all_pathologies().values():
|
||||
scale_range = f"{pathology.scale_min}-{pathology.scale_max}"
|
||||
self.tree.insert(
|
||||
"",
|
||||
"end",
|
||||
values=(
|
||||
pathology.key,
|
||||
pathology.display_name,
|
||||
pathology.scale_info,
|
||||
pathology.color,
|
||||
"Yes" if pathology.default_enabled else "No",
|
||||
scale_range,
|
||||
),
|
||||
)
|
||||
|
||||
def _add_pathology(self):
|
||||
"""Add a new pathology."""
|
||||
PathologyEditDialog(
|
||||
self.window, self.pathology_manager, None, self._on_pathology_changed
|
||||
)
|
||||
|
||||
def _edit_pathology(self):
|
||||
"""Edit selected pathology."""
|
||||
selection = self.tree.selection()
|
||||
if not selection:
|
||||
messagebox.showwarning("No Selection", "Please select a pathology to edit.")
|
||||
return
|
||||
|
||||
item = self.tree.item(selection[0])
|
||||
pathology_key = item["values"][0]
|
||||
pathology = self.pathology_manager.get_pathology(pathology_key)
|
||||
|
||||
if pathology:
|
||||
PathologyEditDialog(
|
||||
self.window,
|
||||
self.pathology_manager,
|
||||
pathology,
|
||||
self._on_pathology_changed,
|
||||
)
|
||||
|
||||
def _remove_pathology(self):
|
||||
"""Remove selected pathology."""
|
||||
selection = self.tree.selection()
|
||||
if not selection:
|
||||
messagebox.showwarning(
|
||||
"No Selection", "Please select a pathology to remove."
|
||||
)
|
||||
return
|
||||
|
||||
item = self.tree.item(selection[0])
|
||||
pathology_key = item["values"][0]
|
||||
pathology_name = item["values"][1]
|
||||
|
||||
if messagebox.askyesno(
|
||||
"Confirm Removal",
|
||||
f"Are you sure you want to remove '{pathology_name}'?\n\n"
|
||||
"This will also remove all associated data from your records!",
|
||||
):
|
||||
if self.pathology_manager.remove_pathology(pathology_key):
|
||||
messagebox.showinfo(
|
||||
"Success", f"'{pathology_name}' removed successfully!"
|
||||
)
|
||||
self._populate_pathology_list()
|
||||
self._refresh_main_app()
|
||||
else:
|
||||
messagebox.showerror("Error", f"Failed to remove '{pathology_name}'.")
|
||||
|
||||
def _on_pathology_changed(self):
|
||||
"""Handle pathology changes."""
|
||||
self._populate_pathology_list()
|
||||
self._refresh_main_app()
|
||||
|
||||
def _refresh_main_app(self):
|
||||
"""Refresh the main application."""
|
||||
if self.refresh_callback:
|
||||
self.refresh_callback()
|
||||
|
||||
|
||||
class PathologyEditDialog:
|
||||
"""Dialog for adding/editing a pathology."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: tk.Toplevel,
|
||||
pathology_manager: PathologyManager,
|
||||
pathology: Pathology | None,
|
||||
callback,
|
||||
):
|
||||
self.parent = parent
|
||||
self.pathology_manager = pathology_manager
|
||||
self.pathology = pathology
|
||||
self.callback = callback
|
||||
self.is_edit = pathology is not None
|
||||
|
||||
# Create dialog
|
||||
self.dialog = tk.Toplevel(parent)
|
||||
self.dialog.title("Edit Pathology" if self.is_edit else "Add Pathology")
|
||||
self.dialog.geometry("450x400")
|
||||
self.dialog.resizable(False, False)
|
||||
|
||||
# Make modal
|
||||
self.dialog.transient(parent)
|
||||
self.dialog.grab_set()
|
||||
|
||||
self._setup_dialog()
|
||||
self._populate_fields()
|
||||
|
||||
# Center dialog
|
||||
self.dialog.update_idletasks()
|
||||
x = parent.winfo_x() + (parent.winfo_width() // 2) - (450 // 2)
|
||||
y = parent.winfo_y() + (parent.winfo_height() // 2) - (400 // 2)
|
||||
self.dialog.geometry(f"450x400+{x}+{y}")
|
||||
|
||||
def _setup_dialog(self):
|
||||
"""Set up the dialog UI."""
|
||||
# Main frame
|
||||
main_frame = ttk.Frame(self.dialog, padding="15")
|
||||
main_frame.grid(row=0, column=0, sticky="nsew")
|
||||
self.dialog.grid_rowconfigure(0, weight=1)
|
||||
self.dialog.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Form fields
|
||||
self.key_var = tk.StringVar()
|
||||
self.name_var = tk.StringVar()
|
||||
self.scale_info_var = tk.StringVar()
|
||||
self.color_var = tk.StringVar()
|
||||
self.default_var = tk.BooleanVar()
|
||||
self.scale_min_var = tk.IntVar(value=0)
|
||||
self.scale_max_var = tk.IntVar(value=10)
|
||||
self.orientation_var = tk.StringVar(value="normal")
|
||||
|
||||
# Key field
|
||||
ttk.Label(main_frame, text="Key:").grid(
|
||||
row=0, column=0, sticky="w", pady=(0, 5)
|
||||
)
|
||||
key_entry = ttk.Entry(main_frame, textvariable=self.key_var, width=40)
|
||||
key_entry.grid(row=0, column=1, sticky="ew", pady=(0, 5))
|
||||
ttk.Label(main_frame, text="(alphanumeric, underscores, hyphens only)").grid(
|
||||
row=0, column=2, sticky="w", padx=(5, 0), pady=(0, 5)
|
||||
)
|
||||
|
||||
# Display name field
|
||||
ttk.Label(main_frame, text="Display Name:").grid(
|
||||
row=1, column=0, sticky="w", pady=(0, 5)
|
||||
)
|
||||
ttk.Entry(main_frame, textvariable=self.name_var, width=40).grid(
|
||||
row=1, column=1, sticky="ew", pady=(0, 5)
|
||||
)
|
||||
|
||||
# Scale info field
|
||||
ttk.Label(main_frame, text="Scale Info:").grid(
|
||||
row=2, column=0, sticky="w", pady=(0, 5)
|
||||
)
|
||||
ttk.Entry(main_frame, textvariable=self.scale_info_var, width=40).grid(
|
||||
row=2, column=1, sticky="ew", pady=(0, 5)
|
||||
)
|
||||
ttk.Label(main_frame, text='(e.g., "0:good, 10:bad")').grid(
|
||||
row=2, column=2, sticky="w", padx=(5, 0), pady=(0, 5)
|
||||
)
|
||||
|
||||
# Scale range
|
||||
scale_frame = ttk.Frame(main_frame)
|
||||
scale_frame.grid(row=3, column=1, sticky="ew", pady=(0, 5))
|
||||
|
||||
ttk.Label(main_frame, text="Scale Range:").grid(
|
||||
row=3, column=0, sticky="w", pady=(0, 5)
|
||||
)
|
||||
ttk.Label(scale_frame, text="Min:").grid(row=0, column=0, sticky="w")
|
||||
ttk.Entry(scale_frame, textvariable=self.scale_min_var, width=5).grid(
|
||||
row=0, column=1, padx=(5, 10)
|
||||
)
|
||||
ttk.Label(scale_frame, text="Max:").grid(row=0, column=2, sticky="w")
|
||||
ttk.Entry(scale_frame, textvariable=self.scale_max_var, width=5).grid(
|
||||
row=0, column=3, padx=5
|
||||
)
|
||||
|
||||
# Scale orientation
|
||||
ttk.Label(main_frame, text="Scale Orientation:").grid(
|
||||
row=4, column=0, sticky="w", pady=(0, 5)
|
||||
)
|
||||
orientation_frame = ttk.Frame(main_frame)
|
||||
orientation_frame.grid(row=4, column=1, sticky="ew", pady=(0, 5))
|
||||
|
||||
ttk.Radiobutton(
|
||||
orientation_frame,
|
||||
text="Normal (0=good)",
|
||||
variable=self.orientation_var,
|
||||
value="normal",
|
||||
).grid(row=0, column=0, sticky="w")
|
||||
ttk.Radiobutton(
|
||||
orientation_frame,
|
||||
text="Inverted (0=bad)",
|
||||
variable=self.orientation_var,
|
||||
value="inverted",
|
||||
).grid(row=0, column=1, sticky="w", padx=(20, 0))
|
||||
|
||||
# Color field
|
||||
ttk.Label(main_frame, text="Color:").grid(
|
||||
row=5, column=0, sticky="w", pady=(0, 5)
|
||||
)
|
||||
ttk.Entry(main_frame, textvariable=self.color_var, width=40).grid(
|
||||
row=5, column=1, sticky="ew", pady=(0, 5)
|
||||
)
|
||||
ttk.Label(main_frame, text="(hex format, e.g., #FF6B6B)").grid(
|
||||
row=5, column=2, sticky="w", padx=(5, 0), pady=(0, 5)
|
||||
)
|
||||
|
||||
# Default enabled checkbox
|
||||
ttk.Checkbutton(
|
||||
main_frame, text="Show in graph by default", variable=self.default_var
|
||||
).grid(row=6, column=1, sticky="w", pady=(10, 15))
|
||||
|
||||
# Buttons
|
||||
button_frame = ttk.Frame(main_frame)
|
||||
button_frame.grid(row=7, column=0, columnspan=3, sticky="ew", pady=(10, 0))
|
||||
|
||||
ttk.Button(button_frame, text="Save", command=self._save_pathology).pack(
|
||||
side="right", padx=(5, 0)
|
||||
)
|
||||
ttk.Button(button_frame, text="Cancel", command=self.dialog.destroy).pack(
|
||||
side="right"
|
||||
)
|
||||
|
||||
# Configure column weights
|
||||
main_frame.grid_columnconfigure(1, weight=1)
|
||||
|
||||
# Focus on first field
|
||||
key_entry.focus()
|
||||
|
||||
def _populate_fields(self):
|
||||
"""Populate fields if editing."""
|
||||
if self.pathology:
|
||||
self.key_var.set(self.pathology.key)
|
||||
self.name_var.set(self.pathology.display_name)
|
||||
self.scale_info_var.set(self.pathology.scale_info)
|
||||
self.color_var.set(self.pathology.color)
|
||||
self.default_var.set(self.pathology.default_enabled)
|
||||
self.scale_min_var.set(self.pathology.scale_min)
|
||||
self.scale_max_var.set(self.pathology.scale_max)
|
||||
self.orientation_var.set(self.pathology.scale_orientation)
|
||||
|
||||
def _save_pathology(self):
|
||||
"""Save the pathology."""
|
||||
# Validate fields
|
||||
key = self.key_var.get().strip()
|
||||
name = self.name_var.get().strip()
|
||||
scale_info = self.scale_info_var.get().strip()
|
||||
color = self.color_var.get().strip()
|
||||
scale_min = self.scale_min_var.get()
|
||||
scale_max = self.scale_max_var.get()
|
||||
|
||||
if not all([key, name, scale_info, color]):
|
||||
messagebox.showerror("Error", "All fields are required.")
|
||||
return
|
||||
|
||||
# Validate key format (alphanumeric and underscores only)
|
||||
if not key.replace("_", "").replace("-", "").isalnum():
|
||||
messagebox.showerror(
|
||||
"Error",
|
||||
"Key must contain only letters, numbers, underscores, and hyphens.",
|
||||
)
|
||||
return
|
||||
|
||||
# Validate scale range
|
||||
if scale_min >= scale_max:
|
||||
messagebox.showerror("Error", "Scale minimum must be less than maximum.")
|
||||
return
|
||||
|
||||
# Validate color format
|
||||
if not color.startswith("#") or len(color) != 7:
|
||||
messagebox.showerror(
|
||||
"Error", "Color must be in hex format (e.g., #FF6B6B)."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
int(color[1:], 16) # Validate hex color
|
||||
except ValueError:
|
||||
messagebox.showerror("Error", "Invalid hex color format.")
|
||||
return
|
||||
|
||||
# Create pathology object
|
||||
new_pathology = Pathology(
|
||||
key=key,
|
||||
display_name=name,
|
||||
scale_info=scale_info,
|
||||
color=color,
|
||||
default_enabled=self.default_var.get(),
|
||||
scale_min=scale_min,
|
||||
scale_max=scale_max,
|
||||
scale_orientation=self.orientation_var.get(),
|
||||
)
|
||||
|
||||
# Save pathology
|
||||
success = False
|
||||
if self.is_edit:
|
||||
success = self.pathology_manager.update_pathology(
|
||||
self.pathology.key, new_pathology
|
||||
)
|
||||
else:
|
||||
success = self.pathology_manager.add_pathology(new_pathology)
|
||||
|
||||
if success:
|
||||
action = "updated" if self.is_edit else "added"
|
||||
messagebox.showinfo("Success", f"Pathology {action} successfully!")
|
||||
self.callback()
|
||||
self.dialog.destroy()
|
||||
else:
|
||||
action = "update" if self.is_edit else "add"
|
||||
messagebox.showerror("Error", f"Failed to {action} pathology.")
|
||||
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
Pathology configuration manager for the MedTracker application.
|
||||
Handles dynamic loading and saving of pathology/symptom configurations.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pathology:
|
||||
"""Data class representing a pathology/symptom."""
|
||||
|
||||
key: str # Internal key (e.g., "depression")
|
||||
display_name: str # Display name (e.g., "Depression")
|
||||
scale_info: str # Scale information (e.g., "0:good, 10:bad")
|
||||
color: str # Color for graph display
|
||||
default_enabled: bool = True # Whether to show in graph by default
|
||||
scale_min: int = 0 # Minimum scale value
|
||||
scale_max: int = 10 # Maximum scale value
|
||||
scale_orientation: str = "normal" # "normal" (0=good) or "inverted" (0=bad)
|
||||
|
||||
|
||||
class PathologyManager:
|
||||
"""Manages pathology configurations and provides access to pathology data."""
|
||||
|
||||
def __init__(
|
||||
self, config_file: str = "pathologies.json", logger: logging.Logger = None
|
||||
):
|
||||
self.config_file = config_file
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.pathologies: dict[str, Pathology] = {}
|
||||
self._load_pathologies()
|
||||
|
||||
def _get_default_pathologies(self) -> list[Pathology]:
|
||||
"""Get the default pathology configuration."""
|
||||
return [
|
||||
Pathology(
|
||||
key="depression",
|
||||
display_name="Depression",
|
||||
scale_info="0:good, 10:bad",
|
||||
color="#FF6B6B",
|
||||
default_enabled=True,
|
||||
scale_orientation="normal",
|
||||
),
|
||||
Pathology(
|
||||
key="anxiety",
|
||||
display_name="Anxiety",
|
||||
scale_info="0:good, 10:bad",
|
||||
color="#FFA726",
|
||||
default_enabled=True,
|
||||
scale_orientation="normal",
|
||||
),
|
||||
Pathology(
|
||||
key="sleep",
|
||||
display_name="Sleep Quality",
|
||||
scale_info="0:bad, 10:good",
|
||||
color="#66BB6A",
|
||||
default_enabled=True,
|
||||
scale_orientation="inverted",
|
||||
),
|
||||
Pathology(
|
||||
key="appetite",
|
||||
display_name="Appetite",
|
||||
scale_info="0:bad, 10:good",
|
||||
color="#42A5F5",
|
||||
default_enabled=True,
|
||||
scale_orientation="inverted",
|
||||
),
|
||||
]
|
||||
|
||||
def _load_pathologies(self) -> None:
|
||||
"""Load pathologies from configuration file."""
|
||||
if os.path.exists(self.config_file):
|
||||
try:
|
||||
with open(self.config_file) as f:
|
||||
data = json.load(f)
|
||||
|
||||
self.pathologies = {}
|
||||
for pathology_data in data.get("pathologies", []):
|
||||
pathology = Pathology(**pathology_data)
|
||||
self.pathologies[pathology.key] = pathology
|
||||
|
||||
self.logger.info(
|
||||
f"Loaded {len(self.pathologies)} pathologies from "
|
||||
f"{self.config_file}"
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading pathologies config: {e}")
|
||||
self._create_default_config()
|
||||
else:
|
||||
self._create_default_config()
|
||||
|
||||
def _create_default_config(self) -> None:
|
||||
"""Create default pathology configuration."""
|
||||
default_pathologies = self._get_default_pathologies()
|
||||
self.pathologies = {path.key: path for path in default_pathologies}
|
||||
self.save_pathologies()
|
||||
self.logger.info("Created default pathology configuration")
|
||||
|
||||
def save_pathologies(self) -> bool:
|
||||
"""Save current pathologies to configuration file."""
|
||||
try:
|
||||
data = {
|
||||
"pathologies": [
|
||||
asdict(pathology) for pathology in self.pathologies.values()
|
||||
]
|
||||
}
|
||||
|
||||
with open(self.config_file, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
self.logger.info(
|
||||
f"Saved {len(self.pathologies)} pathologies to {self.config_file}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving pathologies config: {e}")
|
||||
return False
|
||||
|
||||
def get_all_pathologies(self) -> dict[str, Pathology]:
|
||||
"""Get all pathologies."""
|
||||
return self.pathologies.copy()
|
||||
|
||||
def get_pathology(self, key: str) -> Pathology | None:
|
||||
"""Get a specific pathology by key."""
|
||||
return self.pathologies.get(key)
|
||||
|
||||
def add_pathology(self, pathology: Pathology) -> bool:
|
||||
"""Add a new pathology."""
|
||||
if pathology.key in self.pathologies:
|
||||
self.logger.warning(f"Pathology with key '{pathology.key}' already exists")
|
||||
return False
|
||||
|
||||
self.pathologies[pathology.key] = pathology
|
||||
return self.save_pathologies()
|
||||
|
||||
def update_pathology(self, key: str, pathology: Pathology) -> bool:
|
||||
"""Update an existing pathology."""
|
||||
if key not in self.pathologies:
|
||||
self.logger.warning(f"Pathology with key '{key}' does not exist")
|
||||
return False
|
||||
|
||||
# If key is changing, remove old entry
|
||||
if key != pathology.key:
|
||||
del self.pathologies[key]
|
||||
|
||||
self.pathologies[pathology.key] = pathology
|
||||
return self.save_pathologies()
|
||||
|
||||
def remove_pathology(self, key: str) -> bool:
|
||||
"""Remove a pathology."""
|
||||
if key not in self.pathologies:
|
||||
self.logger.warning(f"Pathology with key '{key}' does not exist")
|
||||
return False
|
||||
|
||||
del self.pathologies[key]
|
||||
return self.save_pathologies()
|
||||
|
||||
def get_pathology_keys(self) -> list[str]:
|
||||
"""Get list of all pathology keys."""
|
||||
return list(self.pathologies.keys())
|
||||
|
||||
def get_display_names(self) -> dict[str, str]:
|
||||
"""Get mapping of keys to display names."""
|
||||
return {key: path.display_name for key, path in self.pathologies.items()}
|
||||
|
||||
def get_graph_colors(self) -> dict[str, str]:
|
||||
"""Get mapping of pathology keys to graph colors."""
|
||||
return {key: path.color for key, path in self.pathologies.items()}
|
||||
|
||||
def get_default_enabled_pathologies(self) -> list[str]:
|
||||
"""Get list of pathologies that should be enabled by default in graphs."""
|
||||
return [key for key, path in self.pathologies.items() if path.default_enabled]
|
||||
|
||||
def get_pathology_vars_dict(self) -> dict[str, tuple[Any, str]]:
|
||||
"""Get pathology variables dictionary for UI compatibility."""
|
||||
# This maintains compatibility with existing UI code
|
||||
import tkinter as tk
|
||||
|
||||
return {
|
||||
key: (tk.IntVar(value=0), path.display_name)
|
||||
for key, path in self.pathologies.items()
|
||||
}
|
||||
|
||||
def get_scale_info(self, key: str) -> tuple[int, int, str, str]:
|
||||
"""Get scale information for a pathology."""
|
||||
pathology = self.get_pathology(key)
|
||||
if pathology:
|
||||
return (
|
||||
pathology.scale_min,
|
||||
pathology.scale_max,
|
||||
pathology.scale_info,
|
||||
pathology.scale_orientation,
|
||||
)
|
||||
return (0, 10, "0-10", "normal")
|
||||
+1463
-253
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
# Tests for TheChart application
|
||||
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
Fixtures and configuration for pytest tests.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
import pytest
|
||||
import pandas as pd
|
||||
from unittest.mock import Mock
|
||||
import logging
|
||||
|
||||
# Add src to path for imports
|
||||
import sys
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||
|
||||
from src.medicine_manager import MedicineManager, Medicine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_csv_file():
|
||||
"""Create a temporary CSV file for testing."""
|
||||
fd, path = tempfile.mkstemp(suffix='.csv')
|
||||
os.close(fd)
|
||||
yield path
|
||||
# Cleanup
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_medicine_manager():
|
||||
"""Create a mock medicine manager with default medicines for testing."""
|
||||
mock_manager = Mock(spec=MedicineManager)
|
||||
|
||||
# Default medicines matching the original system
|
||||
default_medicines = {
|
||||
"bupropion": Medicine(
|
||||
key="bupropion",
|
||||
display_name="Bupropion",
|
||||
dosage_info="150/300 mg",
|
||||
quick_doses=["150", "300"],
|
||||
color="#FF6B6B",
|
||||
default_enabled=True
|
||||
),
|
||||
"hydroxyzine": Medicine(
|
||||
key="hydroxyzine",
|
||||
display_name="Hydroxyzine",
|
||||
dosage_info="25 mg",
|
||||
quick_doses=["25", "50"],
|
||||
color="#4ECDC4",
|
||||
default_enabled=False
|
||||
),
|
||||
"gabapentin": Medicine(
|
||||
key="gabapentin",
|
||||
display_name="Gabapentin",
|
||||
dosage_info="100 mg",
|
||||
quick_doses=["100", "300", "600"],
|
||||
color="#45B7D1",
|
||||
default_enabled=False
|
||||
),
|
||||
"propranolol": Medicine(
|
||||
key="propranolol",
|
||||
display_name="Propranolol",
|
||||
dosage_info="10 mg",
|
||||
quick_doses=["10", "20", "40"],
|
||||
color="#96CEB4",
|
||||
default_enabled=True
|
||||
),
|
||||
"quetiapine": Medicine(
|
||||
key="quetiapine",
|
||||
display_name="Quetiapine",
|
||||
dosage_info="25 mg",
|
||||
quick_doses=["25", "50", "100"],
|
||||
color="#FFEAA7",
|
||||
default_enabled=False
|
||||
)
|
||||
}
|
||||
|
||||
mock_manager.get_medicine_keys.return_value = list(default_medicines.keys())
|
||||
mock_manager.get_all_medicines.return_value = default_medicines
|
||||
mock_manager.get_medicine.side_effect = lambda key: default_medicines.get(key)
|
||||
mock_manager.get_graph_colors.return_value = {k: v.color for k, v in default_medicines.items()}
|
||||
mock_manager.get_quick_doses.side_effect = lambda key: default_medicines.get(key, Medicine("", "", "", [], "", False)).quick_doses
|
||||
|
||||
return mock_manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pathology_manager():
|
||||
"""Create a mock pathology manager with default pathologies for testing."""
|
||||
mock_manager = Mock()
|
||||
|
||||
# Default pathologies matching the original system
|
||||
mock_manager.get_pathology_keys.return_value = ["depression", "anxiety", "sleep", "appetite"]
|
||||
|
||||
return mock_manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data():
|
||||
"""Sample data for testing."""
|
||||
return [
|
||||
["2024-01-01", 3, 2, 4, 3, 1, "", 0, "", 2, "", 1, "", 0, "", "Test note 1"],
|
||||
["2024-01-02", 2, 3, 3, 4, 1, "", 1, "", 2, "", 0, "", 1, "", "Test note 2"],
|
||||
["2024-01-03", 4, 1, 5, 2, 0, "", 0, "", 1, "", 1, "", 0, "", ""],
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_dataframe():
|
||||
"""Sample DataFrame for testing."""
|
||||
return pd.DataFrame({
|
||||
'date': ['2024-01-01', '2024-01-02', '2024-01-03'],
|
||||
'depression': [3, 2, 4],
|
||||
'anxiety': [2, 3, 1],
|
||||
'sleep': [4, 3, 5],
|
||||
'appetite': [3, 4, 2],
|
||||
'bupropion': [1, 1, 0],
|
||||
'bupropion_doses': ['2024-01-01 08:00:00:150mg', '2024-01-02 08:00:00:300mg', ''],
|
||||
'hydroxyzine': [0, 1, 0],
|
||||
'hydroxyzine_doses': ['', '2024-01-02 20:00:00:25mg', ''],
|
||||
'gabapentin': [2, 2, 1],
|
||||
'gabapentin_doses': ['2024-01-01 12:00:00:100mg|2024-01-01 20:00:00:100mg',
|
||||
'2024-01-02 12:00:00:100mg|2024-01-02 20:00:00:100mg',
|
||||
'2024-01-03 12:00:00:100mg'],
|
||||
'propranolol': [1, 0, 1],
|
||||
'propranolol_doses': ['2024-01-01 12:00:00:10mg', '', '2024-01-03 12:00:00:20mg'],
|
||||
'quetiapine': [0, 1, 0],
|
||||
'quetiapine_doses': ['', '2024-01-02 22:00:00:50mg', ''],
|
||||
'note': ['Test note 1', 'Test note 2', '']
|
||||
})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_logger():
|
||||
"""Mock logger for testing."""
|
||||
return Mock(spec=logging.Logger)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_log_dir():
|
||||
"""Create a temporary directory for log files."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
yield temp_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_env_vars(monkeypatch):
|
||||
"""Mock environment variables."""
|
||||
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
|
||||
monkeypatch.setenv("LOG_PATH", "/tmp/test_logs")
|
||||
monkeypatch.setenv("LOG_CLEAR", "False")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_dose_data():
|
||||
"""Sample dose data for testing dose calculation."""
|
||||
return {
|
||||
'standard_format': '2025-07-28 18:59:45:150mg|2025-07-28 19:34:19:75mg', # Should sum to 225
|
||||
'with_bullets': '• • • • 2025-07-30 07:50:00:300', # Should be 300
|
||||
'decimal_doses': '2025-07-28 18:59:45:12.5mg|2025-07-28 19:34:19:7.5mg', # Should sum to 20
|
||||
'no_timestamp': '100mg|50mg', # Should sum to 150
|
||||
'mixed_format': '• 2025-07-30 22:50:00:10|75mg', # Should sum to 85
|
||||
'empty_string': '', # Should be 0
|
||||
'nan_value': 'nan', # Should be 0
|
||||
'no_units': '2025-07-28 18:59:45:10|2025-07-28 19:34:19:5', # Should sum to 15
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def legend_test_dataframe():
|
||||
"""DataFrame specifically designed for testing legend functionality."""
|
||||
return pd.DataFrame({
|
||||
'date': ['2024-01-01', '2024-01-02', '2024-01-03'],
|
||||
'depression': [3, 2, 4],
|
||||
'anxiety': [2, 3, 1],
|
||||
'sleep': [4, 3, 5],
|
||||
'appetite': [3, 4, 2],
|
||||
# Medicine with consistent doses for average testing
|
||||
'bupropion': [1, 1, 1],
|
||||
'bupropion_doses': ['2024-01-01 08:00:00:100mg',
|
||||
'2024-01-02 08:00:00:200mg',
|
||||
'2024-01-03 08:00:00:150mg'], # Average: 150mg
|
||||
# Medicine with varying doses
|
||||
'propranolol': [1, 1, 0],
|
||||
'propranolol_doses': ['2024-01-01 12:00:00:10mg',
|
||||
'2024-01-02 12:00:00:20mg',
|
||||
''], # Average: 15mg (10+20)/2
|
||||
# Medicines without dose data
|
||||
'hydroxyzine': [0, 0, 0],
|
||||
'hydroxyzine_doses': ['', '', ''],
|
||||
'gabapentin': [0, 0, 0],
|
||||
'gabapentin_doses': ['', '', ''],
|
||||
'quetiapine': [0, 0, 0],
|
||||
'quetiapine_doses': ['', '', ''],
|
||||
'note': ['Test note 1', 'Test note 2', 'Test note 3']
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Tests for constants module.
|
||||
"""
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""Test cases for the constants module."""
|
||||
|
||||
def test_default_log_level(self):
|
||||
"""Test default LOG_LEVEL when not set in environment."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
# Re-import to get fresh values
|
||||
import importlib
|
||||
if 'constants' in sys.modules:
|
||||
importlib.reload(sys.modules['constants'])
|
||||
else:
|
||||
import src.constants
|
||||
|
||||
assert src.constants.LOG_LEVEL == "INFO"
|
||||
|
||||
def test_custom_log_level(self):
|
||||
"""Test custom LOG_LEVEL from environment."""
|
||||
with patch.dict(os.environ, {'LOG_LEVEL': 'debug'}, clear=True):
|
||||
import importlib
|
||||
if 'constants' in sys.modules:
|
||||
importlib.reload(sys.modules['constants'])
|
||||
else:
|
||||
import src.constants
|
||||
|
||||
assert src.constants.LOG_LEVEL == "DEBUG"
|
||||
|
||||
def test_default_log_path(self):
|
||||
"""Test default LOG_PATH when not set in environment."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
import importlib
|
||||
if 'constants' in sys.modules:
|
||||
importlib.reload(sys.modules['constants'])
|
||||
else:
|
||||
import src.constants
|
||||
|
||||
assert src.constants.LOG_PATH == "/tmp/logs/thechart"
|
||||
|
||||
def test_custom_log_path(self):
|
||||
"""Test custom LOG_PATH from environment."""
|
||||
with patch.dict(os.environ, {'LOG_PATH': '/custom/log/path'}, clear=True):
|
||||
import importlib
|
||||
if 'constants' in sys.modules:
|
||||
importlib.reload(sys.modules['constants'])
|
||||
else:
|
||||
import src.constants
|
||||
|
||||
assert src.constants.LOG_PATH == "/custom/log/path"
|
||||
|
||||
def test_default_log_clear(self):
|
||||
"""Test default LOG_CLEAR when not set in environment."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
import importlib
|
||||
if 'constants' in sys.modules:
|
||||
importlib.reload(sys.modules['constants'])
|
||||
else:
|
||||
import src.constants
|
||||
|
||||
assert src.constants.LOG_CLEAR == "False"
|
||||
|
||||
def test_custom_log_clear_true(self):
|
||||
"""Test LOG_CLEAR when set to true in environment."""
|
||||
with patch.dict(os.environ, {'LOG_CLEAR': 'true'}, clear=True):
|
||||
import importlib
|
||||
if 'constants' in sys.modules:
|
||||
importlib.reload(sys.modules['constants'])
|
||||
else:
|
||||
import src.constants
|
||||
|
||||
assert src.constants.LOG_CLEAR == "True"
|
||||
|
||||
def test_custom_log_clear_false(self):
|
||||
"""Test LOG_CLEAR when set to false in environment."""
|
||||
with patch.dict(os.environ, {'LOG_CLEAR': 'false'}, clear=True):
|
||||
import importlib
|
||||
if 'constants' in sys.modules:
|
||||
importlib.reload(sys.modules['constants'])
|
||||
else:
|
||||
import src.constants
|
||||
|
||||
assert src.constants.LOG_CLEAR == "False"
|
||||
|
||||
def test_log_level_case_insensitive(self):
|
||||
"""Test that LOG_LEVEL is converted to uppercase."""
|
||||
with patch.dict(os.environ, {'LOG_LEVEL': 'warning'}, clear=True):
|
||||
import importlib
|
||||
if 'constants' in sys.modules:
|
||||
importlib.reload(sys.modules['constants'])
|
||||
else:
|
||||
import src.constants
|
||||
|
||||
assert src.constants.LOG_LEVEL == "WARNING"
|
||||
|
||||
def test_dotenv_override(self):
|
||||
"""Test that dotenv override parameter is set to True."""
|
||||
# This is a structural test since dotenv is loaded during import
|
||||
with patch('constants.load_dotenv') as mock_load_dotenv:
|
||||
import importlib
|
||||
if 'constants' in sys.modules:
|
||||
importlib.reload(sys.modules['constants'])
|
||||
else:
|
||||
import src.constants
|
||||
|
||||
mock_load_dotenv.assert_called_once_with(override=True)
|
||||
|
||||
def test_all_constants_are_strings(self):
|
||||
"""Test that all constants are string type."""
|
||||
import src.constants
|
||||
|
||||
assert isinstance(src.constants.LOG_LEVEL, str)
|
||||
assert isinstance(src.constants.LOG_PATH, str)
|
||||
assert isinstance(src.constants.LOG_CLEAR, str)
|
||||
|
||||
def test_constants_not_empty(self):
|
||||
"""Test that constants are not empty strings."""
|
||||
import src.constants
|
||||
|
||||
assert src.constants.LOG_LEVEL != ""
|
||||
assert src.constants.LOG_PATH != ""
|
||||
assert src.constants.LOG_CLEAR != ""
|
||||
@@ -0,0 +1,303 @@
|
||||
"""
|
||||
Tests for the DataManager class.
|
||||
"""
|
||||
import os
|
||||
import csv
|
||||
from unittest.mock import patch
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||
|
||||
from src.data_manager import DataManager
|
||||
|
||||
|
||||
class TestDataManager:
|
||||
"""Test cases for the DataManager class."""
|
||||
|
||||
def test_init(self, temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager):
|
||||
"""Test DataManager initialization."""
|
||||
dm = DataManager(temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager)
|
||||
assert dm.filename == temp_csv_file
|
||||
assert dm.logger == mock_logger
|
||||
assert dm.medicine_manager == mock_medicine_manager
|
||||
assert os.path.exists(temp_csv_file)
|
||||
|
||||
def test_initialize_csv_creates_file_with_headers(self, temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager):
|
||||
"""Test that initialize_csv creates a file with proper headers."""
|
||||
# Remove the file if it exists
|
||||
if os.path.exists(temp_csv_file):
|
||||
os.unlink(temp_csv_file)
|
||||
|
||||
dm = DataManager(temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager)
|
||||
|
||||
# Check file exists and has correct headers
|
||||
assert os.path.exists(temp_csv_file)
|
||||
with open(temp_csv_file, 'r') as f:
|
||||
reader = csv.reader(f)
|
||||
headers = next(reader)
|
||||
expected_headers = [
|
||||
"date", "depression", "anxiety", "sleep", "appetite",
|
||||
"bupropion", "bupropion_doses", "hydroxyzine", "hydroxyzine_doses",
|
||||
"gabapentin", "gabapentin_doses", "propranolol", "propranolol_doses",
|
||||
"quetiapine", "quetiapine_doses", "note"
|
||||
]
|
||||
assert headers == expected_headers
|
||||
|
||||
def test_initialize_csv_does_not_overwrite_existing_file(self, temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager):
|
||||
"""Test that initialize_csv does not overwrite existing file."""
|
||||
# Write some data to the file first
|
||||
with open(temp_csv_file, 'w') as f:
|
||||
f.write("existing,data\n1,2\n")
|
||||
|
||||
dm = DataManager(temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager)
|
||||
|
||||
# Check that existing data is preserved
|
||||
with open(temp_csv_file, 'r') as f:
|
||||
content = f.read()
|
||||
assert "existing,data" in content
|
||||
|
||||
def test_load_data_empty_file(self, temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager):
|
||||
"""Test loading data from an empty file."""
|
||||
dm = DataManager(temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager)
|
||||
df = dm.load_data()
|
||||
assert df.empty
|
||||
|
||||
def test_load_data_nonexistent_file(self, mock_logger, mock_medicine_manager, mock_pathology_manager):
|
||||
"""Test loading data from a nonexistent file."""
|
||||
dm = DataManager("nonexistent.csv", mock_logger, mock_medicine_manager, mock_pathology_manager)
|
||||
df = dm.load_data()
|
||||
assert df.empty
|
||||
mock_logger.warning.assert_called()
|
||||
|
||||
def test_load_data_with_valid_data(self, temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager, sample_data):
|
||||
"""Test loading valid data from CSV file."""
|
||||
# Write sample data to file
|
||||
with open(temp_csv_file, 'w', newline='') as f:
|
||||
writer = csv.writer(f)
|
||||
# Write headers first
|
||||
writer.writerow([
|
||||
"date", "depression", "anxiety", "sleep", "appetite",
|
||||
"bupropion", "bupropion_doses", "hydroxyzine", "hydroxyzine_doses",
|
||||
"gabapentin", "gabapentin_doses", "propranolol", "propranolol_doses",
|
||||
"quetiapine", "quetiapine_doses", "note"
|
||||
])
|
||||
# Write sample data
|
||||
writer.writerows(sample_data)
|
||||
|
||||
dm = DataManager(temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager)
|
||||
df = dm.load_data()
|
||||
|
||||
assert not df.empty
|
||||
assert len(df) == 3
|
||||
assert list(df.columns) == [
|
||||
"date", "depression", "anxiety", "sleep", "appetite",
|
||||
"bupropion", "bupropion_doses", "hydroxyzine", "hydroxyzine_doses",
|
||||
"gabapentin", "gabapentin_doses", "propranolol", "propranolol_doses",
|
||||
"quetiapine", "quetiapine_doses", "note"
|
||||
]
|
||||
# Check data types
|
||||
assert df["depression"].dtype == int
|
||||
assert df["anxiety"].dtype == int
|
||||
assert df["note"].dtype == object
|
||||
|
||||
def test_load_data_sorted_by_date(self, temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager):
|
||||
"""Test that loaded data is sorted by date."""
|
||||
# Write data in random order
|
||||
unsorted_data = [
|
||||
["2024-01-03", 1, 1, 1, 1, 1, "", 1, "", 1, "", 1, "", 0, "", "third"],
|
||||
["2024-01-01", 2, 2, 2, 2, 2, "", 2, "", 2, "", 2, "", 1, "", "first"],
|
||||
["2024-01-02", 3, 3, 3, 3, 3, "", 3, "", 3, "", 3, "", 0, "", "second"],
|
||||
]
|
||||
|
||||
with open(temp_csv_file, 'w', newline='') as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow([
|
||||
"date", "depression", "anxiety", "sleep", "appetite",
|
||||
"bupropion", "bupropion_doses", "hydroxyzine", "hydroxyzine_doses",
|
||||
"gabapentin", "gabapentin_doses", "propranolol", "propranolol_doses",
|
||||
"quetiapine", "quetiapine_doses", "note"
|
||||
])
|
||||
writer.writerows(unsorted_data)
|
||||
|
||||
dm = DataManager(temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager)
|
||||
df = dm.load_data()
|
||||
|
||||
# Check that data is sorted by date
|
||||
assert df.iloc[0]["note"] == "first"
|
||||
assert df.iloc[1]["note"] == "second"
|
||||
assert df.iloc[2]["note"] == "third"
|
||||
|
||||
def test_add_entry_success(self, temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager):
|
||||
"""Test successfully adding an entry."""
|
||||
dm = DataManager(temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager)
|
||||
entry = ["2024-01-01", 3, 2, 4, 3, 1, "", 0, "", 2, "", 1, "", 0, "", "Test note"]
|
||||
|
||||
result = dm.add_entry(entry)
|
||||
assert result is True
|
||||
|
||||
# Verify entry was added
|
||||
df = dm.load_data()
|
||||
assert len(df) == 1
|
||||
assert df.iloc[0]["date"] == "2024-01-01"
|
||||
assert df.iloc[0]["note"] == "Test note"
|
||||
|
||||
def test_add_entry_duplicate_date(self, temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager, sample_data):
|
||||
"""Test adding entry with duplicate date."""
|
||||
# Add initial data
|
||||
with open(temp_csv_file, 'w', newline='') as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow([
|
||||
"date", "depression", "anxiety", "sleep", "appetite",
|
||||
"bupropion", "bupropion_doses", "hydroxyzine", "hydroxyzine_doses",
|
||||
"gabapentin", "gabapentin_doses", "propranolol", "propranolol_doses",
|
||||
"quetiapine", "quetiapine_doses", "note"
|
||||
])
|
||||
writer.writerows(sample_data)
|
||||
|
||||
dm = DataManager(temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager)
|
||||
# Try to add entry with existing date
|
||||
duplicate_entry = ["2024-01-01", 5, 5, 5, 5, 1, "", 1, "", 1, "", 1, "", 0, "", "Duplicate"]
|
||||
|
||||
result = dm.add_entry(duplicate_entry)
|
||||
assert result is False
|
||||
mock_logger.warning.assert_called_with("Entry with date 2024-01-01 already exists.")
|
||||
|
||||
def test_update_entry_success(self, temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager, sample_data):
|
||||
"""Test successfully updating an entry."""
|
||||
# Add initial data
|
||||
with open(temp_csv_file, 'w', newline='') as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow([
|
||||
"date", "depression", "anxiety", "sleep", "appetite",
|
||||
"bupropion", "bupropion_doses", "hydroxyzine", "hydroxyzine_doses",
|
||||
"gabapentin", "gabapentin_doses", "propranolol", "propranolol_doses",
|
||||
"quetiapine", "quetiapine_doses", "note"
|
||||
])
|
||||
writer.writerows(sample_data)
|
||||
|
||||
dm = DataManager(temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager)
|
||||
updated_values = ["2024-01-01", 5, 5, 5, 5, 2, "", 2, "", 2, "", 2, "", 1, "", "Updated note"]
|
||||
|
||||
result = dm.update_entry("2024-01-01", updated_values)
|
||||
assert result is True
|
||||
|
||||
# Verify entry was updated
|
||||
df = dm.load_data()
|
||||
updated_row = df[df["date"] == "2024-01-01"].iloc[0]
|
||||
assert updated_row["depression"] == 5
|
||||
assert updated_row["note"] == "Updated note"
|
||||
|
||||
def test_update_entry_change_date(self, temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager, sample_data):
|
||||
"""Test updating an entry with a date change."""
|
||||
# Add initial data
|
||||
with open(temp_csv_file, 'w', newline='') as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow([
|
||||
"date", "depression", "anxiety", "sleep", "appetite",
|
||||
"bupropion", "bupropion_doses", "hydroxyzine", "hydroxyzine_doses",
|
||||
"gabapentin", "gabapentin_doses", "propranolol", "propranolol_doses",
|
||||
"quetiapine", "quetiapine_doses", "note"
|
||||
])
|
||||
writer.writerows(sample_data)
|
||||
|
||||
dm = DataManager(temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager)
|
||||
updated_values = ["2024-01-05", 5, 5, 5, 5, 2, "", 2, "", 2, "", 2, "", 1, "", "Updated note"]
|
||||
|
||||
result = dm.update_entry("2024-01-01", updated_values)
|
||||
assert result is True
|
||||
|
||||
# Verify old date is gone and new date exists
|
||||
df = dm.load_data()
|
||||
assert not any(df["date"] == "2024-01-01")
|
||||
assert any(df["date"] == "2024-01-05")
|
||||
|
||||
def test_update_entry_duplicate_date(self, temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager, sample_data):
|
||||
"""Test updating entry to a date that already exists."""
|
||||
# Add initial data
|
||||
with open(temp_csv_file, 'w', newline='') as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow([
|
||||
"date", "depression", "anxiety", "sleep", "appetite",
|
||||
"bupropion", "bupropion_doses", "hydroxyzine", "hydroxyzine_doses",
|
||||
"gabapentin", "gabapentin_doses", "propranolol", "propranolol_doses",
|
||||
"quetiapine", "quetiapine_doses", "note"
|
||||
])
|
||||
writer.writerows(sample_data)
|
||||
|
||||
dm = DataManager(temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager)
|
||||
# Try to change date to one that already exists
|
||||
updated_values = ["2024-01-02", 5, 5, 5, 5, 2, "", 2, "", 2, "", 2, "", 1, "", "Updated note"]
|
||||
|
||||
result = dm.update_entry("2024-01-01", updated_values)
|
||||
assert result is False
|
||||
mock_logger.warning.assert_called_with(
|
||||
"Cannot update: entry with date 2024-01-02 already exists."
|
||||
)
|
||||
|
||||
def test_delete_entry_success(self, temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager, sample_data):
|
||||
"""Test successfully deleting an entry."""
|
||||
# Add initial data
|
||||
with open(temp_csv_file, 'w', newline='') as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow([
|
||||
"date", "depression", "anxiety", "sleep", "appetite",
|
||||
"bupropion", "bupropion_doses", "hydroxyzine", "hydroxyzine_doses",
|
||||
"gabapentin", "gabapentin_doses", "propranolol", "propranolol_doses",
|
||||
"quetiapine", "quetiapine_doses", "note"
|
||||
])
|
||||
writer.writerows(sample_data)
|
||||
|
||||
dm = DataManager(temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager)
|
||||
|
||||
result = dm.delete_entry("2024-01-02")
|
||||
assert result is True
|
||||
|
||||
# Verify entry was deleted
|
||||
df = dm.load_data()
|
||||
assert len(df) == 2
|
||||
assert not any(df["date"] == "2024-01-02")
|
||||
|
||||
def test_delete_entry_nonexistent(self, temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager, sample_data):
|
||||
"""Test deleting a nonexistent entry."""
|
||||
# Add initial data
|
||||
with open(temp_csv_file, 'w', newline='') as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow([
|
||||
"date", "depression", "anxiety", "sleep", "appetite",
|
||||
"bupropion", "bupropion_doses", "hydroxyzine", "hydroxyzine_doses",
|
||||
"gabapentin", "gabapentin_doses", "propranolol", "propranolol_doses",
|
||||
"quetiapine", "quetiapine_doses", "note"
|
||||
])
|
||||
writer.writerows(sample_data)
|
||||
|
||||
dm = DataManager(temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager)
|
||||
|
||||
result = dm.delete_entry("2024-01-10")
|
||||
assert result is True # Should return True even if no matching entry
|
||||
|
||||
# Verify no data was lost
|
||||
df = dm.load_data()
|
||||
assert len(df) == 3
|
||||
|
||||
@patch('pandas.read_csv')
|
||||
def test_load_data_exception_handling(self, mock_read_csv, temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager):
|
||||
"""Test exception handling in load_data."""
|
||||
mock_read_csv.side_effect = Exception("Test error")
|
||||
|
||||
dm = DataManager(temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager)
|
||||
df = dm.load_data()
|
||||
|
||||
assert df.empty
|
||||
mock_logger.error.assert_called_with("Error loading data: Test error")
|
||||
|
||||
@patch('builtins.open')
|
||||
def test_add_entry_exception_handling(self, mock_open, temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager):
|
||||
"""Test exception handling in add_entry."""
|
||||
mock_open.side_effect = Exception("Test error")
|
||||
|
||||
dm = DataManager(temp_csv_file, mock_logger, mock_medicine_manager, mock_pathology_manager)
|
||||
entry = ["2024-01-01", 3, 2, 4, 3, 1, 0, 2, 1, "Test note"]
|
||||
|
||||
result = dm.add_entry(entry)
|
||||
assert result is False
|
||||
mock_logger.error.assert_called_with("Error adding entry: Test error")
|
||||
@@ -0,0 +1,50 @@
|
||||
import pytest
|
||||
import tkinter as tk
|
||||
from src.ui_manager import UIManager
|
||||
|
||||
@pytest.fixture
|
||||
def root_window():
|
||||
root = tk.Tk()
|
||||
yield root
|
||||
root.destroy()
|
||||
|
||||
@pytest.fixture
|
||||
def ui_manager(root_window):
|
||||
class DummyLogger:
|
||||
def debug(self, *a, **k): pass
|
||||
def warning(self, *a, **k): pass
|
||||
def error(self, *a, **k): pass
|
||||
return UIManager(root_window, DummyLogger())
|
||||
|
||||
def test_parse_dose_history_for_saving_bullet_and_delete(ui_manager):
|
||||
# Simulate user editing: add, delete, and custom lines
|
||||
date_str = "07/30/2025"
|
||||
# User deletes one line, adds a custom one
|
||||
text = """
|
||||
• 09:00 AM - 150mg
|
||||
• 06:00 PM - 150mg
|
||||
Custom note
|
||||
""".strip()
|
||||
result = ui_manager._parse_dose_history_for_saving(text, date_str)
|
||||
# Should parse both bullets and keep the custom line
|
||||
assert "2025-07-30 09:00:00:150mg" in result
|
||||
assert "2025-07-30 18:00:00:150mg" in result
|
||||
assert "Custom note" in result
|
||||
# If user deletes all, should return empty string
|
||||
assert ui_manager._parse_dose_history_for_saving("", date_str) == ""
|
||||
assert ui_manager._parse_dose_history_for_saving("No doses recorded today", date_str) == ""
|
||||
|
||||
def test_parse_dose_history_for_saving_simple_time(ui_manager):
|
||||
date_str = "07/30/2025"
|
||||
text = "09:00 150mg\n18:00 150mg"
|
||||
result = ui_manager._parse_dose_history_for_saving(text, date_str)
|
||||
assert "2025-07-30 09:00:00:150mg" in result
|
||||
assert "2025-07-30 18:00:00:150mg" in result
|
||||
|
||||
def test_parse_dose_history_for_saving_mixed(ui_manager):
|
||||
date_str = "07/30/2025"
|
||||
text = "• 09:00 AM - 150mg\n18:00 150mg\nJust a note"
|
||||
result = ui_manager._parse_dose_history_for_saving(text, date_str)
|
||||
assert "2025-07-30 09:00:00:150mg" in result
|
||||
assert "2025-07-30 18:00:00:150mg" in result
|
||||
assert "Just a note" in result
|
||||
@@ -0,0 +1,788 @@
|
||||
"""
|
||||
Tests for the GraphManager class.
|
||||
"""
|
||||
import os
|
||||
import pytest
|
||||
import pandas as pd
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||
|
||||
from src.graph_manager import GraphManager
|
||||
|
||||
|
||||
class TestGraphManager:
|
||||
"""Test cases for the GraphManager class."""
|
||||
|
||||
@pytest.fixture
|
||||
def root_window(self):
|
||||
"""Create a root window for testing."""
|
||||
root = tk.Tk()
|
||||
yield root
|
||||
root.destroy()
|
||||
|
||||
@pytest.fixture
|
||||
def parent_frame(self, root_window):
|
||||
"""Create a parent frame for testing."""
|
||||
frame = ttk.LabelFrame(root_window, text="Test Frame")
|
||||
frame.pack()
|
||||
return frame
|
||||
|
||||
def test_init(self, parent_frame):
|
||||
"""Test GraphManager initialization."""
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
assert gm.parent_frame == parent_frame
|
||||
assert isinstance(gm.toggle_vars, dict)
|
||||
|
||||
# Check symptom toggles
|
||||
assert "depression" in gm.toggle_vars
|
||||
assert "anxiety" in gm.toggle_vars
|
||||
assert "sleep" in gm.toggle_vars
|
||||
assert "appetite" in gm.toggle_vars
|
||||
|
||||
# Check medicine toggles
|
||||
assert "bupropion" in gm.toggle_vars
|
||||
assert "hydroxyzine" in gm.toggle_vars
|
||||
assert "gabapentin" in gm.toggle_vars
|
||||
assert "propranolol" in gm.toggle_vars
|
||||
assert "quetiapine" in gm.toggle_vars
|
||||
|
||||
# Check that symptom toggles are initially True
|
||||
for symptom in ["depression", "anxiety", "sleep", "appetite"]:
|
||||
assert gm.toggle_vars[symptom].get() is True
|
||||
|
||||
# Check that some medicine toggles are True by default
|
||||
assert gm.toggle_vars["bupropion"].get() is True
|
||||
assert gm.toggle_vars["propranolol"].get() is True
|
||||
|
||||
# Check that some medicine toggles are False by default
|
||||
assert gm.toggle_vars["hydroxyzine"].get() is False
|
||||
assert gm.toggle_vars["gabapentin"].get() is False
|
||||
assert gm.toggle_vars["quetiapine"].get() is False
|
||||
|
||||
def test_toggle_controls_creation(self, parent_frame):
|
||||
"""Test that toggle controls are created properly."""
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
# Check that control frame exists
|
||||
assert hasattr(gm, 'control_frame')
|
||||
assert isinstance(gm.control_frame, ttk.Frame)
|
||||
|
||||
# Check that all toggle variables exist
|
||||
expected_toggles = ["depression", "anxiety", "sleep", "appetite",
|
||||
"bupropion", "hydroxyzine", "gabapentin", "propranolol", "quetiapine"]
|
||||
for toggle in expected_toggles:
|
||||
assert toggle in gm.toggle_vars
|
||||
assert isinstance(gm.toggle_vars[toggle], tk.BooleanVar)
|
||||
|
||||
def test_graph_frame_creation(self, parent_frame):
|
||||
"""Test that graph frame is created properly."""
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
assert hasattr(gm, 'graph_frame')
|
||||
assert isinstance(gm.graph_frame, ttk.Frame)
|
||||
|
||||
@patch('matplotlib.pyplot.subplots')
|
||||
def test_matplotlib_initialization(self, mock_subplots, parent_frame):
|
||||
"""Test matplotlib figure and canvas initialization."""
|
||||
mock_fig = Mock()
|
||||
mock_ax = Mock()
|
||||
mock_subplots.return_value = (mock_fig, mock_ax)
|
||||
|
||||
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
|
||||
mock_canvas = Mock()
|
||||
mock_canvas_class.return_value = mock_canvas
|
||||
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
assert gm.fig == mock_fig
|
||||
assert gm.ax == mock_ax
|
||||
assert gm.canvas == mock_canvas
|
||||
mock_canvas_class.assert_called_once_with(figure=mock_fig, master=gm.graph_frame)
|
||||
|
||||
def test_update_graph_empty_dataframe(self, parent_frame):
|
||||
"""Test updating graph with empty DataFrame."""
|
||||
with patch('matplotlib.pyplot.subplots') as mock_subplots:
|
||||
mock_fig = Mock()
|
||||
mock_ax = Mock()
|
||||
mock_subplots.return_value = (mock_fig, mock_ax)
|
||||
|
||||
with patch('graph_manager.FigureCanvasTkAgg'):
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
# Test with empty DataFrame
|
||||
empty_df = pd.DataFrame()
|
||||
gm.update_graph(empty_df)
|
||||
|
||||
# Verify ax.clear() was called
|
||||
mock_ax.clear.assert_called()
|
||||
|
||||
def test_update_graph_with_data(self, parent_frame, sample_dataframe):
|
||||
"""Test updating graph with valid data."""
|
||||
with patch('matplotlib.pyplot.subplots') as mock_subplots:
|
||||
mock_fig = Mock()
|
||||
mock_ax = Mock()
|
||||
mock_subplots.return_value = (mock_fig, mock_ax)
|
||||
|
||||
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
|
||||
mock_canvas = Mock()
|
||||
mock_canvas_class.return_value = mock_canvas
|
||||
|
||||
gm = GraphManager(parent_frame)
|
||||
gm.update_graph(sample_dataframe)
|
||||
|
||||
# Verify methods were called
|
||||
mock_ax.clear.assert_called()
|
||||
mock_canvas.draw.assert_called()
|
||||
|
||||
def test_toggle_functionality(self, parent_frame, sample_dataframe):
|
||||
"""Test that toggle variables affect graph display."""
|
||||
with patch('matplotlib.pyplot.subplots') as mock_subplots:
|
||||
mock_fig = Mock()
|
||||
mock_ax = Mock()
|
||||
mock_subplots.return_value = (mock_fig, mock_ax)
|
||||
|
||||
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
|
||||
mock_canvas = Mock()
|
||||
mock_canvas_class.return_value = mock_canvas
|
||||
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
# Turn off depression toggle
|
||||
gm.toggle_vars["depression"].set(False)
|
||||
gm.update_graph(sample_dataframe)
|
||||
|
||||
# The graph should still update (specific plotting logic would need more detailed testing)
|
||||
mock_ax.clear.assert_called()
|
||||
mock_canvas.draw.assert_called()
|
||||
|
||||
def test_close_method(self, parent_frame):
|
||||
"""Test the close method."""
|
||||
with patch('matplotlib.pyplot.subplots') as mock_subplots:
|
||||
mock_fig = Mock()
|
||||
mock_ax = Mock()
|
||||
mock_subplots.return_value = (mock_fig, mock_ax)
|
||||
|
||||
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
|
||||
mock_canvas = Mock()
|
||||
mock_canvas_class.return_value = mock_canvas
|
||||
|
||||
with patch('matplotlib.pyplot.close') as mock_plt_close:
|
||||
gm = GraphManager(parent_frame)
|
||||
gm.close()
|
||||
|
||||
mock_plt_close.assert_called_once_with(mock_fig)
|
||||
|
||||
def test_date_parsing_in_update_graph(self, parent_frame):
|
||||
"""Test that date parsing works correctly in update_graph."""
|
||||
# Create a DataFrame with date strings
|
||||
df_with_dates = pd.DataFrame({
|
||||
'date': ['2024-01-01', '2024-01-02', '2024-01-03'],
|
||||
'depression': [3, 2, 4],
|
||||
'anxiety': [2, 3, 1],
|
||||
'sleep': [4, 3, 5],
|
||||
'appetite': [3, 4, 2],
|
||||
'bupropion': [1, 1, 0],
|
||||
'hydroxyzine': [0, 1, 0],
|
||||
'gabapentin': [2, 2, 1],
|
||||
'propranolol': [1, 0, 1],
|
||||
'note': ['Test note 1', 'Test note 2', '']
|
||||
})
|
||||
|
||||
with patch('matplotlib.pyplot.subplots') as mock_subplots:
|
||||
mock_fig = Mock()
|
||||
mock_ax = Mock()
|
||||
mock_subplots.return_value = (mock_fig, mock_ax)
|
||||
|
||||
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
|
||||
mock_canvas = Mock()
|
||||
mock_canvas_class.return_value = mock_canvas
|
||||
|
||||
with patch('pandas.to_datetime') as mock_to_datetime:
|
||||
gm = GraphManager(parent_frame)
|
||||
gm.update_graph(df_with_dates)
|
||||
|
||||
# Verify pandas.to_datetime was called
|
||||
mock_to_datetime.assert_called()
|
||||
|
||||
@patch('matplotlib.pyplot.subplots')
|
||||
def test_exception_handling_in_update_graph(self, mock_subplots, parent_frame, sample_dataframe):
|
||||
"""Test exception handling in update_graph method."""
|
||||
mock_fig = Mock()
|
||||
mock_ax = Mock()
|
||||
mock_ax.plot.side_effect = Exception("Plot error")
|
||||
mock_subplots.return_value = (mock_fig, mock_ax)
|
||||
|
||||
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
|
||||
mock_canvas = Mock()
|
||||
mock_canvas_class.return_value = mock_canvas
|
||||
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
# This should not raise an exception, but handle it gracefully
|
||||
try:
|
||||
gm.update_graph(sample_dataframe)
|
||||
except Exception as e:
|
||||
pytest.fail(f"update_graph should handle exceptions gracefully, but raised: {e}")
|
||||
|
||||
def test_grid_configuration(self, parent_frame):
|
||||
"""Test that grid configuration is set up correctly."""
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
# The parent frame should have grid configuration
|
||||
# Note: In a real test, you might need to check grid_info() or similar
|
||||
# This is a basic structure test
|
||||
assert hasattr(gm, 'parent_frame')
|
||||
assert hasattr(gm, 'control_frame')
|
||||
assert hasattr(gm, 'graph_frame')
|
||||
|
||||
def test_canvas_widget_packing(self, parent_frame):
|
||||
"""Test that canvas widget is properly packed."""
|
||||
with patch('matplotlib.pyplot.subplots') as mock_subplots:
|
||||
mock_fig = Mock()
|
||||
mock_ax = Mock()
|
||||
mock_subplots.return_value = (mock_fig, mock_ax)
|
||||
|
||||
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
|
||||
mock_canvas = Mock()
|
||||
mock_canvas.get_tk_widget.return_value = Mock()
|
||||
mock_canvas_class.return_value = mock_canvas
|
||||
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
# Verify get_tk_widget was called (for packing)
|
||||
mock_canvas.get_tk_widget.assert_called()
|
||||
|
||||
def test_multiple_toggle_combinations(self, parent_frame, sample_dataframe):
|
||||
"""Test various combinations of toggle states."""
|
||||
with patch('matplotlib.pyplot.subplots') as mock_subplots:
|
||||
mock_fig = Mock()
|
||||
mock_ax = Mock()
|
||||
mock_subplots.return_value = (mock_fig, mock_ax)
|
||||
|
||||
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
|
||||
mock_canvas = Mock()
|
||||
mock_canvas_class.return_value = mock_canvas
|
||||
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
# Test all toggles off
|
||||
for toggle in gm.toggle_vars.values():
|
||||
toggle.set(False)
|
||||
gm.update_graph(sample_dataframe)
|
||||
|
||||
# Test mixed toggles
|
||||
gm.toggle_vars["depression"].set(True)
|
||||
gm.toggle_vars["anxiety"].set(False)
|
||||
gm.update_graph(sample_dataframe)
|
||||
|
||||
# Verify the graph was updated in each case
|
||||
assert mock_ax.clear.call_count >= 2
|
||||
assert mock_canvas.draw.call_count >= 2
|
||||
|
||||
def test_calculate_daily_dose_empty_input(self, parent_frame):
|
||||
"""Test dose calculation with empty/invalid input."""
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
# Test empty string
|
||||
assert gm._calculate_daily_dose("") == 0.0
|
||||
|
||||
# Test NaN values
|
||||
assert gm._calculate_daily_dose("nan") == 0.0
|
||||
assert gm._calculate_daily_dose("NaN") == 0.0
|
||||
|
||||
# Test None (will be converted to string)
|
||||
assert gm._calculate_daily_dose(None) == 0.0
|
||||
|
||||
def test_calculate_daily_dose_standard_format(self, parent_frame):
|
||||
"""Test dose calculation with standard timestamp:dose format."""
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
# Single dose
|
||||
dose_str = "2025-07-28 18:59:45:150mg"
|
||||
assert gm._calculate_daily_dose(dose_str) == 150.0
|
||||
|
||||
# Multiple doses
|
||||
dose_str = "2025-07-28 18:59:45:150mg|2025-07-28 19:34:19:75mg"
|
||||
assert gm._calculate_daily_dose(dose_str) == 225.0
|
||||
|
||||
# Doses without units
|
||||
dose_str = "2025-07-28 18:59:45:10|2025-07-28 19:34:19:5"
|
||||
assert gm._calculate_daily_dose(dose_str) == 15.0
|
||||
|
||||
def test_calculate_daily_dose_with_symbols(self, parent_frame):
|
||||
"""Test dose calculation with bullet symbols."""
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
# With bullet symbols
|
||||
dose_str = "• • • • 2025-07-30 07:50:00:300"
|
||||
assert gm._calculate_daily_dose(dose_str) == 300.0
|
||||
|
||||
# Multiple bullets
|
||||
dose_str = "• 2025-07-30 22:50:00:10|• 2025-07-30 23:50:00:5"
|
||||
assert gm._calculate_daily_dose(dose_str) == 15.0
|
||||
|
||||
def test_calculate_daily_dose_no_timestamp(self, parent_frame):
|
||||
"""Test dose calculation without timestamp."""
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
# Just dose value
|
||||
dose_str = "150mg"
|
||||
assert gm._calculate_daily_dose(dose_str) == 150.0
|
||||
|
||||
# Multiple values without timestamp
|
||||
dose_str = "100|50"
|
||||
assert gm._calculate_daily_dose(dose_str) == 150.0
|
||||
|
||||
def test_calculate_daily_dose_decimal_values(self, parent_frame):
|
||||
"""Test dose calculation with decimal values."""
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
# Decimal dose
|
||||
dose_str = "2025-07-28 18:59:45:12.5mg"
|
||||
assert gm._calculate_daily_dose(dose_str) == 12.5
|
||||
|
||||
# Multiple decimal doses
|
||||
dose_str = "2025-07-28 18:59:45:12.5mg|2025-07-28 19:34:19:7.5mg"
|
||||
assert gm._calculate_daily_dose(dose_str) == 20.0
|
||||
|
||||
def test_medicine_dose_plotting(self, parent_frame):
|
||||
"""Test that medicine doses are plotted correctly."""
|
||||
# Create a DataFrame with dose data
|
||||
df_with_doses = pd.DataFrame({
|
||||
'date': ['2024-01-01', '2024-01-02', '2024-01-03'],
|
||||
'depression': [3, 2, 4],
|
||||
'anxiety': [2, 3, 1],
|
||||
'sleep': [4, 3, 5],
|
||||
'appetite': [3, 4, 2],
|
||||
'bupropion': [1, 1, 0],
|
||||
'bupropion_doses': ['2024-01-01 08:00:00:150mg', '2024-01-02 08:00:00:300mg', ''],
|
||||
'hydroxyzine': [0, 1, 0],
|
||||
'hydroxyzine_doses': ['', '2024-01-02 20:00:00:25mg', ''],
|
||||
'gabapentin': [0, 0, 0],
|
||||
'gabapentin_doses': ['', '', ''],
|
||||
'propranolol': [1, 0, 1],
|
||||
'propranolol_doses': ['2024-01-01 12:00:00:10mg', '', '2024-01-03 12:00:00:20mg'],
|
||||
'quetiapine': [0, 0, 0],
|
||||
'quetiapine_doses': ['', '', ''],
|
||||
})
|
||||
|
||||
with patch('matplotlib.pyplot.subplots') as mock_subplots:
|
||||
mock_fig = Mock()
|
||||
mock_ax = Mock()
|
||||
mock_subplots.return_value = (mock_fig, mock_ax)
|
||||
|
||||
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
|
||||
mock_canvas = Mock()
|
||||
mock_canvas_class.return_value = mock_canvas
|
||||
|
||||
gm = GraphManager(parent_frame)
|
||||
gm.update_graph(df_with_doses)
|
||||
|
||||
# Verify that bar plots were called (for medicines with doses)
|
||||
mock_ax.bar.assert_called()
|
||||
|
||||
# Verify canvas was redrawn
|
||||
mock_canvas.draw.assert_called()
|
||||
|
||||
def test_medicine_toggle_functionality(self, parent_frame):
|
||||
"""Test that medicine toggles affect dose display."""
|
||||
df_with_doses = pd.DataFrame({
|
||||
'date': ['2024-01-01'],
|
||||
'depression': [3],
|
||||
'anxiety': [2],
|
||||
'sleep': [4],
|
||||
'appetite': [3],
|
||||
'bupropion': [1],
|
||||
'bupropion_doses': ['2024-01-01 08:00:00:150mg'],
|
||||
'hydroxyzine': [0],
|
||||
'hydroxyzine_doses': [''],
|
||||
'gabapentin': [0],
|
||||
'gabapentin_doses': [''],
|
||||
'propranolol': [1],
|
||||
'propranolol_doses': ['2024-01-01 12:00:00:10mg'],
|
||||
'quetiapine': [0],
|
||||
'quetiapine_doses': [''],
|
||||
})
|
||||
|
||||
with patch('matplotlib.pyplot.subplots') as mock_subplots:
|
||||
mock_fig = Mock()
|
||||
mock_ax = Mock()
|
||||
mock_subplots.return_value = (mock_fig, mock_ax)
|
||||
|
||||
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
|
||||
mock_canvas = Mock()
|
||||
mock_canvas_class.return_value = mock_canvas
|
||||
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
# Turn off bupropion toggle
|
||||
gm.toggle_vars["bupropion"].set(False)
|
||||
gm.update_graph(df_with_doses)
|
||||
|
||||
# Turn on hydroxyzine toggle (though it has no doses)
|
||||
gm.toggle_vars["hydroxyzine"].set(True)
|
||||
gm.update_graph(df_with_doses)
|
||||
|
||||
# Verify the graph was updated
|
||||
assert mock_ax.clear.call_count >= 2
|
||||
assert mock_canvas.draw.call_count >= 2
|
||||
|
||||
def test_enhanced_legend_functionality(self, parent_frame):
|
||||
"""Test that the enhanced legend displays correctly with medicine data."""
|
||||
df_with_doses = pd.DataFrame({
|
||||
'date': ['2024-01-01', '2024-01-02'],
|
||||
'depression': [3, 2],
|
||||
'anxiety': [2, 3],
|
||||
'sleep': [4, 3],
|
||||
'appetite': [3, 4],
|
||||
'bupropion': [1, 1],
|
||||
'bupropion_doses': ['2024-01-01 08:00:00:150mg', '2024-01-02 08:00:00:200mg'],
|
||||
'hydroxyzine': [0, 0],
|
||||
'hydroxyzine_doses': ['', ''],
|
||||
'gabapentin': [0, 0],
|
||||
'gabapentin_doses': ['', ''],
|
||||
'propranolol': [1, 1],
|
||||
'propranolol_doses': ['2024-01-01 12:00:00:10mg', '2024-01-02 12:00:00:15mg'],
|
||||
'quetiapine': [0, 0],
|
||||
'quetiapine_doses': ['', ''],
|
||||
})
|
||||
|
||||
with patch('matplotlib.pyplot.subplots') as mock_subplots:
|
||||
mock_fig = Mock()
|
||||
mock_ax = Mock()
|
||||
mock_ax.get_legend_handles_labels.return_value = ([], [])
|
||||
mock_subplots.return_value = (mock_fig, mock_ax)
|
||||
|
||||
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
|
||||
mock_canvas = Mock()
|
||||
mock_canvas_class.return_value = mock_canvas
|
||||
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
# Enable some medicine toggles
|
||||
gm.toggle_vars["bupropion"].set(True)
|
||||
gm.toggle_vars["propranolol"].set(True)
|
||||
gm.toggle_vars["hydroxyzine"].set(True) # No dose data
|
||||
|
||||
gm.update_graph(df_with_doses)
|
||||
|
||||
# Verify that legend is called with enhanced parameters
|
||||
mock_ax.legend.assert_called()
|
||||
legend_call = mock_ax.legend.call_args
|
||||
|
||||
# Check that enhanced legend parameters are used
|
||||
assert 'ncol' in legend_call.kwargs
|
||||
assert legend_call.kwargs['ncol'] == 2
|
||||
assert 'fontsize' in legend_call.kwargs
|
||||
assert legend_call.kwargs['fontsize'] == 'small'
|
||||
assert 'frameon' in legend_call.kwargs
|
||||
assert legend_call.kwargs['frameon'] is True
|
||||
|
||||
def test_legend_with_medicines_without_data(self, parent_frame):
|
||||
"""Test that medicines without dose data are properly tracked in legend."""
|
||||
df_with_partial_doses = pd.DataFrame({
|
||||
'date': ['2024-01-01'],
|
||||
'depression': [3],
|
||||
'anxiety': [2],
|
||||
'sleep': [4],
|
||||
'appetite': [3],
|
||||
'bupropion': [1],
|
||||
'bupropion_doses': ['2024-01-01 08:00:00:150mg'],
|
||||
'hydroxyzine': [0],
|
||||
'hydroxyzine_doses': [''], # No dose data
|
||||
'gabapentin': [0],
|
||||
'gabapentin_doses': [''], # No dose data
|
||||
'propranolol': [0],
|
||||
'propranolol_doses': [''],
|
||||
'quetiapine': [0],
|
||||
'quetiapine_doses': [''],
|
||||
})
|
||||
|
||||
with patch('matplotlib.pyplot.subplots') as mock_subplots:
|
||||
mock_fig = Mock()
|
||||
mock_ax = Mock()
|
||||
|
||||
# Mock the legend handles and labels
|
||||
original_handles = [Mock()]
|
||||
original_labels = ['Bupropion (avg: 150.0mg)']
|
||||
mock_ax.get_legend_handles_labels.return_value = (original_handles, original_labels)
|
||||
|
||||
mock_subplots.return_value = (mock_fig, mock_ax)
|
||||
|
||||
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
|
||||
mock_canvas = Mock()
|
||||
mock_canvas_class.return_value = mock_canvas
|
||||
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
# Enable medicines with and without data
|
||||
gm.toggle_vars["bupropion"].set(True) # Has data
|
||||
gm.toggle_vars["hydroxyzine"].set(True) # No data
|
||||
gm.toggle_vars["gabapentin"].set(True) # No data
|
||||
|
||||
gm.update_graph(df_with_partial_doses)
|
||||
|
||||
# Verify legend was called
|
||||
mock_ax.legend.assert_called()
|
||||
|
||||
# Check that the legend call includes additional handles/labels
|
||||
legend_call = mock_ax.legend.call_args
|
||||
handles, labels = legend_call.args[:2]
|
||||
|
||||
# Should have more labels than just the original ones
|
||||
assert len(labels) > len(original_labels)
|
||||
|
||||
def test_average_dose_calculation_in_legend(self, parent_frame):
|
||||
"""Test that average doses are correctly calculated and displayed in legend."""
|
||||
df_with_varying_doses = pd.DataFrame({
|
||||
'date': ['2024-01-01', '2024-01-02', '2024-01-03'],
|
||||
'depression': [3, 2, 4],
|
||||
'anxiety': [2, 3, 1],
|
||||
'sleep': [4, 3, 5],
|
||||
'appetite': [3, 4, 2],
|
||||
'bupropion': [1, 1, 1],
|
||||
'bupropion_doses': ['2024-01-01 08:00:00:100mg',
|
||||
'2024-01-02 08:00:00:200mg',
|
||||
'2024-01-03 08:00:00:150mg'], # Average should be 150mg
|
||||
'propranolol': [1, 1, 0],
|
||||
'propranolol_doses': ['2024-01-01 12:00:00:10mg',
|
||||
'2024-01-02 12:00:00:20mg',
|
||||
''], # Average should be 15mg
|
||||
'hydroxyzine': [0, 0, 0],
|
||||
'hydroxyzine_doses': ['', '', ''],
|
||||
'gabapentin': [0, 0, 0],
|
||||
'gabapentin_doses': ['', '', ''],
|
||||
'quetiapine': [0, 0, 0],
|
||||
'quetiapine_doses': ['', '', ''],
|
||||
})
|
||||
|
||||
with patch('matplotlib.pyplot.subplots') as mock_subplots:
|
||||
mock_fig = Mock()
|
||||
mock_ax = Mock()
|
||||
mock_subplots.return_value = (mock_fig, mock_ax)
|
||||
|
||||
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
|
||||
mock_canvas = Mock()
|
||||
mock_canvas_class.return_value = mock_canvas
|
||||
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
# Test the average calculation directly
|
||||
bup_avg = gm._calculate_daily_dose('2024-01-01 08:00:00:100mg')
|
||||
assert bup_avg == 100.0
|
||||
|
||||
prop_avg = gm._calculate_daily_dose('2024-01-01 12:00:00:10mg')
|
||||
assert prop_avg == 10.0
|
||||
|
||||
# Test with full data
|
||||
gm.toggle_vars["bupropion"].set(True)
|
||||
gm.toggle_vars["propranolol"].set(True)
|
||||
gm.update_graph(df_with_varying_doses)
|
||||
|
||||
# Verify that bars were plotted (indicating dose data was processed)
|
||||
mock_ax.bar.assert_called()
|
||||
|
||||
def test_legend_positioning_and_styling(self, parent_frame):
|
||||
"""Test that legend positioning and styling parameters are correctly applied."""
|
||||
df_simple = pd.DataFrame({
|
||||
'date': ['2024-01-01'],
|
||||
'depression': [3],
|
||||
'anxiety': [2],
|
||||
'sleep': [4],
|
||||
'appetite': [3],
|
||||
'bupropion': [1],
|
||||
'bupropion_doses': ['2024-01-01 08:00:00:150mg'],
|
||||
'hydroxyzine': [0],
|
||||
'hydroxyzine_doses': [''],
|
||||
'gabapentin': [0],
|
||||
'gabapentin_doses': [''],
|
||||
'propranolol': [0],
|
||||
'propranolol_doses': [''],
|
||||
'quetiapine': [0],
|
||||
'quetiapine_doses': [''],
|
||||
})
|
||||
|
||||
with patch('matplotlib.pyplot.subplots') as mock_subplots:
|
||||
mock_fig = Mock()
|
||||
mock_ax = Mock()
|
||||
mock_ax.get_legend_handles_labels.return_value = ([Mock()], ['Test Label'])
|
||||
mock_subplots.return_value = (mock_fig, mock_ax)
|
||||
|
||||
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
|
||||
mock_canvas = Mock()
|
||||
mock_canvas_class.return_value = mock_canvas
|
||||
|
||||
gm = GraphManager(parent_frame)
|
||||
gm.update_graph(df_simple)
|
||||
|
||||
# Verify legend styling parameters
|
||||
mock_ax.legend.assert_called()
|
||||
legend_call = mock_ax.legend.call_args
|
||||
|
||||
expected_params = {
|
||||
'loc': 'upper left',
|
||||
'bbox_to_anchor': (0, 1),
|
||||
'ncol': 2,
|
||||
'fontsize': 'small',
|
||||
'frameon': True,
|
||||
'fancybox': True,
|
||||
'shadow': True,
|
||||
'framealpha': 0.9
|
||||
}
|
||||
|
||||
for param, expected_value in expected_params.items():
|
||||
assert param in legend_call.kwargs
|
||||
assert legend_call.kwargs[param] == expected_value
|
||||
|
||||
def test_medicine_tracking_lists(self, parent_frame):
|
||||
"""Test that medicines are correctly categorized into with_data and without_data lists."""
|
||||
df_mixed_data = pd.DataFrame({
|
||||
'date': ['2024-01-01', '2024-01-02'],
|
||||
'depression': [3, 2],
|
||||
'anxiety': [2, 3],
|
||||
'sleep': [4, 3],
|
||||
'appetite': [3, 4],
|
||||
# Medicines with data
|
||||
'bupropion': [1, 1],
|
||||
'bupropion_doses': ['2024-01-01 08:00:00:150mg', '2024-01-02 08:00:00:200mg'],
|
||||
'propranolol': [1, 1],
|
||||
'propranolol_doses': ['2024-01-01 12:00:00:10mg', '2024-01-02 12:00:00:15mg'],
|
||||
# Medicines without data (but toggled on)
|
||||
'hydroxyzine': [0, 0],
|
||||
'hydroxyzine_doses': ['', ''],
|
||||
'gabapentin': [0, 0],
|
||||
'gabapentin_doses': ['', ''],
|
||||
'quetiapine': [0, 0],
|
||||
'quetiapine_doses': ['', ''],
|
||||
})
|
||||
|
||||
with patch('matplotlib.pyplot.subplots') as mock_subplots:
|
||||
mock_fig = Mock()
|
||||
mock_ax = Mock()
|
||||
mock_ax.get_legend_handles_labels.return_value = ([], [])
|
||||
mock_subplots.return_value = (mock_fig, mock_ax)
|
||||
|
||||
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
|
||||
mock_canvas = Mock()
|
||||
mock_canvas_class.return_value = mock_canvas
|
||||
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
# Enable all medicines
|
||||
gm.toggle_vars["bupropion"].set(True) # Has data
|
||||
gm.toggle_vars["propranolol"].set(True) # Has data
|
||||
gm.toggle_vars["hydroxyzine"].set(True) # No data
|
||||
gm.toggle_vars["gabapentin"].set(True) # No data
|
||||
gm.toggle_vars["quetiapine"].set(False) # Disabled
|
||||
|
||||
gm.update_graph(df_mixed_data)
|
||||
|
||||
# Verify that the method was called and plotting occurred
|
||||
mock_ax.bar.assert_called() # Should be called for medicines with data
|
||||
mock_ax.legend.assert_called() # Legend should be created
|
||||
|
||||
def test_legend_dummy_handle_creation(self, parent_frame):
|
||||
"""Test that dummy handles are created for medicines without data."""
|
||||
df_no_dose_data = pd.DataFrame({
|
||||
'date': ['2024-01-01'],
|
||||
'depression': [3],
|
||||
'anxiety': [2],
|
||||
'sleep': [4],
|
||||
'appetite': [3],
|
||||
'bupropion': [0],
|
||||
'bupropion_doses': [''],
|
||||
'hydroxyzine': [0],
|
||||
'hydroxyzine_doses': [''],
|
||||
'gabapentin': [0],
|
||||
'gabapentin_doses': [''],
|
||||
'propranolol': [0],
|
||||
'propranolol_doses': [''],
|
||||
'quetiapine': [0],
|
||||
'quetiapine_doses': [''],
|
||||
})
|
||||
|
||||
with patch('matplotlib.pyplot.subplots') as mock_subplots:
|
||||
mock_fig = Mock()
|
||||
mock_ax = Mock()
|
||||
mock_ax.get_legend_handles_labels.return_value = ([Mock()], ['Depression'])
|
||||
mock_subplots.return_value = (mock_fig, mock_ax)
|
||||
|
||||
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
|
||||
mock_canvas = Mock()
|
||||
mock_canvas_class.return_value = mock_canvas
|
||||
|
||||
# Mock Rectangle import for dummy handle creation
|
||||
with patch('matplotlib.patches.Rectangle') as mock_rectangle:
|
||||
mock_dummy_handle = Mock()
|
||||
mock_rectangle.return_value = mock_dummy_handle
|
||||
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
# Enable some medicines without data
|
||||
gm.toggle_vars["hydroxyzine"].set(True)
|
||||
gm.toggle_vars["gabapentin"].set(True)
|
||||
|
||||
gm.update_graph(df_no_dose_data)
|
||||
|
||||
# If there are medicines without data, Rectangle should be called
|
||||
# to create dummy handles
|
||||
if gm.toggle_vars["hydroxyzine"].get() or gm.toggle_vars["gabapentin"].get():
|
||||
mock_rectangle.assert_called()
|
||||
|
||||
def test_empty_dataframe_legend_handling(self, parent_frame):
|
||||
"""Test that legend is handled correctly with empty DataFrame."""
|
||||
empty_df = pd.DataFrame()
|
||||
|
||||
with patch('matplotlib.pyplot.subplots') as mock_subplots:
|
||||
mock_fig = Mock()
|
||||
mock_ax = Mock()
|
||||
mock_subplots.return_value = (mock_fig, mock_ax)
|
||||
|
||||
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
|
||||
mock_canvas = Mock()
|
||||
mock_canvas_class.return_value = mock_canvas
|
||||
|
||||
gm = GraphManager(parent_frame)
|
||||
gm.update_graph(empty_df)
|
||||
|
||||
# With empty data, legend should not be called
|
||||
mock_ax.legend.assert_not_called()
|
||||
mock_ax.clear.assert_called()
|
||||
mock_canvas.draw.assert_called()
|
||||
|
||||
def test_dose_calculation_comprehensive(self, parent_frame, sample_dose_data):
|
||||
"""Test dose calculation with comprehensive test cases."""
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
# Test all sample dose data cases
|
||||
assert gm._calculate_daily_dose(sample_dose_data['standard_format']) == 225.0
|
||||
assert gm._calculate_daily_dose(sample_dose_data['with_bullets']) == 300.0
|
||||
assert gm._calculate_daily_dose(sample_dose_data['decimal_doses']) == 20.0
|
||||
assert gm._calculate_daily_dose(sample_dose_data['no_timestamp']) == 150.0
|
||||
assert gm._calculate_daily_dose(sample_dose_data['mixed_format']) == 85.0
|
||||
assert gm._calculate_daily_dose(sample_dose_data['empty_string']) == 0.0
|
||||
assert gm._calculate_daily_dose(sample_dose_data['nan_value']) == 0.0
|
||||
assert gm._calculate_daily_dose(sample_dose_data['no_units']) == 15.0
|
||||
|
||||
def test_dose_calculation_edge_cases(self, parent_frame):
|
||||
"""Test dose calculation with edge cases."""
|
||||
gm = GraphManager(parent_frame)
|
||||
|
||||
# Test with malformed data
|
||||
assert gm._calculate_daily_dose("malformed:data") == 0.0
|
||||
assert gm._calculate_daily_dose("::::") == 0.0
|
||||
assert gm._calculate_daily_dose("2025-07-28:") == 0.0
|
||||
assert gm._calculate_daily_dose("2025-07-28::mg") == 0.0
|
||||
|
||||
# Test with partial data
|
||||
assert gm._calculate_daily_dose("2025-07-28 18:59:45:150") == 150.0 # no units
|
||||
assert gm._calculate_daily_dose("150mg") == 150.0 # no timestamp
|
||||
|
||||
# Test with spaces and special characters
|
||||
assert gm._calculate_daily_dose(" 2025-07-28 18:59:45:150mg ") == 150.0
|
||||
assert gm._calculate_daily_dose("••• 2025-07-28 18:59:45:150mg •••") == 150.0
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
Tests for init module.
|
||||
"""
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||
|
||||
|
||||
class TestInit:
|
||||
"""Test cases for the init module."""
|
||||
|
||||
def test_log_directory_creation(self, temp_log_dir):
|
||||
"""Test that log directory is created if it doesn't exist."""
|
||||
with patch('init.LOG_PATH', temp_log_dir + '/new_dir'), \
|
||||
patch('os.path.exists', return_value=False), \
|
||||
patch('os.mkdir') as mock_mkdir:
|
||||
|
||||
# Re-import to trigger the directory creation logic
|
||||
import importlib
|
||||
if 'init' in sys.modules:
|
||||
importlib.reload(sys.modules['init'])
|
||||
else:
|
||||
import src.init
|
||||
|
||||
mock_mkdir.assert_called_once()
|
||||
|
||||
def test_log_directory_exists(self, temp_log_dir):
|
||||
"""Test behavior when log directory already exists."""
|
||||
with patch('init.LOG_PATH', temp_log_dir), \
|
||||
patch('os.path.exists', return_value=True), \
|
||||
patch('os.mkdir') as mock_mkdir:
|
||||
|
||||
import importlib
|
||||
if 'init' in sys.modules:
|
||||
importlib.reload(sys.modules['init'])
|
||||
else:
|
||||
import src.init
|
||||
|
||||
mock_mkdir.assert_not_called()
|
||||
|
||||
def test_log_directory_creation_error(self, temp_log_dir):
|
||||
"""Test handling of errors during log directory creation."""
|
||||
with patch('init.LOG_PATH', '/invalid/path'), \
|
||||
patch('os.path.exists', return_value=False), \
|
||||
patch('os.mkdir', side_effect=PermissionError("Permission denied")), \
|
||||
patch('builtins.print') as mock_print:
|
||||
|
||||
import importlib
|
||||
if 'init' in sys.modules:
|
||||
importlib.reload(sys.modules['init'])
|
||||
else:
|
||||
import src.init
|
||||
|
||||
mock_print.assert_called()
|
||||
|
||||
def test_logger_initialization(self, temp_log_dir):
|
||||
"""Test that logger is initialized correctly."""
|
||||
with patch('init.LOG_PATH', temp_log_dir), \
|
||||
patch('init.LOG_LEVEL', 'INFO'), \
|
||||
patch('init.init_logger') as mock_init_logger:
|
||||
|
||||
mock_logger = Mock()
|
||||
mock_init_logger.return_value = mock_logger
|
||||
|
||||
import importlib
|
||||
if 'init' in sys.modules:
|
||||
importlib.reload(sys.modules['init'])
|
||||
else:
|
||||
import src.init
|
||||
|
||||
mock_init_logger.assert_called_once_with('init', testing_mode=False)
|
||||
|
||||
def test_logger_initialization_debug_mode(self, temp_log_dir):
|
||||
"""Test logger initialization in debug mode."""
|
||||
with patch('init.LOG_PATH', temp_log_dir), \
|
||||
patch('init.LOG_LEVEL', 'DEBUG'), \
|
||||
patch('init.init_logger') as mock_init_logger:
|
||||
|
||||
mock_logger = Mock()
|
||||
mock_init_logger.return_value = mock_logger
|
||||
|
||||
import importlib
|
||||
if 'init' in sys.modules:
|
||||
importlib.reload(sys.modules['init'])
|
||||
else:
|
||||
import src.init
|
||||
|
||||
mock_init_logger.assert_called_once_with('init', testing_mode=True)
|
||||
|
||||
def test_log_files_definition(self, temp_log_dir):
|
||||
"""Test that log files tuple is defined correctly."""
|
||||
with patch('init.LOG_PATH', temp_log_dir):
|
||||
import importlib
|
||||
if 'init' in sys.modules:
|
||||
importlib.reload(sys.modules['init'])
|
||||
else:
|
||||
import src.init
|
||||
|
||||
expected_files = (
|
||||
f"{temp_log_dir}/thechart.log",
|
||||
f"{temp_log_dir}/thechart.warning.log",
|
||||
f"{temp_log_dir}/thechart.error.log",
|
||||
)
|
||||
|
||||
assert src.init.log_files == expected_files
|
||||
|
||||
def test_testing_mode_detection(self, temp_log_dir):
|
||||
"""Test that testing mode is detected correctly."""
|
||||
with patch('init.LOG_PATH', temp_log_dir):
|
||||
# Test with DEBUG level
|
||||
with patch('init.LOG_LEVEL', 'DEBUG'):
|
||||
import importlib
|
||||
if 'init' in sys.modules:
|
||||
importlib.reload(sys.modules['init'])
|
||||
else:
|
||||
import src.init
|
||||
|
||||
assert src.init.testing_mode is True
|
||||
|
||||
# Test with non-DEBUG level
|
||||
with patch('init.LOG_LEVEL', 'INFO'):
|
||||
importlib.reload(sys.modules['init'])
|
||||
assert src.init.testing_mode is False
|
||||
|
||||
def test_log_clear_true(self, temp_log_dir):
|
||||
"""Test log file clearing when LOG_CLEAR is True."""
|
||||
# Create some test log files
|
||||
log_files = [
|
||||
os.path.join(temp_log_dir, "thechart.log"),
|
||||
os.path.join(temp_log_dir, "thechart.warning.log"),
|
||||
os.path.join(temp_log_dir, "thechart.error.log"),
|
||||
]
|
||||
|
||||
for log_file in log_files:
|
||||
with open(log_file, 'w') as f:
|
||||
f.write("Old log content")
|
||||
|
||||
with patch('init.LOG_PATH', temp_log_dir), \
|
||||
patch('init.LOG_CLEAR', 'True'), \
|
||||
patch('init.log_files', log_files):
|
||||
|
||||
import importlib
|
||||
if 'init' in sys.modules:
|
||||
importlib.reload(sys.modules['init'])
|
||||
else:
|
||||
import src.init
|
||||
|
||||
# Check that files were truncated
|
||||
for log_file in log_files:
|
||||
with open(log_file, 'r') as f:
|
||||
assert f.read() == ""
|
||||
|
||||
def test_log_clear_false(self, temp_log_dir):
|
||||
"""Test that log files are not cleared when LOG_CLEAR is False."""
|
||||
# Create some test log files
|
||||
log_files = [
|
||||
os.path.join(temp_log_dir, "thechart.log"),
|
||||
os.path.join(temp_log_dir, "thechart.warning.log"),
|
||||
os.path.join(temp_log_dir, "thechart.error.log"),
|
||||
]
|
||||
|
||||
original_content = "Original log content"
|
||||
for log_file in log_files:
|
||||
with open(log_file, 'w') as f:
|
||||
f.write(original_content)
|
||||
|
||||
with patch('init.LOG_PATH', temp_log_dir), \
|
||||
patch('init.LOG_CLEAR', 'False'), \
|
||||
patch('init.log_files', log_files):
|
||||
|
||||
import importlib
|
||||
if 'init' in sys.modules:
|
||||
importlib.reload(sys.modules['init'])
|
||||
else:
|
||||
import src.init
|
||||
|
||||
# Check that files were not truncated
|
||||
for log_file in log_files:
|
||||
with open(log_file, 'r') as f:
|
||||
assert f.read() == original_content
|
||||
|
||||
def test_log_clear_nonexistent_files(self, temp_log_dir):
|
||||
"""Test log clearing when some log files don't exist."""
|
||||
log_files = [
|
||||
os.path.join(temp_log_dir, "thechart.log"),
|
||||
os.path.join(temp_log_dir, "nonexistent.log"),
|
||||
]
|
||||
|
||||
# Create only one of the files
|
||||
with open(log_files[0], 'w') as f:
|
||||
f.write("Content")
|
||||
|
||||
with patch('init.LOG_PATH', temp_log_dir), \
|
||||
patch('init.LOG_CLEAR', 'True'), \
|
||||
patch('init.log_files', log_files):
|
||||
|
||||
# This should not raise an exception
|
||||
import importlib
|
||||
if 'init' in sys.modules:
|
||||
importlib.reload(sys.modules['init'])
|
||||
else:
|
||||
import src.init
|
||||
|
||||
def test_log_clear_permission_error(self, temp_log_dir):
|
||||
"""Test handling of permission errors during log clearing."""
|
||||
log_files = [os.path.join(temp_log_dir, "thechart.log")]
|
||||
|
||||
with open(log_files[0], 'w') as f:
|
||||
f.write("Content")
|
||||
|
||||
with patch('init.LOG_PATH', temp_log_dir), \
|
||||
patch('init.LOG_CLEAR', 'True'), \
|
||||
patch('init.log_files', log_files), \
|
||||
patch('builtins.open', side_effect=PermissionError("Permission denied")), \
|
||||
patch('init.logger') as mock_logger:
|
||||
|
||||
mock_logger.error = Mock()
|
||||
|
||||
# Should raise the exception after logging
|
||||
with pytest.raises(PermissionError):
|
||||
import importlib
|
||||
if 'init' in sys.modules:
|
||||
importlib.reload(sys.modules['init'])
|
||||
else:
|
||||
import src.init
|
||||
|
||||
def test_module_exports(self, temp_log_dir):
|
||||
"""Test that module exports expected objects."""
|
||||
with patch('init.LOG_PATH', temp_log_dir):
|
||||
import importlib
|
||||
if 'init' in sys.modules:
|
||||
importlib.reload(sys.modules['init'])
|
||||
else:
|
||||
import src.init
|
||||
|
||||
# Check that expected objects are available
|
||||
assert hasattr(src.init, 'logger')
|
||||
assert hasattr(src.init, 'log_files')
|
||||
assert hasattr(src.init, 'testing_mode')
|
||||
|
||||
def test_log_path_printing(self, temp_log_dir):
|
||||
"""Test that LOG_PATH is printed when directory is created."""
|
||||
with patch('init.LOG_PATH', temp_log_dir + '/new_dir'), \
|
||||
patch('os.path.exists', return_value=False), \
|
||||
patch('os.mkdir'), \
|
||||
patch('builtins.print') as mock_print:
|
||||
|
||||
import importlib
|
||||
if 'init' in sys.modules:
|
||||
importlib.reload(sys.modules['init'])
|
||||
else:
|
||||
import src.init
|
||||
|
||||
mock_print.assert_called_with(temp_log_dir + '/new_dir')
|
||||
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Tests for logger module.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||
|
||||
from src.logger import init_logger
|
||||
|
||||
|
||||
class TestLogger:
|
||||
"""Test cases for the logger module."""
|
||||
|
||||
def test_init_logger_basic(self, temp_log_dir):
|
||||
"""Test basic logger initialization."""
|
||||
with patch('logger.LOG_PATH', temp_log_dir):
|
||||
logger = init_logger("test_logger", testing_mode=False)
|
||||
|
||||
assert isinstance(logger, logging.Logger)
|
||||
assert logger.name == "test_logger"
|
||||
assert logger.level == logging.INFO
|
||||
|
||||
def test_init_logger_testing_mode(self, temp_log_dir):
|
||||
"""Test logger initialization in testing mode."""
|
||||
with patch('logger.LOG_PATH', temp_log_dir):
|
||||
logger = init_logger("test_logger", testing_mode=True)
|
||||
|
||||
assert logger.level == logging.DEBUG
|
||||
|
||||
def test_init_logger_production_mode(self, temp_log_dir):
|
||||
"""Test logger initialization in production mode."""
|
||||
with patch('logger.LOG_PATH', temp_log_dir):
|
||||
logger = init_logger("test_logger", testing_mode=False)
|
||||
|
||||
assert logger.level == logging.INFO
|
||||
|
||||
def test_file_handlers_created(self, temp_log_dir):
|
||||
"""Test that file handlers are created correctly."""
|
||||
with patch('logger.LOG_PATH', temp_log_dir):
|
||||
logger = init_logger("test_logger", testing_mode=False)
|
||||
|
||||
# Check that handlers were added
|
||||
assert len(logger.handlers) >= 3 # At least 3 file handlers
|
||||
|
||||
def test_file_handler_levels(self, temp_log_dir):
|
||||
"""Test that file handlers have correct log levels."""
|
||||
with patch('logger.LOG_PATH', temp_log_dir):
|
||||
logger = init_logger("test_logger", testing_mode=False)
|
||||
|
||||
handler_levels = [handler.level for handler in logger.handlers if isinstance(handler, logging.FileHandler)]
|
||||
|
||||
# Should have handlers for DEBUG, WARNING, and ERROR levels
|
||||
assert logging.DEBUG in handler_levels
|
||||
assert logging.WARNING in handler_levels
|
||||
assert logging.ERROR in handler_levels
|
||||
|
||||
def test_log_file_paths(self, temp_log_dir):
|
||||
"""Test that log files are created with correct paths."""
|
||||
with patch('logger.LOG_PATH', temp_log_dir):
|
||||
logger = init_logger("test_logger", testing_mode=False)
|
||||
|
||||
# Log something to trigger file creation
|
||||
logger.debug("Test debug message")
|
||||
logger.warning("Test warning message")
|
||||
logger.error("Test error message")
|
||||
|
||||
# Check that log files would be created (paths are correct)
|
||||
expected_files = [
|
||||
os.path.join(temp_log_dir, "app.log"),
|
||||
os.path.join(temp_log_dir, "app.warning.log"),
|
||||
os.path.join(temp_log_dir, "app.error.log")
|
||||
]
|
||||
|
||||
# The files should exist or be ready to be created
|
||||
for handler in logger.handlers:
|
||||
if isinstance(handler, logging.FileHandler):
|
||||
assert handler.baseFilename in expected_files
|
||||
|
||||
def test_formatter_format(self, temp_log_dir):
|
||||
"""Test that formatters are set correctly."""
|
||||
with patch('logger.LOG_PATH', temp_log_dir):
|
||||
logger = init_logger("test_logger", testing_mode=False)
|
||||
|
||||
expected_format = "%(asctime)s - %(name)s - %(funcName)s - %(levelname)s - %(message)s"
|
||||
|
||||
for handler in logger.handlers:
|
||||
if isinstance(handler, logging.FileHandler):
|
||||
assert handler.formatter._fmt == expected_format
|
||||
|
||||
@patch('colorlog.basicConfig')
|
||||
def test_colorlog_configuration(self, mock_basicConfig, temp_log_dir):
|
||||
"""Test that colorlog is configured correctly."""
|
||||
with patch('logger.LOG_PATH', temp_log_dir):
|
||||
init_logger("test_logger", testing_mode=False)
|
||||
|
||||
mock_basicConfig.assert_called_once()
|
||||
|
||||
# Check that format includes color and bold formatting
|
||||
call_args = mock_basicConfig.call_args
|
||||
assert 'format' in call_args[1]
|
||||
format_string = call_args[1]['format']
|
||||
assert '%(log_color)s' in format_string
|
||||
assert '\033[1m' in format_string # Bold sequence
|
||||
|
||||
def test_multiple_logger_instances(self, temp_log_dir):
|
||||
"""Test creating multiple logger instances."""
|
||||
with patch('logger.LOG_PATH', temp_log_dir):
|
||||
logger1 = init_logger("logger1", testing_mode=False)
|
||||
logger2 = init_logger("logger2", testing_mode=True)
|
||||
|
||||
assert logger1.name == "logger1"
|
||||
assert logger2.name == "logger2"
|
||||
assert logger1.level == logging.INFO
|
||||
assert logger2.level == logging.DEBUG
|
||||
|
||||
def test_logger_inheritance(self, temp_log_dir):
|
||||
"""Test that logger follows Python logging hierarchy."""
|
||||
with patch('logger.LOG_PATH', temp_log_dir):
|
||||
logger = init_logger("test.module.logger", testing_mode=False)
|
||||
|
||||
assert logger.name == "test.module.logger"
|
||||
|
||||
@patch('logging.FileHandler')
|
||||
def test_file_handler_error_handling(self, mock_file_handler, temp_log_dir):
|
||||
"""Test error handling when file handler creation fails."""
|
||||
mock_file_handler.side_effect = PermissionError("Cannot create log file")
|
||||
|
||||
with patch('logger.LOG_PATH', temp_log_dir):
|
||||
# Should not raise an exception, but handle gracefully
|
||||
try:
|
||||
logger = init_logger("test_logger", testing_mode=False)
|
||||
# Logger should still be created, just without file handlers
|
||||
assert isinstance(logger, logging.Logger)
|
||||
except PermissionError:
|
||||
pytest.fail("init_logger should handle file creation errors gracefully")
|
||||
|
||||
def test_logger_name_parameter(self, temp_log_dir):
|
||||
"""Test that logger name is set correctly from parameter."""
|
||||
with patch('logger.LOG_PATH', temp_log_dir):
|
||||
test_name = "my.custom.logger.name"
|
||||
logger = init_logger(test_name, testing_mode=False)
|
||||
|
||||
assert logger.name == test_name
|
||||
|
||||
def test_testing_mode_boolean(self, temp_log_dir):
|
||||
"""Test that testing_mode parameter accepts boolean values."""
|
||||
with patch('logger.LOG_PATH', temp_log_dir):
|
||||
logger_true = init_logger("test1", testing_mode=True)
|
||||
logger_false = init_logger("test2", testing_mode=False)
|
||||
|
||||
assert logger_true.level == logging.DEBUG
|
||||
assert logger_false.level == logging.INFO
|
||||
|
||||
def test_log_format_contains_required_fields(self, temp_log_dir):
|
||||
"""Test that log format contains all required fields."""
|
||||
with patch('logger.LOG_PATH', temp_log_dir):
|
||||
logger = init_logger("test_logger", testing_mode=False)
|
||||
|
||||
log_format = "%(asctime)s - %(name)s - %(funcName)s - %(levelname)s - %(message)s"
|
||||
|
||||
# Check that format contains all expected fields
|
||||
expected_fields = ['%(asctime)s', '%(name)s', '%(funcName)s', '%(levelname)s', '%(message)s']
|
||||
for field in expected_fields:
|
||||
assert field in log_format
|
||||
|
||||
def test_handler_file_mode(self, temp_log_dir):
|
||||
"""Test that file handlers use append mode by default."""
|
||||
with patch('logger.LOG_PATH', temp_log_dir):
|
||||
logger = init_logger("test_logger", testing_mode=False)
|
||||
|
||||
# File handlers should be in append mode by default
|
||||
for handler in logger.handlers:
|
||||
if isinstance(handler, logging.FileHandler):
|
||||
# FileHandler uses 'a' mode by default
|
||||
assert hasattr(handler, 'mode') # Basic check that it's a file handler
|
||||
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
Tests for the main application and MedTrackerApp class.
|
||||
"""
|
||||
import os
|
||||
import pytest
|
||||
import tkinter as tk
|
||||
from unittest.mock import Mock, patch
|
||||
import pandas as pd
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||
|
||||
from src.main import MedTrackerApp
|
||||
|
||||
|
||||
class TestMedTrackerApp:
|
||||
"""Test cases for the MedTrackerApp class."""
|
||||
|
||||
@pytest.fixture
|
||||
def root_window(self):
|
||||
"""Create a root window for testing."""
|
||||
root = tk.Tk()
|
||||
yield root
|
||||
root.destroy()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_managers(self):
|
||||
"""Mock the manager classes."""
|
||||
with patch('main.UIManager') as mock_ui, \
|
||||
patch('main.DataManager') as mock_data, \
|
||||
patch('main.GraphManager') as mock_graph:
|
||||
yield {
|
||||
'ui': mock_ui,
|
||||
'data': mock_data,
|
||||
'graph': mock_graph
|
||||
}
|
||||
|
||||
def test_init_default_filename(self, root_window, mock_managers):
|
||||
"""Test initialization with default filename."""
|
||||
with patch('sys.argv', ['main.py']):
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
assert app.filename == "thechart_data.csv"
|
||||
assert app.root == root_window
|
||||
assert root_window.title() == "Thechart - medication tracker"
|
||||
|
||||
def test_init_custom_filename_exists(self, root_window, mock_managers):
|
||||
"""Test initialization with custom filename that exists."""
|
||||
with patch('sys.argv', ['main.py', 'custom_data.csv']), \
|
||||
patch('os.path.exists', return_value=True):
|
||||
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
assert app.filename == "custom_data.csv"
|
||||
|
||||
def test_init_custom_filename_not_exists(self, root_window, mock_managers):
|
||||
"""Test initialization with custom filename that doesn't exist."""
|
||||
with patch('sys.argv', ['main.py', 'nonexistent.csv']), \
|
||||
patch('os.path.exists', return_value=False):
|
||||
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
assert app.filename == "thechart_data.csv"
|
||||
|
||||
@patch('main.LOG_LEVEL', 'DEBUG')
|
||||
def test_debug_logging(self, root_window, mock_managers):
|
||||
"""Test debug logging when LOG_LEVEL is DEBUG."""
|
||||
with patch('sys.argv', ['main.py', 'test.csv']), \
|
||||
patch('os.path.exists', return_value=True), \
|
||||
patch('main.logger') as mock_logger:
|
||||
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
# Check that debug messages were logged
|
||||
mock_logger.debug.assert_called()
|
||||
|
||||
def test_setup_main_ui_components(self, root_window, mock_managers):
|
||||
"""Test that main UI components are set up correctly."""
|
||||
with patch('sys.argv', ['main.py']):
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
# Check that managers were instantiated
|
||||
mock_managers['ui'].assert_called()
|
||||
mock_managers['data'].assert_called()
|
||||
|
||||
def test_icon_setup(self, root_window, mock_managers):
|
||||
"""Test icon setup functionality."""
|
||||
with patch('sys.argv', ['main.py']), \
|
||||
patch('os.path.exists', return_value=True):
|
||||
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
# Check that setup_application_icon was called on UI manager
|
||||
app.ui_manager.setup_application_icon.assert_called()
|
||||
|
||||
def test_icon_setup_fallback_path(self, root_window, mock_managers):
|
||||
"""Test icon setup with fallback path."""
|
||||
def mock_exists(path):
|
||||
return path == "./chart-671.png"
|
||||
|
||||
with patch('sys.argv', ['main.py']), \
|
||||
patch('os.path.exists', side_effect=mock_exists):
|
||||
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
# Check that setup_application_icon was called with fallback path
|
||||
app.ui_manager.setup_application_icon.assert_called_with(img_path="./chart-671.png")
|
||||
|
||||
def test_add_new_entry_success(self, root_window, mock_managers):
|
||||
"""Test successful entry addition."""
|
||||
with patch('sys.argv', ['main.py']):
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
# Mock the UI variables
|
||||
app.date_var = Mock()
|
||||
app.date_var.get.return_value = "2024-01-01"
|
||||
app.symptom_vars = {
|
||||
"depression": Mock(), "anxiety": Mock(),
|
||||
"sleep": Mock(), "appetite": Mock()
|
||||
}
|
||||
for var in app.symptom_vars.values():
|
||||
var.get.return_value = 3
|
||||
|
||||
app.medicine_vars = {
|
||||
"bupropion": [Mock()], "hydroxyzine": [Mock()],
|
||||
"gabapentin": [Mock()], "propranolol": [Mock()]
|
||||
}
|
||||
for med_var in app.medicine_vars.values():
|
||||
med_var[0].get.return_value = 1
|
||||
|
||||
app.note_var = Mock()
|
||||
app.note_var.get.return_value = "Test note"
|
||||
|
||||
# Mock data manager to return success
|
||||
app.data_manager.add_entry.return_value = True
|
||||
|
||||
with patch('tkinter.messagebox.showinfo') as mock_info, \
|
||||
patch.object(app, '_clear_entries') as mock_clear, \
|
||||
patch.object(app, 'refresh_data_display') as mock_load:
|
||||
|
||||
app.add_new_entry()
|
||||
|
||||
mock_info.assert_called_once()
|
||||
mock_clear.assert_called_once()
|
||||
mock_load.assert_called_once()
|
||||
|
||||
def test_add_new_entry_empty_date(self, root_window, mock_managers):
|
||||
"""Test adding entry with empty date."""
|
||||
with patch('sys.argv', ['main.py']):
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
app.date_var = Mock()
|
||||
app.date_var.get.return_value = " " # Empty/whitespace date
|
||||
|
||||
with patch('tkinter.messagebox.showerror') as mock_error:
|
||||
app.add_new_entry()
|
||||
|
||||
mock_error.assert_called_once_with(
|
||||
"Error", "Please enter a date.", parent=app.root
|
||||
)
|
||||
|
||||
def test_add_new_entry_duplicate_date(self, root_window, mock_managers):
|
||||
"""Test adding entry with duplicate date."""
|
||||
with patch('sys.argv', ['main.py']):
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
# Set up UI variables
|
||||
app.date_var = Mock()
|
||||
app.date_var.get.return_value = "2024-01-01"
|
||||
app.symptom_vars = {"depression": Mock(), "anxiety": Mock(),
|
||||
"sleep": Mock(), "appetite": Mock()}
|
||||
for var in app.symptom_vars.values():
|
||||
var.get.return_value = 3
|
||||
app.medicine_vars = {"bupropion": [Mock()], "hydroxyzine": [Mock()],
|
||||
"gabapentin": [Mock()], "propranolol": [Mock()]}
|
||||
for med_var in app.medicine_vars.values():
|
||||
med_var[0].get.return_value = 1
|
||||
app.note_var = Mock()
|
||||
app.note_var.get.return_value = "Test"
|
||||
|
||||
# Mock data manager to return failure (duplicate)
|
||||
app.data_manager.add_entry.return_value = False
|
||||
|
||||
# Mock load_data to return DataFrame with existing date
|
||||
mock_df = pd.DataFrame({'date': ['2024-01-01']})
|
||||
app.data_manager.load_data.return_value = mock_df
|
||||
|
||||
with patch('tkinter.messagebox.showerror') as mock_error:
|
||||
app.add_new_entry()
|
||||
|
||||
mock_error.assert_called_once()
|
||||
assert "already exists" in mock_error.call_args[0][1]
|
||||
|
||||
def test_handle_double_click(self, root_window, mock_managers):
|
||||
"""Test double-click event handling."""
|
||||
with patch('sys.argv', ['main.py']):
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
# Mock tree with selection
|
||||
app.tree = Mock()
|
||||
app.tree.get_children.return_value = ['item1']
|
||||
app.tree.selection.return_value = ['item1']
|
||||
app.tree.item.return_value = {'values': ('2024-01-01', '3', '2', '4', '3', '1', '0', '2', '1', 'Note')}
|
||||
|
||||
mock_event = Mock()
|
||||
|
||||
with patch.object(app, '_create_edit_window') as mock_create_edit:
|
||||
app.handle_double_click(mock_event)
|
||||
|
||||
mock_create_edit.assert_called_once()
|
||||
|
||||
def test_handle_double_click_empty_tree(self, root_window, mock_managers):
|
||||
"""Test double-click when tree is empty."""
|
||||
with patch('sys.argv', ['main.py']):
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
app.tree = Mock()
|
||||
app.tree.get_children.return_value = []
|
||||
|
||||
mock_event = Mock()
|
||||
|
||||
with patch.object(app, '_create_edit_window') as mock_create_edit:
|
||||
app.handle_double_click(mock_event)
|
||||
|
||||
mock_create_edit.assert_not_called()
|
||||
|
||||
def test_save_edit_success(self, root_window, mock_managers):
|
||||
"""Test successful save edit operation."""
|
||||
with patch('sys.argv', ['main.py']):
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
# Mock edit window
|
||||
mock_edit_win = Mock()
|
||||
|
||||
# Mock data manager to return success
|
||||
app.data_manager.update_entry.return_value = True
|
||||
|
||||
with patch('tkinter.messagebox.showinfo') as mock_info, \
|
||||
patch.object(app, '_clear_entries') as mock_clear, \
|
||||
patch.object(app, 'refresh_data_display') as mock_load:
|
||||
|
||||
app._save_edit(
|
||||
mock_edit_win, "2024-01-01", "2024-01-01",
|
||||
3, 2, 4, 3, 1, 0, 2, 1, "Updated note"
|
||||
)
|
||||
|
||||
mock_edit_win.destroy.assert_called_once()
|
||||
mock_info.assert_called_once()
|
||||
mock_clear.assert_called_once()
|
||||
mock_load.assert_called_once()
|
||||
|
||||
def test_save_edit_duplicate_date(self, root_window, mock_managers):
|
||||
"""Test save edit with duplicate date."""
|
||||
with patch('sys.argv', ['main.py']):
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
mock_edit_win = Mock()
|
||||
|
||||
# Mock data manager to return failure
|
||||
app.data_manager.update_entry.return_value = False
|
||||
|
||||
# Mock load_data to return DataFrame with existing date
|
||||
mock_df = pd.DataFrame({'date': ['2024-01-02']})
|
||||
app.data_manager.load_data.return_value = mock_df
|
||||
|
||||
with patch('tkinter.messagebox.showerror') as mock_error:
|
||||
app._save_edit(
|
||||
mock_edit_win, "2024-01-01", "2024-01-02", # Different dates
|
||||
3, 2, 4, 3, 1, 0, 2, 1, "Updated note"
|
||||
)
|
||||
|
||||
mock_error.assert_called_once()
|
||||
assert "already exists" in mock_error.call_args[0][1]
|
||||
|
||||
def test_delete_entry_success(self, root_window, mock_managers):
|
||||
"""Test successful entry deletion."""
|
||||
with patch('sys.argv', ['main.py']):
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
mock_edit_win = Mock()
|
||||
app.tree = Mock()
|
||||
app.tree.item.return_value = {'values': ['2024-01-01']}
|
||||
|
||||
# Mock data manager to return success
|
||||
app.data_manager.delete_entry.return_value = True
|
||||
|
||||
with patch('tkinter.messagebox.askyesno', return_value=True) as mock_confirm, \
|
||||
patch('tkinter.messagebox.showinfo') as mock_info, \
|
||||
patch.object(app, 'refresh_data_display') as mock_load:
|
||||
|
||||
app._delete_entry(mock_edit_win, 'item1')
|
||||
|
||||
mock_confirm.assert_called_once()
|
||||
mock_edit_win.destroy.assert_called_once()
|
||||
mock_info.assert_called_once()
|
||||
mock_load.assert_called_once()
|
||||
|
||||
def test_delete_entry_cancelled(self, root_window, mock_managers):
|
||||
"""Test deletion when user cancels."""
|
||||
with patch('sys.argv', ['main.py']):
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
mock_edit_win = Mock()
|
||||
|
||||
with patch('tkinter.messagebox.askyesno', return_value=False) as mock_confirm:
|
||||
app._delete_entry(mock_edit_win, 'item1')
|
||||
|
||||
mock_confirm.assert_called_once()
|
||||
mock_edit_win.destroy.assert_not_called()
|
||||
|
||||
def test_clear_entries(self, root_window, mock_managers):
|
||||
"""Test clearing input entries."""
|
||||
with patch('sys.argv', ['main.py']):
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
# Mock variables
|
||||
app.date_var = Mock()
|
||||
app.symptom_vars = {"depression": Mock(), "anxiety": Mock()}
|
||||
app.medicine_vars = {"bupropion": [Mock()], "hydroxyzine": [Mock()]}
|
||||
app.note_var = Mock()
|
||||
|
||||
app._clear_entries()
|
||||
|
||||
app.date_var.set.assert_called_with("")
|
||||
app.note_var.set.assert_called_with("")
|
||||
for var in app.symptom_vars.values():
|
||||
var.set.assert_called_with(0)
|
||||
for med_var in app.medicine_vars.values():
|
||||
med_var[0].set.assert_called_with(0)
|
||||
|
||||
def test_refresh_data_display(self, root_window, mock_managers):
|
||||
"""Test loading data into tree and graph."""
|
||||
with patch('sys.argv', ['main.py']):
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
# Mock tree
|
||||
app.tree = Mock()
|
||||
app.tree.get_children.return_value = ['item1', 'item2']
|
||||
|
||||
# Mock data
|
||||
mock_df = pd.DataFrame({
|
||||
'date': ['2024-01-01', '2024-01-02'],
|
||||
'depression': [3, 2],
|
||||
'note': ['Note1', 'Note2']
|
||||
})
|
||||
app.data_manager.load_data.return_value = mock_df
|
||||
|
||||
app.refresh_data_display()
|
||||
|
||||
# Check that tree was cleared and populated
|
||||
app.tree.delete.assert_called()
|
||||
app.tree.insert.assert_called()
|
||||
|
||||
# Check that graph was updated
|
||||
app.graph_manager.update_graph.assert_called_with(mock_df)
|
||||
|
||||
def test_refresh_data_display_empty_dataframe(self, root_window, mock_managers):
|
||||
"""Test loading empty data."""
|
||||
with patch('sys.argv', ['main.py']):
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
app.tree = Mock()
|
||||
app.tree.get_children.return_value = []
|
||||
|
||||
# Mock empty DataFrame
|
||||
empty_df = pd.DataFrame()
|
||||
app.data_manager.load_data.return_value = empty_df
|
||||
|
||||
app.refresh_data_display()
|
||||
|
||||
# Graph should still be updated even with empty data
|
||||
app.graph_manager.update_graph.assert_called_with(empty_df)
|
||||
|
||||
def test_handle_window_closing_confirmed(self, root_window, mock_managers):
|
||||
"""Test application closing when confirmed."""
|
||||
with patch('sys.argv', ['main.py']):
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
with patch('tkinter.messagebox.askokcancel', return_value=True) as mock_confirm:
|
||||
app.handle_window_closing()
|
||||
|
||||
mock_confirm.assert_called_once()
|
||||
app.graph_manager.close.assert_called_once()
|
||||
|
||||
def test_handle_window_closing_cancelled(self, root_window, mock_managers):
|
||||
"""Test application closing when cancelled."""
|
||||
with patch('sys.argv', ['main.py']):
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
with patch('tkinter.messagebox.askokcancel', return_value=False) as mock_confirm:
|
||||
app.handle_window_closing()
|
||||
|
||||
mock_confirm.assert_called_once()
|
||||
app.graph_manager.close.assert_not_called()
|
||||
|
||||
def test_protocol_handler_setup(self, root_window, mock_managers):
|
||||
"""Test that window close protocol is set up."""
|
||||
with patch('sys.argv', ['main.py']):
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
# The protocol should be set during initialization
|
||||
# This is more of a structural test
|
||||
assert app.root is root_window
|
||||
|
||||
def test_window_properties(self, root_window, mock_managers):
|
||||
"""Test window properties are set correctly."""
|
||||
with patch('sys.argv', ['main.py']):
|
||||
app = MedTrackerApp(root_window)
|
||||
|
||||
assert root_window.title() == "Thechart - medication tracker"
|
||||
# Note: Testing resizable would require more complex mocking
|
||||
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
Tests for the UIManager class.
|
||||
"""
|
||||
import os
|
||||
import pytest
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||
|
||||
from src.ui_manager import UIManager
|
||||
|
||||
|
||||
class TestUIManager:
|
||||
"""Test cases for the UIManager class."""
|
||||
|
||||
@pytest.fixture
|
||||
def root_window(self):
|
||||
"""Create a root window for testing."""
|
||||
root = tk.Tk()
|
||||
yield root
|
||||
root.destroy()
|
||||
|
||||
@pytest.fixture
|
||||
def ui_manager(self, root_window, mock_logger):
|
||||
"""Create a UIManager instance for testing."""
|
||||
return UIManager(root_window, mock_logger)
|
||||
|
||||
def test_init(self, root_window, mock_logger):
|
||||
"""Test UIManager initialization."""
|
||||
ui = UIManager(root_window, mock_logger)
|
||||
assert ui.root == root_window
|
||||
assert ui.logger == mock_logger
|
||||
|
||||
@patch('os.path.exists')
|
||||
@patch('PIL.Image.open')
|
||||
def test_setup_application_icon_success(self, mock_image_open, mock_exists, ui_manager):
|
||||
"""Test successful icon setup."""
|
||||
mock_exists.return_value = True
|
||||
mock_image = Mock()
|
||||
mock_image.resize.return_value = mock_image
|
||||
mock_image_open.return_value = mock_image
|
||||
|
||||
with patch('PIL.ImageTk.PhotoImage') as mock_photo:
|
||||
mock_photo_instance = Mock()
|
||||
mock_photo.return_value = mock_photo_instance
|
||||
|
||||
with patch.object(ui_manager.root, 'iconphoto') as mock_iconphoto, \
|
||||
patch.object(ui_manager.root, 'wm_iconphoto') as mock_wm_iconphoto:
|
||||
|
||||
result = ui_manager.setup_application_icon("test_icon.png")
|
||||
|
||||
assert result is True
|
||||
mock_image_open.assert_called_once_with("test_icon.png")
|
||||
mock_image.resize.assert_called_once()
|
||||
ui_manager.logger.info.assert_called_with("Trying to load icon from: test_icon.png")
|
||||
|
||||
@patch('os.path.exists')
|
||||
def test_setup_application_icon_file_not_found(self, mock_exists, ui_manager):
|
||||
"""Test icon setup when file is not found."""
|
||||
mock_exists.return_value = False
|
||||
|
||||
result = ui_manager.setup_application_icon("nonexistent_icon.png")
|
||||
|
||||
assert result is False
|
||||
ui_manager.logger.warning.assert_called_with("Icon file not found at nonexistent_icon.png")
|
||||
|
||||
@patch('os.path.exists')
|
||||
@patch('PIL.Image.open')
|
||||
def test_setup_application_icon_exception(self, mock_image_open, mock_exists, ui_manager):
|
||||
"""Test icon setup with exception."""
|
||||
mock_exists.return_value = True
|
||||
mock_image_open.side_effect = Exception("Test error")
|
||||
|
||||
result = ui_manager.setup_application_icon("test_icon.png")
|
||||
|
||||
assert result is False
|
||||
ui_manager.logger.error.assert_called_with("Error setting icon: Test error")
|
||||
|
||||
@patch('sys._MEIPASS', '/test/bundle/path', create=True)
|
||||
@patch('os.path.exists')
|
||||
@patch('PIL.Image.open')
|
||||
def test_setup_application_icon_pyinstaller_bundle(self, mock_image_open, mock_exists, ui_manager):
|
||||
"""Test icon setup in PyInstaller bundle."""
|
||||
# Mock exists to return False for original path, True for bundle path
|
||||
def mock_exists_side_effect(path):
|
||||
if 'test_icon.png' in path and '/test/bundle/path' in path:
|
||||
return True
|
||||
return False
|
||||
|
||||
mock_exists.side_effect = mock_exists_side_effect
|
||||
mock_image = Mock()
|
||||
mock_image.resize.return_value = mock_image
|
||||
mock_image_open.return_value = mock_image
|
||||
|
||||
with patch('PIL.ImageTk.PhotoImage') as mock_photo:
|
||||
mock_photo_instance = Mock()
|
||||
mock_photo.return_value = mock_photo_instance
|
||||
|
||||
with patch.object(ui_manager.root, 'iconphoto') as mock_iconphoto, \
|
||||
patch.object(ui_manager.root, 'wm_iconphoto') as mock_wm_iconphoto:
|
||||
|
||||
result = ui_manager.setup_application_icon("test_icon.png")
|
||||
|
||||
assert result is True
|
||||
ui_manager.logger.info.assert_called_with("Found icon in PyInstaller bundle: /test/bundle/path/test_icon.png")
|
||||
|
||||
def test_create_graph_frame(self, ui_manager, root_window):
|
||||
"""Test creation of graph frame."""
|
||||
main_frame = ttk.Frame(root_window)
|
||||
|
||||
graph_frame = ui_manager.create_graph_frame(main_frame)
|
||||
|
||||
assert isinstance(graph_frame, ttk.LabelFrame)
|
||||
assert graph_frame.winfo_parent() == str(main_frame)
|
||||
|
||||
def test_create_input_frame(self, ui_manager, root_window):
|
||||
"""Test creation of input frame."""
|
||||
main_frame = ttk.Frame(root_window)
|
||||
|
||||
input_ui = ui_manager.create_input_frame(main_frame)
|
||||
|
||||
assert isinstance(input_ui, dict)
|
||||
assert "frame" in input_ui
|
||||
assert "symptom_vars" in input_ui
|
||||
assert "medicine_vars" in input_ui
|
||||
assert "note_var" in input_ui
|
||||
assert "date_var" in input_ui
|
||||
|
||||
assert isinstance(input_ui["frame"], ttk.LabelFrame)
|
||||
assert isinstance(input_ui["symptom_vars"], dict)
|
||||
assert isinstance(input_ui["medicine_vars"], dict)
|
||||
assert isinstance(input_ui["note_var"], tk.StringVar)
|
||||
assert isinstance(input_ui["date_var"], tk.StringVar)
|
||||
|
||||
def test_create_input_frame_symptom_vars(self, ui_manager, root_window):
|
||||
"""Test that symptom variables are created correctly."""
|
||||
main_frame = ttk.Frame(root_window)
|
||||
|
||||
input_ui = ui_manager.create_input_frame(main_frame)
|
||||
symptom_vars = input_ui["symptom_vars"]
|
||||
|
||||
expected_symptoms = ["depression", "anxiety", "sleep", "appetite"]
|
||||
for symptom in expected_symptoms:
|
||||
assert symptom in symptom_vars
|
||||
assert isinstance(symptom_vars[symptom], tk.IntVar)
|
||||
|
||||
def test_create_input_frame_medicine_vars(self, ui_manager, root_window):
|
||||
"""Test that medicine variables are created correctly."""
|
||||
main_frame = ttk.Frame(root_window)
|
||||
|
||||
input_ui = ui_manager.create_input_frame(main_frame)
|
||||
medicine_vars = input_ui["medicine_vars"]
|
||||
|
||||
expected_medicines = ["bupropion", "hydroxyzine", "gabapentin", "propranolol", "quetiapine"]
|
||||
for medicine in expected_medicines:
|
||||
assert medicine in medicine_vars
|
||||
assert isinstance(medicine_vars[medicine], tuple)
|
||||
assert len(medicine_vars[medicine]) == 2 # IntVar and display text
|
||||
assert isinstance(medicine_vars[medicine][0], tk.IntVar)
|
||||
assert isinstance(medicine_vars[medicine][1], str)
|
||||
|
||||
@patch('src.ui_manager.datetime')
|
||||
def test_create_input_frame_default_date(self, mock_datetime, ui_manager, root_window):
|
||||
"""Test that default date is set to today."""
|
||||
mock_datetime.now.return_value.strftime.return_value = "07/30/2025"
|
||||
|
||||
main_frame = ttk.Frame(root_window)
|
||||
input_ui = ui_manager.create_input_frame(main_frame)
|
||||
|
||||
# The actual date will be today's date, not the mocked value
|
||||
# because the datetime import is within the function
|
||||
assert input_ui["date_var"].get() == "07/30/2025"
|
||||
|
||||
def test_create_table_frame(self, ui_manager, root_window):
|
||||
"""Test creation of table frame."""
|
||||
main_frame = ttk.Frame(root_window)
|
||||
|
||||
table_ui = ui_manager.create_table_frame(main_frame)
|
||||
|
||||
assert isinstance(table_ui, dict)
|
||||
assert "tree" in table_ui
|
||||
assert isinstance(table_ui["tree"], ttk.Treeview)
|
||||
|
||||
def test_create_table_frame_columns(self, ui_manager, root_window):
|
||||
"""Test that table columns are set up correctly."""
|
||||
main_frame = ttk.Frame(root_window)
|
||||
|
||||
table_ui = ui_manager.create_table_frame(main_frame)
|
||||
tree = table_ui["tree"]
|
||||
|
||||
expected_columns = [
|
||||
"Date", "Depression", "Anxiety", "Sleep", "Appetite",
|
||||
"Bupropion", "Hydroxyzine", "Gabapentin", "Propranolol", "Quetiapine", "Note"
|
||||
]
|
||||
|
||||
# Check that columns are configured
|
||||
assert tree["columns"] == tuple(expected_columns)
|
||||
|
||||
def test_add_buttons(self, ui_manager, root_window):
|
||||
"""Test adding buttons to a frame."""
|
||||
frame = ttk.Frame(root_window)
|
||||
|
||||
buttons_config = [
|
||||
{"text": "Test Button 1", "command": lambda: None},
|
||||
{"text": "Test Button 2", "command": lambda: None, "fill": "x"},
|
||||
]
|
||||
|
||||
ui_manager.add_buttons(frame, buttons_config)
|
||||
|
||||
# Check that a button frame was added
|
||||
children = frame.winfo_children()
|
||||
assert len(children) >= 1 # At least the button frame should be added
|
||||
|
||||
def test_create_edit_window(self, ui_manager):
|
||||
"""Test creation of edit window."""
|
||||
values = ("2024-01-01", "3", "2", "4", "3", "1", "0", "2", "1", "Test note")
|
||||
callbacks = {
|
||||
"save": lambda win, *args: None,
|
||||
"delete": lambda win: None
|
||||
}
|
||||
|
||||
edit_window = ui_manager.create_edit_window(values, callbacks)
|
||||
|
||||
assert isinstance(edit_window, tk.Toplevel)
|
||||
assert edit_window.title() == "Edit Entry"
|
||||
|
||||
def test_create_edit_window_widgets(self, ui_manager):
|
||||
"""Test that edit window contains expected widgets."""
|
||||
values = ("2024-01-01", "3", "2", "4", "3", "1", "0", "2", "1", "Test note")
|
||||
callbacks = {
|
||||
"save": lambda win, *args: None,
|
||||
"delete": lambda win: None
|
||||
}
|
||||
|
||||
edit_window = ui_manager.create_edit_window(values, callbacks)
|
||||
|
||||
# Check that window has children (widgets)
|
||||
children = edit_window.winfo_children()
|
||||
assert len(children) > 0
|
||||
|
||||
def test_create_edit_window_initial_values(self, ui_manager):
|
||||
"""Test that edit window is populated with initial values."""
|
||||
values = ("2024-01-01", "3", "2", "4", "3", "1", "0", "2", "1", "Test note")
|
||||
callbacks = {
|
||||
"save": lambda win, *args: None,
|
||||
"delete": lambda win: None
|
||||
}
|
||||
|
||||
edit_window = ui_manager.create_edit_window(values, callbacks)
|
||||
|
||||
# The window should be created successfully
|
||||
assert edit_window is not None
|
||||
# More detailed testing would require examining the internal widgets
|
||||
|
||||
def test_frame_positioning(self, ui_manager, root_window):
|
||||
"""Test that frames are positioned correctly."""
|
||||
main_frame = ttk.Frame(root_window)
|
||||
|
||||
# Create multiple frames
|
||||
graph_frame = ui_manager.create_graph_frame(main_frame)
|
||||
input_ui = ui_manager.create_input_frame(main_frame)
|
||||
table_ui = ui_manager.create_table_frame(main_frame)
|
||||
|
||||
# All frames should be created successfully
|
||||
assert graph_frame is not None
|
||||
assert input_ui["frame"] is not None
|
||||
assert table_ui["tree"] is not None
|
||||
|
||||
def test_widget_configuration(self, ui_manager, root_window):
|
||||
"""Test that widgets are configured with appropriate properties."""
|
||||
main_frame = ttk.Frame(root_window)
|
||||
input_ui = ui_manager.create_input_frame(main_frame)
|
||||
|
||||
# Check that variables have default values
|
||||
for var in input_ui["symptom_vars"].values():
|
||||
assert var.get() == 0
|
||||
|
||||
for medicine_data in input_ui["medicine_vars"].values():
|
||||
assert medicine_data[0].get() == 0 # IntVar should be 0
|
||||
|
||||
@patch('tkinter.messagebox.showerror')
|
||||
def test_error_handling_in_setup_application_icon(self, mock_showerror, ui_manager):
|
||||
"""Test error handling in setup_application_icon method."""
|
||||
with patch('PIL.Image.open') as mock_open:
|
||||
mock_open.side_effect = Exception("Image error")
|
||||
|
||||
result = ui_manager.setup_application_icon("test.png")
|
||||
|
||||
assert result is False
|
||||
ui_manager.logger.error.assert_called()
|
||||
@@ -96,6 +96,59 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/87/0e/66dbd4c6a7f0758a8d18044c048779ba21fb94856e1edcf764bd5403e710/coverage-7.10.1.tar.gz", hash = "sha256:ae2b4856f29ddfe827106794f3589949a57da6f0d38ab01e24ec35107979ba57", size = 819938, upload-time = "2025-07-27T14:13:39.045Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/72/135ff5fef09b1ffe78dbe6fcf1e16b2e564cd35faeacf3d63d60d887f12d/coverage-7.10.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebb08d0867c5a25dffa4823377292a0ffd7aaafb218b5d4e2e106378b1061e39", size = 214960, upload-time = "2025-07-27T14:11:55.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/aa/73a5d1a6fc08ca709a8177825616aa95ee6bf34d522517c2595484a3e6c9/coverage-7.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f32a95a83c2e17422f67af922a89422cd24c6fa94041f083dd0bb4f6057d0bc7", size = 215220, upload-time = "2025-07-27T14:11:57.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/40/3124fdd45ed3772a42fc73ca41c091699b38a2c3bd4f9cb564162378e8b6/coverage-7.10.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c4c746d11c8aba4b9f58ca8bfc6fbfd0da4efe7960ae5540d1a1b13655ee8892", size = 245772, upload-time = "2025-07-27T14:12:00.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/62/a77b254822efa8c12ad59e8039f2bc3df56dc162ebda55e1943e35ba31a5/coverage-7.10.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7f39edd52c23e5c7ed94e0e4bf088928029edf86ef10b95413e5ea670c5e92d7", size = 248116, upload-time = "2025-07-27T14:12:03.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/01/8101f062f472a3a6205b458d18ef0444a63ae5d36a8a5ed5dd0f6167f4db/coverage-7.10.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab6e19b684981d0cd968906e293d5628e89faacb27977c92f3600b201926b994", size = 249554, upload-time = "2025-07-27T14:12:04.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/7b/e51bc61573e71ff7275a4f167aecbd16cb010aefdf54bcd8b0a133391263/coverage-7.10.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5121d8cf0eacb16133501455d216bb5f99899ae2f52d394fe45d59229e6611d0", size = 247766, upload-time = "2025-07-27T14:12:06.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/71/1c96d66a51d4204a9d6d12df53c4071d87e110941a2a1fe94693192262f5/coverage-7.10.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df1c742ca6f46a6f6cbcaef9ac694dc2cb1260d30a6a2f5c68c5f5bcfee1cfd7", size = 245735, upload-time = "2025-07-27T14:12:08.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/d5/efbc2ac4d35ae2f22ef6df2ca084c60e13bd9378be68655e3268c80349ab/coverage-7.10.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:40f9a38676f9c073bf4b9194707aa1eb97dca0e22cc3766d83879d72500132c7", size = 247118, upload-time = "2025-07-27T14:12:09.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/22/073848352bec28ca65f2b6816b892fcf9a31abbef07b868487ad15dd55f1/coverage-7.10.1-cp313-cp313-win32.whl", hash = "sha256:2348631f049e884839553b9974f0821d39241c6ffb01a418efce434f7eba0fe7", size = 217381, upload-time = "2025-07-27T14:12:11.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/df/df6a0ff33b042f000089bd11b6bb034bab073e2ab64a56e78ed882cba55d/coverage-7.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:4072b31361b0d6d23f750c524f694e1a417c1220a30d3ef02741eed28520c48e", size = 218152, upload-time = "2025-07-27T14:12:13.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/e3/5085ca849a40ed6b47cdb8f65471c2f754e19390b5a12fa8abd25cbfaa8f/coverage-7.10.1-cp313-cp313-win_arm64.whl", hash = "sha256:3e31dfb8271937cab9425f19259b1b1d1f556790e98eb266009e7a61d337b6d4", size = 216559, upload-time = "2025-07-27T14:12:14.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/93/58714efbfdeb547909feaabe1d67b2bdd59f0597060271b9c548d5efb529/coverage-7.10.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1c4f679c6b573a5257af6012f167a45be4c749c9925fd44d5178fd641ad8bf72", size = 215677, upload-time = "2025-07-27T14:12:16.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/0c/18eaa5897e7e8cb3f8c45e563e23e8a85686b4585e29d53cacb6bc9cb340/coverage-7.10.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:871ebe8143da284bd77b84a9136200bd638be253618765d21a1fce71006d94af", size = 215899, upload-time = "2025-07-27T14:12:18.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/c1/9d1affacc3c75b5a184c140377701bbf14fc94619367f07a269cd9e4fed6/coverage-7.10.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:998c4751dabf7d29b30594af416e4bf5091f11f92a8d88eb1512c7ba136d1ed7", size = 257140, upload-time = "2025-07-27T14:12:20.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/0f/339bc6b8fa968c346df346068cca1f24bdea2ddfa93bb3dc2e7749730962/coverage-7.10.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:780f750a25e7749d0af6b3631759c2c14f45de209f3faaa2398312d1c7a22759", size = 259005, upload-time = "2025-07-27T14:12:22.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/22/89390864b92ea7c909079939b71baba7e5b42a76bf327c1d615bd829ba57/coverage-7.10.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:590bdba9445df4763bdbebc928d8182f094c1f3947a8dc0fc82ef014dbdd8324", size = 261143, upload-time = "2025-07-27T14:12:23.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/56/3d04d89017c0c41c7a71bd69b29699d919b6bbf2649b8b2091240b97dd6a/coverage-7.10.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b2df80cb6a2af86d300e70acb82e9b79dab2c1e6971e44b78dbfc1a1e736b53", size = 258735, upload-time = "2025-07-27T14:12:25.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/40/312252c8afa5ca781063a09d931f4b9409dc91526cd0b5a2b84143ffafa2/coverage-7.10.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d6a558c2725bfb6337bf57c1cd366c13798bfd3bfc9e3dd1f4a6f6fc95a4605f", size = 256871, upload-time = "2025-07-27T14:12:27.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/2b/564947d5dede068215aaddb9e05638aeac079685101462218229ddea9113/coverage-7.10.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e6150d167f32f2a54690e572e0a4c90296fb000a18e9b26ab81a6489e24e78dd", size = 257692, upload-time = "2025-07-27T14:12:29.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/1b/c8a867ade85cb26d802aea2209b9c2c80613b9c122baa8c8ecea6799648f/coverage-7.10.1-cp313-cp313t-win32.whl", hash = "sha256:d946a0c067aa88be4a593aad1236493313bafaa27e2a2080bfe88db827972f3c", size = 218059, upload-time = "2025-07-27T14:12:31.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/fe/cd4ab40570ae83a516bf5e754ea4388aeedd48e660e40c50b7713ed4f930/coverage-7.10.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e37c72eaccdd5ed1130c67a92ad38f5b2af66eeff7b0abe29534225db2ef7b18", size = 219150, upload-time = "2025-07-27T14:12:32.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/16/6e5ed5854be6d70d0c39e9cb9dd2449f2c8c34455534c32c1a508c7dbdb5/coverage-7.10.1-cp313-cp313t-win_arm64.whl", hash = "sha256:89ec0ffc215c590c732918c95cd02b55c7d0f569d76b90bb1a5e78aa340618e4", size = 217014, upload-time = "2025-07-27T14:12:34.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/8e/6d0bfe9c3d7121cf936c5f8b03e8c3da1484fb801703127dba20fb8bd3c7/coverage-7.10.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:166d89c57e877e93d8827dac32cedae6b0277ca684c6511497311249f35a280c", size = 214951, upload-time = "2025-07-27T14:12:36.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/29/e3e51a8c653cf2174c60532aafeb5065cea0911403fa144c9abe39790308/coverage-7.10.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bed4a2341b33cd1a7d9ffc47df4a78ee61d3416d43b4adc9e18b7d266650b83e", size = 215229, upload-time = "2025-07-27T14:12:37.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/59/3c972080b2fa18b6c4510201f6d4dc87159d450627d062cd9ad051134062/coverage-7.10.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ddca1e4f5f4c67980533df01430184c19b5359900e080248bbf4ed6789584d8b", size = 245738, upload-time = "2025-07-27T14:12:39.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/04/fc0d99d3f809452654e958e1788454f6e27b34e43f8f8598191c8ad13537/coverage-7.10.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:37b69226001d8b7de7126cad7366b0778d36777e4d788c66991455ba817c5b41", size = 248045, upload-time = "2025-07-27T14:12:41.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/2e/afcbf599e77e0dfbf4c97197747250d13d397d27e185b93987d9eaac053d/coverage-7.10.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2f22102197bcb1722691296f9e589f02b616f874e54a209284dd7b9294b0b7f", size = 249666, upload-time = "2025-07-27T14:12:43.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/ae/bc47f7f8ecb7a06cbae2bf86a6fa20f479dd902bc80f57cff7730438059d/coverage-7.10.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1e0c768b0f9ac5839dac5cf88992a4bb459e488ee8a1f8489af4cb33b1af00f1", size = 247692, upload-time = "2025-07-27T14:12:44.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/26/cbfa3092d31ccba8ba7647e4d25753263e818b4547eba446b113d7d1efdf/coverage-7.10.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:991196702d5e0b120a8fef2664e1b9c333a81d36d5f6bcf6b225c0cf8b0451a2", size = 245536, upload-time = "2025-07-27T14:12:46.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/77/9c68e92500e6a1c83d024a70eadcc9a173f21aadd73c4675fe64c9c43fdf/coverage-7.10.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae8e59e5f4fd85d6ad34c2bb9d74037b5b11be072b8b7e9986beb11f957573d4", size = 246954, upload-time = "2025-07-27T14:12:49.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/a5/ba96671c5a669672aacd9877a5987c8551501b602827b4e84256da2a30a7/coverage-7.10.1-cp314-cp314-win32.whl", hash = "sha256:042125c89cf74a074984002e165d61fe0e31c7bd40ebb4bbebf07939b5924613", size = 217616, upload-time = "2025-07-27T14:12:51.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/3c/e1e1eb95fc1585f15a410208c4795db24a948e04d9bde818fe4eb893bc85/coverage-7.10.1-cp314-cp314-win_amd64.whl", hash = "sha256:a22c3bfe09f7a530e2c94c87ff7af867259c91bef87ed2089cd69b783af7b84e", size = 218412, upload-time = "2025-07-27T14:12:53.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/85/7e1e5be2cb966cba95566ba702b13a572ca744fbb3779df9888213762d67/coverage-7.10.1-cp314-cp314-win_arm64.whl", hash = "sha256:ee6be07af68d9c4fca4027c70cea0c31a0f1bc9cb464ff3c84a1f916bf82e652", size = 216776, upload-time = "2025-07-27T14:12:55.482Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/0f/5bb8f29923141cca8560fe2217679caf4e0db643872c1945ac7d8748c2a7/coverage-7.10.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d24fb3c0c8ff0d517c5ca5de7cf3994a4cd559cde0315201511dbfa7ab528894", size = 215698, upload-time = "2025-07-27T14:12:57.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/29/547038ffa4e8e4d9e82f7dfc6d152f75fcdc0af146913f0ba03875211f03/coverage-7.10.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1217a54cfd79be20512a67ca81c7da3f2163f51bbfd188aab91054df012154f5", size = 215902, upload-time = "2025-07-27T14:12:59.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/8a/7aaa8fbfaed900147987a424e112af2e7790e1ac9cd92601e5bd4e1ba60a/coverage-7.10.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:51f30da7a52c009667e02f125737229d7d8044ad84b79db454308033a7808ab2", size = 257230, upload-time = "2025-07-27T14:13:01.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/1d/c252b5ffac44294e23a0d79dd5acf51749b39795ccc898faeabf7bee903f/coverage-7.10.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ed3718c757c82d920f1c94089066225ca2ad7f00bb904cb72b1c39ebdd906ccb", size = 259194, upload-time = "2025-07-27T14:13:03.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/ad/6c8d9f83d08f3bac2e7507534d0c48d1a4f52c18e6f94919d364edbdfa8f/coverage-7.10.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc452481e124a819ced0c25412ea2e144269ef2f2534b862d9f6a9dae4bda17b", size = 261316, upload-time = "2025-07-27T14:13:04.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/4e/f9bbf3a36c061e2e0e0f78369c006d66416561a33d2bee63345aee8ee65e/coverage-7.10.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9d6f494c307e5cb9b1e052ec1a471060f1dea092c8116e642e7a23e79d9388ea", size = 258794, upload-time = "2025-07-27T14:13:06.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/82/e600bbe78eb2cb0541751d03cef9314bcd0897e8eea156219c39b685f869/coverage-7.10.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fc0e46d86905ddd16b85991f1f4919028092b4e511689bbdaff0876bd8aab3dd", size = 256869, upload-time = "2025-07-27T14:13:08.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/5d/2fc9a9236c5268f68ac011d97cd3a5ad16cc420535369bedbda659fdd9b7/coverage-7.10.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80b9ccd82e30038b61fc9a692a8dc4801504689651b281ed9109f10cc9fe8b4d", size = 257765, upload-time = "2025-07-27T14:13:10.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/05/b4e00b2bd48a2dc8e1c7d2aea7455f40af2e36484ab2ef06deb85883e9fe/coverage-7.10.1-cp314-cp314t-win32.whl", hash = "sha256:e58991a2b213417285ec866d3cd32db17a6a88061a985dbb7e8e8f13af429c47", size = 218420, upload-time = "2025-07-27T14:13:12.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/fb/d21d05f33ea27ece327422240e69654b5932b0b29e7fbc40fbab3cf199bf/coverage-7.10.1-cp314-cp314t-win_amd64.whl", hash = "sha256:e88dd71e4ecbc49d9d57d064117462c43f40a21a1383507811cf834a4a620651", size = 219536, upload-time = "2025-07-27T14:13:14.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/68/7fea94b141281ed8be3d1d5c4319a97f2befc3e487ce33657fc64db2c45e/coverage-7.10.1-cp314-cp314t-win_arm64.whl", hash = "sha256:1aadfb06a30c62c2eb82322171fe1f7c288c80ca4156d46af0ca039052814bab", size = 217190, upload-time = "2025-07-27T14:13:16.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/64/922899cff2c0fd3496be83fa8b81230f5a8d82a2ad30f98370b133c2c83b/coverage-7.10.1-py3-none-any.whl", hash = "sha256:fa2a258aa6bf188eb9a8948f7102a83da7c430a0dce918dbd8b60ef8fcb772d7", size = 206597, upload-time = "2025-07-27T14:13:37.221Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cycler"
|
||||
version = "0.12.1"
|
||||
@@ -160,6 +213,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/cd/18f8da995b658420625f7ef13f037be53ae04ec5ad33f9b718240dcfd48c/identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2", size = 99145, upload-time = "2025-05-23T20:37:51.495Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kiwisolver"
|
||||
version = "1.4.8"
|
||||
@@ -409,6 +471,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
version = "4.2.0"
|
||||
@@ -425,6 +496,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyinstaller"
|
||||
version = "6.14.2"
|
||||
@@ -475,6 +555,48 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120, upload-time = "2025-03-25T05:01:24.908Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "6.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload-time = "2025-06-12T10:47:47.684Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-mock"
|
||||
version = "3.14.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/28/67172c96ba684058a4d24ffe144d64783d2a270d0af0d9e792737bddc75c/pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e", size = 33241, upload-time = "2025-05-26T13:58:45.167Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
@@ -576,7 +698,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "thechart"
|
||||
version = "1.0.1"
|
||||
version = "1.6.1"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "colorlog" },
|
||||
@@ -588,8 +710,12 @@ dependencies = [
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pre-commit" },
|
||||
{ name = "pyinstaller" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-mock" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
@@ -604,8 +730,12 @@ requires-dist = [
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "coverage", specifier = ">=7.3.0" },
|
||||
{ name = "pre-commit", specifier = ">=4.2.0" },
|
||||
{ name = "pyinstaller", specifier = ">=6.14.2" },
|
||||
{ name = "pytest", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-cov", specifier = ">=4.0.0" },
|
||||
{ name = "pytest-mock", specifier = ">=3.12.0" },
|
||||
{ name = "ruff", specifier = ">=0.12.5" },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user