parent
dc8a4897c6
commit
f669b79f4d
@ -0,0 +1,59 @@
|
||||
######################################################################
|
||||
# @CCOSTAN - Follow Me on X
|
||||
# For more info visit https://www.vcloudinfo.com/click-here
|
||||
# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
|
||||
# -------------------------------------------------------------------
|
||||
# Config Validation - Deterministic repository validation gate.
|
||||
# Runs mutation-sensitive tests plus strict dashboard and holiday checks.
|
||||
# -------------------------------------------------------------------
|
||||
######################################################################
|
||||
name: Config Validation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: ["master"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: config-validation-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deterministic-validation:
|
||||
name: Deterministic validation
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: pip
|
||||
|
||||
- name: Install validation dependencies
|
||||
run: python -m pip install --requirement requirements-dev.txt
|
||||
|
||||
- name: Run mutation-sensitive validator tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
|
||||
- name: Validate dashboard YAML and include graph
|
||||
shell: pwsh
|
||||
run: ./tools/validate_dashboards.ps1
|
||||
|
||||
- name: Validate holiday calendars, scenes, and routing
|
||||
shell: pwsh
|
||||
run: ./tools/holiday_lighting_coverage.ps1
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Validate browser smoke script syntax
|
||||
run: node --check tools/ha_ui_smoke.mjs
|
||||
@ -1,28 +0,0 @@
|
||||
######################################################################
|
||||
# @CCOSTAN - Follow Me on X
|
||||
# For more info visit https://www.vcloudinfo.com/click-here
|
||||
# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
|
||||
# -------------------------------------------------------------------
|
||||
# Travis CI - legacy Home Assistant configuration validation.
|
||||
# Defines the Travis build used to validate this repository.
|
||||
# -------------------------------------------------------------------
|
||||
######################################################################
|
||||
language: python
|
||||
|
||||
python: "3.8"
|
||||
|
||||
before_install:
|
||||
- touch fake_key.pem
|
||||
- mv config/travis_secrets.yaml config/secrets.yaml
|
||||
|
||||
install:
|
||||
- pip3 install homeassistant
|
||||
- pip3 install colorlog
|
||||
- pip3 install python-dateutil
|
||||
- git clone https://github.com/custom-components/youtube.git youtube_component/
|
||||
- mv youtube_component/custom_components config/custom_components
|
||||
- git clone https://github.com/mrk-its/homeassistant-blitzortung.git blitzortung_component/
|
||||
- mv blitzortung_component/custom_components config/custom_components
|
||||
|
||||
script:
|
||||
- hass -c config/ --script check_config --info all
|
||||
@ -0,0 +1 @@
|
||||
PyYAML==6.0.3
|
||||
@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
TOOLS = REPO / "tools"
|
||||
sys.path.insert(0, str(TOOLS))
|
||||
|
||||
from holiday_lighting_coverage import CoverageError, validate_coverage # noqa: E402
|
||||
|
||||
|
||||
class HolidayLightingCoverageTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tempdir = tempfile.TemporaryDirectory(prefix="holiday-coverage-")
|
||||
self.root = Path(self.tempdir.name)
|
||||
files = (
|
||||
"config/www/json_data/holidays.json",
|
||||
"config/www/json_data/flag_days.json",
|
||||
"config/scene/monthly_colors.yaml",
|
||||
"config/packages/holiday.yaml",
|
||||
"config/script/monthly_color_scene.yaml",
|
||||
)
|
||||
for relative in files:
|
||||
source = REPO / relative
|
||||
target = self.root / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, target)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.tempdir.cleanup()
|
||||
|
||||
def _replace(self, relative: str, old: str, new: str, count: int = -1) -> None:
|
||||
path = self.root / relative
|
||||
original = path.read_text(encoding="utf-8")
|
||||
changed = original.replace(old, new, count)
|
||||
self.assertNotEqual(original, changed, f"mutation target not found: {old}")
|
||||
path.write_text(changed, encoding="utf-8")
|
||||
|
||||
def test_current_contract_passes(self) -> None:
|
||||
results = validate_coverage(self.root, date(2026, 7, 22), 24)
|
||||
self.assertEqual(
|
||||
results["Easter Sunday"],
|
||||
(date(2027, 3, 28), date(2028, 4, 16)),
|
||||
)
|
||||
|
||||
def test_inverted_scene_state_fails(self) -> None:
|
||||
self._replace(
|
||||
"config/scene/monthly_colors.yaml",
|
||||
"state: 'on'",
|
||||
"state: 'off'",
|
||||
1,
|
||||
)
|
||||
with self.assertRaisesRegex(CoverageError, "must have state: on"):
|
||||
validate_coverage(self.root, date(2026, 7, 22), 24)
|
||||
|
||||
def test_missing_required_year_fails(self) -> None:
|
||||
self._replace(
|
||||
"config/www/json_data/holidays.json",
|
||||
' "03/28/2027": "Easter Sunday",\n',
|
||||
"",
|
||||
1,
|
||||
)
|
||||
with self.assertRaisesRegex(CoverageError, "missing 2027-03-28"):
|
||||
validate_coverage(self.root, date(2026, 7, 22), 24)
|
||||
|
||||
def test_wrong_static_holiday_name_fails(self) -> None:
|
||||
self._replace(
|
||||
"config/www/json_data/holidays.json",
|
||||
'"1/1": "New Years Day"',
|
||||
'"1/1": "Wrong Holiday"',
|
||||
1,
|
||||
)
|
||||
with self.assertRaisesRegex(CoverageError, "New Years Day"):
|
||||
validate_coverage(self.root, date(2026, 7, 22), 24)
|
||||
|
||||
def test_invalid_unrelated_dynamic_date_fails(self) -> None:
|
||||
self._replace(
|
||||
"config/www/json_data/holidays.json",
|
||||
'"01/19/2026": "MLK Day"',
|
||||
'"2026-01-19": "MLK Day"',
|
||||
1,
|
||||
)
|
||||
with self.assertRaisesRegex(CoverageError, "invalid date key"):
|
||||
validate_coverage(self.root, date(2026, 7, 22), 24)
|
||||
|
||||
def test_scene_routing_typo_fails(self) -> None:
|
||||
self._replace(
|
||||
"config/packages/holiday.yaml",
|
||||
"scene.month_{{ mode }}_colors",
|
||||
"scene.month_{{ mode }}_colour",
|
||||
1,
|
||||
)
|
||||
with self.assertRaisesRegex(CoverageError, "package routing"):
|
||||
validate_coverage(self.root, date(2026, 7, 22), 24)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate holiday calendars, lighting scenes, and scene-routing contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import calendar
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from validate_dashboards import ValidationError as YamlValidationError
|
||||
from validate_dashboards import parse_yaml
|
||||
|
||||
|
||||
class CoverageError(RuntimeError):
|
||||
"""Holiday lighting coverage is incomplete or internally inconsistent."""
|
||||
|
||||
|
||||
LIGHTING_MODES = (
|
||||
"standard",
|
||||
"RWB",
|
||||
"new_years_day",
|
||||
"valentine",
|
||||
"mardi_gras",
|
||||
"pi",
|
||||
"st_patty",
|
||||
"easter",
|
||||
"starwars",
|
||||
"cinco_de_mayo",
|
||||
"mothers_day",
|
||||
"fathers_day",
|
||||
"halloween",
|
||||
"veterans",
|
||||
"thanksgiving",
|
||||
"hanukkah",
|
||||
"christmas",
|
||||
)
|
||||
|
||||
STATIC_REQUIREMENTS = {
|
||||
"1/1": "New Years Day",
|
||||
"2/14": "Valentines Day",
|
||||
"3/14": "Pi Day",
|
||||
"3/17": "St. Patricks Day",
|
||||
"5/4": "Star Wars Day",
|
||||
"5/5": "Cinco de Mayo",
|
||||
"7/4": "Independence Day",
|
||||
"10/31": "Halloween",
|
||||
"11/11": "Veterans Day",
|
||||
"12/25": "Christmas Day",
|
||||
"12/31": "New Years Eve",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DynamicRule:
|
||||
name: str
|
||||
source: str
|
||||
expected_date: Callable[[int], date]
|
||||
|
||||
|
||||
def gregorian_easter(year: int) -> date:
|
||||
"""Meeus/Jones/Butcher Gregorian Easter algorithm."""
|
||||
a = year % 19
|
||||
b = year // 100
|
||||
c = year % 100
|
||||
d = b // 4
|
||||
e = b % 4
|
||||
f = (b + 8) // 25
|
||||
g = (b - f + 1) // 3
|
||||
h = (19 * a + b - d - g + 15) % 30
|
||||
i = c // 4
|
||||
k = c % 4
|
||||
ell = (32 + 2 * e + 2 * i - h - k) % 7
|
||||
m = (a + 11 * h + 22 * ell) // 451
|
||||
month = (h + ell - 7 * m + 114) // 31
|
||||
day = ((h + ell - 7 * m + 114) % 31) + 1
|
||||
return date(year, month, day)
|
||||
|
||||
|
||||
def nth_weekday(year: int, month: int, weekday: int, occurrence: int) -> date:
|
||||
first = date(year, month, 1)
|
||||
offset = (weekday - first.weekday()) % 7
|
||||
return first + timedelta(days=offset + 7 * (occurrence - 1))
|
||||
|
||||
|
||||
def last_weekday(year: int, month: int, weekday: int) -> date:
|
||||
last = date(year, month, calendar.monthrange(year, month)[1])
|
||||
return last - timedelta(days=(last.weekday() - weekday) % 7)
|
||||
|
||||
|
||||
DYNAMIC_RULES = (
|
||||
DynamicRule("Easter Sunday", "holidays", gregorian_easter),
|
||||
DynamicRule("Mothers Day", "holidays", lambda year: nth_weekday(year, 5, 6, 2)),
|
||||
DynamicRule("Fathers Day", "holidays", lambda year: nth_weekday(year, 6, 6, 3)),
|
||||
DynamicRule("Thanksgiving Day", "holidays", lambda year: nth_weekday(year, 11, 3, 4)),
|
||||
DynamicRule("Memorial Day", "flag_days", lambda year: last_weekday(year, 5, 0)),
|
||||
DynamicRule("Labor Day", "flag_days", lambda year: nth_weekday(year, 9, 0, 1)),
|
||||
)
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def add_months(start: date, months: int) -> date:
|
||||
if months < 1:
|
||||
raise CoverageError("months must be at least 1")
|
||||
month_index = start.month - 1 + months
|
||||
year = start.year + month_index // 12
|
||||
month = month_index % 12 + 1
|
||||
day = min(start.day, calendar.monthrange(year, month)[1])
|
||||
return date(year, month, day)
|
||||
|
||||
|
||||
def _reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
||||
result: dict[str, object] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise CoverageError(f"duplicate JSON key {key!r}")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict[str, object]:
|
||||
try:
|
||||
value = json.loads(
|
||||
path.read_text(encoding="utf-8"),
|
||||
object_pairs_hook=_reject_duplicate_json_keys,
|
||||
)
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise CoverageError(f"invalid JSON in {path}: {exc}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise CoverageError(f"JSON root must be a mapping: {path}")
|
||||
return value
|
||||
|
||||
|
||||
def _require_mapping(value: object, label: str) -> dict[str, object]:
|
||||
if not isinstance(value, dict):
|
||||
raise CoverageError(f"{label} must be a mapping")
|
||||
return value
|
||||
|
||||
|
||||
def parse_calendar_date(value: str, source: str) -> date:
|
||||
try:
|
||||
month_text, day_text, year_text = value.split("/")
|
||||
parsed = date(int(year_text), int(month_text), int(day_text))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise CoverageError(f"invalid date key {value!r} in {source}") from exc
|
||||
if value != parsed.strftime("%m/%d/%Y"):
|
||||
raise CoverageError(
|
||||
f"dynamic date key must use MM/DD/YYYY format in {source}: {value!r}"
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def validate_dynamic_map(dynamic_map: dict[str, object], source: str) -> None:
|
||||
for key, value in dynamic_map.items():
|
||||
parse_calendar_date(key, source)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise CoverageError(f"dynamic event {key!r} in {source} has no name")
|
||||
|
||||
|
||||
def validate_dynamic_rule(
|
||||
rule: DynamicRule,
|
||||
dynamic_map: dict[str, object],
|
||||
start: date,
|
||||
end: date,
|
||||
) -> tuple[date, ...]:
|
||||
expected = {
|
||||
expected_date
|
||||
for year in range(start.year, end.year + 1)
|
||||
if start <= (expected_date := rule.expected_date(year)) < end
|
||||
}
|
||||
actual = {
|
||||
parse_calendar_date(key, rule.source)
|
||||
for key, value in dynamic_map.items()
|
||||
if value == rule.name and start <= parse_calendar_date(key, rule.source) < end
|
||||
}
|
||||
if actual != expected:
|
||||
missing = sorted(expected - actual)
|
||||
unexpected = sorted(actual - expected)
|
||||
details: list[str] = []
|
||||
if missing:
|
||||
details.append("missing " + ", ".join(day.isoformat() for day in missing))
|
||||
if unexpected:
|
||||
details.append("unexpected " + ", ".join(day.isoformat() for day in unexpected))
|
||||
raise CoverageError(f"{rule.name} coverage mismatch: {'; '.join(details)}")
|
||||
return tuple(sorted(actual))
|
||||
|
||||
|
||||
def validate_scenes(path: Path) -> tuple[str, ...]:
|
||||
try:
|
||||
document = parse_yaml(path)
|
||||
except YamlValidationError as exc:
|
||||
raise CoverageError(str(exc)) from exc
|
||||
if not isinstance(document, list):
|
||||
raise CoverageError(f"scene file must contain a list: {path}")
|
||||
|
||||
scenes: dict[str, dict[str, object]] = {}
|
||||
for index, scene in enumerate(document):
|
||||
if not isinstance(scene, dict):
|
||||
raise CoverageError(f"scene {index} must be a mapping")
|
||||
name = scene.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
raise CoverageError(f"scene {index} is missing name")
|
||||
if name in scenes:
|
||||
raise CoverageError(f"duplicate scene name {name!r}")
|
||||
scenes[name] = scene
|
||||
|
||||
for mode in LIGHTING_MODES:
|
||||
scene_name = f"month_{mode}_colors"
|
||||
scene = scenes.get(scene_name)
|
||||
if scene is None:
|
||||
raise CoverageError(f"missing required scene {scene_name}")
|
||||
entities = scene.get("entities")
|
||||
if not isinstance(entities, dict) or not entities:
|
||||
raise CoverageError(f"scene {scene_name} must contain entities")
|
||||
for entity_id, state_config in entities.items():
|
||||
if not isinstance(state_config, dict):
|
||||
raise CoverageError(
|
||||
f"scene {scene_name} entity {entity_id} must use a state mapping"
|
||||
)
|
||||
if state_config.get("state") != "on":
|
||||
raise CoverageError(
|
||||
f"scene {scene_name} entity {entity_id} must have state: on"
|
||||
)
|
||||
return tuple(sorted(scenes))
|
||||
|
||||
|
||||
def validate_routing_contract(package_path: Path, script_path: Path) -> None:
|
||||
try:
|
||||
package_text = package_path.read_text(encoding="utf-8")
|
||||
script_text = script_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as exc:
|
||||
raise CoverageError(f"cannot read holiday routing configuration: {exc}") from exc
|
||||
|
||||
required_package_fragments = (
|
||||
"resource: http://localhost:8123/local/json_data/holidays.json",
|
||||
"resource: http://localhost:8123/local/json_data/flag_days.json",
|
||||
"unique_id: holiday_lighting_mode",
|
||||
"unique_id: holiday_lighting_scene",
|
||||
"scene.month_{{ mode }}_colors",
|
||||
"scene.month_standard_colors",
|
||||
)
|
||||
for fragment in required_package_fragments:
|
||||
if fragment not in package_text:
|
||||
raise CoverageError(f"holiday package routing is missing {fragment!r}")
|
||||
|
||||
for mode in LIGHTING_MODES:
|
||||
if mode == "standard":
|
||||
continue
|
||||
assignment = f"mode.value = '{mode}'"
|
||||
if assignment not in package_text:
|
||||
raise CoverageError(f"holiday lighting mode is never assigned: {mode}")
|
||||
|
||||
required_script_fragments = (
|
||||
"service: scene.turn_on",
|
||||
"sensor.holiday_lighting_scene",
|
||||
"scene.month_standard_colors",
|
||||
)
|
||||
for fragment in required_script_fragments:
|
||||
if fragment not in script_text:
|
||||
raise CoverageError(f"monthly scene script is missing {fragment!r}")
|
||||
|
||||
|
||||
def validate_coverage(root: Path, start: date, months: int) -> dict[str, tuple[date, ...]]:
|
||||
root = root.resolve()
|
||||
end = add_months(start, months)
|
||||
holiday_path = root / "config" / "www" / "json_data" / "holidays.json"
|
||||
flag_path = root / "config" / "www" / "json_data" / "flag_days.json"
|
||||
scene_path = root / "config" / "scene" / "monthly_colors.yaml"
|
||||
|
||||
holidays = _require_mapping(read_json(holiday_path).get("MAJOR_US"), "MAJOR_US")
|
||||
flags = _require_mapping(read_json(flag_path).get("Flag_Days_US"), "Flag_Days_US")
|
||||
holiday_static = _require_mapping(holidays.get("static"), "MAJOR_US.static")
|
||||
holiday_dynamic = _require_mapping(holidays.get("dynamic"), "MAJOR_US.dynamic")
|
||||
flag_dynamic = _require_mapping(flags.get("dynamic"), "Flag_Days_US.dynamic")
|
||||
validate_dynamic_map(holiday_dynamic, "holidays")
|
||||
validate_dynamic_map(flag_dynamic, "flag_days")
|
||||
|
||||
for key, expected_name in STATIC_REQUIREMENTS.items():
|
||||
actual_name = holiday_static.get(key)
|
||||
if actual_name != expected_name:
|
||||
raise CoverageError(
|
||||
f"static holiday {key} must be {expected_name!r}, got {actual_name!r}"
|
||||
)
|
||||
|
||||
dates: dict[str, tuple[date, ...]] = {}
|
||||
for rule in DYNAMIC_RULES:
|
||||
source_map = holiday_dynamic if rule.source == "holidays" else flag_dynamic
|
||||
dates[rule.name] = validate_dynamic_rule(rule, source_map, start, end)
|
||||
|
||||
validate_scenes(scene_path)
|
||||
validate_routing_contract(
|
||||
root / "config" / "packages" / "holiday.yaml",
|
||||
root / "config" / "script" / "monthly_color_scene.yaml",
|
||||
)
|
||||
return dates
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=repo_root())
|
||||
parser.add_argument("--months", type=int, default=24)
|
||||
parser.add_argument("--start-date", type=date.fromisoformat, default=date.today())
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Iterable[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
results = validate_coverage(args.root, args.start_date, args.months)
|
||||
end = add_months(args.start_date, args.months)
|
||||
except CoverageError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print(f"Holiday lighting coverage window: {args.start_date} to {end}")
|
||||
for name, dates in results.items():
|
||||
print(f"{name}: {', '.join(day.isoformat() for day in dates)}")
|
||||
print(
|
||||
"Coverage OK: exact per-year calendar dates, scene behavior, and routing contracts validated."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Reference in new issue