diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..60bbd1e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,24 @@ +name: Tests + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: pip install pytest + + - name: Run tests + run: python -m pytest tests/ -v diff --git a/.gitignore b/.gitignore index a2f004d..9b75b55 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ *.swp +__pycache__/ +*.pyc +.pytest_cache/ # Dev environment - generated files dev/config/.storage/auth diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..4584de7 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +pythonpath = . diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..217fced --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,164 @@ +"""Shared test configuration and Home Assistant stubs for TaskMate tests. + +All homeassistant stubs are installed into sys.modules here, at module-load +time, so that any subsequent `from custom_components.taskmate.xxx import …` +statements resolve without needing a real HA installation. +""" +from __future__ import annotations + +import asyncio +import sys +from unittest.mock import AsyncMock, MagicMock +import datetime as _dt + +import pytest + +# --------------------------------------------------------------------------- +# Home Assistant stubs +# These must be in place BEFORE any integration module is imported. +# --------------------------------------------------------------------------- + +_UTC = _dt.timezone.utc + + +# ── homeassistant.core ────────────────────────────────────────────────────── + +class FakeHass: + """Minimal mock of HomeAssistant.""" + + def __init__(self): + self.services = MagicMock() + self.services.async_call = AsyncMock() + self.bus = MagicMock() + + def async_create_task(self, coro): + # Don't schedule; just close to avoid 'coroutine never awaited' warnings + if asyncio.iscoroutine(coro): + coro.close() + return None + + +_ha_core = MagicMock() +_ha_core.HomeAssistant = FakeHass +_ha_core.callback = lambda f: f # pass-through decorator +_ha_core.ServiceCall = MagicMock + + +# ── homeassistant.helpers.update_coordinator ──────────────────────────────── + +class FakeDataUpdateCoordinator: + """Minimal base class that TaskMateCoordinator inherits from.""" + + def __init__(self, hass, logger, *, name, update_interval=None): + self.hass = hass + self.data: dict = {} + + async def async_refresh(self): + """No-op in tests unless overridden.""" + + +_ha_coordinator = MagicMock() +_ha_coordinator.DataUpdateCoordinator = FakeDataUpdateCoordinator + + +# ── homeassistant.helpers.storage ─────────────────────────────────────────── + +class FakeStore: + """In-memory Store substitute that avoids the filesystem.""" + + def __init__(self, hass, version, key): + self._data = None + + async def async_load(self): + return self._data + + async def async_save(self, data): + self._data = data + + +_ha_storage_mod = MagicMock() +_ha_storage_mod.Store = FakeStore + + +# ── homeassistant.helpers.event ───────────────────────────────────────────── + +_ha_event = MagicMock() +_ha_event.async_track_time_change = MagicMock(return_value=lambda: None) + + +# ── homeassistant.util.dt ──────────────────────────────────────────────────── +# coordinator.py imports this as: from homeassistant.util import dt as dt_util + +_DEFAULT_NOW = _dt.datetime(2024, 3, 20, 12, 0, 0, tzinfo=_UTC) # Wednesday + + +class _DtUtilMock: + """Controllable drop-in for homeassistant.util.dt.""" + + _now: _dt.datetime = _DEFAULT_NOW + + def now(self) -> _dt.datetime: + return self._now + + @staticmethod + def as_local(dt: _dt.datetime) -> _dt.datetime: + return dt # treat everything as UTC in tests + + +dt_util_mock = _DtUtilMock() + +_ha_util = MagicMock() +_ha_util.dt = dt_util_mock # `from homeassistant.util import dt` resolves here + + +# ── Register all stubs ─────────────────────────────────────────────────────── + +sys.modules.update( + { + "homeassistant": MagicMock(), + "homeassistant.core": _ha_core, + "homeassistant.config_entries": MagicMock(), + "homeassistant.const": MagicMock(), + "homeassistant.helpers": MagicMock(), + "homeassistant.helpers.storage": _ha_storage_mod, + "homeassistant.helpers.event": _ha_event, + "homeassistant.helpers.update_coordinator": _ha_coordinator, + "homeassistant.helpers.config_validation": MagicMock(), + "homeassistant.util": _ha_util, + "homeassistant.util.dt": dt_util_mock, + # Stub the frontend sub-module so __init__.py's relative import succeeds + # without executing frontend.py (which has its own heavy HA dependencies). + "custom_components.taskmate.frontend": MagicMock(), + # voluptuous is used by __init__.py for service schemas + "voluptuous": MagicMock(), + } +) + + +# --------------------------------------------------------------------------- +# Pytest fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def hass(): + """Return a fresh FakeHass instance.""" + return FakeHass() + + +@pytest.fixture +def event_loop(): + """Provide a fresh asyncio event loop per test.""" + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +def run_async(coro, loop=None): + """Run a coroutine synchronously in tests.""" + if loop is None: + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + return loop.run_until_complete(coro) diff --git a/tests/test_coordinator_logic.py b/tests/test_coordinator_logic.py new file mode 100644 index 0000000..54a1084 --- /dev/null +++ b/tests/test_coordinator_logic.py @@ -0,0 +1,669 @@ +"""Tests for the core business logic in TaskMateCoordinator. + +We test pure logic methods (streak tracking, milestone bonuses, perfect week, +prune history, recurrence availability) by constructing a coordinator with a +fully mocked storage layer, avoiding any real Home Assistant dependencies. +""" +from __future__ import annotations + +import asyncio +import datetime as dt +from datetime import date, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from custom_components.taskmate.coordinator import TaskMateCoordinator +from custom_components.taskmate.models import Child, Chore, ChoreCompletion + +UTC = timezone.utc + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _date(year: int, month: int, day: int) -> dt.datetime: + return dt.datetime(year, month, day, 12, 0, 0, tzinfo=UTC) + + +def _make_child( + *, + points: int = 0, + current_streak: int = 0, + best_streak: int = 0, + last_completion_date: str | None = None, + streak_paused: bool = False, + streak_milestones_achieved: list | None = None, + awarded_perfect_weeks: list | None = None, +) -> Child: + return Child( + name="Test Child", + points=points, + total_points_earned=points, + current_streak=current_streak, + best_streak=best_streak, + last_completion_date=last_completion_date, + streak_paused=streak_paused, + streak_milestones_achieved=streak_milestones_achieved or [], + awarded_perfect_weeks=awarded_perfect_weeks or [], + ) + + +def _make_coord( + *, + settings: dict | None = None, + children: list | None = None, + completions: list | None = None, +) -> TaskMateCoordinator: + """Build a coordinator with mocked storage for unit testing.""" + coord = object.__new__(TaskMateCoordinator) + coord.hass = MagicMock() + coord.data = {} + coord._unsub_midnight = None + coord._unsub_prune = None + + _settings = settings or {} + _children = children or [] + _completions = completions or [] + + storage = MagicMock() + storage.get_setting = MagicMock(side_effect=lambda k, d="": _settings.get(k, d)) + storage.get_children = MagicMock(return_value=_children) + storage.get_completions = MagicMock(return_value=_completions) + storage.update_child = MagicMock() + storage.add_points_transaction = MagicMock() + storage.async_save = AsyncMock() + # Provide a real _data dict so that async_prune_history (which writes to + # storage._data["completions"] directly) can be tested without MagicMock absorbing writes. + storage._data = {"completions": [c.to_dict() for c in _completions]} + + coord.storage = storage + coord.async_refresh = AsyncMock() + return coord + + +def run(coro): + """Run a coroutine synchronously.""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +# --------------------------------------------------------------------------- +# parse_milestone_setting +# --------------------------------------------------------------------------- + +class TestParseMilestoneSetting: + def test_empty_string_returns_empty_dict(self): + result = TaskMateCoordinator.parse_milestone_setting("") + assert result == {} + + def test_whitespace_only_returns_empty_dict(self): + result = TaskMateCoordinator.parse_milestone_setting(" ") + assert result == {} + + def test_valid_single_milestone(self): + result = TaskMateCoordinator.parse_milestone_setting("7:10") + assert result == {7: 10} + + def test_valid_multiple_milestones(self): + result = TaskMateCoordinator.parse_milestone_setting("3:5, 7:10, 14:20") + assert result == {3: 5, 7: 10, 14: 20} + + def test_whitespace_around_values_handled(self): + result = TaskMateCoordinator.parse_milestone_setting(" 7 : 10 ") + assert result == {7: 10} + + def test_missing_colon_raises_value_error(self): + with pytest.raises(ValueError, match="Invalid format"): + TaskMateCoordinator.parse_milestone_setting("7-10") + + def test_non_integer_days_raises_value_error(self): + with pytest.raises(ValueError): + TaskMateCoordinator.parse_milestone_setting("week:10") + + def test_non_integer_points_raises_value_error(self): + with pytest.raises(ValueError): + TaskMateCoordinator.parse_milestone_setting("7:lots") + + def test_days_zero_raises_value_error(self): + with pytest.raises(ValueError, match="at least 1"): + TaskMateCoordinator.parse_milestone_setting("0:10") + + def test_points_zero_raises_value_error(self): + with pytest.raises(ValueError, match="at least 1"): + TaskMateCoordinator.parse_milestone_setting("7:0") + + def test_duplicate_days_raises_value_error(self): + with pytest.raises(ValueError, match="Duplicate"): + TaskMateCoordinator.parse_milestone_setting("7:10, 7:20") + + +# --------------------------------------------------------------------------- +# _award_points — streak tracking +# --------------------------------------------------------------------------- + +class TestAwardPointsStreakTracking: + """dt_util.now() is patched to control the 'current date' seen by _award_points.""" + + def _run_award(self, coord, child, points, now_dt, completion_date=None): + import custom_components.taskmate.coordinator as _mod + with patch.object(_mod.dt_util, "now", return_value=now_dt): + run(coord._award_points(child, points, completion_date=completion_date)) + + def test_first_completion_sets_streak_to_one(self): + coord = _make_coord() + child = _make_child() + now = _date(2024, 3, 20) + self._run_award(coord, child, 10, now) + assert child.current_streak == 1 + assert child.last_completion_date == "2024-03-20" + + def test_completing_same_day_does_not_change_streak(self): + coord = _make_coord() + child = _make_child(current_streak=3, last_completion_date="2024-03-20") + now = _date(2024, 3, 20) + self._run_award(coord, child, 10, now) + assert child.current_streak == 3 # unchanged + + def test_consecutive_day_increments_streak(self): + coord = _make_coord() + child = _make_child(current_streak=4, last_completion_date="2024-03-19") + now = _date(2024, 3, 20) + self._run_award(coord, child, 10, now) + assert child.current_streak == 5 + + def test_missed_day_in_reset_mode_resets_streak(self): + coord = _make_coord(settings={"streak_reset_mode": "reset"}) + child = _make_child(current_streak=10, last_completion_date="2024-03-17") + now = _date(2024, 3, 20) # skipped the 18th and 19th + self._run_award(coord, child, 10, now) + assert child.current_streak == 1 # reset then incremented to 1 + + def test_missed_day_in_pause_mode_resumes_streak(self): + coord = _make_coord(settings={"streak_reset_mode": "pause"}) + child = _make_child( + current_streak=10, + last_completion_date="2024-03-17", + streak_paused=True, + ) + now = _date(2024, 3, 20) + self._run_award(coord, child, 10, now) + # Streak should be preserved (not reset), paused flag cleared + assert child.current_streak == 10 + assert child.streak_paused is False + + def test_best_streak_updates_when_current_exceeds_it(self): + coord = _make_coord() + child = _make_child(current_streak=4, best_streak=4, last_completion_date="2024-03-19") + now = _date(2024, 3, 20) + self._run_award(coord, child, 10, now) + assert child.current_streak == 5 + assert child.best_streak == 5 + + def test_best_streak_not_reduced(self): + coord = _make_coord() + # streak reset from 10 to 1 — best_streak should stay 10 + child = _make_child(current_streak=10, best_streak=10, last_completion_date="2024-03-15") + now = _date(2024, 3, 20) + self._run_award(coord, child, 10, now) + assert child.current_streak == 1 + assert child.best_streak == 10 + + def test_points_awarded_to_child(self): + coord = _make_coord() + child = _make_child(points=50) + now = _date(2024, 3, 20) + self._run_award(coord, child, 10, now) + assert child.points >= 60 # at least base points added + + def test_total_chores_completed_incremented(self): + coord = _make_coord() + child = _make_child() + now = _date(2024, 3, 20) + self._run_award(coord, child, 10, now) + assert child.total_chores_completed == 1 + + +# --------------------------------------------------------------------------- +# _award_points — weekend multiplier +# --------------------------------------------------------------------------- + +class TestAwardPointsWeekendMultiplier: + def _run_award(self, coord, child, points, now_dt, completion_date=None): + import custom_components.taskmate.coordinator as _mod + with patch.object(_mod.dt_util, "now", return_value=now_dt): + run(coord._award_points(child, points, completion_date=completion_date)) + + def test_weekday_no_bonus(self): + # Wednesday 2024-03-20 + coord = _make_coord(settings={"weekend_multiplier": "2.0"}) + child = _make_child(points=0) + now = _date(2024, 3, 20) # Wednesday + self._run_award(coord, child, 10, now) + assert child.points == 10 # no bonus + + def test_saturday_applies_multiplier(self): + # Saturday 2024-03-23 + coord = _make_coord(settings={"weekend_multiplier": "2.0"}) + child = _make_child(points=0) + now = _date(2024, 3, 23) # Saturday + self._run_award(coord, child, 10, now, completion_date=date(2024, 3, 23)) + # 10 base + 10 bonus (2x multiplier means +100% = +10 extra) + assert child.points == 20 + + def test_sunday_applies_multiplier(self): + coord = _make_coord(settings={"weekend_multiplier": "2.0"}) + child = _make_child(points=0) + now = _date(2024, 3, 24) # Sunday + self._run_award(coord, child, 10, now, completion_date=date(2024, 3, 24)) + assert child.points == 20 + + def test_multiplier_one_no_bonus(self): + coord = _make_coord(settings={"weekend_multiplier": "1.0"}) + child = _make_child(points=0) + now = _date(2024, 3, 23) # Saturday + self._run_award(coord, child, 10, now, completion_date=date(2024, 3, 23)) + assert child.points == 10 # multiplier=1 means no extra bonus + + +# --------------------------------------------------------------------------- +# _award_points — streak milestone bonuses +# --------------------------------------------------------------------------- + +class TestAwardPointsMilestoneBonuses: + def _run_award(self, coord, child, points, now_dt): + import custom_components.taskmate.coordinator as _mod + with patch.object(_mod.dt_util, "now", return_value=now_dt): + run(coord._award_points(child, points)) + + def test_milestone_bonus_awarded_on_reaching_threshold(self): + settings = { + "streak_milestones_enabled": "true", + "streak_milestones": "3:5, 7:10", + } + coord = _make_coord(settings=settings) + # Streak is at 2, completing today puts it at 3 + child = _make_child(points=0, current_streak=2, last_completion_date="2024-03-19") + now = _date(2024, 3, 20) + self._run_award(coord, child, 10, now) + # Should have received base 10 + milestone bonus 5 = 15 + assert child.points == 15 + assert 3 in child.streak_milestones_achieved + + def test_milestone_bonus_not_awarded_twice(self): + settings = { + "streak_milestones_enabled": "true", + "streak_milestones": "3:5", + } + coord = _make_coord(settings=settings) + # Streak is already 3, milestone already achieved + child = _make_child( + points=0, + current_streak=3, + last_completion_date="2024-03-19", + streak_milestones_achieved=[3], + ) + now = _date(2024, 3, 20) + self._run_award(coord, child, 10, now) + # Only base points, no milestone bonus + assert child.points == 10 + + def test_milestones_cleared_on_streak_reset(self): + settings = { + "streak_milestones_enabled": "true", + "streak_milestones": "3:5, 7:10", + "streak_reset_mode": "reset", + } + coord = _make_coord(settings=settings) + child = _make_child( + current_streak=7, + best_streak=7, + last_completion_date="2024-03-10", # 10 days ago → missed + streak_milestones_achieved=[3, 7], + ) + now = _date(2024, 3, 20) + self._run_award(coord, child, 10, now) + # Streak reset, milestones cleared + assert child.streak_milestones_achieved == [] + + def test_milestones_disabled_no_bonus(self): + settings = { + "streak_milestones_enabled": "false", + "streak_milestones": "3:5", + } + coord = _make_coord(settings=settings) + child = _make_child(current_streak=2, last_completion_date="2024-03-19") + now = _date(2024, 3, 20) + self._run_award(coord, child, 10, now) + assert child.points == 10 + + +# --------------------------------------------------------------------------- +# _async_check_streaks +# --------------------------------------------------------------------------- + +class TestCheckStreaks: + def _run_check(self, coord, now_dt): + import custom_components.taskmate.coordinator as _mod + with patch.object(_mod.dt_util, "now", return_value=now_dt): + run(coord._async_check_streaks()) + + def test_no_completions_skipped(self): + child = _make_child(current_streak=5, last_completion_date=None) + coord = _make_coord(children=[child]) + now = _date(2024, 3, 20) + self._run_check(coord, now) + assert child.current_streak == 5 # untouched + + def test_completed_today_streak_unchanged(self): + child = _make_child(current_streak=5, last_completion_date="2024-03-20") + coord = _make_coord(children=[child]) + now = _date(2024, 3, 20) + self._run_check(coord, now) + assert child.current_streak == 5 + + def test_completed_yesterday_streak_unchanged(self): + child = _make_child(current_streak=5, last_completion_date="2024-03-19") + coord = _make_coord(children=[child]) + now = _date(2024, 3, 20) + self._run_check(coord, now) + assert child.current_streak == 5 + + def test_missed_day_reset_mode_clears_streak(self): + child = _make_child(current_streak=8, last_completion_date="2024-03-15") + coord = _make_coord( + settings={"streak_reset_mode": "reset"}, + children=[child], + ) + now = _date(2024, 3, 20) + self._run_check(coord, now) + assert child.current_streak == 0 + assert child.streak_paused is False + + def test_missed_day_pause_mode_sets_paused_flag(self): + child = _make_child(current_streak=8, last_completion_date="2024-03-15") + coord = _make_coord( + settings={"streak_reset_mode": "pause"}, + children=[child], + ) + now = _date(2024, 3, 20) + self._run_check(coord, now) + # Streak value preserved but flagged as paused + assert child.current_streak == 8 + assert child.streak_paused is True + + def test_zero_streak_not_modified(self): + child = _make_child(current_streak=0, last_completion_date="2024-03-10") + coord = _make_coord(children=[child]) + now = _date(2024, 3, 20) + self._run_check(coord, now) + # current_streak was 0, nothing to reset + coord.storage.update_child.assert_not_called() + + +# --------------------------------------------------------------------------- +# _async_check_perfect_week +# --------------------------------------------------------------------------- + +class TestCheckPerfectWeek: + def _make_completion(self, child_id: str, date_str: str) -> ChoreCompletion: + return ChoreCompletion( + chore_id="chore1", + child_id=child_id, + completed_at=dt.datetime.fromisoformat(f"{date_str}T12:00:00+00:00"), + approved=True, + points_awarded=10, + ) + + def _run_check(self, coord, now_dt): + import custom_components.taskmate.coordinator as _mod + with patch.object(_mod.dt_util, "now", return_value=now_dt): + run(coord._async_check_perfect_week()) + + def test_perfect_week_awards_bonus(self): + # Today is Monday 2024-03-18; last week = Mon 2024-03-11 … Sun 2024-03-17 + child = _make_child(points=50) + child.id = "kid1" + last_week_dates = [ + "2024-03-11", "2024-03-12", "2024-03-13", + "2024-03-14", "2024-03-15", "2024-03-16", "2024-03-17", + ] + completions = [self._make_completion("kid1", d) for d in last_week_dates] + + coord = _make_coord( + settings={ + "perfect_week_enabled": "true", + "perfect_week_bonus": "50", + }, + children=[child], + completions=completions, + ) + now = _date(2024, 3, 18) # Monday + self._run_check(coord, now) + + assert child.points == 100 # 50 + 50 bonus + assert "2024-03-11" in child.awarded_perfect_weeks + + def test_perfect_week_not_awarded_twice(self): + child = _make_child(points=50, awarded_perfect_weeks=["2024-03-11"]) + child.id = "kid1" + last_week_dates = [ + "2024-03-11", "2024-03-12", "2024-03-13", + "2024-03-14", "2024-03-15", "2024-03-16", "2024-03-17", + ] + completions = [self._make_completion("kid1", d) for d in last_week_dates] + + coord = _make_coord( + settings={"perfect_week_enabled": "true", "perfect_week_bonus": "50"}, + children=[child], + completions=completions, + ) + now = _date(2024, 3, 18) + self._run_check(coord, now) + assert child.points == 50 # no bonus awarded again + + def test_incomplete_week_no_bonus(self): + child = _make_child(points=50) + child.id = "kid1" + # Missing Sunday 2024-03-17 + completions = [ + self._make_completion("kid1", d) + for d in ["2024-03-11", "2024-03-12", "2024-03-13", + "2024-03-14", "2024-03-15", "2024-03-16"] + ] + coord = _make_coord( + settings={"perfect_week_enabled": "true", "perfect_week_bonus": "50"}, + children=[child], + completions=completions, + ) + now = _date(2024, 3, 18) + self._run_check(coord, now) + assert child.points == 50 # unchanged + + def test_feature_disabled_skips_check(self): + child = _make_child(points=50) + child.id = "kid1" + last_week_dates = [ + "2024-03-11", "2024-03-12", "2024-03-13", + "2024-03-14", "2024-03-15", "2024-03-16", "2024-03-17", + ] + completions = [self._make_completion("kid1", d) for d in last_week_dates] + coord = _make_coord( + settings={"perfect_week_enabled": "false"}, + children=[child], + completions=completions, + ) + now = _date(2024, 3, 18) + self._run_check(coord, now) + assert child.points == 50 # no change + + def test_not_monday_skips_check(self): + child = _make_child(points=50) + child.id = "kid1" + coord = _make_coord( + settings={"perfect_week_enabled": "true", "perfect_week_bonus": "50"}, + children=[child], + completions=[], + ) + now = _date(2024, 3, 20) # Wednesday + self._run_check(coord, now) + assert child.points == 50 + + +# --------------------------------------------------------------------------- +# async_prune_history +# --------------------------------------------------------------------------- + +class TestPruneHistory: + def _make_completion( + self, *, approved: bool, days_old: int, now: dt.datetime + ) -> ChoreCompletion: + completed_at = now - dt.timedelta(days=days_old) + return ChoreCompletion( + chore_id="chore1", + child_id="child1", + completed_at=completed_at, + approved=approved, + points_awarded=10 if approved else 0, + ) + + def test_old_approved_completions_pruned(self): + now = dt.datetime(2024, 3, 20, 12, 0, 0, tzinfo=UTC) + old_approved = self._make_completion(approved=True, days_old=100, now=now) + recent_approved = self._make_completion(approved=True, days_old=30, now=now) + + coord = _make_coord(completions=[old_approved, recent_approved]) + + import custom_components.taskmate.coordinator as _mod + with patch.object(_mod.dt_util, "now", return_value=now): + run(coord.async_prune_history(days=90)) + + # The coordinator filters via storage._data; verify storage._data was set + saved_data = coord.storage._data["completions"] + ids_kept = {c["id"] for c in saved_data} + assert old_approved.id not in ids_kept + assert recent_approved.id in ids_kept + + def test_unapproved_completions_always_kept(self): + now = dt.datetime(2024, 3, 20, 12, 0, 0, tzinfo=UTC) + old_pending = self._make_completion(approved=False, days_old=200, now=now) + coord = _make_coord(completions=[old_pending]) + + import custom_components.taskmate.coordinator as _mod + with patch.object(_mod.dt_util, "now", return_value=now): + run(coord.async_prune_history(days=90)) + + saved_data = coord.storage._data["completions"] + ids_kept = {c["id"] for c in saved_data} + assert old_pending.id in ids_kept + + def test_no_pruning_needed_storage_not_written(self): + now = dt.datetime(2024, 3, 20, 12, 0, 0, tzinfo=UTC) + recent = self._make_completion(approved=True, days_old=10, now=now) + coord = _make_coord(completions=[recent]) + + import custom_components.taskmate.coordinator as _mod + with patch.object(_mod.dt_util, "now", return_value=now): + run(coord.async_prune_history(days=90)) + + coord.storage.async_save.assert_not_called() + + +# --------------------------------------------------------------------------- +# is_chore_available_for_child +# --------------------------------------------------------------------------- + +class TestChoreAvailability: + def _make_recurring_chore( + self, + recurrence: str = "weekly", + recurrence_day: str = "", + recurrence_start: str = "", + first_occurrence_mode: str = "available_immediately", + ) -> Chore: + return Chore( + name="Recurring chore", + schedule_mode="recurring", + recurrence=recurrence, + recurrence_day=recurrence_day, + recurrence_start=recurrence_start, + first_occurrence_mode=first_occurrence_mode, + ) + + def _run(self, coord, chore, child_id, now_dt): + import custom_components.taskmate.coordinator as _mod + with patch.object(_mod.dt_util, "now", return_value=now_dt): + return coord.is_chore_available_for_child(chore, child_id) + + def test_mode_a_always_available(self): + coord = _make_coord() + chore = Chore(name="Specific day chore", schedule_mode="specific_days") + coord.storage.get_last_completed = MagicMock(return_value={}) + now = _date(2024, 3, 20) + assert self._run(coord, chore, "kid1", now) is True + + def test_mode_b_never_completed_available_immediately(self): + coord = _make_coord() + coord.storage.get_last_completed = MagicMock(return_value={}) + chore = self._make_recurring_chore(first_occurrence_mode="available_immediately") + now = _date(2024, 3, 20) + assert self._run(coord, chore, "kid1", now) is True + + def test_mode_b_completed_within_window_not_available(self): + coord = _make_coord() + # Last completed 3 days ago, window is 7 days + coord.storage.get_last_completed = MagicMock( + return_value={"current": "2024-03-17T12:00:00+00:00"} + ) + chore = self._make_recurring_chore(recurrence="weekly") + now = _date(2024, 3, 20) # 3 days after last completion + assert self._run(coord, chore, "kid1", now) is False + + def test_mode_b_completed_outside_window_available(self): + coord = _make_coord() + # Last completed 8 days ago, window is 7 days + coord.storage.get_last_completed = MagicMock( + return_value={"current": "2024-03-12T12:00:00+00:00"} + ) + chore = self._make_recurring_chore(recurrence="weekly") + now = _date(2024, 3, 20) # 8 days after + assert self._run(coord, chore, "kid1", now) is True + + def test_mode_b_with_recurrence_day_wrong_day_not_available(self): + coord = _make_coord() + coord.storage.get_last_completed = MagicMock( + return_value={"current": "2024-03-13T12:00:00+00:00"} # 7 days ago + ) + chore = self._make_recurring_chore( + recurrence="weekly", + recurrence_day="friday", # only available on Fridays + ) + # 2024-03-20 is a Wednesday — not the target day + now = _date(2024, 3, 20) + assert self._run(coord, chore, "kid1", now) is False + + def test_mode_b_with_recurrence_day_correct_day_available(self): + coord = _make_coord() + coord.storage.get_last_completed = MagicMock( + return_value={"current": "2024-03-13T12:00:00+00:00"} # 7 days ago + ) + chore = self._make_recurring_chore( + recurrence="weekly", + recurrence_day="wednesday", # 2024-03-20 is a Wednesday + ) + now = _date(2024, 3, 20) + assert self._run(coord, chore, "kid1", now) is True + + def test_every_2_days_recurrence(self): + coord = _make_coord() + # Last completed yesterday — not yet available (needs 2 days) + coord.storage.get_last_completed = MagicMock( + return_value={"current": "2024-03-19T12:00:00+00:00"} + ) + chore = self._make_recurring_chore(recurrence="every_2_days") + now = _date(2024, 3, 20) # only 1 day since last — not available yet + assert self._run(coord, chore, "kid1", now) is False diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..b77bbc8 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,340 @@ +"""Tests for custom_components.taskmate.models.""" +from __future__ import annotations + +import datetime as dt +from datetime import timezone + +import pytest + +from custom_components.taskmate.models import ( + Child, + Chore, + ChoreCompletion, + PointsTransaction, + Reward, + RewardClaim, + format_datetime, + parse_datetime, +) + +UTC = timezone.utc + + +# --------------------------------------------------------------------------- +# parse_datetime +# --------------------------------------------------------------------------- + +class TestParseDatetime: + def test_none_returns_none(self): + assert parse_datetime(None) is None + + def test_naive_datetime_gets_utc(self): + naive = dt.datetime(2024, 1, 15, 10, 0, 0) + result = parse_datetime(naive) + assert result.tzinfo == UTC + assert result.replace(tzinfo=None) == naive + + def test_aware_datetime_returned_unchanged(self): + aware = dt.datetime(2024, 1, 15, 10, 0, 0, tzinfo=UTC) + assert parse_datetime(aware) is aware + + def test_naive_iso_string_gets_utc(self): + result = parse_datetime("2024-01-15T10:00:00") + assert result.tzinfo == UTC + assert result.year == 2024 + assert result.month == 1 + assert result.day == 15 + + def test_aware_iso_string_with_z_offset(self): + result = parse_datetime("2024-01-15T10:00:00+00:00") + assert result.tzinfo is not None + assert result.year == 2024 + + def test_aware_iso_string_with_positive_offset(self): + result = parse_datetime("2024-01-15T12:00:00+02:00") + assert result.tzinfo is not None + assert result.hour == 12 + + +# --------------------------------------------------------------------------- +# format_datetime +# --------------------------------------------------------------------------- + +class TestFormatDatetime: + def test_none_returns_none(self): + assert format_datetime(None) is None + + def test_utc_datetime_uses_z_suffix(self): + d = dt.datetime(2024, 6, 1, 8, 30, 0, tzinfo=UTC) + result = format_datetime(d) + assert result == "2024-06-01T08:30:00Z" + + def test_naive_datetime_treated_as_utc(self): + naive = dt.datetime(2024, 6, 1, 8, 30, 0) + result = format_datetime(naive) + assert result == "2024-06-01T08:30:00Z" + + def test_non_utc_datetime_converted_to_utc(self): + tz_plus2 = timezone(dt.timedelta(hours=2)) + d = dt.datetime(2024, 6, 1, 10, 0, 0, tzinfo=tz_plus2) + result = format_datetime(d) + # 10:00+02:00 → 08:00 UTC + assert result == "2024-06-01T08:00:00Z" + + def test_roundtrip(self): + original = dt.datetime(2024, 3, 15, 14, 22, 45, tzinfo=UTC) + assert parse_datetime(format_datetime(original)) == original + + +# --------------------------------------------------------------------------- +# Child +# --------------------------------------------------------------------------- + +class TestChild: + def test_defaults(self): + child = Child(name="Alice") + assert child.points == 0 + assert child.current_streak == 0 + assert child.best_streak == 0 + assert child.avatar == "mdi:account-circle" + assert child.pending_rewards == [] + assert child.chore_order == [] + assert child.streak_paused is False + + def test_roundtrip(self): + child = Child( + name="Bob", + avatar="mdi:robot-happy", + points=120, + total_points_earned=300, + total_chores_completed=25, + current_streak=7, + best_streak=14, + last_completion_date="2024-03-19", + streak_milestones_achieved=[3, 7], + awarded_perfect_weeks=["2024-03-11"], + id="abc12345", + ) + restored = Child.from_dict(child.to_dict()) + assert restored.name == child.name + assert restored.points == child.points + assert restored.current_streak == child.current_streak + assert restored.best_streak == child.best_streak + assert restored.streak_milestones_achieved == child.streak_milestones_achieved + assert restored.awarded_perfect_weeks == child.awarded_perfect_weeks + assert restored.id == child.id + + def test_from_dict_missing_fields_use_defaults(self): + child = Child.from_dict({"name": "Charlie"}) + assert child.points == 0 + assert child.current_streak == 0 + assert child.streak_milestones_achieved == [] + assert child.awarded_perfect_weeks == [] + + def test_none_streak_milestones_serialises_to_empty_list(self): + child = Child(name="Dana", streak_milestones_achieved=None) + data = child.to_dict() + assert data["streak_milestones_achieved"] == [] + + def test_none_awarded_perfect_weeks_serialises_to_empty_list(self): + child = Child(name="Eve", awarded_perfect_weeks=None) + data = child.to_dict() + assert data["awarded_perfect_weeks"] == [] + + def test_id_generated_when_missing(self): + child = Child.from_dict({"name": "Frank"}) + assert len(child.id) > 0 + + +# --------------------------------------------------------------------------- +# Chore +# --------------------------------------------------------------------------- + +class TestChore: + def test_defaults(self): + chore = Chore(name="Clean room") + assert chore.points == 10 + assert chore.requires_approval is True + assert chore.schedule_mode == "specific_days" + assert chore.due_days == [] + assert chore.daily_limit == 1 + + def test_roundtrip(self): + chore = Chore( + name="Wash dishes", + points=15, + description="After dinner", + assigned_to=["child1", "child2"], + requires_approval=False, + time_category="evening", + daily_limit=2, + schedule_mode="specific_days", + due_days=["monday", "wednesday", "friday"], + id="chore001", + ) + restored = Chore.from_dict(chore.to_dict()) + assert restored.name == chore.name + assert restored.points == chore.points + assert restored.assigned_to == chore.assigned_to + assert restored.due_days == chore.due_days + assert restored.id == chore.id + + def test_legacy_migration_due_days_without_schedule_mode(self): + """Old data with due_days but no schedule_mode should default to specific_days.""" + data = { + "name": "Old chore", + "due_days": ["monday", "tuesday"], + # intentionally no "schedule_mode" key + } + chore = Chore.from_dict(data) + assert chore.schedule_mode == "specific_days" + assert chore.due_days == ["monday", "tuesday"] + + def test_schedule_mode_recurring_preserved(self): + chore = Chore(name="Exercise", schedule_mode="recurring", recurrence="weekly") + restored = Chore.from_dict(chore.to_dict()) + assert restored.schedule_mode == "recurring" + assert restored.recurrence == "weekly" + + +# --------------------------------------------------------------------------- +# Reward +# --------------------------------------------------------------------------- + +class TestReward: + def test_defaults(self): + reward = Reward(name="Movie night") + assert reward.cost == 50 + assert reward.icon == "mdi:gift" + assert reward.assigned_to == [] + assert reward.is_jackpot is False + + def test_roundtrip(self): + reward = Reward( + name="Pizza dinner", + cost=100, + description="Any pizza you want", + icon="mdi:pizza", + assigned_to=["child1"], + is_jackpot=True, + id="reward01", + ) + restored = Reward.from_dict(reward.to_dict()) + assert restored.name == reward.name + assert restored.cost == reward.cost + assert restored.is_jackpot == reward.is_jackpot + assert restored.id == reward.id + + +# --------------------------------------------------------------------------- +# ChoreCompletion +# --------------------------------------------------------------------------- + +class TestChoreCompletion: + def test_roundtrip(self): + comp = ChoreCompletion( + chore_id="chore1", + child_id="child1", + completed_at=dt.datetime(2024, 3, 19, 15, 0, 0, tzinfo=UTC), + approved=True, + approved_at=dt.datetime(2024, 3, 19, 16, 0, 0, tzinfo=UTC), + points_awarded=20, + id="comp001", + ) + restored = ChoreCompletion.from_dict(comp.to_dict()) + assert restored.chore_id == comp.chore_id + assert restored.child_id == comp.child_id + assert restored.approved == comp.approved + assert restored.points_awarded == comp.points_awarded + assert restored.id == comp.id + + def test_completed_at_datetime_preserved(self): + original = dt.datetime(2024, 3, 19, 15, 30, 0, tzinfo=UTC) + comp = ChoreCompletion(chore_id="c", child_id="k", completed_at=original) + restored = ChoreCompletion.from_dict(comp.to_dict()) + assert restored.completed_at == original + + def test_approved_at_none(self): + comp = ChoreCompletion( + chore_id="c", + child_id="k", + completed_at=dt.datetime(2024, 3, 19, 0, 0, 0, tzinfo=UTC), + approved=False, + ) + data = comp.to_dict() + assert data["approved_at"] is None + restored = ChoreCompletion.from_dict(data) + assert restored.approved_at is None + + def test_pending_completion_defaults(self): + comp = ChoreCompletion.from_dict( + { + "chore_id": "c1", + "child_id": "k1", + "completed_at": "2024-03-19T10:00:00Z", + } + ) + assert comp.approved is False + assert comp.points_awarded == 0 + + +# --------------------------------------------------------------------------- +# RewardClaim +# --------------------------------------------------------------------------- + +class TestRewardClaim: + def test_roundtrip(self): + claim = RewardClaim( + reward_id="reward1", + child_id="child1", + claimed_at=dt.datetime(2024, 3, 19, 12, 0, 0, tzinfo=UTC), + approved=True, + approved_at=dt.datetime(2024, 3, 19, 13, 0, 0, tzinfo=UTC), + id="claim01", + ) + restored = RewardClaim.from_dict(claim.to_dict()) + assert restored.reward_id == claim.reward_id + assert restored.child_id == claim.child_id + assert restored.approved == claim.approved + assert restored.id == claim.id + + def test_pending_claim_defaults(self): + claim = RewardClaim.from_dict( + { + "reward_id": "r1", + "child_id": "k1", + "claimed_at": "2024-03-19T10:00:00Z", + } + ) + assert claim.approved is False + assert claim.approved_at is None + + +# --------------------------------------------------------------------------- +# PointsTransaction +# --------------------------------------------------------------------------- + +class TestPointsTransaction: + def test_roundtrip(self): + tx = PointsTransaction( + child_id="child1", + points=25, + reason="Bonus for helping", + created_at=dt.datetime(2024, 3, 19, 9, 0, 0, tzinfo=UTC), + id="tx001", + ) + restored = PointsTransaction.from_dict(tx.to_dict()) + assert restored.child_id == tx.child_id + assert restored.points == tx.points + assert restored.reason == tx.reason + assert restored.id == tx.id + + def test_negative_points_preserved(self): + tx = PointsTransaction( + child_id="child1", + points=-10, + reason="Penalty", + created_at=dt.datetime(2024, 3, 19, 9, 0, 0, tzinfo=UTC), + ) + restored = PointsTransaction.from_dict(tx.to_dict()) + assert restored.points == -10 diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..d6e6bda --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,365 @@ +"""Tests for custom_components.taskmate.storage.TaskMateStorage. + +We use an in-memory FakeStore (defined in conftest) so no filesystem I/O +occurs and no real Home Assistant is required. +""" +from __future__ import annotations + +import asyncio +import datetime as dt +from datetime import timezone + +import pytest + +from custom_components.taskmate.models import Child, Chore, ChoreCompletion, Reward +from custom_components.taskmate.storage import TaskMateStorage + +UTC = timezone.utc + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def run(coro): + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +def _make_storage(initial_data: dict | None = None) -> TaskMateStorage: + """Return a TaskMateStorage backed by an in-memory FakeStore.""" + # conftest.py has already swapped homeassistant.helpers.storage.Store with FakeStore + from tests.conftest import FakeStore + + storage = TaskMateStorage.__new__(TaskMateStorage) + storage.entry_id = "test_entry" + + fake_store = FakeStore(None, 1, "test") + if initial_data is not None: + # Simulate pre-existing saved data + fake_store._data = initial_data + + storage._store = fake_store + storage._data = {} + return storage + + +# --------------------------------------------------------------------------- +# async_load — defaults and migration +# --------------------------------------------------------------------------- + +class TestAsyncLoad: + def test_fresh_load_creates_default_structure(self): + storage = _make_storage(initial_data=None) + run(storage.async_load()) + assert storage._data["children"] == [] + assert storage._data["chores"] == [] + assert storage._data["rewards"] == [] + assert storage._data["completions"] == [] + assert storage._data["reward_claims"] == [] + assert storage._data["points_transactions"] == [] + assert storage._data["points_name"] == "Stars" + assert storage._data["points_icon"] == "mdi:star" + assert "last_completed" in storage._data + + def test_existing_data_without_last_completed_gets_migrated(self): + existing = { + "children": [], + "chores": [], + "rewards": [], + "completions": [], + "reward_claims": [], + "points_transactions": [], + "points_name": "Coins", + "points_icon": "mdi:coin", + # intentionally missing "last_completed" + } + storage = _make_storage(initial_data=existing) + run(storage.async_load()) + assert "last_completed" in storage._data + + def test_existing_data_preserved_on_load(self): + existing = { + "children": [{"name": "Alice", "id": "abc", "points": 100, + "total_points_earned": 100, "total_chores_completed": 5, + "current_streak": 2, "best_streak": 5, "avatar": "mdi:account-circle", + "pending_rewards": [], "chore_order": [], + "last_completion_date": "2024-03-19", + "streak_paused": False, + "streak_milestones_achieved": [], "awarded_perfect_weeks": []}], + "chores": [], + "rewards": [], + "completions": [], + "reward_claims": [], + "points_transactions": [], + "last_completed": {}, + } + storage = _make_storage(initial_data=existing) + run(storage.async_load()) + children = storage.get_children() + assert len(children) == 1 + assert children[0].name == "Alice" + assert children[0].points == 100 + + +# --------------------------------------------------------------------------- +# _migrate_assigned_to_child_ids +# --------------------------------------------------------------------------- + +class TestMigrateAssignedTo: + def _build_storage_with_data(self, children_raw, chores_raw): + storage = _make_storage() + storage._data = { + "children": children_raw, + "chores": chores_raw, + "rewards": [], + "completions": [], + "reward_claims": [], + "points_transactions": [], + "last_completed": {}, + } + return storage + + def test_chore_with_valid_ids_unchanged(self): + children = [{"id": "id1", "name": "Alice"}] + chores = [{"id": "c1", "name": "Sweep", "assigned_to": ["id1"]}] + storage = self._build_storage_with_data(children, chores) + run(storage._migrate_assigned_to_child_ids()) + assert storage._data["chores"][0]["assigned_to"] == ["id1"] + + def test_chore_with_child_name_migrated_to_id(self): + children = [{"id": "id1", "name": "Alice"}] + chores = [{"id": "c1", "name": "Sweep", "assigned_to": ["Alice"]}] + storage = self._build_storage_with_data(children, chores) + run(storage._migrate_assigned_to_child_ids()) + assert storage._data["chores"][0]["assigned_to"] == ["id1"] + + def test_chore_with_no_children_skipped(self): + storage = self._build_storage_with_data([], [{"id": "c1", "name": "Sweep", "assigned_to": []}]) + run(storage._migrate_assigned_to_child_ids()) # should not raise + + def test_empty_chores_and_children_skipped(self): + storage = self._build_storage_with_data([], []) + run(storage._migrate_assigned_to_child_ids()) # should not raise + + +# --------------------------------------------------------------------------- +# Children CRUD +# --------------------------------------------------------------------------- + +class TestChildrenCrud: + def _storage(self): + s = _make_storage() + s._data = {"children": [], "chores": [], "rewards": [], + "completions": [], "reward_claims": [], "points_transactions": [], + "last_completed": {}} + return s + + def test_add_then_get(self): + storage = self._storage() + child = Child(name="Alice", id="alice1") + storage.add_child(child) + found = storage.get_child("alice1") + assert found is not None + assert found.name == "Alice" + + def test_get_unknown_id_returns_none(self): + storage = self._storage() + assert storage.get_child("unknown") is None + + def test_get_children_returns_all(self): + storage = self._storage() + storage.add_child(Child(name="Alice", id="a1")) + storage.add_child(Child(name="Bob", id="b1")) + children = storage.get_children() + assert len(children) == 2 + + def test_update_child_modifies_existing(self): + storage = self._storage() + child = Child(name="Alice", points=0, id="a1") + storage.add_child(child) + child.points = 50 + storage.update_child(child) + assert storage.get_child("a1").points == 50 + + def test_update_child_adds_if_not_found(self): + storage = self._storage() + child = Child(name="Alice", id="a1") + storage.update_child(child) # no prior add + assert storage.get_child("a1") is not None + + def test_remove_child(self): + storage = self._storage() + storage.add_child(Child(name="Alice", id="a1")) + storage.remove_child("a1") + assert storage.get_child("a1") is None + + def test_remove_nonexistent_child_harmless(self): + storage = self._storage() + storage.remove_child("does-not-exist") # should not raise + + +# --------------------------------------------------------------------------- +# last_completed store +# --------------------------------------------------------------------------- + +class TestLastCompleted: + def _storage(self): + s = _make_storage() + s._data = {"last_completed": {}} + return s + + def test_get_returns_empty_dict_when_never_completed(self): + storage = self._storage() + result = storage.get_last_completed("chore1", "kid1") + assert result == {} + + def test_set_then_get(self): + storage = self._storage() + storage.set_last_completed("chore1", "kid1", "2024-03-20T12:00:00+00:00") + result = storage.get_last_completed("chore1", "kid1") + assert result["current"] == "2024-03-20T12:00:00+00:00" + assert result["previous"] is None + + def test_second_set_shifts_current_to_previous(self): + storage = self._storage() + storage.set_last_completed("chore1", "kid1", "2024-03-19T12:00:00+00:00") + storage.set_last_completed("chore1", "kid1", "2024-03-20T12:00:00+00:00") + result = storage.get_last_completed("chore1", "kid1") + assert result["current"] == "2024-03-20T12:00:00+00:00" + assert result["previous"] == "2024-03-19T12:00:00+00:00" + + def test_undo_restores_previous_as_current(self): + storage = self._storage() + storage.set_last_completed("chore1", "kid1", "2024-03-19T12:00:00+00:00") + storage.set_last_completed("chore1", "kid1", "2024-03-20T12:00:00+00:00") + storage.undo_last_completed("chore1", "kid1") + result = storage.get_last_completed("chore1", "kid1") + assert result["current"] == "2024-03-19T12:00:00+00:00" + + def test_undo_with_no_previous_removes_record(self): + storage = self._storage() + storage.set_last_completed("chore1", "kid1", "2024-03-20T12:00:00+00:00") + storage.undo_last_completed("chore1", "kid1") + result = storage.get_last_completed("chore1", "kid1") + assert result == {} + + def test_undo_nonexistent_is_harmless(self): + storage = self._storage() + storage.undo_last_completed("chore1", "kid1") # should not raise + + +# --------------------------------------------------------------------------- +# Points transactions — 200-item cap +# --------------------------------------------------------------------------- + +class TestPointsTransactionCap: + def _storage(self): + s = _make_storage() + s._data = {"points_transactions": []} + return s + + def test_transactions_capped_at_200(self): + from custom_components.taskmate.models import PointsTransaction + storage = self._storage() + for i in range(210): + tx = PointsTransaction( + child_id="kid1", + points=1, + reason=f"tx{i}", + created_at=dt.datetime(2024, 1, 1, tzinfo=UTC), + ) + storage.add_points_transaction(tx) + assert len(storage._data["points_transactions"]) == 200 + + def test_most_recent_transactions_kept(self): + from custom_components.taskmate.models import PointsTransaction + storage = self._storage() + for i in range(205): + tx = PointsTransaction( + child_id="kid1", + points=i, # use points as a unique marker + reason=f"tx{i}", + created_at=dt.datetime(2024, 1, 1, tzinfo=UTC), + ) + storage.add_points_transaction(tx) + # The last 200 entries should be kept (indices 5..204) + kept_points = [t["points"] for t in storage._data["points_transactions"]] + assert kept_points[0] == 5 # oldest kept + assert kept_points[-1] == 204 # most recent + + +# --------------------------------------------------------------------------- +# get_pending_completions +# --------------------------------------------------------------------------- + +class TestGetPendingCompletions: + def _storage(self): + s = _make_storage() + s._data = {"completions": []} + return s + + def test_returns_only_unapproved(self): + storage = self._storage() + approved = ChoreCompletion( + chore_id="c1", child_id="k1", + completed_at=dt.datetime(2024, 3, 19, 12, 0, 0, tzinfo=UTC), + approved=True, points_awarded=10, + ) + pending = ChoreCompletion( + chore_id="c1", child_id="k1", + completed_at=dt.datetime(2024, 3, 20, 12, 0, 0, tzinfo=UTC), + approved=False, + ) + storage.add_completion(approved) + storage.add_completion(pending) + result = storage.get_pending_completions() + assert len(result) == 1 + assert result[0].id == pending.id + + def test_empty_when_all_approved(self): + storage = self._storage() + comp = ChoreCompletion( + chore_id="c1", child_id="k1", + completed_at=dt.datetime(2024, 3, 20, 12, 0, 0, tzinfo=UTC), + approved=True, + ) + storage.add_completion(comp) + assert storage.get_pending_completions() == [] + + +# --------------------------------------------------------------------------- +# Settings helpers +# --------------------------------------------------------------------------- + +class TestSettings: + def _storage(self): + s = _make_storage() + s._data = {} + return s + + def test_get_missing_setting_returns_default(self): + storage = self._storage() + assert storage.get_setting("nonexistent", "fallback") == "fallback" + + def test_set_then_get_setting(self): + storage = self._storage() + storage.set_setting("streak_reset_mode", "pause") + assert storage.get_setting("streak_reset_mode") == "pause" + + def test_points_name_roundtrip(self): + storage = self._storage() + storage._data["points_name"] = "Stars" + assert storage.get_points_name() == "Stars" + storage.set_points_name("Coins") + assert storage.get_points_name() == "Coins" + + def test_points_icon_roundtrip(self): + storage = self._storage() + storage._data["points_icon"] = "mdi:star" + assert storage.get_points_icon() == "mdi:star" + storage.set_points_icon("mdi:coin") + assert storage.get_points_icon() == "mdi:coin"