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:** **Testing:**
```bash ```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 ## Testing Changes
**IMPORTANT: Never start `./scripts/develop` automatically.** **IMPORTANT: Never start `./scripts/develop` automatically.**

View file

@ -41,6 +41,12 @@ ignore = [
"ISC001", # incompatible with formatter "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] [tool.ruff.lint.flake8-pytest-style]
fixture-parentheses = false fixture-parentheses = false
@ -53,3 +59,22 @@ max-complexity = 25
[tool.ruff.lint.isort] [tool.ruff.lint.isort]
force-single-line = false force-single-line = false
known-first-party = ["custom_components", "homeassistant"] 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) # Check if a commit or code change has been released (is contained in any version tag)
# #
# Usage: # Usage:
@ -11,8 +11,7 @@
set -e set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$(dirname "$0")/.."
cd "${SCRIPT_DIR}/.."
# Colors for output # Colors for output
RED='\033[0;31m' RED='\033[0;31m'
@ -35,7 +34,7 @@ DETAILS="${2:-}"
# Validate commit exists # Validate commit exists
if ! git rev-parse --verify "$COMMIT" >/dev/null 2>&1; then 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 exit 1
fi fi
@ -56,17 +55,17 @@ echo ""
TAGS=$(git tag --contains "$COMMIT" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+' || true) TAGS=$(git tag --contains "$COMMIT" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+' || true)
if [ -z "$TAGS" ]; then 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 "This commit is not part of any version tag."
echo "" 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 exit 0
else 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 "This commit is included in the following version tags:"
echo "$TAGS" | sed 's/^/ - /' echo "$TAGS" | sed 's/^/ - /'
echo "" 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 " 1. If migration is SIMPLE (e.g., .lower(), key rename) → Add it"
echo " 2. If migration is COMPLEX → Document in release notes instead" echo " 2. If migration is COMPLEX → Document in release notes instead"
echo " 3. Home Assistant style: Prefer breaking changes over code complexity" 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 # script/clean: Clean up development artifacts and caches
# Usage: # Usage:
@ -19,13 +19,13 @@ elif [ "$1" = "--deep" ]; then
DEEP_MODE=true DEEP_MODE=true
fi fi
if [ "$MINIMAL_MODE" = false ]; then if [ "$MINIMAL_MODE" = "false" ]; then
echo "==> Cleaning development artifacts..." echo "==> Cleaning development artifacts..."
fi fi
# Clean up accidental package installation (always, even in minimal mode) # Clean up accidental package installation (always, even in minimal mode)
if [ -d "tibber_prices.egg-info" ]; then if [ -d "tibber_prices.egg-info" ]; then
if [ "$MINIMAL_MODE" = false ]; then if [ "$MINIMAL_MODE" = "false" ]; then
echo " → Removing tibber_prices.egg-info" echo " → Removing tibber_prices.egg-info"
fi fi
rm -rf tibber_prices.egg-info rm -rf tibber_prices.egg-info
@ -36,7 +36,7 @@ fi
PACKAGE_INSTALLED=false PACKAGE_INSTALLED=false
if pip show tibber_prices >/dev/null 2>&1 || uv pip show tibber_prices >/dev/null 2>&1; then if pip show tibber_prices >/dev/null 2>&1 || uv pip show tibber_prices >/dev/null 2>&1; then
PACKAGE_INSTALLED=true PACKAGE_INSTALLED=true
if [ "$MINIMAL_MODE" = false ]; then if [ "$MINIMAL_MODE" = "false" ]; then
echo " → Uninstalling accidentally installed package" echo " → Uninstalling accidentally installed package"
fi fi
# Use regular pip (cleaner output, always works in venv) # 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 fi
# Exit early if minimal mode # Exit early if minimal mode
if [ "$MINIMAL_MODE" = true ]; then if [ "$MINIMAL_MODE" = "true" ]; then
exit 0 exit 0
fi fi
@ -73,7 +73,7 @@ if [ -d ".ruff_cache" ]; then
fi fi
# Optional: Clean __pycache__ (normally not needed, but useful for troubleshooting) # 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" echo " → Deep clean: Removing all __pycache__ directories"
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
echo " → Deep clean: Removing all .pyc files" echo " → Deep clean: Removing all .pyc files"
@ -82,7 +82,7 @@ fi
echo "" echo ""
echo "==> Cleanup complete!" echo "==> Cleanup complete!"
if [ "$DEEP_MODE" = false ]; then if [ "$DEEP_MODE" = "false" ]; then
echo "" echo ""
echo "Tip: Use './scripts/clean --deep' to also remove __pycache__ directories" echo "Tip: Use './scripts/clean --deep' to also remove __pycache__ directories"
echo " (normally not needed - __pycache__ speeds up Home Assistant startup)" 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 # script/hassfest: Lightweight local validation for Home Assistant integration
# Note: This is a simplified version. Full hassfest runs in GitHub Actions. # 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 # ast.parse() validates syntax without writing any files to disk
echo "✓ Checking Python syntax..." echo "✓ Checking Python syntax..."
PYTHON_ERRORS=0 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 if ! python -c "import ast; ast.parse(open('$py_file').read())" 2>/dev/null; then
echo " ✗ ERROR: $py_file has syntax errors" echo " ✗ ERROR: $py_file has syntax errors"
PYTHON_ERRORS=$((PYTHON_ERRORS + 1)) PYTHON_ERRORS=$((PYTHON_ERRORS + 1))
ERRORS=$((ERRORS + 1)) ERRORS=$((ERRORS + 1))
fi fi
done < <(find "$INTEGRATION_PATH" -name "*.py" -type f) done
if [ $PYTHON_ERRORS -eq 0 ]; then if [ $PYTHON_ERRORS -eq 0 ]; then
echo " ✓ All Python files have valid syntax" echo " ✓ All Python files have valid syntax"
fi fi
# Check 6: Required manifest fields # Check 6: Required manifest fields
echo "✓ Checking required manifest fields..." echo "✓ Checking required manifest fields..."
REQUIRED_FIELDS=("domain" "name" "version" "documentation" "issue_tracker" "codeowners") REQUIRED_FIELDS="domain name version documentation issue_tracker codeowners"
for field in "${REQUIRED_FIELDS[@]}"; do 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 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" echo " ✗ ERROR: manifest.json missing required field: $field"
ERRORS=$((ERRORS + 1)) ERRORS=$((ERRORS + 1))

