Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -14,6 +14,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Fetch full history for release notes generation
|
||||||
|
|
||||||
- name: Install Docker
|
- name: Install Docker
|
||||||
run: curl -fsSL https://get.docker.com | sh
|
run: curl -fsSL https://get.docker.com | sh
|
||||||
@@ -55,3 +57,49 @@ jobs:
|
|||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=registry,ref=gitea-http.taildb3494.ts.net/will/thechart:buildcache
|
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
|
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
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Build and distribution
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Python bytecode
|
||||||
*.pyc
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
*.spec
|
*.spec
|
||||||
|
|
||||||
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
.venv/
|
.venv/
|
||||||
.poetry/
|
.poetry/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Testing
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
coverage.xml
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
|
||||||
|
# Code quality tools
|
||||||
.ruff_cache/
|
.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: uv-export
|
||||||
# - id: pip-compile
|
# - id: pip-compile
|
||||||
# args: [requirements.in, -o, requirements.txt]
|
# 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
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"editor.autoIndent": "advanced"
|
"editor.autoIndent": "advanced"
|
||||||
},
|
},
|
||||||
"ansible.python.interpreterPath": "/home/will/Code/thechart/.venv/bin/python",
|
"ansible.python.interpreterPath": "${workspaceFolder}/.venv/bin/python",
|
||||||
"makefile.configureOnOpen": true,
|
"makefile.configureOnOpen": true,
|
||||||
"vs-kubernetes": {
|
"vs-kubernetes": {
|
||||||
"vs-kubernetes.crd-code-completion": "enabled",
|
"vs-kubernetes.crd-code-completion": "enabled",
|
||||||
|
|||||||
Vendored
+21
-1
@@ -4,10 +4,30 @@
|
|||||||
{
|
{
|
||||||
"label": "Run TheChart App",
|
"label": "Run TheChart App",
|
||||||
"type": "shell",
|
"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",
|
"group": "build",
|
||||||
"isBackground": false,
|
"isBackground": false,
|
||||||
"problemMatcher": []
|
"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 chown -R ${UID}:${GUID} /home/docker_user/
|
||||||
RUN chmod -R 777 /home/docker_user/${TARGET}
|
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
|
# Set environment variables for X11 forwarding
|
||||||
ENV DISPLAY=:0
|
ENV DISPLAY=:0
|
||||||
ENV XAUTHORITY=/tmp/.docker.xauth
|
ENV XAUTHORITY=/tmp/.docker.xauth
|
||||||
|
|||||||
@@ -1,32 +1,105 @@
|
|||||||
TARGET=thechart
|
TARGET=thechart
|
||||||
VERSION=1.0.0
|
VERSION=1.6.1
|
||||||
ROOT=/home/will
|
ROOT=/home/will
|
||||||
ICON=chart-671.png
|
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
|
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}'
|
@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
|
install: ## Set up the development environment
|
||||||
@echo "Setting up the development environment..."
|
@echo "Setting up the development environment..."
|
||||||
# poetry env use 3.13
|
@echo "Creating virtual environment..."
|
||||||
# eval $(poetry env use 3.13) # bash/zsh/csh
|
@bash -c 'if [ -d "$(VENV_DIR)" ]; then \
|
||||||
eval (poetry env activate)
|
echo "Virtual environment already exists. Recreating..."; \
|
||||||
poetry install --no-root
|
rm -rf $(VENV_DIR); \
|
||||||
poetry run pre-commit install --install-hooks --overwrite
|
fi'
|
||||||
poetry run pre-commit autoupdate
|
uv venv $(VENV_DIR) --python=python3.13
|
||||||
poetry run pre-commit run --all-files
|
@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
|
build: ## Build the Docker image
|
||||||
@echo "Building the Docker image..."
|
@echo "Building the Docker image..."
|
||||||
docker buildx build --platform linux/amd64,linux/arm64 -t ${IMAGE} --push .
|
docker buildx build --platform linux/amd64,linux/arm64 -t ${IMAGE} --push .
|
||||||
deploy: ## Deploy the application as a standalone executable
|
deploy: ## Deploy the application as a standalone executable
|
||||||
@echo "Deploying the application..."
|
@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 ./thechart_data.csv ${ROOT}/Documents/
|
||||||
cp -f ./dist/${TARGET} ${ROOT}/Applications/
|
cp -f ./dist/${TARGET} ${ROOT}/Applications/
|
||||||
cp -f ./deploy/${TARGET}.desktop ${ROOT}/.local/share/applications/
|
cp -f ./deploy/${TARGET}.desktop ${ROOT}/.local/share/applications/
|
||||||
desktop-file-validate ${ROOT}/.local/share/applications/${TARGET}.desktop
|
desktop-file-validate ${ROOT}/.local/share/applications/${TARGET}.desktop
|
||||||
run: ## Run the application
|
run: $(VENV_ACTIVATE) ## Run the application
|
||||||
@echo "Running 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
|
start: ## Start the application
|
||||||
@echo "Starting the application..."
|
@echo "Starting the application..."
|
||||||
docker-compose up -d --build
|
docker-compose up -d --build
|
||||||
@@ -35,7 +108,34 @@ stop: ## Stop the application
|
|||||||
docker-compose down
|
docker-compose down
|
||||||
test: ## Run the tests
|
test: ## Run the tests
|
||||||
@echo "Running 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
|
lint: ## Run the linter
|
||||||
@echo "Running the linter..."
|
@echo "Running the linter..."
|
||||||
docker-compose exec ${TARGET} pipenv run pre-commit run --all-files
|
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
|
docker-compose exec -it ${TARGET} /bin/bash
|
||||||
shell: ## Open a shell in the local environment
|
shell: ## Open a shell in the local environment
|
||||||
@echo "Opening 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
|
requirements: ## Export the requirements to a file
|
||||||
@echo "Exporting requirements to requirements.txt..."
|
@echo "Exporting requirements to requirements.txt..."
|
||||||
poetry export --without-hashes -f requirements.txt -o 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,15 +1,34 @@
|
|||||||
# Thechart
|
# TheChart
|
||||||
App to manage medication and see the evolution of its effects.
|
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
|
## Table of Contents
|
||||||
- [Prerequisites](#prerequisites)
|
- [Prerequisites](#prerequisites)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Running the Application](#running-the-application)
|
- [Running the Application](#running-the-application)
|
||||||
|
- [Key Features](#key-features)
|
||||||
- [Development](#development)
|
- [Development](#development)
|
||||||
- [Deployment](#deployment)
|
- [Deployment](#deployment)
|
||||||
- [Docker Usage](#docker-usage)
|
- [Docker Usage](#docker-usage)
|
||||||
- [Troubleshooting](#troubleshooting)
|
- [Troubleshooting](#troubleshooting)
|
||||||
- [Make Commands Reference](#make-commands-reference)
|
- [Quick Reference](#quick-reference)
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -179,75 +198,85 @@ python src/main.py
|
|||||||
On first run, the application will:
|
On first run, the application will:
|
||||||
- Create a default CSV data file (`thechart_data.csv`) if it doesn't exist
|
- Create a default CSV data file (`thechart_data.csv`) if it doesn't exist
|
||||||
- Set up logging in the `logs/` directory
|
- Set up logging in the `logs/` directory
|
||||||
- Create necessary configuration files
|
- 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
|
## Development
|
||||||
|
|
||||||
### Code Quality Tools
|
### Testing Framework
|
||||||
The project includes several code quality tools that are automatically set up:
|
TheChart includes a comprehensive testing suite with **93% code coverage**:
|
||||||
|
|
||||||
#### Formatting and Linting
|
```bash
|
||||||
```shell
|
# Run all tests
|
||||||
make format # Format code with ruff
|
make test
|
||||||
make lint # Run linter checks
|
|
||||||
|
# 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
|
||||||
```
|
```
|
||||||
|
|
||||||
**With uv directly:**
|
**Testing Statistics:**
|
||||||
```shell
|
- **112 total tests** across 6 test modules
|
||||||
uv run ruff format . # Format code
|
- **93% overall coverage** (482 statements, 33 missed)
|
||||||
uv run ruff check . # Check for issues
|
- **Pre-commit testing** prevents broken commits
|
||||||
```
|
|
||||||
|
|
||||||
#### Running Tests
|
### Code Quality
|
||||||
```shell
|
```bash
|
||||||
make test # Run unit tests
|
# Format code
|
||||||
```
|
make format
|
||||||
|
|
||||||
**With uv directly:**
|
# Check code quality
|
||||||
```shell
|
make lint
|
||||||
uv run pytest # Run tests with pytest
|
|
||||||
|
# Run pre-commit checks
|
||||||
|
pre-commit run --all-files
|
||||||
```
|
```
|
||||||
|
|
||||||
### Package Management with uv
|
### Package Management with uv
|
||||||
|
```bash
|
||||||
#### Adding Dependencies
|
# Add dependencies
|
||||||
```shell
|
|
||||||
# Add a runtime dependency
|
|
||||||
uv add package-name
|
uv add package-name
|
||||||
|
|
||||||
# Add a development dependency
|
# Add development dependencies
|
||||||
uv add --dev package-name
|
uv add --dev package-name
|
||||||
|
|
||||||
# Add specific version
|
# Update dependencies
|
||||||
uv add "package-name>=1.0.0"
|
uv sync --upgrade
|
||||||
```
|
|
||||||
|
|
||||||
#### Removing Dependencies
|
# Remove dependencies
|
||||||
```shell
|
|
||||||
uv remove package-name
|
uv remove package-name
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Updating Dependencies
|
For detailed development information, see **[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)**.
|
||||||
```shell
|
|
||||||
# Update all dependencies
|
|
||||||
uv sync --upgrade
|
|
||||||
|
|
||||||
# Update specific package
|
|
||||||
uv add "package-name>=new-version"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Pre-commit Hooks
|
|
||||||
Pre-commit hooks are automatically installed and will run on every commit to ensure code quality. They include:
|
|
||||||
- Code formatting with ruff
|
|
||||||
- Linting checks
|
|
||||||
- Import sorting
|
|
||||||
- Basic file checks
|
|
||||||
|
|
||||||
### Development Dependencies
|
|
||||||
The following development tools are included:
|
|
||||||
- **ruff** - Fast Python linter and formatter
|
|
||||||
- **pre-commit** - Git hook management
|
|
||||||
- **pyinstaller** - For creating standalone executables
|
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
@@ -312,43 +341,33 @@ python src/main.py
|
|||||||
|
|
||||||
## Docker Usage
|
## Docker Usage
|
||||||
|
|
||||||
## Docker Usage
|
### Quick Start with Docker
|
||||||
|
```bash
|
||||||
### Building the Container Image
|
# Build and start the application
|
||||||
Build a multi-platform Docker image:
|
|
||||||
```shell
|
|
||||||
make build
|
make build
|
||||||
```
|
|
||||||
|
|
||||||
### Running with Docker Compose
|
|
||||||
The project includes Docker Compose configuration for easy container management:
|
|
||||||
|
|
||||||
1. **Start the application:**
|
|
||||||
```shell
|
|
||||||
make start
|
make start
|
||||||
```
|
|
||||||
|
|
||||||
2. **Stop the application:**
|
# Stop the application
|
||||||
```shell
|
|
||||||
make stop
|
make stop
|
||||||
```
|
|
||||||
|
|
||||||
3. **Access container shell:**
|
# Access container shell
|
||||||
```shell
|
|
||||||
make attach
|
make attach
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manual Docker Commands
|
### Manual Docker Commands
|
||||||
If you prefer using Docker directly:
|
```bash
|
||||||
|
|
||||||
```shell
|
|
||||||
# Build image
|
# Build image
|
||||||
docker build -t thechart .
|
docker build -t thechart .
|
||||||
|
|
||||||
# Run container
|
# Run container with X11 forwarding (Linux)
|
||||||
docker run -it --rm thechart
|
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
|
## Troubleshooting
|
||||||
|
|
||||||
### Common Issues
|
### Common Issues
|
||||||
@@ -407,34 +426,10 @@ If you encounter issues not covered here:
|
|||||||
3. Try rebuilding the virtual environment
|
3. Try rebuilding the virtual environment
|
||||||
4. Verify file permissions for deployment directories
|
4. Verify file permissions for deployment directories
|
||||||
|
|
||||||
## Make Commands Reference
|
## Quick Reference
|
||||||
|
|
||||||
The project uses a Makefile to simplify common development and deployment tasks.
|
### Essential Commands
|
||||||
|
```bash
|
||||||
### Show Help Menu
|
|
||||||
```shell
|
|
||||||
make help
|
|
||||||
```
|
|
||||||
|
|
||||||
### Available Commands
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `install` | Set up the development environment |
|
|
||||||
| `run` | Run the application |
|
|
||||||
| `shell` | Open a shell in the local environment |
|
|
||||||
| `format` | Format the code with ruff |
|
|
||||||
| `lint` | Run the linter |
|
|
||||||
| `test` | Run the tests |
|
|
||||||
| `requirements` | Export the requirements to a file |
|
|
||||||
| `build` | Build the Docker image |
|
|
||||||
| `start` | Start the app (Docker) |
|
|
||||||
| `stop` | Stop the app (Docker) |
|
|
||||||
| `attach` | Open a shell in the container |
|
|
||||||
| `deploy` | Deploy standalone app executable |
|
|
||||||
| `help` | Show this help |
|
|
||||||
|
|
||||||
### Quick Reference
|
|
||||||
```shell
|
|
||||||
# Development workflow
|
# Development workflow
|
||||||
make install # One-time setup
|
make install # One-time setup
|
||||||
make run # Run application
|
make run # Run application
|
||||||
@@ -451,6 +446,35 @@ make start # Start containerized app
|
|||||||
make stop # Stop 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?
|
## Why uv?
|
||||||
@@ -471,13 +495,3 @@ make stop # Stop containerized app
|
|||||||
| Add package | `uv add package` | `poetry add package` |
|
| Add package | `uv add package` | `poetry add package` |
|
||||||
| Run command | `uv run command` | `poetry run command` |
|
| Run command | `uv run command` | `poetry run command` |
|
||||||
| Activate environment | `source .venv/bin/activate` | `poetry shell` |
|
| Activate environment | `source .venv/bin/activate` | `poetry shell` |
|
||||||
|
|
||||||
**Project Structure:**
|
|
||||||
- `src/` - Main application source code
|
|
||||||
- `logs/` - Application log files
|
|
||||||
- `deploy/` - Deployment configuration files
|
|
||||||
- `build/` - Build artifacts (created during deployment)
|
|
||||||
- `.venv/` - Virtual environment (created by uv)
|
|
||||||
- `uv.lock` - Lock file with exact dependency versions
|
|
||||||
- `pyproject.toml` - Project configuration and dependencies
|
|
||||||
- `thechart_data.csv` - Application data file
|
|
||||||
|
|||||||
@@ -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]
|
[project]
|
||||||
name = "thechart"
|
name = "thechart"
|
||||||
version = "1.0.1"
|
version = "1.6.1"
|
||||||
description = "Chart to monitor your medication intake over time."
|
description = "Chart to monitor your medication intake over time."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
@@ -13,7 +13,47 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[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]
|
[tool.ruff]
|
||||||
target-version = "py313" # Target Python 3.13
|
target-version = "py313" # Target Python 3.13
|
||||||
|
|||||||
@@ -3,3 +3,7 @@
|
|||||||
|
|
||||||
pre-commit
|
pre-commit
|
||||||
pyinstaller
|
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 os
|
||||||
|
import sys
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
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_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
LOG_PATH = os.getenv("LOG_PATH", "/tmp/logs/thechart")
|
LOG_PATH = os.getenv("LOG_PATH", "/tmp/logs/thechart")
|
||||||
|
|||||||
+108
-52
@@ -4,34 +4,47 @@ import os
|
|||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
|
from medicine_manager import MedicineManager
|
||||||
|
from pathology_manager import PathologyManager
|
||||||
|
|
||||||
|
|
||||||
class DataManager:
|
class DataManager:
|
||||||
"""Handle all data operations for the application."""
|
"""Handle all data operations for the application."""
|
||||||
|
|
||||||
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.filename: str = filename
|
||||||
self.logger: logging.Logger = logger
|
self.logger: logging.Logger = logger
|
||||||
self.initialize_csv()
|
self.medicine_manager = medicine_manager
|
||||||
|
self.pathology_manager = pathology_manager
|
||||||
|
self._initialize_csv_file()
|
||||||
|
|
||||||
def initialize_csv(self) -> None:
|
def _get_csv_headers(self) -> list[str]:
|
||||||
"""Create CSV file with headers if it doesn't exist."""
|
"""Get CSV headers based on current pathology and medicine configuration."""
|
||||||
if not os.path.exists(self.filename):
|
# 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"])
|
||||||
|
|
||||||
|
return headers + ["note"]
|
||||||
|
|
||||||
|
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:
|
with open(self.filename, mode="w", newline="") as file:
|
||||||
writer = csv.writer(file)
|
writer = csv.writer(file)
|
||||||
writer.writerow(
|
writer.writerow(self._get_csv_headers())
|
||||||
[
|
|
||||||
"date",
|
|
||||||
"depression",
|
|
||||||
"anxiety",
|
|
||||||
"sleep",
|
|
||||||
"appetite",
|
|
||||||
"bupropion",
|
|
||||||
"hydroxyzine",
|
|
||||||
"gabapentin",
|
|
||||||
"propranolol",
|
|
||||||
"note",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
def load_data(self) -> pd.DataFrame:
|
def load_data(self) -> pd.DataFrame:
|
||||||
"""Load data from CSV file."""
|
"""Load data from CSV file."""
|
||||||
@@ -40,21 +53,19 @@ class DataManager:
|
|||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
df: pd.DataFrame = pd.read_csv(
|
# Build dtype dictionary dynamically
|
||||||
self.filename,
|
dtype_dict = {"date": str, "note": str}
|
||||||
dtype={
|
|
||||||
"depression": int,
|
# Add pathology types
|
||||||
"anxiety": int,
|
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||||
"sleep": int,
|
dtype_dict[pathology_key] = int
|
||||||
"appetite": int,
|
|
||||||
"bupropion": int,
|
# Add medicine types
|
||||||
"hydroxyzine": int,
|
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||||
"gabapentin": int,
|
dtype_dict[medicine_key] = int
|
||||||
"propranolol": int,
|
dtype_dict[f"{medicine_key}_doses"] = str
|
||||||
"note": str,
|
|
||||||
"date": str,
|
df: pd.DataFrame = pd.read_csv(self.filename, dtype=dtype_dict).fillna("")
|
||||||
},
|
|
||||||
).fillna("")
|
|
||||||
return df.sort_values(by="date").reset_index(drop=True)
|
return df.sort_values(by="date").reset_index(drop=True)
|
||||||
except pd.errors.EmptyDataError:
|
except pd.errors.EmptyDataError:
|
||||||
self.logger.warning("CSV file is empty. No data to load.")
|
self.logger.warning("CSV file is empty. No data to load.")
|
||||||
@@ -66,6 +77,14 @@ class DataManager:
|
|||||||
def add_entry(self, entry_data: list[str | int]) -> bool:
|
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."""
|
||||||
try:
|
try:
|
||||||
|
# Check if date already exists
|
||||||
|
df: pd.DataFrame = self.load_data()
|
||||||
|
date_to_add: str = str(entry_data[0])
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
with open(self.filename, mode="a", newline="") as file:
|
with open(self.filename, mode="a", newline="") as file:
|
||||||
writer = csv.writer(file)
|
writer = csv.writer(file)
|
||||||
writer.writerow(entry_data)
|
writer.writerow(entry_data)
|
||||||
@@ -74,26 +93,37 @@ class DataManager:
|
|||||||
self.logger.error(f"Error adding entry: {str(e)}")
|
self.logger.error(f"Error adding entry: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def update_entry(self, date: str, values: list[str | int]) -> bool:
|
def update_entry(self, original_date: str, values: list[str | int]) -> bool:
|
||||||
"""Update an existing entry identified by date."""
|
"""Update an existing entry identified by original_date."""
|
||||||
try:
|
try:
|
||||||
df: pd.DataFrame = self.load_data()
|
df: pd.DataFrame = self.load_data()
|
||||||
# Find the row to update using date as a unique identifier
|
new_date: str = str(values[0])
|
||||||
df.loc[
|
|
||||||
df["date"] == date,
|
# If the date is being changed, check if the new date already exists
|
||||||
[
|
if original_date != new_date and new_date in df["date"].values:
|
||||||
"date",
|
self.logger.warning(
|
||||||
"depression",
|
f"Cannot update: entry with date {new_date} already exists."
|
||||||
"anxiety",
|
)
|
||||||
"sleep",
|
return False
|
||||||
"appetite",
|
|
||||||
"bupropion",
|
# Get current CSV headers to match with values
|
||||||
"hydroxyzine",
|
headers = self._get_csv_headers()
|
||||||
"gabapentin",
|
|
||||||
"propranolol",
|
# Ensure we have the right number of values
|
||||||
"note",
|
if len(values) != len(headers):
|
||||||
],
|
self.logger.warning(
|
||||||
] = values
|
f"Value count mismatch: expected {len(headers)}, got {len(values)}"
|
||||||
|
)
|
||||||
|
# Pad with defaults if too few values
|
||||||
|
while len(values) < len(headers):
|
||||||
|
header = headers[len(values)]
|
||||||
|
if header == "note" or header.endswith("_doses"):
|
||||||
|
values.append("")
|
||||||
|
else:
|
||||||
|
values.append(0)
|
||||||
|
|
||||||
|
# Update the row using column names
|
||||||
|
df.loc[df["date"] == original_date, headers] = values
|
||||||
df.to_csv(self.filename, index=False)
|
df.to_csv(self.filename, index=False)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -112,3 +142,29 @@ class DataManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error deleting entry: {str(e)}")
|
self.logger.error(f"Error deleting entry: {str(e)}")
|
||||||
return False
|
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."""
|
||||||
|
try:
|
||||||
|
df: pd.DataFrame = self.load_data()
|
||||||
|
if df.empty or date not in df["date"].values:
|
||||||
|
return []
|
||||||
|
|
||||||
|
dose_column = f"{medicine_name}_doses"
|
||||||
|
doses_str = df.loc[df["date"] == date, dose_column].iloc[0]
|
||||||
|
|
||||||
|
if not doses_str:
|
||||||
|
return []
|
||||||
|
|
||||||
|
doses = []
|
||||||
|
for dose_entry in doses_str.split("|"):
|
||||||
|
if ":" in dose_entry:
|
||||||
|
timestamp, dose = dose_entry.split(":", 1)
|
||||||
|
doses.append((timestamp, dose))
|
||||||
|
|
||||||
|
return doses
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error getting medicine doses: {str(e)}")
|
||||||
|
return []
|
||||||
|
|||||||
+197
-44
@@ -7,31 +7,54 @@ import pandas as pd
|
|||||||
from matplotlib.axes import Axes
|
from matplotlib.axes import Axes
|
||||||
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
||||||
|
|
||||||
|
from medicine_manager import MedicineManager
|
||||||
|
from pathology_manager import PathologyManager
|
||||||
|
|
||||||
|
|
||||||
class GraphManager:
|
class GraphManager:
|
||||||
"""Handle all graph-related operations for the application."""
|
"""Handle all graph-related operations for the application."""
|
||||||
|
|
||||||
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.parent_frame: ttk.LabelFrame = parent_frame
|
||||||
|
self.medicine_manager = medicine_manager
|
||||||
|
self.pathology_manager = pathology_manager
|
||||||
|
|
||||||
# Configure graph frame to expand
|
# Configure graph frame to expand
|
||||||
self.parent_frame.grid_rowconfigure(0, weight=1)
|
self.parent_frame.grid_rowconfigure(0, weight=1)
|
||||||
self.parent_frame.grid_columnconfigure(0, weight=1)
|
self.parent_frame.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
# Initialize toggle variables for chart elements
|
self._initialize_toggle_vars()
|
||||||
self.toggle_vars: dict[str, tk.BooleanVar] = {
|
self._setup_ui()
|
||||||
"depression": tk.BooleanVar(value=True),
|
|
||||||
"anxiety": tk.BooleanVar(value=True),
|
|
||||||
"sleep": tk.BooleanVar(value=True),
|
|
||||||
"appetite": tk.BooleanVar(value=True),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
def _initialize_toggle_vars(self) -> None:
|
||||||
|
"""Initialize toggle variables for chart elements."""
|
||||||
|
self.toggle_vars: dict[str, tk.BooleanVar] = {}
|
||||||
|
|
||||||
|
# Initialize pathology toggles dynamically
|
||||||
|
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||||
|
pathology = self.pathology_manager.get_pathology(pathology_key)
|
||||||
|
default_value = pathology.default_enabled if pathology else True
|
||||||
|
self.toggle_vars[pathology_key] = tk.BooleanVar(value=default_value)
|
||||||
|
|
||||||
|
# Add medicine toggles dynamically
|
||||||
|
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||||
|
medicine = self.medicine_manager.get_medicine(medicine_key)
|
||||||
|
default_value = medicine.default_enabled if medicine else False
|
||||||
|
self.toggle_vars[medicine_key] = tk.BooleanVar(value=default_value)
|
||||||
|
|
||||||
|
def _setup_ui(self) -> None:
|
||||||
|
"""Set up the UI components."""
|
||||||
# Create control frame for toggles
|
# Create control frame for toggles
|
||||||
self.control_frame: ttk.Frame = ttk.Frame(self.parent_frame)
|
self.control_frame: ttk.Frame = ttk.Frame(self.parent_frame)
|
||||||
self.control_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
|
self.control_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
|
||||||
|
|
||||||
# Create toggle checkboxes
|
# Create toggle checkboxes
|
||||||
self._create_toggle_controls()
|
self._create_chart_toggles()
|
||||||
|
|
||||||
# Create graph frame
|
# Create graph frame
|
||||||
self.graph_frame: ttk.Frame = ttk.Frame(self.parent_frame)
|
self.graph_frame: ttk.Frame = ttk.Frame(self.parent_frame)
|
||||||
@@ -53,29 +76,43 @@ class GraphManager:
|
|||||||
# Store current data for replotting
|
# Store current data for replotting
|
||||||
self.current_data: pd.DataFrame = pd.DataFrame()
|
self.current_data: pd.DataFrame = pd.DataFrame()
|
||||||
|
|
||||||
def _create_toggle_controls(self) -> None:
|
def _create_chart_toggles(self) -> None:
|
||||||
"""Create toggle controls for chart elements."""
|
"""Create toggle controls for chart elements."""
|
||||||
ttk.Label(self.control_frame, text="Show/Hide Elements:").pack(
|
ttk.Label(self.control_frame, text="Show/Hide Elements:").pack(
|
||||||
side="left", padx=5
|
side="left", padx=5
|
||||||
)
|
)
|
||||||
|
|
||||||
toggle_configs = [
|
# Pathologies toggles - dynamic based on pathology manager
|
||||||
("depression", "Depression"),
|
pathologies_frame = ttk.LabelFrame(self.control_frame, text="Pathologies")
|
||||||
("anxiety", "Anxiety"),
|
pathologies_frame.pack(side="left", padx=5, pady=2)
|
||||||
("sleep", "Sleep"),
|
|
||||||
("appetite", "Appetite"),
|
|
||||||
]
|
|
||||||
|
|
||||||
for key, label in toggle_configs:
|
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||||
checkbox = ttk.Checkbutton(
|
pathology = self.pathology_manager.get_pathology(pathology_key)
|
||||||
self.control_frame,
|
if pathology:
|
||||||
text=label,
|
checkbox = ttk.Checkbutton(
|
||||||
variable=self.toggle_vars[key],
|
pathologies_frame,
|
||||||
command=self._on_toggle_changed,
|
text=pathology.display_name,
|
||||||
)
|
variable=self.toggle_vars[pathology_key],
|
||||||
checkbox.pack(side="left", padx=5)
|
command=self._handle_toggle_changed,
|
||||||
|
)
|
||||||
|
checkbox.pack(side="left", padx=3)
|
||||||
|
|
||||||
def _on_toggle_changed(self) -> None:
|
# Medicines toggles - dynamic based on medicine manager
|
||||||
|
medicines_frame = ttk.LabelFrame(self.control_frame, text="Medicines")
|
||||||
|
medicines_frame.pack(side="left", padx=5, pady=2)
|
||||||
|
|
||||||
|
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||||
|
medicine = self.medicine_manager.get_medicine(medicine_key)
|
||||||
|
if medicine:
|
||||||
|
checkbox = ttk.Checkbutton(
|
||||||
|
medicines_frame,
|
||||||
|
text=medicine.display_name,
|
||||||
|
variable=self.toggle_vars[medicine_key],
|
||||||
|
command=self._handle_toggle_changed,
|
||||||
|
)
|
||||||
|
checkbox.pack(side="left", padx=3)
|
||||||
|
|
||||||
|
def _handle_toggle_changed(self) -> None:
|
||||||
"""Handle toggle changes by replotting the graph."""
|
"""Handle toggle changes by replotting the graph."""
|
||||||
if not self.current_data.empty:
|
if not self.current_data.empty:
|
||||||
self._plot_graph_data(self.current_data)
|
self._plot_graph_data(self.current_data)
|
||||||
@@ -98,30 +135,110 @@ class GraphManager:
|
|||||||
# Track if any series are plotted
|
# Track if any series are plotted
|
||||||
has_plotted_series = False
|
has_plotted_series = False
|
||||||
|
|
||||||
# Plot data series based on toggle states
|
# Plot pathology data series based on toggle states
|
||||||
if self.toggle_vars["depression"].get():
|
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||||
self._plot_series(
|
if self.toggle_vars[pathology_key].get():
|
||||||
df, "depression", "Depression (0:good, 10:bad)", "o", "-"
|
pathology = self.pathology_manager.get_pathology(pathology_key)
|
||||||
)
|
if pathology and pathology_key in df.columns:
|
||||||
has_plotted_series = True
|
label = f"{pathology.display_name} ({pathology.scale_info})"
|
||||||
if self.toggle_vars["anxiety"].get():
|
linestyle = (
|
||||||
self._plot_series(df, "anxiety", "Anxiety (0:good, 10:bad)", "o", "-")
|
"dashed"
|
||||||
has_plotted_series = True
|
if pathology.scale_orientation == "inverted"
|
||||||
if self.toggle_vars["sleep"].get():
|
else "-"
|
||||||
self._plot_series(df, "sleep", "Sleep (0:bad, 10:good)", "o", "dashed")
|
)
|
||||||
has_plotted_series = True
|
self._plot_series(df, pathology_key, label, "o", linestyle)
|
||||||
if self.toggle_vars["appetite"].get():
|
has_plotted_series = True
|
||||||
self._plot_series(
|
|
||||||
df, "appetite", "Appetite (0:bad, 10:good)", "o", "dashed"
|
# Plot medicine dose data
|
||||||
)
|
# Get medicine colors from medicine manager
|
||||||
has_plotted_series = True
|
medicine_colors = self.medicine_manager.get_graph_colors()
|
||||||
|
|
||||||
|
# Get medicines dynamically from medicine manager
|
||||||
|
medicines = self.medicine_manager.get_medicine_keys()
|
||||||
|
|
||||||
|
# Track medicines with and without data for legend
|
||||||
|
medicines_with_data = []
|
||||||
|
medicines_without_data = []
|
||||||
|
|
||||||
|
for medicine in medicines:
|
||||||
|
dose_column = f"{medicine}_doses"
|
||||||
|
if self.toggle_vars[medicine].get() and dose_column in df.columns:
|
||||||
|
# Calculate daily dose totals
|
||||||
|
daily_doses = []
|
||||||
|
for dose_str in df[dose_column]:
|
||||||
|
total_dose = self._calculate_daily_dose(dose_str)
|
||||||
|
daily_doses.append(total_dose)
|
||||||
|
|
||||||
|
# Only plot if there are non-zero doses
|
||||||
|
if any(dose > 0 for dose in daily_doses):
|
||||||
|
medicines_with_data.append(medicine)
|
||||||
|
# Scale doses for better visibility
|
||||||
|
# (divide by 10 to fit with 0-10 scale)
|
||||||
|
scaled_doses = [dose / 10 for dose in daily_doses]
|
||||||
|
|
||||||
|
# Calculate total dosage for this medicine across all days
|
||||||
|
total_medicine_dose = sum(daily_doses)
|
||||||
|
non_zero_doses = [d for d in daily_doses if d > 0]
|
||||||
|
avg_dose = total_medicine_dose / len(non_zero_doses)
|
||||||
|
|
||||||
|
# Create more informative label
|
||||||
|
label = f"{medicine.capitalize()} (avg: {avg_dose:.1f}mg)"
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
has_plotted_series = True
|
||||||
|
else:
|
||||||
|
# Medicine is toggled on but has no dose data
|
||||||
|
if self.toggle_vars[medicine].get():
|
||||||
|
medicines_without_data.append(medicine)
|
||||||
|
|
||||||
# Configure graph appearance
|
# Configure graph appearance
|
||||||
if has_plotted_series:
|
if has_plotted_series:
|
||||||
self.ax.legend()
|
# Get current legend handles and labels
|
||||||
|
handles, labels = self.ax.get_legend_handles_labels()
|
||||||
|
|
||||||
|
# Add information about medicines without data if any are toggled on
|
||||||
|
if medicines_without_data:
|
||||||
|
# Add a text note about medicines without dose data
|
||||||
|
med_list = ", ".join(medicines_without_data)
|
||||||
|
info_text = f"Tracked (no doses): {med_list}"
|
||||||
|
labels.append(info_text)
|
||||||
|
# Create a dummy handle for the info text (invisible)
|
||||||
|
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 an expanded legend with better formatting
|
||||||
|
self.ax.legend(
|
||||||
|
handles,
|
||||||
|
labels,
|
||||||
|
loc="upper left",
|
||||||
|
bbox_to_anchor=(0, 1),
|
||||||
|
ncol=2, # Display in 2 columns for better space usage
|
||||||
|
fontsize="small",
|
||||||
|
frameon=True,
|
||||||
|
fancybox=True,
|
||||||
|
shadow=True,
|
||||||
|
framealpha=0.9,
|
||||||
|
)
|
||||||
self.ax.set_title("Medication Effects Over Time")
|
self.ax.set_title("Medication Effects Over Time")
|
||||||
self.ax.set_xlabel("Date")
|
self.ax.set_xlabel("Date")
|
||||||
self.ax.set_ylabel("Rating (0-10)")
|
self.ax.set_ylabel("Rating (0-10) / Dose (mg)")
|
||||||
|
|
||||||
|
# Adjust y-axis to accommodate medicine bars at bottom
|
||||||
|
current_ylim = self.ax.get_ylim()
|
||||||
|
self.ax.set_ylim(bottom=current_ylim[0], top=max(10, current_ylim[1]))
|
||||||
|
|
||||||
self.fig.autofmt_xdate()
|
self.fig.autofmt_xdate()
|
||||||
|
|
||||||
# Redraw the canvas
|
# Redraw the canvas
|
||||||
@@ -144,6 +261,42 @@ class GraphManager:
|
|||||||
label=label,
|
label=label,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _calculate_daily_dose(self, dose_str: str) -> float:
|
||||||
|
"""Calculate total daily dose from dose string format."""
|
||||||
|
if not dose_str or pd.isna(dose_str) or str(dose_str).lower() == "nan":
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
total_dose = 0.0
|
||||||
|
# Handle different separators and clean the string
|
||||||
|
dose_str = str(dose_str).replace("•", "").strip()
|
||||||
|
|
||||||
|
# Split by | or by spaces if no | present
|
||||||
|
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:
|
||||||
|
# Extract dose part after the last colon (timestamp:dose format)
|
||||||
|
dose_part = entry.split(":")[-1] if ":" in entry else entry
|
||||||
|
|
||||||
|
# Extract numeric part from dose (e.g., "150mg" -> 150)
|
||||||
|
dose_value = ""
|
||||||
|
for char in dose_part:
|
||||||
|
if char.isdigit() or char == ".":
|
||||||
|
dose_value += char
|
||||||
|
elif dose_value: # Stop at first non-digit after finding digits
|
||||||
|
break
|
||||||
|
|
||||||
|
if dose_value:
|
||||||
|
total_dose += float(dose_value)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return total_dose
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
"""Clean up resources."""
|
"""Clean up resources."""
|
||||||
plt.close(self.fig)
|
plt.close(self.fig)
|
||||||
|
|||||||
+263
-68
@@ -2,7 +2,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from tkinter import messagebox
|
from tkinter import messagebox, ttk
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -11,6 +11,10 @@ from constants import LOG_LEVEL, LOG_PATH
|
|||||||
from data_manager import DataManager
|
from data_manager import DataManager
|
||||||
from graph_manager import GraphManager
|
from graph_manager import GraphManager
|
||||||
from init import logger
|
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
|
from ui_manager import UIManager
|
||||||
|
|
||||||
|
|
||||||
@@ -19,7 +23,7 @@ class MedTrackerApp:
|
|||||||
self.root: tk.Tk = root
|
self.root: tk.Tk = root
|
||||||
self.root.resizable(True, True)
|
self.root.resizable(True, True)
|
||||||
self.root.title("Thechart - medication tracker")
|
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
|
# Set up data file
|
||||||
self.filename: str = "thechart_data.csv"
|
self.filename: str = "thechart_data.csv"
|
||||||
@@ -42,18 +46,27 @@ class MedTrackerApp:
|
|||||||
logger.debug(f"First argument: {first_argument}")
|
logger.debug(f"First argument: {first_argument}")
|
||||||
|
|
||||||
# Initialize managers
|
# Initialize managers
|
||||||
self.ui_manager: UIManager = UIManager(root, logger)
|
self.medicine_manager: MedicineManager = MedicineManager(logger=logger)
|
||||||
self.data_manager: DataManager = DataManager(self.filename, 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
|
# Set up application icon
|
||||||
icon_path: str = "chart-671.png"
|
icon_path: str = "chart-671.png"
|
||||||
if not os.path.exists(icon_path) and os.path.exists("./chart-671.png"):
|
if not os.path.exists(icon_path) and os.path.exists("./chart-671.png"):
|
||||||
icon_path = "./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
|
# Set up the main application UI
|
||||||
self._setup_main_ui()
|
self._setup_main_ui()
|
||||||
|
|
||||||
|
# Add menu bar
|
||||||
|
self._setup_menu()
|
||||||
|
|
||||||
def _setup_main_ui(self) -> None:
|
def _setup_main_ui(self) -> None:
|
||||||
"""Set up the main UI components."""
|
"""Set up the main UI components."""
|
||||||
import tkinter.ttk as ttk
|
import tkinter.ttk as ttk
|
||||||
@@ -74,41 +87,104 @@ class MedTrackerApp:
|
|||||||
|
|
||||||
# --- Create Graph Frame ---
|
# --- Create Graph Frame ---
|
||||||
graph_frame: ttk.Frame = self.ui_manager.create_graph_frame(main_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 ---
|
# --- Create Input Frame ---
|
||||||
input_ui: dict[str, Any] = self.ui_manager.create_input_frame(main_frame)
|
input_ui: dict[str, Any] = self.ui_manager.create_input_frame(main_frame)
|
||||||
self.input_frame: ttk.Frame = input_ui["frame"]
|
self.input_frame: ttk.Frame = input_ui["frame"]
|
||||||
self.symptom_vars: dict[str, tk.IntVar] = input_ui["symptom_vars"]
|
self.pathology_vars: dict[str, tk.IntVar] = input_ui["pathology_vars"]
|
||||||
self.medicine_vars: dict[str, list[tk.IntVar | ttk.Spinbox]] = input_ui[
|
self.medicine_vars: dict[str, tuple[tk.IntVar, str]] = input_ui["medicine_vars"]
|
||||||
"medicine_vars"
|
|
||||||
]
|
|
||||||
self.note_var: tk.StringVar = input_ui["note_var"]
|
self.note_var: tk.StringVar = input_ui["note_var"]
|
||||||
self.date_var: tk.StringVar = input_ui["date_var"]
|
self.date_var: tk.StringVar = input_ui["date_var"]
|
||||||
|
|
||||||
# Add buttons to input frame
|
# Add buttons to input frame
|
||||||
self.ui_manager.add_buttons(
|
self.ui_manager.add_action_buttons(
|
||||||
self.input_frame,
|
self.input_frame,
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"text": "Add Entry",
|
"text": "Add Entry",
|
||||||
"command": self.add_entry,
|
"command": self.add_new_entry,
|
||||||
"fill": "both",
|
"fill": "both",
|
||||||
"expand": True,
|
"expand": True,
|
||||||
},
|
},
|
||||||
{"text": "Quit", "command": self.on_closing},
|
{"text": "Quit", "command": self.handle_window_closing},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Create Table Frame ---
|
# --- Create Table Frame ---
|
||||||
table_ui: dict[str, Any] = self.ui_manager.create_table_frame(main_frame)
|
table_ui: dict[str, Any] = self.ui_manager.create_table_frame(main_frame)
|
||||||
self.tree: ttk.Treeview = table_ui["tree"]
|
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
|
# 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."""
|
||||||
|
# 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."""
|
"""Handle double-click event to edit an entry."""
|
||||||
logger.debug("Double-click event triggered on treeview.")
|
logger.debug("Double-click event triggered on treeview.")
|
||||||
if len(self.tree.get_children()) > 0:
|
if len(self.tree.get_children()) > 0:
|
||||||
@@ -119,84 +195,187 @@ class MedTrackerApp:
|
|||||||
|
|
||||||
def _create_edit_window(self, item_id: str, values: tuple[str, ...]) -> None:
|
def _create_edit_window(self, item_id: str, values: tuple[str, ...]) -> None:
|
||||||
"""Create a new Toplevel window for editing an entry."""
|
"""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
|
# Define callbacks for edit window buttons
|
||||||
callbacks: dict[str, Callable] = {
|
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),
|
"delete": lambda win: self._delete_entry(win, item_id),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create edit window using UI manager
|
# Create edit window using UI manager with full data
|
||||||
_: tk.Toplevel = self.ui_manager.create_edit_window(values, callbacks)
|
_: tk.Toplevel = self.ui_manager.create_edit_window(full_values, callbacks)
|
||||||
|
|
||||||
def _save_edit(
|
def _save_edit(
|
||||||
self,
|
self,
|
||||||
edit_win: tk.Toplevel,
|
edit_win: tk.Toplevel,
|
||||||
date: str,
|
original_date: str,
|
||||||
dep: int,
|
*args,
|
||||||
anx: int,
|
|
||||||
slp: int,
|
|
||||||
app: int,
|
|
||||||
bup: int,
|
|
||||||
hydro: int,
|
|
||||||
gaba: int,
|
|
||||||
prop: int,
|
|
||||||
note: str,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Save the edited data to the CSV file."""
|
"""Save edited data to CSV file with dynamic pathology/medicine support."""
|
||||||
values: list[str | int] = [
|
# Parse dynamic arguments
|
||||||
date,
|
# Format: date, pathology1, pathology2, ..., medicine1, medicine2,
|
||||||
dep,
|
# ..., note, dose_data
|
||||||
anx,
|
|
||||||
slp,
|
|
||||||
app,
|
|
||||||
bup,
|
|
||||||
hydro,
|
|
||||||
gaba,
|
|
||||||
prop,
|
|
||||||
note,
|
|
||||||
]
|
|
||||||
|
|
||||||
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()
|
edit_win.destroy()
|
||||||
messagebox.showinfo(
|
messagebox.showinfo(
|
||||||
"Success", "Entry updated successfully!", parent=self.root
|
"Success", "Entry updated successfully!", parent=self.root
|
||||||
)
|
)
|
||||||
self._clear_entries()
|
self._clear_entries()
|
||||||
self.load_data()
|
self.refresh_data_display()
|
||||||
else:
|
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(
|
if messagebox.askokcancel(
|
||||||
"Quit", "Do you want to quit the application?", parent=self.root
|
"Quit", "Do you want to quit the application?", parent=self.root
|
||||||
):
|
):
|
||||||
self.graph_manager.close()
|
self.graph_manager.close()
|
||||||
self.root.destroy()
|
self.root.destroy()
|
||||||
|
|
||||||
def add_entry(self) -> None:
|
def add_new_entry(self) -> None:
|
||||||
"""Add a new entry to the CSV file."""
|
"""Add a new entry to the CSV file."""
|
||||||
entry: list[str | int] = [
|
# Get current doses for today
|
||||||
self.date_var.get(),
|
today = self.date_var.get()
|
||||||
self.symptom_vars["depression"].get(),
|
dose_values = {}
|
||||||
self.symptom_vars["anxiety"].get(),
|
|
||||||
self.symptom_vars["sleep"].get(),
|
if today:
|
||||||
self.symptom_vars["appetite"].get(),
|
# Get doses for all medicines dynamically
|
||||||
self.medicine_vars["bupropion"][0].get(),
|
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||||
self.medicine_vars["hydroxyzine"][0].get(),
|
doses = self.data_manager.get_today_medicine_doses(today, medicine_key)
|
||||||
self.medicine_vars["gabapentin"][0].get(),
|
dose_values[f"{medicine_key}_doses"] = "|".join(
|
||||||
self.medicine_vars["propranolol"][0].get(),
|
[f"{ts}:{dose}" for ts, dose in doses]
|
||||||
self.note_var.get(),
|
)
|
||||||
]
|
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}")
|
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):
|
if self.data_manager.add_entry(entry):
|
||||||
messagebox.showinfo(
|
messagebox.showinfo(
|
||||||
"Success", "Entry added successfully!", parent=self.root
|
"Success", "Entry added successfully!", parent=self.root
|
||||||
)
|
)
|
||||||
self._clear_entries()
|
self._clear_entries()
|
||||||
self.load_data()
|
self.refresh_data_display()
|
||||||
else:
|
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:
|
def _delete_entry(self, edit_win: tk.Toplevel, item_id: str) -> None:
|
||||||
"""Delete the selected entry from the CSV file."""
|
"""Delete the selected entry from the CSV file."""
|
||||||
@@ -213,9 +392,9 @@ class MedTrackerApp:
|
|||||||
if self.data_manager.delete_entry(date):
|
if self.data_manager.delete_entry(date):
|
||||||
edit_win.destroy()
|
edit_win.destroy()
|
||||||
messagebox.showinfo(
|
messagebox.showinfo(
|
||||||
"Success", "Entry deleted successfully!", parent=edit_win
|
"Success", "Entry deleted successfully!", parent=self.root
|
||||||
)
|
)
|
||||||
self.load_data()
|
self.refresh_data_display()
|
||||||
else:
|
else:
|
||||||
messagebox.showerror("Error", "Failed to delete entry", parent=edit_win)
|
messagebox.showerror("Error", "Failed to delete entry", parent=edit_win)
|
||||||
|
|
||||||
@@ -223,13 +402,13 @@ class MedTrackerApp:
|
|||||||
"""Clear all input fields."""
|
"""Clear all input fields."""
|
||||||
logger.debug("Clearing input fields.")
|
logger.debug("Clearing input fields.")
|
||||||
self.date_var.set("")
|
self.date_var.set("")
|
||||||
for key in self.symptom_vars:
|
for key in self.pathology_vars:
|
||||||
self.symptom_vars[key].set(0)
|
self.pathology_vars[key].set(0)
|
||||||
for key in self.medicine_vars:
|
for key in self.medicine_vars:
|
||||||
self.medicine_vars[key][0].set(0)
|
self.medicine_vars[key][0].set(0)
|
||||||
self.note_var.set("")
|
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."""
|
"""Load data from the CSV file into the table and graph."""
|
||||||
logger.debug("Loading data from CSV.")
|
logger.debug("Loading data from CSV.")
|
||||||
|
|
||||||
@@ -242,9 +421,25 @@ class MedTrackerApp:
|
|||||||
|
|
||||||
# Update the treeview with the data
|
# Update the treeview with the data
|
||||||
if not df.empty:
|
if not df.empty:
|
||||||
for _index, row in df.iterrows():
|
# Build display columns dynamically (exclude dose columns for table view)
|
||||||
|
display_columns = ["date", "depression", "anxiety", "sleep", "appetite"]
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
for _index, row in display_df.iterrows():
|
||||||
self.tree.insert(parent="", index="end", values=list(row))
|
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
|
# Update the graph
|
||||||
self.graph_manager.update_graph(df)
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "cycler"
|
name = "cycler"
|
||||||
version = "0.12.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "kiwisolver"
|
name = "kiwisolver"
|
||||||
version = "1.4.8"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pre-commit"
|
name = "pre-commit"
|
||||||
version = "4.2.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pyinstaller"
|
name = "pyinstaller"
|
||||||
version = "6.14.2"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "python-dateutil"
|
name = "python-dateutil"
|
||||||
version = "2.9.0.post0"
|
version = "2.9.0.post0"
|
||||||
@@ -576,7 +698,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thechart"
|
name = "thechart"
|
||||||
version = "1.0.1"
|
version = "1.6.1"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorlog" },
|
{ name = "colorlog" },
|
||||||
@@ -588,8 +710,12 @@ dependencies = [
|
|||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "coverage" },
|
||||||
{ name = "pre-commit" },
|
{ name = "pre-commit" },
|
||||||
{ name = "pyinstaller" },
|
{ name = "pyinstaller" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "pytest-mock" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -604,8 +730,12 @@ requires-dist = [
|
|||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "coverage", specifier = ">=7.3.0" },
|
||||||
{ name = "pre-commit", specifier = ">=4.2.0" },
|
{ name = "pre-commit", specifier = ">=4.2.0" },
|
||||||
{ name = "pyinstaller", specifier = ">=6.14.2" },
|
{ 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" },
|
{ name = "ruff", specifier = ">=0.12.5" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user