test: add pytest framework and midnight-crossing tests

Set up pytest with Home Assistant support and created 6 tests for
midnight-crossing period logic (5 unit tests + 1 integration test).

Added pytest configuration, test dependencies, test runner script
(./scripts/test), and comprehensive tests for group_periods_by_day()
and midnight turnover consistency.

All tests pass in 0.12s.

Impact: Provides regression testing for midnight-crossing period bugs.
Tests validate periods remain visible across midnight turnover.
This commit is contained in:
Julian Pawlowski 2025-11-21 23:47:01 +00:00
parent 47b0a298d4
commit f60b5990ae
14 changed files with 458 additions and 130 deletions

View file

@ -741,9 +741,14 @@ Note: The local `hassfest` script performs basic validation checks (JSON syntax,
**Testing:**
```bash
pytest tests/ # Unit tests exist (test_*.py) but no framework enforced
./scripts/test # Run all tests (pytest with project configuration)
./scripts/test -v # Verbose output
./scripts/test -k test_midnight # Run specific test by name
./scripts/test tests/test_midnight_periods.py # Run specific file
```
Test framework: pytest with Home Assistant custom component support. Tests live in `/tests/` directory. Use `@pytest.mark.unit` for fast tests, `@pytest.mark.integration` for tests that use real coordinator/time services.
## Testing Changes
**IMPORTANT: Never start `./scripts/develop` automatically.**

View file

@ -41,6 +41,12 @@ ignore = [
"ISC001", # incompatible with formatter
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = [
"S101", # assert is fine in tests
"PLR2004", # Magic values are fine in tests
]
[tool.ruff.lint.flake8-pytest-style]
fixture-parentheses = false
@ -53,3 +59,22 @@ max-complexity = 25
[tool.ruff.lint.isort]
force-single-line = false
known-first-party = ["custom_components", "homeassistant"]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
asyncio_default_fixture_loop_scope = "function"
addopts = "-ra -q --strict-markers"
markers = [
"unit: Unit tests (fast, no external dependencies)",
"integration: Integration tests (may use coordinator/time service)",
]
[project.optional-dependencies]
test = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"pytest-homeassistant-custom-component>=0.13.0",
]

View file

@ -1,4 +1,4 @@
#!/bin/bash
#!/bin/sh
# Check if a commit or code change has been released (is contained in any version tag)
#
# Usage:
@ -11,8 +11,7 @@
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${SCRIPT_DIR}/.."
cd "$(dirname "$0")/.."
# Colors for output
RED='\033[0;31m'
@ -35,7 +34,7 @@ DETAILS="${2:-}"
# Validate commit exists
if ! git rev-parse --verify "$COMMIT" >/dev/null 2>&1; then
echo -e "${RED}Error: Commit '$COMMIT' not found${NC}"
printf '%bError: Commit '\''%s'\'' not found%b\n' "$RED" "$COMMIT" "$NC"
exit 1
fi
@ -56,17 +55,17 @@ echo ""
TAGS=$(git tag --contains "$COMMIT" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+' || true)
if [ -z "$TAGS" ]; then
echo -e "${GREEN}✓ NOT RELEASED${NC}"
printf '%b✓ NOT RELEASED%b\n' "$GREEN" "$NC"
echo "This commit is not part of any version tag."
echo ""
echo -e "${YELLOW}→ No legacy migration needed for code introduced in this commit${NC}"
printf '%b→ No legacy migration needed for code introduced in this commit%b\n' "$YELLOW" "$NC"
exit 0
else
echo -e "${RED}✗ ALREADY RELEASED${NC}"
printf '%b✗ ALREADY RELEASED%b\n' "$RED" "$NC"
echo "This commit is included in the following version tags:"
echo "$TAGS" | sed 's/^/ - /'
echo ""
echo -e "${YELLOW}⚠ Breaking Change Decision:${NC}"
printf '%b⚠ Breaking Change Decision:%b\n' "$YELLOW" "$NC"
echo " 1. If migration is SIMPLE (e.g., .lower(), key rename) → Add it"
echo " 2. If migration is COMPLEX → Document in release notes instead"
echo " 3. Home Assistant style: Prefer breaking changes over code complexity"

View file

@ -1,4 +1,4 @@
#!/usr/bin/env bash
#!/bin/sh
# script/clean: Clean up development artifacts and caches
# Usage:
@ -19,13 +19,13 @@ elif [ "$1" = "--deep" ]; then
DEEP_MODE=true
fi
if [ "$MINIMAL_MODE" = false ]; then
if [ "$MINIMAL_MODE" = "false" ]; then
echo "==> Cleaning development artifacts..."
fi
# Clean up accidental package installation (always, even in minimal mode)
if [ -d "tibber_prices.egg-info" ]; then
if [ "$MINIMAL_MODE" = false ]; then
if [ "$MINIMAL_MODE" = "false" ]; then
echo " → Removing tibber_prices.egg-info"
fi
rm -rf tibber_prices.egg-info
@ -36,7 +36,7 @@ fi
PACKAGE_INSTALLED=false
if pip show tibber_prices >/dev/null 2>&1 || uv pip show tibber_prices >/dev/null 2>&1; then
PACKAGE_INSTALLED=true
if [ "$MINIMAL_MODE" = false ]; then
if [ "$MINIMAL_MODE" = "false" ]; then
echo " → Uninstalling accidentally installed package"
fi
# Use regular pip (cleaner output, always works in venv)
@ -46,7 +46,7 @@ if pip show tibber_prices >/dev/null 2>&1 || uv pip show tibber_prices >/dev/nul
fi
# Exit early if minimal mode
if [ "$MINIMAL_MODE" = true ]; then
if [ "$MINIMAL_MODE" = "true" ]; then
exit 0
fi
@ -73,7 +73,7 @@ if [ -d ".ruff_cache" ]; then
fi
# Optional: Clean __pycache__ (normally not needed, but useful for troubleshooting)
if [ "$DEEP_MODE" = true ]; then
if [ "$DEEP_MODE" = "true" ]; then
echo " → Deep clean: Removing all __pycache__ directories"
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
echo " → Deep clean: Removing all .pyc files"
@ -82,7 +82,7 @@ fi
echo ""
echo "==> Cleanup complete!"
if [ "$DEEP_MODE" = false ]; then
if [ "$DEEP_MODE" = "false" ]; then
echo ""
echo "Tip: Use './scripts/clean --deep' to also remove __pycache__ directories"
echo " (normally not needed - __pycache__ speeds up Home Assistant startup)"

View file

@ -1,4 +1,4 @@
#!/usr/bin/env bash
#!/bin/sh
# script/hassfest: Lightweight local validation for Home Assistant integration
# Note: This is a simplified version. Full hassfest runs in GitHub Actions.
@ -66,21 +66,21 @@ fi
# ast.parse() validates syntax without writing any files to disk
echo "✓ Checking Python syntax..."
PYTHON_ERRORS=0
while IFS= read -r py_file; do
find "$INTEGRATION_PATH" -name "*.py" -type f | while IFS= read -r py_file; do
if ! python -c "import ast; ast.parse(open('$py_file').read())" 2>/dev/null; then
echo " ✗ ERROR: $py_file has syntax errors"
PYTHON_ERRORS=$((PYTHON_ERRORS + 1))
ERRORS=$((ERRORS + 1))
fi
done < <(find "$INTEGRATION_PATH" -name "*.py" -type f)
done
if [ $PYTHON_ERRORS -eq 0 ]; then
echo " ✓ All Python files have valid syntax"
fi
# Check 6: Required manifest fields
echo "✓ Checking required manifest fields..."
REQUIRED_FIELDS=("domain" "name" "version" "documentation" "issue_tracker" "codeowners")
for field in "${REQUIRED_FIELDS[@]}"; do
REQUIRED_FIELDS="domain name version documentation issue_tracker codeowners"
for field in $REQUIRED_FIELDS; do
if ! python -c "import json; data=json.load(open('$INTEGRATION_PATH/manifest.json')); exit(0 if '$field' in data else 1)" 2>/dev/null; then
echo " ✗ ERROR: manifest.json missing required field: $field"
ERRORS=$((ERRORS + 1))

View file

@ -1,4 +1,4 @@
#!/usr/bin/env bash
#!/bin/sh
# script/help: Display information about available scripts.
@ -14,26 +14,26 @@ echo "Available scripts:"
echo ""
find scripts -type f -perm -111 -print0 | sort -z | while IFS= read -r -d '' script; do
script_name=$(basename "$script")
description=$(awk -v prefix="# script/$script_name:" '
BEGIN {desc=""}
$0 ~ prefix {
line = $0
sub(prefix, "", line)
sub(/^# */, "", line)
desc = desc (desc ? " " : "") line
next
}
desc != "" {exit}
END {print desc}
' "$script")
if [ -z "$description" ]; then
description="No description available"
fi
if [ ${#description} -gt 60 ]; then
description=$(echo "$description" | cut -c1-57)...
fi
printf " \033[36m %-25s\033[0m %s\n" "scripts/$script_name" "$description"
script_name=$(basename "$script")
description=$(awk -v prefix="# script/$script_name:" '
BEGIN {desc=""}
$0 ~ prefix {
line = $0
sub(prefix, "", line)
sub(/^# */, "", line)
desc = desc (desc ? " " : "") line
next
}
desc != "" {exit}
END {print desc}
' "$script")
if [ -z "$description" ]; then
description="No description available"
fi
if [ "${#description}" -gt 60 ]; then
description=$(echo "$description" | cut -c1-57)...
fi
printf " \033[36m %-25s\033[0m %s\n" "scripts/$script_name" "$description"
done
echo ""

View file

@ -1,4 +1,4 @@
#!/usr/bin/env bash
#!/bin/sh
# script/prepare-release: Prepare a new release by bumping version and creating tag
#
@ -15,7 +15,7 @@
# ./scripts/prepare-release 0.3.0
# ./scripts/prepare-release 1.0.0
set -euo pipefail
set -eu
# Colors
RED='\033[0;31m'
@ -28,12 +28,12 @@ NC='\033[0m' # No Color
cd "$(dirname "$0")/.."
# Check if --suggest or no argument
if [[ "${1:-}" == "--suggest" ]] || [[ -z "${1:-}" ]]; then
if [ "${1:-}" = "--suggest" ] || [ -z "${1:-}" ]; then
./scripts/suggest-version
if [[ -z "${1:-}" ]]; then
if [ -z "${1:-}" ]; then
echo ""
echo -e "${YELLOW}Provide version number as argument:${NC}"
printf "${YELLOW}Provide version number as argument:${NC}\n"
echo " ./scripts/prepare-release X.Y.Z"
exit 0
fi
@ -42,15 +42,15 @@ fi
# Check if we have uncommitted changes
if ! git diff-index --quiet HEAD --; then
echo -e "${RED}❌ Error: You have uncommitted changes.${NC}"
printf "${RED}❌ Error: You have uncommitted changes.${NC}\n"
echo "Please commit or stash them first."
exit 1
fi
# Parse version argument
VERSION="${1:-}"
if [[ -z "$VERSION" ]]; then
echo -e "${RED}❌ Error: No version specified.${NC}"
if [ -z "$VERSION" ]; then
printf "${RED}❌ Error: No version specified.${NC}\n"
echo ""
echo "Usage: $0 VERSION"
echo ""
@ -65,7 +65,7 @@ VERSION="${VERSION#v}"
# Validate version format (X.Y.Z)
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo -e "${RED}❌ Error: Invalid version format: $VERSION${NC}"
printf "${RED}❌ Error: Invalid version format: $VERSION${NC}\n"
echo "Expected format: X.Y.Z (e.g., 0.3.0, 1.0.0)"
exit 1
fi
@ -74,33 +74,33 @@ TAG="v$VERSION"
MANIFEST="custom_components/tibber_prices/manifest.json"
# Check if manifest.json exists
if [[ ! -f "$MANIFEST" ]]; then
echo -e "${RED}❌ Error: Manifest file not found: $MANIFEST${NC}"
if [ ! -f "$MANIFEST" ]; then
printf "${RED}❌ Error: Manifest file not found: $MANIFEST${NC}\n"
exit 1
fi
# Check if tag already exists (locally or remotely)
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo -e "${RED}❌ Error: Tag $TAG already exists locally!${NC}"
printf "${RED}❌ Error: Tag $TAG already exists locally!${NC}\n"
echo "To remove it: git tag -d $TAG"
exit 1
fi
if git ls-remote --tags origin | grep -q "refs/tags/$TAG"; then
echo -e "${RED}❌ Error: Tag $TAG already exists on remote!${NC}"
printf "${RED}❌ Error: Tag $TAG already exists on remote!${NC}\n"
exit 1
fi
# Get current version
CURRENT_VERSION=$(jq -r '.version' "$MANIFEST")
echo -e "${BLUE}Current version: ${CURRENT_VERSION}${NC}"
echo -e "${BLUE}New version: ${VERSION}${NC}"
printf "${BLUE}Current version: ${CURRENT_VERSION}${NC}\n"
printf "${BLUE}New version: ${VERSION}${NC}\n"
echo ""
# Update manifest.json
echo -e "${YELLOW}📝 Updating $MANIFEST...${NC}"
printf "${YELLOW}📝 Updating $MANIFEST...${NC}\n"
if ! command -v jq >/dev/null 2>&1; then
echo -e "${RED}❌ Error: jq is not installed${NC}"
printf "${RED}❌ Error: jq is not installed${NC}\n"
echo "Please install jq: apt-get install jq (or brew install jq)"
exit 1
fi
@ -110,7 +110,7 @@ cp "$MANIFEST" "$MANIFEST.backup"
# Update version with jq
if ! jq ".version = \"$VERSION\"" "$MANIFEST" > "$MANIFEST.tmp"; then
echo -e "${RED}❌ Error: Failed to update manifest.json${NC}"
printf "${RED}❌ Error: Failed to update manifest.json${NC}\n"
mv "$MANIFEST.backup" "$MANIFEST"
exit 1
fi
@ -118,44 +118,44 @@ fi
mv "$MANIFEST.tmp" "$MANIFEST"
rm "$MANIFEST.backup"
echo -e "${GREEN}✓ Updated manifest.json${NC}"
printf "${GREEN}✓ Updated manifest.json${NC}\n"
# Stage and commit
echo -e "${YELLOW}📦 Creating commit...${NC}"
printf "${YELLOW}📦 Creating commit...${NC}\n"
git add "$MANIFEST"
git commit -m "chore(release): bump version to $VERSION"
echo -e "${GREEN}✓ Created commit${NC}"
printf "${GREEN}✓ Created commit${NC}\n"
# Create annotated tag
echo -e "${YELLOW}🏷️ Creating tag $TAG...${NC}"
printf "${YELLOW}🏷️ Creating tag $TAG...${NC}\n"
git tag -a "$TAG" -m "chore(release): version $VERSION"
echo -e "${GREEN}✓ Created tag $TAG${NC}"
printf "${GREEN}✓ Created tag $TAG${NC}\n"
# Show preview
echo ""
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${GREEN}✅ Release $VERSION prepared successfully!${NC}"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
printf "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
printf "${GREEN}✅ Release $VERSION prepared successfully!${NC}\n"
printf "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
echo ""
echo -e "${BLUE}Review the changes:${NC}"
printf "${BLUE}Review the changes:${NC}\n"
git log -1 --stat
echo ""
echo -e "${BLUE}Review the tag:${NC}"
printf "${BLUE}Review the tag:${NC}\n"
git show "$TAG" --no-patch
echo ""
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${YELLOW}Next steps:${NC}"
printf "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
printf "${YELLOW}Next steps:${NC}\n"
echo ""
echo -e " ${GREEN}✓ To push and trigger release:${NC}"
echo -e " git push origin main $TAG"
printf " ${GREEN}✓ To push and trigger release:${NC}\n"
printf " git push origin main $TAG\n"
echo ""
echo -e " ${RED}✗ To abort and undo:${NC}"
echo -e " git reset --hard HEAD~1 # Undo commit"
echo -e " git tag -d $TAG # Delete tag"
printf " ${RED}✗ To abort and undo:${NC}\n"
printf " git reset --hard HEAD~1 # Undo commit\n"
printf " git tag -d $TAG # Delete tag\n"
echo ""
echo -e "${BLUE}What happens after push:${NC}"
printf "${BLUE}What happens after push:${NC}\n"
echo " 1. Both commit and tag are pushed to GitHub"
echo " 2. CI/CD detects the new tag"
echo " 3. Release notes are generated automatically"
echo " 4. GitHub release is created"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
printf "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"

View file

@ -1,4 +1,4 @@
#!/usr/bin/env bash
#!/bin/sh
# script/setup: Setup script used by DevContainers to prepare the project

View file

@ -1,4 +1,4 @@
#!/bin/bash
#!/bin/sh
# Analyze commits since last release and suggest next version number
#
# Usage:
@ -10,7 +10,7 @@
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "${SCRIPT_DIR}/.."
# Colors
@ -26,14 +26,14 @@ FROM_TAG="${2:-}"
# Get current version from manifest.json
MANIFEST="custom_components/tibber_prices/manifest.json"
if [[ ! -f "$MANIFEST" ]]; then
echo -e "${RED}Error: Manifest file not found: $MANIFEST${NC}"
if [ ! -f "$MANIFEST" ]; then
printf "%bError: Manifest file not found: %s%b\n" "$RED" "$MANIFEST" "$NC"
exit 1
fi
# Require jq for JSON parsing
if ! command -v jq >/dev/null 2>&1; then
echo -e "${RED}Error: jq is not installed${NC}"
printf "%bError: jq is not installed%b\n" "$RED" "$NC"
echo "Please install jq: apt-get install jq (or brew install jq)"
exit 1
fi
@ -45,7 +45,7 @@ MANIFEST_TAG="v${MANIFEST_VERSION}"
if [ -z "$FROM_TAG" ]; then
FROM_TAG=$(git tag -l 'v*.*.*' --sort=-version:refname | head -1)
if [ -z "$FROM_TAG" ]; then
echo -e "${RED}Error: No version tags found${NC}"
printf "%bError: No version tags found%b\n" "$RED" "$NC"
exit 1
fi
fi
@ -54,11 +54,11 @@ fi
if git rev-parse "$MANIFEST_TAG" >/dev/null 2>&1; then
# Manifest version is already tagged - analyze from that tag
FROM_TAG="$MANIFEST_TAG"
echo -e "${YELLOW}Note: manifest.json version ${MANIFEST_VERSION} already tagged as ${MANIFEST_TAG}${NC}"
printf "%bNote: manifest.json version %s already tagged as %s%b\n" "$YELLOW" "$MANIFEST_VERSION" "$MANIFEST_TAG" "$NC"
echo ""
fi
echo -e "${BOLD}Analyzing commits since $FROM_TAG${NC}"
printf "%bAnalyzing commits since %s%b\n" "$BOLD" "$FROM_TAG" "$NC"
echo ""
# Parse current version (from the tag we're analyzing from)
@ -68,8 +68,8 @@ MINOR=$(echo "$CURRENT_VERSION" | cut -d. -f2)
PATCH=$(echo "$CURRENT_VERSION" | cut -d. -f3)
echo "Current released version: v${MAJOR}.${MINOR}.${PATCH}"
if [[ "$MANIFEST_VERSION" != "$CURRENT_VERSION" ]]; then
echo -e "${YELLOW}Manifest.json version: ${MANIFEST_VERSION} (not yet tagged)${NC}"
if [ "$MANIFEST_VERSION" != "$CURRENT_VERSION" ]; then
printf "%bManifest.json version: %s (not yet tagged)%b\n" "$YELLOW" "$MANIFEST_VERSION" "$NC"
fi
echo ""
@ -77,12 +77,12 @@ echo ""
COMMITS=$(git log "$FROM_TAG"..HEAD --format="%s" --no-merges | grep -v "^chore(release):" || true)
if [ -z "$COMMITS" ]; then
echo -e "${YELLOW}No new commits since last release${NC}"
printf "%bNo new commits since last release%b\n" "$YELLOW" "$NC"
# Check if manifest.json needs to be tagged
if [[ "$MANIFEST_VERSION" != "$CURRENT_VERSION" ]]; then
if [ "$MANIFEST_VERSION" != "$CURRENT_VERSION" ]; then
echo ""
echo -e "${BLUE}Manifest.json has version ${MANIFEST_VERSION} but no tag exists yet.${NC}"
printf "%bManifest.json has version %s but no tag exists yet.%b\n" "$BLUE" "$MANIFEST_VERSION" "$NC"
echo "Create tag with:"
echo " git tag -a v${MANIFEST_VERSION} -m \"Release ${MANIFEST_VERSION}\""
echo " git push origin v${MANIFEST_VERSION}"
@ -90,33 +90,33 @@ if [ -z "$COMMITS" ]; then
exit 0
fi
# Count commit types
# Count commit types (using grep -c with || true to handle zero matches)
BREAKING_COUNT=$(echo "$COMMITS" | grep -c "^[^:]*!:" || true)
FEAT_COUNT=$(echo "$COMMITS" | grep -cE "^feat(\(.+\))?:" || true)
FIX_COUNT=$(echo "$COMMITS" | grep -cE "^fix(\(.+\))?:" || true)
REFACTOR_COUNT=$(echo "$COMMITS" | grep -cE "^refactor(\(.+\))?:" || true)
DOCS_COUNT=$(echo "$COMMITS" | grep -cE "^docs(\(.+\))?:" || true)
OTHER_COUNT=$(echo "$COMMITS" | grep -vcE "^(feat|fix|refactor|docs)(\(.+\))?:" || true)
FEAT_COUNT=$(echo "$COMMITS" | grep -c -E "^feat(\(.+\))?:" || true)
FIX_COUNT=$(echo "$COMMITS" | grep -c -E "^fix(\(.+\))?:" || true)
REFACTOR_COUNT=$(echo "$COMMITS" | grep -c -E "^refactor(\(.+\))?:" || true)
DOCS_COUNT=$(echo "$COMMITS" | grep -c -E "^docs(\(.+\))?:" || true)
OTHER_COUNT=$(echo "$COMMITS" | grep -v -c -E "^(feat|fix|refactor|docs)(\(.+\))?:" || true)
# Check for breaking changes in commit messages or Impact sections
BREAKING_IN_BODY=$(git log "$FROM_TAG"..HEAD --format="%b" --no-merges | grep -ci "BREAKING CHANGE:" || true)
BREAKING_IN_BODY=$(git log "$FROM_TAG"..HEAD --format="%b" --no-merges | grep -c -i "BREAKING CHANGE:" || true)
TOTAL_BREAKING=$((BREAKING_COUNT + BREAKING_IN_BODY))
echo -e "${BOLD}Commit Analysis:${NC}"
printf "%bCommit Analysis:%b\n" "$BOLD" "$NC"
echo ""
if [ $TOTAL_BREAKING -gt 0 ]; then
echo -e " ${RED}⚠ Breaking changes:${NC} $TOTAL_BREAKING"
if [ "$TOTAL_BREAKING" -gt 0 ]; then
printf " %b⚠ Breaking changes:%b %s\n" "$RED" "$NC" "$TOTAL_BREAKING"
fi
echo -e " ${GREEN}✨ New features:${NC} $FEAT_COUNT"
echo -e " ${BLUE}🐛 Bug fixes:${NC} $FIX_COUNT"
if [ $REFACTOR_COUNT -gt 0 ]; then
echo -e " ${YELLOW}🔧 Refactorings:${NC} $REFACTOR_COUNT"
printf " %b✨ New features:%b %s\n" "$GREEN" "$NC" "$FEAT_COUNT"
printf " %b🐛 Bug fixes:%b %s\n" "$BLUE" "$NC" "$FIX_COUNT"
if [ "$REFACTOR_COUNT" -gt 0 ]; then
printf " %b🔧 Refactorings:%b %s\n" "$YELLOW" "$NC" "$REFACTOR_COUNT"
fi
if [ $DOCS_COUNT -gt 0 ]; then
echo -e " 📚 Documentation: $DOCS_COUNT"
if [ "$DOCS_COUNT" -gt 0 ]; then
printf " 📚 Documentation: %s\n" "$DOCS_COUNT"
fi
if [ $OTHER_COUNT -gt 0 ]; then
echo -e " 📦 Other: $OTHER_COUNT"
if [ "$OTHER_COUNT" -gt 0 ]; then
printf " 📦 Other: %s\n" "$OTHER_COUNT"
fi
echo ""
@ -125,10 +125,10 @@ SUGGESTED_MAJOR=$MAJOR
SUGGESTED_MINOR=$MINOR
SUGGESTED_PATCH=$PATCH
if [ $TOTAL_BREAKING -gt 0 ]; then
if [ "$TOTAL_BREAKING" -gt 0 ]; then
# Before v1.0.0: Breaking changes bump minor
# After v1.0.0: Breaking changes bump major
if [ $MAJOR -eq 0 ]; then
if [ "$MAJOR" -eq 0 ]; then
SUGGESTED_MINOR=$((MINOR + 1))
SUGGESTED_PATCH=0
BUMP_TYPE="MINOR (breaking changes in 0.x)"
@ -140,12 +140,12 @@ if [ $TOTAL_BREAKING -gt 0 ]; then
BUMP_TYPE="MAJOR (breaking)"
BUMP_REASON="Breaking changes detected"
fi
elif [ $FEAT_COUNT -gt 0 ]; then
elif [ "$FEAT_COUNT" -gt 0 ]; then
SUGGESTED_MINOR=$((MINOR + 1))
SUGGESTED_PATCH=0
BUMP_TYPE="MINOR (features)"
BUMP_REASON="New features added"
elif [ $FIX_COUNT -gt 0 ]; then
elif [ "$FIX_COUNT" -gt 0 ]; then
SUGGESTED_PATCH=$((PATCH + 1))
BUMP_TYPE="PATCH (fixes)"
BUMP_REASON="Bug fixes only"
@ -157,36 +157,36 @@ fi
SUGGESTED_VERSION="v${SUGGESTED_MAJOR}.${SUGGESTED_MINOR}.${SUGGESTED_PATCH}"
echo -e "${BOLD}${GREEN}Suggested Version: $SUGGESTED_VERSION${NC}"
echo -e " Bump type: ${BUMP_TYPE}"
echo -e " Reason: ${BUMP_REASON}"
printf "%b%bSuggested Version: %s%b\n" "$BOLD" "$GREEN" "$SUGGESTED_VERSION" "$NC"
printf " Bump type: %s\n" "$BUMP_TYPE"
printf " Reason: %s\n" "$BUMP_REASON"
echo ""
# Show alternative versions
echo -e "${BOLD}Alternative Versions:${NC}"
echo -e " ${YELLOW}MAJOR:${NC} v$((MAJOR + 1)).0.0 (if you want to release v1.0.0 or have breaking changes)"
echo -e " ${GREEN}MINOR:${NC} v${MAJOR}.$((MINOR + 1)).0 (if adding features)"
echo -e " ${BLUE}PATCH:${NC} v${MAJOR}.${MINOR}.$((PATCH + 1)) (if only fixes/docs)"
printf "%bAlternative Versions:%b\n" "$BOLD" "$NC"
printf " %bMAJOR:%b v%s.0.0 (if you want to release v1.0.0 or have breaking changes)\n" "$YELLOW" "$NC" "$((MAJOR + 1))"
printf " %bMINOR:%b v%s.%s.0 (if adding features)\n" "$GREEN" "$NC" "$MAJOR" "$((MINOR + 1))"
printf " %bPATCH:%b v%s.%s.%s (if only fixes/docs)\n" "$BLUE" "$NC" "$MAJOR" "$MINOR" "$((PATCH + 1))"
echo ""
# Show preview command
echo -e "${BOLD}Preview Release Notes:${NC}"
printf "%bPreview Release Notes:%b\n" "$BOLD" "$NC"
echo " ./scripts/generate-release-notes $FROM_TAG HEAD"
echo ""
echo -e "${BOLD}Create Release:${NC}"
printf "%bCreate Release:%b\n" "$BOLD" "$NC"
echo " ./scripts/prepare-release ${SUGGESTED_MAJOR}.${SUGGESTED_MINOR}.${SUGGESTED_PATCH}"
echo ""
# Show warning if breaking changes detected
if [ $TOTAL_BREAKING -gt 0 ]; then
echo -e "${RED}${BOLD}⚠ WARNING: Breaking changes detected!${NC}"
echo -e "${RED}Make sure to document migration steps in release notes.${NC}"
if [ "$TOTAL_BREAKING" -gt 0 ]; then
printf "%b%b⚠ WARNING: Breaking changes detected!%b\n" "$RED" "$BOLD" "$NC"
printf "%bMake sure to document migration steps in release notes.%b\n" "$RED" "$NC"
echo ""
fi
# Show note about pre-1.0 versioning
if [ $MAJOR -eq 0 ]; then
echo -e "${YELLOW}Note: Pre-1.0 versioning (0.x.y)${NC}"
if [ "$MAJOR" -eq 0 ]; then
printf "%bNote: Pre-1.0 versioning (0.x.y)%b\n" "$YELLOW" "$NC"
echo " - Breaking changes bump MINOR (0.x.0)"
echo " - Features bump MINOR (0.x.0)"
echo " - Fixes bump PATCH (0.0.x)"

View file

@ -1,4 +1,4 @@
#!/usr/bin/env bash
#!/bin/sh
# script/sync-hacs: Sync HACS-installed integrations to custom_components/

23
scripts/test Executable file
View file

@ -0,0 +1,23 @@
#!/bin/sh
# script/test: Run project tests
set -e
cd "$(dirname "$0")/.."
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Check if pytest is available
if ! .venv/bin/python -c "import pytest" 2>/dev/null; then
printf "${YELLOW}pytest not found. Installing test dependencies...${NC}\n"
.venv/bin/pip install -e ".[test]"
fi
# Run pytest with project configuration
printf "${GREEN}Running tests...${NC}\n\n"
.venv/bin/pytest "$@"

1
tests/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Tests for Tibber Prices integration."""

View file

@ -0,0 +1,136 @@
"""Test midnight-crossing period assignment with group_periods_by_day()."""
from __future__ import annotations
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import pytest
from custom_components.tibber_prices.coordinator.period_handlers.relaxation import (
group_periods_by_day,
)
@pytest.fixture
def base_date() -> datetime:
"""Provide base date for tests."""
return datetime(2025, 11, 21, 0, 0, 0, tzinfo=ZoneInfo("Europe/Berlin"))
def create_test_period(start_hour: int, end_hour: int, base_date: datetime) -> dict:
"""Create a test period dict."""
start = base_date.replace(hour=start_hour, minute=0, second=0, microsecond=0)
# Handle periods crossing midnight
if end_hour < start_hour:
end = (base_date + timedelta(days=1)).replace(hour=end_hour, minute=0, second=0, microsecond=0)
else:
end = base_date.replace(hour=end_hour, minute=0, second=0, microsecond=0)
return {
"start": start,
"end": end,
"duration_minutes": int((end - start).total_seconds() / 60),
"price_avg": 25.5,
}
@pytest.mark.unit
def test_period_within_single_day(base_date: datetime) -> None:
"""Test period completely within one day (10:00-14:00)."""
periods = [create_test_period(10, 14, base_date)]
result = group_periods_by_day(periods)
assert len(result) == 1, f"Expected 1 day, got {len(result)}"
@pytest.mark.unit
def test_period_crossing_midnight(base_date: datetime) -> None:
"""Test period crossing midnight (23:00-02:00)."""
periods = [create_test_period(23, 2, base_date)]
result = group_periods_by_day(periods)
assert len(result) == 2, f"Expected 2 days, got {len(result)}"
# Verify the period appears in both days
today = base_date.date()
tomorrow = (base_date + timedelta(days=1)).date()
assert today in result, f"Period should appear in {today}"
assert tomorrow in result, f"Period should appear in {tomorrow}"
@pytest.mark.unit
def test_multiple_periods_with_midnight_crossing(base_date: datetime) -> None:
"""Test multiple periods, some crossing midnight (8:00-12:00, 14:00-18:00, 22:00-03:00)."""
periods = [
create_test_period(8, 12, base_date), # Morning, same day
create_test_period(14, 18, base_date), # Afternoon, same day
create_test_period(22, 3, base_date), # Night, crosses midnight
]
result = group_periods_by_day(periods)
today = base_date.date()
tomorrow = (base_date + timedelta(days=1)).date()
# Check that today has 3 periods (all of them)
today_periods = result.get(today, [])
assert len(today_periods) == 3, f"Today should have 3 periods, got {len(today_periods)}"
# Check that tomorrow has 1 period (the midnight-crossing one)
tomorrow_periods = result.get(tomorrow, [])
assert len(tomorrow_periods) == 1, f"Tomorrow should have 1 period, got {len(tomorrow_periods)}"
@pytest.mark.unit
def test_period_spanning_three_days(base_date: datetime) -> None:
"""Test period spanning 3 days (22:00 day1 - 02:00 day3)."""
day1 = base_date
day3 = base_date + timedelta(days=2)
period = {
"start": day1.replace(hour=22, minute=0),
"end": day3.replace(hour=2, minute=0),
"duration_minutes": int((day3.replace(hour=2) - day1.replace(hour=22)).total_seconds() / 60),
"price_avg": 25.5,
}
periods = [period]
result = group_periods_by_day(periods)
assert len(result) == 3, f"Expected 3 days, got {len(result)}"
# Verify the period appears in all 3 days
day1_date = day1.date()
day2_date = (base_date + timedelta(days=1)).date()
day3_date = day3.date()
assert day1_date in result, f"Period should appear in {day1_date}"
assert day2_date in result, f"Period should appear in {day2_date}"
assert day3_date in result, f"Period should appear in {day3_date}"
@pytest.mark.unit
def test_min_periods_scenario(base_date: datetime) -> None:
"""Test real-world scenario with min_periods=2 per day."""
# Yesterday: 2 periods (one crosses midnight to today)
yesterday = base_date - timedelta(days=1)
periods = [
create_test_period(10, 14, yesterday), # Yesterday 10-14
create_test_period(23, 2, yesterday), # Yesterday 23 - Today 02 (crosses midnight!)
create_test_period(15, 19, base_date), # Today 15-19
]
result = group_periods_by_day(periods)
yesterday_date = yesterday.date()
today_date = base_date.date()
yesterday_periods = result.get(yesterday_date, [])
today_periods = result.get(today_date, [])
# Both days should have 2 periods (min_periods requirement met)
assert len(yesterday_periods) == 2, "Yesterday should have 2 periods"
assert len(today_periods) == 2, "Today should have 2 periods (including midnight-crosser)"

View file

@ -0,0 +1,139 @@
"""Test midnight turnover consistency - period visibility before/after midnight."""
from __future__ import annotations
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import pytest
from custom_components.tibber_prices.coordinator.period_handlers.core import (
calculate_periods,
)
from custom_components.tibber_prices.coordinator.period_handlers.types import (
TibberPricesPeriodConfig,
)
from custom_components.tibber_prices.coordinator.time_service import (
TibberPricesTimeService,
)
def create_price_interval(dt: datetime, price: float) -> dict:
"""Create a price interval dict."""
return {
"startsAt": dt,
"total": price,
"level": "NORMAL",
"rating_level": "NORMAL",
}
def create_price_data_scenario() -> tuple[list[dict], list[dict], list[dict], list[dict]]:
"""Create a realistic price scenario with a period crossing midnight."""
tz = ZoneInfo("Europe/Berlin")
base = datetime(2025, 11, 21, 0, 0, 0, tzinfo=tz)
# Define cheap hour ranges for each day
cheap_hours = {
"yesterday": range(22, 24), # 22:00-23:45
"today": range(21, 24), # 21:00-23:45 (crosses midnight!)
"tomorrow": range(1), # 00:00-00:45 (continuation)
}
def generate_day_prices(day_dt: datetime, cheap_range: range) -> list[dict]:
"""Generate 15-min interval prices for a day."""
prices = []
for hour in range(24):
for minute in [0, 15, 30, 45]:
dt = day_dt.replace(hour=hour, minute=minute)
price = 15.0 if hour in cheap_range else 30.0
prices.append(create_price_interval(dt, price))
return prices
yesterday_prices = generate_day_prices(base - timedelta(days=1), cheap_hours["yesterday"])
today_prices = generate_day_prices(base, cheap_hours["today"])
tomorrow_prices = generate_day_prices(base + timedelta(days=1), cheap_hours["tomorrow"])
day_after_tomorrow_prices = generate_day_prices(base + timedelta(days=2), range(0)) # No cheap hours
return yesterday_prices, today_prices, tomorrow_prices, day_after_tomorrow_prices
@pytest.fixture
def period_config() -> TibberPricesPeriodConfig:
"""Provide test period configuration."""
return TibberPricesPeriodConfig(
reverse_sort=False, # Best price (cheap periods)
flex=0.50, # 50% flexibility
min_distance_from_avg=5.0,
min_period_length=60, # 60 minutes minimum
threshold_low=20.0,
threshold_high=30.0,
threshold_volatility_moderate=0.3,
threshold_volatility_high=0.5,
threshold_volatility_very_high=0.7,
level_filter=None,
gap_count=0,
)
@pytest.mark.integration
def test_midnight_crossing_period_consistency(period_config: TibberPricesPeriodConfig) -> None:
"""
Test that midnight-crossing periods remain visible before and after midnight turnover.
This test simulates the real-world scenario where:
- Before midnight (21st 22:00): Period 21:0001:00 is visible
- After midnight (22nd 00:30): Same period should still be visible
The period starts on 2025-11-21 (yesterday after turnover) and ends on 2025-11-22 (today).
"""
tz = ZoneInfo("Europe/Berlin")
yesterday_prices, today_prices, tomorrow_prices, day_after_tomorrow_prices = create_price_data_scenario()
# SCENARIO 1: Before midnight (today = 2025-11-21 22:00)
current_time_before = datetime(2025, 11, 21, 22, 0, 0, tzinfo=tz)
time_service_before = TibberPricesTimeService(current_time_before)
all_prices_before = yesterday_prices + today_prices + tomorrow_prices
result_before = calculate_periods(all_prices_before, config=period_config, time=time_service_before)
periods_before = result_before["periods"]
# Find the midnight-crossing period (starts 21st, ends 22nd)
midnight_period_before = None
for period in periods_before:
if period["start"].date().isoformat() == "2025-11-21" and period["end"].date().isoformat() == "2025-11-22":
midnight_period_before = period
break
assert midnight_period_before is not None, "Expected to find midnight-crossing period before turnover"
# SCENARIO 2: After midnight turnover (today = 2025-11-22 00:30)
current_time_after = datetime(2025, 11, 22, 0, 30, 0, tzinfo=tz)
time_service_after = TibberPricesTimeService(current_time_after)
# Simulate coordinator data shift: yesterday=21st, today=22nd, tomorrow=23rd
yesterday_after_turnover = today_prices
today_after_turnover = tomorrow_prices
tomorrow_after_turnover = day_after_tomorrow_prices
all_prices_after = yesterday_after_turnover + today_after_turnover + tomorrow_after_turnover
result_after = calculate_periods(all_prices_after, config=period_config, time=time_service_after)
periods_after = result_after["periods"]
# Find period that started on 2025-11-21 (now "yesterday")
period_from_yesterday_after = None
for period in periods_after:
if period["start"].date().isoformat() == "2025-11-21":
period_from_yesterday_after = period
break
assert period_from_yesterday_after is not None, (
"Expected midnight-crossing period to remain visible after turnover (we're at 00:30, period ends at 01:00)"
)
# Verify consistency: same absolute times
assert midnight_period_before["start"] == period_from_yesterday_after["start"], "Start time should match"
assert midnight_period_before["end"] == period_from_yesterday_after["end"], "End time should match"
assert midnight_period_before["duration_minutes"] == period_from_yesterday_after["duration_minutes"], (
"Duration should match"
)