View file

@ -1,4 +1,4 @@
#!/usr/bin/env bash #!/bin/sh
# script/help: Display information about available scripts. # script/help: Display information about available scripts.
@ -30,7 +30,7 @@ find scripts -type f -perm -111 -print0 | sort -z | while IFS= read -r -d '' scr
if [ -z "$description" ]; then if [ -z "$description" ]; then
description="No description available" description="No description available"
fi fi
if [ ${#description} -gt 60 ]; then if [ "${#description}" -gt 60 ]; then
description=$(echo "$description" | cut -c1-57)... description=$(echo "$description" | cut -c1-57)...
fi fi
printf " \033[36m %-25s\033[0m %s\n" "scripts/$script_name" "$description" printf " \033[36m %-25s\033[0m %s\n" "scripts/$script_name" "$description"

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 # 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 0.3.0
# ./scripts/prepare-release 1.0.0 # ./scripts/prepare-release 1.0.0
set -euo pipefail set -eu
# Colors # Colors
RED='\033[0;31m' RED='\033[0;31m'
@ -28,12 +28,12 @@ NC='\033[0m' # No Color
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
# Check if --suggest or no argument # Check if --suggest or no argument
if [[ "${1:-}" == "--suggest" ]] || [[ -z "${1:-}" ]]; then if [ "${1:-}" = "--suggest" ] || [ -z "${1:-}" ]; then
./scripts/suggest-version ./scripts/suggest-version
if [[ -z "${1:-}" ]]; then if [ -z "${1:-}" ]; then
echo "" 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" echo " ./scripts/prepare-release X.Y.Z"
exit 0 exit 0
fi fi
@ -42,15 +42,15 @@ fi
# Check if we have uncommitted changes # Check if we have uncommitted changes
if ! git diff-index --quiet HEAD --; then 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." echo "Please commit or stash them first."
exit 1 exit 1
fi fi
# Parse version argument # Parse version argument
VERSION="${1:-}" VERSION="${1:-}"
if [[ -z "$VERSION" ]]; then if [ -z "$VERSION" ]; then
echo -e "${RED}❌ Error: No version specified.${NC}" printf "${RED}❌ Error: No version specified.${NC}\n"
echo "" echo ""
echo "Usage: $0 VERSION" echo "Usage: $0 VERSION"
echo "" echo ""
@ -65,7 +65,7 @@ VERSION="${VERSION#v}"
# Validate version format (X.Y.Z) # Validate version format (X.Y.Z)
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then 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)" echo "Expected format: X.Y.Z (e.g., 0.3.0, 1.0.0)"
exit 1 exit 1
fi fi
@ -74,33 +74,33 @@ TAG="v$VERSION"
MANIFEST="custom_components/tibber_prices/manifest.json" MANIFEST="custom_components/tibber_prices/manifest.json"
# Check if manifest.json exists # Check if manifest.json exists
if [[ ! -f "$MANIFEST" ]]; then if [ ! -f "$MANIFEST" ]; then
echo -e "${RED}❌ Error: Manifest file not found: $MANIFEST${NC}" printf "${RED}❌ Error: Manifest file not found: $MANIFEST${NC}\n"
exit 1 exit 1
fi fi
# Check if tag already exists (locally or remotely) # Check if tag already exists (locally or remotely)
if git rev-parse "$TAG" >/dev/null 2>&1; then 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" echo "To remove it: git tag -d $TAG"
exit 1 exit 1
fi fi
if git ls-remote --tags origin | grep -q "refs/tags/$TAG"; then 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 exit 1
fi fi
# Get current version # Get current version
CURRENT_VERSION=$(jq -r '.version' "$MANIFEST") CURRENT_VERSION=$(jq -r '.version' "$MANIFEST")
echo -e "${BLUE}Current version: ${CURRENT_VERSION}${NC}" printf "${BLUE}Current version: ${CURRENT_VERSION}${NC}\n"
echo -e "${BLUE}New version: ${VERSION}${NC}" printf "${BLUE}New version: ${VERSION}${NC}\n"
echo "" echo ""
# Update manifest.json # Update manifest.json
echo -e "${YELLOW}📝 Updating $MANIFEST...${NC}" printf "${YELLOW}📝 Updating $MANIFEST...${NC}\n"
if ! command -v jq >/dev/null 2>&1; then 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)" echo "Please install jq: apt-get install jq (or brew install jq)"
exit 1 exit 1
fi fi
@ -110,7 +110,7 @@ cp "$MANIFEST" "$MANIFEST.backup"
# Update version with jq # Update version with jq
if ! jq ".version = \"$VERSION\"" "$MANIFEST" > "$MANIFEST.tmp"; then 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" mv "$MANIFEST.backup" "$MANIFEST"
exit 1 exit 1
fi fi
@ -118,44 +118,44 @@ fi
mv "$MANIFEST.tmp" "$MANIFEST" mv "$MANIFEST.tmp" "$MANIFEST"
rm "$MANIFEST.backup" rm "$MANIFEST.backup"
echo -e "${GREEN}✓ Updated manifest.json${NC}" printf "${GREEN}✓ Updated manifest.json${NC}\n"
# Stage and commit # Stage and commit
echo -e "${YELLOW}📦 Creating commit...${NC}" printf "${YELLOW}📦 Creating commit...${NC}\n"
git add "$MANIFEST" git add "$MANIFEST"
git commit -m "chore(release): bump version to $VERSION" git commit -m "chore(release): bump version to $VERSION"
echo -e "${GREEN}✓ Created commit${NC}" printf "${GREEN}✓ Created commit${NC}\n"
# Create annotated tag # 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" 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 # Show preview
echo "" echo ""
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" printf "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
echo -e "${GREEN}✅ Release $VERSION prepared successfully!${NC}" printf "${GREEN}✅ Release $VERSION prepared successfully!${NC}\n"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" printf "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
echo "" echo ""
echo -e "${BLUE}Review the changes:${NC}" printf "${BLUE}Review the changes:${NC}\n"
git log -1 --stat git log -1 --stat
echo "" echo ""
echo -e "${BLUE}Review the tag:${NC}" printf "${BLUE}Review the tag:${NC}\n"
git show "$TAG" --no-patch git show "$TAG" --no-patch
echo "" echo ""
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" printf "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
echo -e "${YELLOW}Next steps:${NC}" printf "${YELLOW}Next steps:${NC}\n"
echo "" echo ""
echo -e " ${GREEN}✓ To push and trigger release:${NC}" printf " ${GREEN}✓ To push and trigger release:${NC}\n"
echo -e " git push origin main $TAG" printf " git push origin main $TAG\n"
echo "" echo ""
echo -e " ${RED}✗ To abort and undo:${NC}" printf " ${RED}✗ To abort and undo:${NC}\n"
echo -e " git reset --hard HEAD~1 # Undo commit" printf " git reset --hard HEAD~1 # Undo commit\n"
echo -e " git tag -d $TAG # Delete tag" printf " git tag -d $TAG # Delete tag\n"
echo "" 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 " 1. Both commit and tag are pushed to GitHub"
echo " 2. CI/CD detects the new tag" echo " 2. CI/CD detects the new tag"
echo " 3. Release notes are generated automatically" echo " 3. Release notes are generated automatically"
echo " 4. GitHub release is created" 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 # 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 # Analyze commits since last release and suggest next version number
# #
# Usage: # Usage:
@ -10,7 +10,7 @@
set -e set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "${SCRIPT_DIR}/.." cd "${SCRIPT_DIR}/.."
# Colors # Colors
@ -26,14 +26,14 @@ FROM_TAG="${2:-}"
# Get current version from manifest.json # Get current version from manifest.json
MANIFEST="custom_components/tibber_prices/manifest.json" MANIFEST="custom_components/tibber_prices/manifest.json"
if [[ ! -f "$MANIFEST" ]]; then if [ ! -f "$MANIFEST" ]; then
echo -e "${RED}Error: Manifest file not found: $MANIFEST${NC}" printf "%bError: Manifest file not found: %s%b\n" "$RED" "$MANIFEST" "$NC"
exit 1 exit 1
fi fi
# Require jq for JSON parsing # Require jq for JSON parsing
if ! command -v jq >/dev/null 2>&1; then 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)" echo "Please install jq: apt-get install jq (or brew install jq)"
exit 1 exit 1
fi fi
@ -45,7 +45,7 @@ MANIFEST_TAG="v${MANIFEST_VERSION}"
if [ -z "$FROM_TAG" ]; then if [ -z "$FROM_TAG" ]; then
FROM_TAG=$(git tag -l 'v*.*.*' --sort=-version:refname | head -1) FROM_TAG=$(git tag -l 'v*.*.*' --sort=-version:refname | head -1)
if [ -z "$FROM_TAG" ]; then 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 exit 1
fi fi
fi fi
@ -54,11 +54,11 @@ fi
if git rev-parse "$MANIFEST_TAG" >/dev/null 2>&1; then if git rev-parse "$MANIFEST_TAG" >/dev/null 2>&1; then
# Manifest version is already tagged - analyze from that tag # Manifest version is already tagged - analyze from that tag
FROM_TAG="$MANIFEST_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 "" echo ""
fi fi
echo -e "${BOLD}Analyzing commits since $FROM_TAG${NC}" printf "%bAnalyzing commits since %s%b\n" "$BOLD" "$FROM_TAG" "$NC"
echo "" echo ""
# Parse current version (from the tag we're analyzing from) # 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) PATCH=$(echo "$CURRENT_VERSION" | cut -d. -f3)
echo "Current released version: v${MAJOR}.${MINOR}.${PATCH}" echo "Current released version: v${MAJOR}.${MINOR}.${PATCH}"
if [[ "$MANIFEST_VERSION" != "$CURRENT_VERSION" ]]; then if [ "$MANIFEST_VERSION" != "$CURRENT_VERSION" ]; then
echo -e "${YELLOW}Manifest.json version: ${MANIFEST_VERSION} (not yet tagged)${NC}" printf "%bManifest.json version: %s (not yet tagged)%b\n" "$YELLOW" "$MANIFEST_VERSION" "$NC"
fi fi
echo "" echo ""
@ -77,12 +77,12 @@ echo ""
COMMITS=$(git log "$FROM_TAG"..HEAD --format="%s" --no-merges | grep -v "^chore(release):" || true) COMMITS=$(git log "$FROM_TAG"..HEAD --format="%s" --no-merges | grep -v "^chore(release):" || true)
if [ -z "$COMMITS" ]; then 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 # Check if manifest.json needs to be tagged
if [[ "$MANIFEST_VERSION" != "$CURRENT_VERSION" ]]; then if [ "$MANIFEST_VERSION" != "$CURRENT_VERSION" ]; then
echo "" 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 "Create tag with:"
echo " git tag -a v${MANIFEST_VERSION} -m \"Release ${MANIFEST_VERSION}\"" echo " git tag -a v${MANIFEST_VERSION} -m \"Release ${MANIFEST_VERSION}\""
echo " git push origin v${MANIFEST_VERSION}" echo " git push origin v${MANIFEST_VERSION}"
@ -90,33 +90,33 @@ if [ -z "$COMMITS" ]; then
exit 0 exit 0
fi fi
# Count commit types # Count commit types (using grep -c with || true to handle zero matches)
BREAKING_COUNT=$(echo "$COMMITS" | grep -c "^[^:]*!:" || true) BREAKING_COUNT=$(echo "$COMMITS" | grep -c "^[^:]*!:" || true)
FEAT_COUNT=$(echo "$COMMITS" | grep -cE "^feat(\(.+\))?:" || true) FEAT_COUNT=$(echo "$COMMITS" | grep -c -E "^feat(\(.+\))?:" || true)
FIX_COUNT=$(echo "$COMMITS" | grep -cE "^fix(\(.+\))?:" || true) FIX_COUNT=$(echo "$COMMITS" | grep -c -E "^fix(\(.+\))?:" || true)
REFACTOR_COUNT=$(echo "$COMMITS" | grep -cE "^refactor(\(.+\))?:" || true) REFACTOR_COUNT=$(echo "$COMMITS" | grep -c -E "^refactor(\(.+\))?:" || true)
DOCS_COUNT=$(echo "$COMMITS" | grep -cE "^docs(\(.+\))?:" || true) DOCS_COUNT=$(echo "$COMMITS" | grep -c -E "^docs(\(.+\))?:" || true)
OTHER_COUNT=$(echo "$COMMITS" | grep -vcE "^(feat|fix|refactor|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 # 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)) TOTAL_BREAKING=$((BREAKING_COUNT + BREAKING_IN_BODY))
echo -e "${BOLD}Commit Analysis:${NC}" printf "%bCommit Analysis:%b\n" "$BOLD" "$NC"
echo "" echo ""
if [ $TOTAL_BREAKING -gt 0 ]; then if [ "$TOTAL_BREAKING" -gt 0 ]; then
echo -e " ${RED}⚠ Breaking changes:${NC} $TOTAL_BREAKING" printf " %b⚠ Breaking changes:%b %s\n" "$RED" "$NC" "$TOTAL_BREAKING"
fi fi
echo -e " ${GREEN}✨ New features:${NC} $FEAT_COUNT" printf " %b✨ New features:%b %s\n" "$GREEN" "$NC" "$FEAT_COUNT"
echo -e " ${BLUE}🐛 Bug fixes:${NC} $FIX_COUNT" printf " %b🐛 Bug fixes:%b %s\n" "$BLUE" "$NC" "$FIX_COUNT"
if [ $REFACTOR_COUNT -gt 0 ]; then if [ "$REFACTOR_COUNT" -gt 0 ]; then
echo -e " ${YELLOW}🔧 Refactorings:${NC} $REFACTOR_COUNT" printf " %b🔧 Refactorings:%b %s\n" "$YELLOW" "$NC" "$REFACTOR_COUNT"
fi fi
if [ $DOCS_COUNT -gt 0 ]; then if [ "$DOCS_COUNT" -gt 0 ]; then
echo -e " 📚 Documentation: $DOCS_COUNT" printf " 📚 Documentation: %s\n" "$DOCS_COUNT"
fi fi
if [ $OTHER_COUNT -gt 0 ]; then if [ "$OTHER_COUNT" -gt 0 ]; then
echo -e " 📦 Other: $OTHER_COUNT" printf " 📦 Other: %s\n" "$OTHER_COUNT"
fi fi
echo "" echo ""
@ -125,10 +125,10 @@ SUGGESTED_MAJOR=$MAJOR
SUGGESTED_MINOR=$MINOR SUGGESTED_MINOR=$MINOR
SUGGESTED_PATCH=$PATCH SUGGESTED_PATCH=$PATCH
if [ $TOTAL_BREAKING -gt 0 ]; then if [ "$TOTAL_BREAKING" -gt 0 ]; then
# Before v1.0.0: Breaking changes bump minor # Before v1.0.0: Breaking changes bump minor
# After v1.0.0: Breaking changes bump major # After v1.0.0: Breaking changes bump major
if [ $MAJOR -eq 0 ]; then if [ "$MAJOR" -eq 0 ]; then
SUGGESTED_MINOR=$((MINOR + 1)) SUGGESTED_MINOR=$((MINOR + 1))
SUGGESTED_PATCH=0 SUGGESTED_PATCH=0
BUMP_TYPE="MINOR (breaking changes in 0.x)" BUMP_TYPE="MINOR (breaking changes in 0.x)"
@ -140,12 +140,12 @@ if [ $TOTAL_BREAKING -gt 0 ]; then
BUMP_TYPE="MAJOR (breaking)" BUMP_TYPE="MAJOR (breaking)"
BUMP_REASON="Breaking changes detected" BUMP_REASON="Breaking changes detected"
fi fi
elif [ $FEAT_COUNT -gt 0 ]; then elif [ "$FEAT_COUNT" -gt 0 ]; then
SUGGESTED_MINOR=$((MINOR + 1)) SUGGESTED_MINOR=$((MINOR + 1))
SUGGESTED_PATCH=0 SUGGESTED_PATCH=0
BUMP_TYPE="MINOR (features)" BUMP_TYPE="MINOR (features)"
BUMP_REASON="New features added" BUMP_REASON="New features added"
elif [ $FIX_COUNT -gt 0 ]; then elif [ "$FIX_COUNT" -gt 0 ]; then
SUGGESTED_PATCH=$((PATCH + 1)) SUGGESTED_PATCH=$((PATCH + 1))
BUMP_TYPE="PATCH (fixes)" BUMP_TYPE="PATCH (fixes)"
BUMP_REASON="Bug fixes only" BUMP_REASON="Bug fixes only"
@ -157,36 +157,36 @@ fi
SUGGESTED_VERSION="v${SUGGESTED_MAJOR}.${SUGGESTED_MINOR}.${SUGGESTED_PATCH}" SUGGESTED_VERSION="v${SUGGESTED_MAJOR}.${SUGGESTED_MINOR}.${SUGGESTED_PATCH}"
echo -e "${BOLD}${GREEN}Suggested Version: $SUGGESTED_VERSION${NC}" printf "%b%bSuggested Version: %s%b\n" "$BOLD" "$GREEN" "$SUGGESTED_VERSION" "$NC"
echo -e " Bump type: ${BUMP_TYPE}" printf " Bump type: %s\n" "$BUMP_TYPE"
echo -e " Reason: ${BUMP_REASON}" printf " Reason: %s\n" "$BUMP_REASON"
echo "" echo ""
# Show alternative versions # Show alternative versions
echo -e "${BOLD}Alternative Versions:${NC}" printf "%bAlternative Versions:%b\n" "$BOLD" "$NC"
echo -e " ${YELLOW}MAJOR:${NC} v$((MAJOR + 1)).0.0 (if you want to release v1.0.0 or have breaking changes)" printf " %bMAJOR:%b v%s.0.0 (if you want to release v1.0.0 or have breaking changes)\n" "$YELLOW" "$NC" "$((MAJOR + 1))"
echo -e " ${GREEN}MINOR:${NC} v${MAJOR}.$((MINOR + 1)).0 (if adding features)" printf " %bMINOR:%b v%s.%s.0 (if adding features)\n" "$GREEN" "$NC" "$MAJOR" "$((MINOR + 1))"
echo -e " ${BLUE}PATCH:${NC} v${MAJOR}.${MINOR}.$((PATCH + 1)) (if only fixes/docs)" printf " %bPATCH:%b v%s.%s.%s (if only fixes/docs)\n" "$BLUE" "$NC" "$MAJOR" "$MINOR" "$((PATCH + 1))"
echo "" echo ""
# Show preview command # 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 " ./scripts/generate-release-notes $FROM_TAG HEAD"
echo "" 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 " ./scripts/prepare-release ${SUGGESTED_MAJOR}.${SUGGESTED_MINOR}.${SUGGESTED_PATCH}"
echo "" echo ""
# Show warning if breaking changes detected # Show warning if breaking changes detected
if [ $TOTAL_BREAKING -gt 0 ]; then if [ "$TOTAL_BREAKING" -gt 0 ]; then
echo -e "${RED}${BOLD}⚠ WARNING: Breaking changes detected!${NC}" printf "%b%b⚠ WARNING: Breaking changes detected!%b\n" "$RED" "$BOLD" "$NC"
echo -e "${RED}Make sure to document migration steps in release notes.${NC}" printf "%bMake sure to document migration steps in release notes.%b\n" "$RED" "$NC"
echo "" echo ""
fi fi
# Show note about pre-1.0 versioning # Show note about pre-1.0 versioning
if [ $MAJOR -eq 0 ]; then if [ "$MAJOR" -eq 0 ]; then
echo -e "${YELLOW}Note: Pre-1.0 versioning (0.x.y)${NC}" printf "%bNote: Pre-1.0 versioning (0.x.y)%b\n" "$YELLOW" "$NC"
echo " - Breaking changes bump MINOR (0.x.0)" echo " - Breaking changes bump MINOR (0.x.0)"
echo " - Features bump MINOR (0.x.0)" echo " - Features bump MINOR (0.x.0)"
echo " - Fixes bump PATCH (0.0.x)" 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/ # 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"
)