From 3b502f75ecb625fd1c773b9cd607a4a7126c2405 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Mon, 10 Aug 2026 22:16:05 +0000 Subject: [PATCH 1/3] test: make the source-text assertions format-insensitive Three tests assert against the raw text of select.py and button.py. Both assertions are about behaviour (design ids staying in sync, the icon fallback keying on falsiness) but were written against one exact rendering of the source, so any reformat breaks them. Whitespace- and quote-normalise before matching. Passes with or without the formatter run in the next commit. --- tests/test_accessible_design.py | 10 ++++++---- tests/test_pre_reader_mode.py | 13 ++++++++++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/test_accessible_design.py b/tests/test_accessible_design.py index 834eef0..10d81d5 100644 --- a/tests/test_accessible_design.py +++ b/tests/test_accessible_design.py @@ -5,6 +5,7 @@ allowlist and the select entity — and a mismatch means a style that either can't be chosen or can't be saved. """ + from __future__ import annotations import pathlib @@ -29,7 +30,8 @@ def _ws_ids() -> set[str]: def _select_ids() -> list[str]: - m = re.search(r'\("card_design", "card_design", \[(.*?)\]', SELECT) + # Whitespace-tolerant: the formatter is free to wrap this call across lines. + m = re.search(r'"card_design",\s*"card_design",\s*\[(.*?)\]', SELECT, re.S) assert m return re.findall(r'"([a-z]+)"', m.group(1)) @@ -90,13 +92,13 @@ def test_offered_in_the_card_editor(self): def test_label_exists_in_every_locale(self): import json + for path in sorted((ROOT / "www" / "locales").glob("*.json")): data = json.loads(path.read_text(encoding="utf-8")) assert "common.design.accessible" in data, f"{path.name} missing the label" def _accessible_block(dark: bool = False) -> str: - needle = ('[data-tm-design="accessible"][data-tm-dark]' if dark - else ':host([data-tm-design="accessible"]),') + needle = '[data-tm-design="accessible"][data-tm-dark]' if dark else ':host([data-tm-design="accessible"]),' start = DESIGN_JS.index(needle) - return DESIGN_JS[start:DESIGN_JS.index("}", start)] + return DESIGN_JS[start : DESIGN_JS.index("}", start)] diff --git a/tests/test_pre_reader_mode.py b/tests/test_pre_reader_mode.py index b6d1a35..771793e 100644 --- a/tests/test_pre_reader_mode.py +++ b/tests/test_pre_reader_mode.py @@ -4,6 +4,7 @@ JavaScript; what Python guards is the icon field it depends on — a chore with no picture would leave a pre-reader looking at identical tiles. """ + from __future__ import annotations import json @@ -19,7 +20,7 @@ def _tile_source() -> str: """The tile method body — sliced from its definition, not its call site.""" start = CARD.index(" _renderPreReaderTile(chore, child, todaysCompletions = [], choreIndex = 0) {") - return CARD[start:CARD.index("_renderChoreCard(chore, child, pointsIcon", start)] + return CARD[start : CARD.index("_renderChoreCard(chore, child, pointsIcon", start)] class TestChoreIcon: @@ -46,7 +47,10 @@ def test_button_falls_back_when_no_picture_is_set(self): """The icon default is "", so a fallback keyed on the attribute being absent would leave every button blank.""" button = (WWW.parent / "button.py").read_text(encoding="utf-8") - assert "getattr(chore, 'icon', \"\") or \"mdi:check-circle\"" in button + # Compare with quoting and whitespace normalised — the assertion is + # about the fallback being keyed on falsiness, not about formatting. + normalised = re.sub(r"\s+", "", button.replace("'", '"')) + assert 'getattr(chore,"icon","")or"mdi:check-circle"' in normalised def test_icon_is_exposed_to_the_cards(self): """Cards read chores from the sensor, so an unexposed field is invisible.""" @@ -108,7 +112,10 @@ class TestPreReaderRendersOnEveryDesign: SOURCE = ( _pathlib.Path(__file__).resolve().parent.parent - / "custom_components" / "taskmate" / "www" / "taskmate-child-card.js" + / "custom_components" + / "taskmate" + / "www" + / "taskmate-child-card.js" ).read_text(encoding="utf-8") def _designed_region(self) -> str: From 6af294d26500d4ecd328477e4143ac523c31ac05 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Mon, 10 Aug 2026 22:16:09 +0000 Subject: [PATCH 2/3] style: apply ruff format across the codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure formatting — no behaviour change. 176 files reformatted, full test suite unchanged at 1540 passed. Listed in .git-blame-ignore-revs so this commit does not obscure blame. --- custom_components/taskmate/__init__.py | 353 ++-- custom_components/taskmate/binary_sensor.py | 1 + custom_components/taskmate/button.py | 19 +- custom_components/taskmate/calendar.py | 41 +- custom_components/taskmate/config_flow.py | 5 +- custom_components/taskmate/const.py | 75 +- .../taskmate/coord_assignments.py | 53 +- custom_components/taskmate/coord_avatars.py | 49 +- custom_components/taskmate/coord_badges.py | 160 +- custom_components/taskmate/coord_calendar.py | 43 +- .../taskmate/coord_challenges.py | 65 +- custom_components/taskmate/coord_chores.py | 300 +-- custom_components/taskmate/coord_guests.py | 12 +- custom_components/taskmate/coord_mandatory.py | 85 +- .../taskmate/coord_notifications.py | 161 +- custom_components/taskmate/coord_points.py | 268 +-- custom_components/taskmate/coord_quests.py | 59 +- custom_components/taskmate/coord_reports.py | 214 +- custom_components/taskmate/coord_rewards.py | 134 +- custom_components/taskmate/coord_roulette.py | 13 +- custom_components/taskmate/coord_scheduled.py | 21 +- custom_components/taskmate/coord_templates.py | 43 +- custom_components/taskmate/coord_timed.py | 14 +- custom_components/taskmate/coord_tts.py | 35 +- custom_components/taskmate/coord_unlocks.py | 40 +- custom_components/taskmate/coordinator.py | 162 +- custom_components/taskmate/frontend.py | 30 +- custom_components/taskmate/http_calendar.py | 2 + custom_components/taskmate/http_images.py | 25 +- custom_components/taskmate/http_photos.py | 21 +- custom_components/taskmate/ics.py | 45 +- custom_components/taskmate/images.py | 23 +- custom_components/taskmate/intents.py | 2 + custom_components/taskmate/models.py | 37 +- custom_components/taskmate/number.py | 5 +- custom_components/taskmate/panel.py | 1 + custom_components/taskmate/photos.py | 10 +- custom_components/taskmate/printable.py | 21 +- custom_components/taskmate/select.py | 13 +- custom_components/taskmate/sensor.py | 545 +++--- custom_components/taskmate/storage.py | 162 +- custom_components/taskmate/templates.py | 312 ++- custom_components/taskmate/todo.py | 5 +- custom_components/taskmate/websocket.py | 1713 ++++++++++------- scripts/check_data_files.py | 4 +- tests/conftest.py | 10 +- tests/test_all_cards_use_design_system.py | 8 +- tests/test_allowance_ledger.py | 1 + tests/test_approval_photo_push.py | 17 +- tests/test_approve_all_chores.py | 8 +- tests/test_assignment_modes.py | 312 +-- tests/test_audit_log.py | 7 +- tests/test_availability_cache.py | 24 +- tests/test_avatars.py | 39 +- tests/test_badge_highlight_without_events.py | 8 +- tests/test_badge_models.py | 1 + tests/test_badge_services.py | 1 + tests/test_badge_storage.py | 1 + tests/test_binary_sensor.py | 1 + tests/test_bulk_chore_action.py | 1 + tests/test_button.py | 1 + tests/test_calendar_platform.py | 1 + tests/test_calendar_projection.py | 10 +- tests/test_card_design_setting.py | 4 + tests/test_celebration.py | 4 +- tests/test_challenges.py | 28 +- tests/test_child_vacation.py | 65 +- tests/test_chore_difficulty.py | 11 +- tests/test_chore_expiry.py | 11 +- tests/test_chore_icon.py | 3 +- tests/test_chore_image_cleanup.py | 25 +- tests/test_chore_image_field.py | 37 +- tests/test_chore_image_rendering.py | 17 +- tests/test_chore_image_sharing.py | 1 + tests/test_chore_roulette.py | 46 +- tests/test_chore_swap.py | 9 +- tests/test_clone_chore.py | 1 + tests/test_config_backup.py | 32 +- tests/test_coord_badges.py | 51 +- tests/test_coordinator_logic.py | 123 +- tests/test_coordinator_rewards.py | 182 +- tests/test_data_version_cache.py | 1 + tests/test_due_chores.py | 17 +- tests/test_events.py | 17 +- tests/test_external_state_version.py | 16 +- tests/test_fairness_report.py | 7 +- tests/test_family_goal.py | 12 +- tests/test_friction_report.py | 10 +- tests/test_frontend_retired_cards.py | 21 +- tests/test_gift_points.py | 1 + tests/test_guest_profiles.py | 17 +- tests/test_health_report.py | 27 +- tests/test_ics_export.py | 24 +- tests/test_image_storage.py | 4 +- tests/test_image_views.py | 15 +- tests/test_integration.py | 49 +- tests/test_intents.py | 1 + tests/test_interest.py | 29 +- tests/test_leaderboard_seasons.py | 18 +- tests/test_levels.py | 3 +- tests/test_locales_chore_images.py | 12 +- tests/test_locales_nav_url.py | 11 +- tests/test_locales_point_management.py | 12 +- tests/test_mandatory_actions.py | 6 +- tests/test_mandatory_chores_sensor.py | 7 +- tests/test_mandatory_detection.py | 31 +- tests/test_mandatory_escalation.py | 39 +- tests/test_mandatory_model.py | 12 +- tests/test_mandatory_prune.py | 1 + tests/test_mandatory_schedule.py | 13 +- tests/test_mandatory_services.py | 20 +- tests/test_mandatory_storage.py | 9 +- tests/test_models.py | 19 + tests/test_monthly_report.py | 22 +- tests/test_notifications_actionable.py | 2 + tests/test_notifications_dispatch.py | 63 +- tests/test_notifications_events.py | 65 +- tests/test_notifications_fallback.py | 8 +- tests/test_notifications_milestone.py | 2 + tests/test_notifications_parent_routing.py | 20 +- tests/test_notifications_scheduler.py | 32 +- tests/test_notifications_storage.py | 1 + tests/test_notifications_test_send.py | 6 +- tests/test_notifications_ws.py | 64 +- tests/test_panel_fills_viewport.py | 15 +- tests/test_panel_navigation.py | 8 +- tests/test_panel_undo_deny_list.py | 33 +- tests/test_parent_complete.py | 170 +- tests/test_parent_complete_on_behalf.py | 54 +- tests/test_parent_routing.py | 10 +- tests/test_parent_user_ids_sensor.py | 1 + tests/test_parent_user_ids_ws.py | 1 + tests/test_photo_gallery.py | 4 +- tests/test_photo_proof.py | 58 +- tests/test_photo_storage.py | 8 +- tests/test_points_decay.py | 20 +- tests/test_printable_chart.py | 26 +- tests/test_projection_report.py | 79 +- tests/test_quest_autoapprove.py | 5 +- tests/test_quests.py | 9 +- tests/test_quick_point_amounts.py | 6 +- tests/test_quiet_hours.py | 17 +- tests/test_reactive_chores.py | 20 +- tests/test_read_aloud.py | 61 +- tests/test_reward_restock.py | 56 +- tests/test_rotation_quota.py | 15 +- tests/test_routine_card.py | 11 +- tests/test_save_debounce.py | 2 + tests/test_scheduled_changes.py | 22 +- tests/test_sensor_attributes.py | 30 +- tests/test_service_admin_gate.py | 2 + tests/test_service_desc_fingerprint.py | 22 +- tests/test_service_linked_child_gate.py | 9 +- tests/test_service_parent_gate.py | 9 +- tests/test_service_validation_errors.py | 1 + tests/test_settings_entities.py | 25 +- tests/test_settings_schema_routing.py | 15 +- tests/test_spend_cap.py | 55 +- tests/test_storage.py | 221 ++- tests/test_streak_all_chores.py | 37 +- tests/test_surprise_bonus.py | 43 +- tests/test_template_packs.py | 48 +- tests/test_templates.py | 9 +- tests/test_time_incentives.py | 1 + tests/test_time_periods.py | 73 +- tests/test_timed_task.py | 20 +- tests/test_timed_unlocks.py | 38 +- tests/test_todo.py | 1 + tests/test_undo_chore_approval.py | 55 +- tests/test_undo_transaction.py | 74 +- tests/test_vacation_mode.py | 58 +- tests/test_weather_chores.py | 31 +- tests/test_websocket_admin.py | 9 +- tests/test_weekly_digest.py | 33 +- 174 files changed, 5311 insertions(+), 3587 deletions(-) diff --git a/custom_components/taskmate/__init__.py b/custom_components/taskmate/__init__.py index d2195fd..bfd10f6 100644 --- a/custom_components/taskmate/__init__.py +++ b/custom_components/taskmate/__init__.py @@ -1,4 +1,5 @@ """TaskMate - Family Chore Manager for Home Assistant.""" + from __future__ import annotations import copy @@ -121,7 +122,15 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BUTTON, Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.NUMBER, Platform.SELECT, Platform.TODO] +PLATFORMS: list[Platform] = [ + Platform.SENSOR, + Platform.BUTTON, + Platform.BINARY_SENSOR, + Platform.CALENDAR, + Platform.NUMBER, + Platform.SELECT, + Platform.TODO, +] # Track if services are registered SERVICES_REGISTERED = "services_registered" @@ -152,7 +161,8 @@ def _on_mobile_action(event): hass.async_create_task(coordinator.notifications.handle_mobile_action(event)) coordinator._unsub_mobile_action = hass.bus.async_listen( - "mobile_app_notification_action", _on_mobile_action, + "mobile_app_notification_action", + _on_mobile_action, ) # Register frontend static paths @@ -171,6 +181,7 @@ def _on_mobile_action(event): # stack never blocks setup. try: from .intents import async_setup_intents + async_setup_intents(hass) except Exception as err: # noqa: BLE001 _LOGGER.debug("TaskMate intents not registered: %s", err) @@ -187,9 +198,7 @@ def _on_mobile_action(event): await hass.async_add_executor_job(_load_base_descriptions) _async_update_service_descriptions(hass) - coordinator.async_add_listener( - lambda: _async_update_service_descriptions(hass) - ) + coordinator.async_add_listener(lambda: _async_update_service_descriptions(hass)) return True @@ -207,10 +216,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # If no more entries, unregister services. Count only coordinator # instances — hass.data[DOMAIN] also holds bookkeeping flags. - remaining_entries = [ - value for value in hass.data[DOMAIN].values() - if isinstance(value, TaskMateCoordinator) - ] + remaining_entries = [value for value in hass.data[DOMAIN].values() if isinstance(value, TaskMateCoordinator)] if not remaining_entries: _async_unregister_services(hass) hass.data[DOMAIN][SERVICES_REGISTERED] = False @@ -329,8 +335,16 @@ async def _async_require_parent(hass: HomeAssistant, call: ServiceCall) -> None: _AUDIT_TARGET_KEYS = ( - "chore_id", "reward_id", "penalty_id", "bonus_id", "badge_id", - "task_group_id", "miss_id", "claim_id", "transaction_id", "type_id", + "chore_id", + "reward_id", + "penalty_id", + "bonus_id", + "badge_id", + "task_group_id", + "miss_id", + "claim_id", + "transaction_id", + "type_id", ) @@ -363,9 +377,7 @@ async def _async_record_service_audit(hass: HomeAssistant, call: ServiceCall) -> target = f"{key}={call.data[key]}" break try: - await coordinator.async_record_audit( - user_id, user_name, f"service.{call.service}", target - ) + await coordinator.async_record_audit(user_id, user_name, f"service.{call.service}", target) except Exception: # noqa: BLE001 - audit must never break the action _LOGGER.debug("Failed to record service audit for %s", call.service, exc_info=True) @@ -382,18 +394,18 @@ def _safe(handler): ``ServiceValidationError`` is not a ``ValueError``, so a handler that already raises it (e.g. complete_chore) passes through untouched. """ + @wraps(handler) async def wrapped(call: ServiceCall) -> None: try: await handler(call) except ValueError as err: raise ServiceValidationError(str(err)) from err + return wrapped -async def _async_require_linked_child( - hass: HomeAssistant, call: ServiceCall, coordinator, child_id: str -) -> None: +async def _async_require_linked_child(hass: HomeAssistant, call: ServiceCall, coordinator, child_id: str) -> None: """Restrict a child's self-service call to that child's linked HA user. Opt-in: only enforced when the child has a ``linked_user_id`` set. Children @@ -426,11 +438,13 @@ async def _async_register_services(hass: HomeAssistant) -> None: def _admin(handler): """Wrap a service handler so only admins (or context-less calls) run it.""" + @wraps(handler) async def wrapped(call: ServiceCall) -> None: await _async_require_admin(hass, call) await handler(call) await _async_record_service_audit(hass, call) + # Compose with _safe so admin handlers also convert coordinator # ValueErrors into clean validation errors. The admin gate raises # Unauthorized (not ValueError), so it is unaffected and still 401s. @@ -441,11 +455,13 @@ def _parent(handler): Used for day-to-day parent actions. Structural config keeps _admin. """ + @wraps(handler) async def wrapped(call: ServiceCall) -> None: await _async_require_parent(hass, call) await handler(call) await _async_record_service_audit(hass, call) + return _safe(wrapped) async def handle_complete_chore(call: ServiceCall) -> None: @@ -468,7 +484,9 @@ async def handle_complete_chore(call: ServiceCall) -> None: await _async_require_linked_child(hass, call, coordinator, child_id) try: await coordinator.async_complete_chore( - chore_id, child_id, as_parent=as_parent, + chore_id, + child_id, + as_parent=as_parent, photo_url=call.data.get("photo_url", ""), ) except ValueError as err: @@ -497,9 +515,7 @@ async def handle_start_timed_task(call: ServiceCall) -> None: _LOGGER.error("No TaskMate coordinator available") return await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID]) - await coordinator.async_start_timed_task( - call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID] - ) + await coordinator.async_start_timed_task(call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID]) async def handle_pause_timed_task(call: ServiceCall) -> None: """Handle the pause_timed_task service call.""" @@ -508,9 +524,7 @@ async def handle_pause_timed_task(call: ServiceCall) -> None: _LOGGER.error("No TaskMate coordinator available") return await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID]) - await coordinator.async_pause_timed_task( - call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID] - ) + await coordinator.async_pause_timed_task(call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID]) async def handle_stop_timed_task(call: ServiceCall) -> None: """Handle the stop_timed_task service call.""" @@ -519,9 +533,7 @@ async def handle_stop_timed_task(call: ServiceCall) -> None: _LOGGER.error("No TaskMate coordinator available") return await _async_require_linked_child(hass, call, coordinator, call.data[ATTR_CHILD_ID]) - await coordinator.async_stop_timed_task( - call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID] - ) + await coordinator.async_stop_timed_task(call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID]) async def handle_approve_chore(call: ServiceCall) -> None: """Handle the approve_chore service call.""" @@ -604,7 +616,9 @@ async def handle_gift_points(call: ServiceCall) -> None: _LOGGER.error("No TaskMate coordinator available") return await coordinator.async_gift_points( - call.data["from_child_id"], call.data["to_child_id"], call.data["points"], + call.data["from_child_id"], + call.data["to_child_id"], + call.data["points"], ) async def handle_record_allowance_payout(call: ServiceCall) -> None: @@ -614,7 +628,8 @@ async def handle_record_allowance_payout(call: ServiceCall) -> None: _LOGGER.error("No TaskMate coordinator available") return await coordinator.async_record_allowance_payout( - call.data["child_id"], call.data["points"], + call.data["child_id"], + call.data["points"], ) async def handle_request_swap(call: ServiceCall) -> None: @@ -867,9 +882,7 @@ async def handle_set_chore_manual_start(call: ServiceCall) -> None: _LOGGER.error("No TaskMate coordinator available") return try: - await coordinator.async_set_chore_manual_start( - call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID] - ) + await coordinator.async_set_chore_manual_start(call.data[ATTR_CHORE_ID], call.data[ATTR_CHILD_ID]) except ValueError as err: _LOGGER.warning("set_chore_manual_start rejected: %s", err) raise @@ -1154,16 +1167,22 @@ async def handle_rebuild_badges(call: ServiceCall) -> None: _miss_schema = vol.Schema({vol.Required("miss_id"): cv.string}) hass.services.async_register( - DOMAIN, SERVICE_APPLY_MANDATORY_PENALTY, - _parent(handle_apply_mandatory_penalty), schema=_miss_schema, + DOMAIN, + SERVICE_APPLY_MANDATORY_PENALTY, + _parent(handle_apply_mandatory_penalty), + schema=_miss_schema, ) hass.services.async_register( - DOMAIN, SERVICE_POSTPONE_MANDATORY_CHORE, - _parent(handle_postpone_mandatory_chore), schema=_miss_schema, + DOMAIN, + SERVICE_POSTPONE_MANDATORY_CHORE, + _parent(handle_postpone_mandatory_chore), + schema=_miss_schema, ) hass.services.async_register( - DOMAIN, SERVICE_DISMISS_MANDATORY_CHORE, - _parent(handle_dismiss_mandatory_chore), schema=_miss_schema, + DOMAIN, + SERVICE_DISMISS_MANDATORY_CHORE, + _parent(handle_dismiss_mandatory_chore), + schema=_miss_schema, ) hass.services.async_register( @@ -1240,12 +1259,14 @@ async def handle_rebuild_badges(call: ServiceCall) -> None: DOMAIN, SERVICE_READ_ALOUD, _safe(handle_read_aloud), - schema=vol.Schema({ - vol.Required(ATTR_CHILD_ID): cv.string, - vol.Optional("media_player", default=""): cv.string, - vol.Optional("tts_entity", default=""): cv.string, - vol.Optional("message", default=""): cv.string, - }), + schema=vol.Schema( + { + vol.Required(ATTR_CHILD_ID): cv.string, + vol.Optional("media_player", default=""): cv.string, + vol.Optional("tts_entity", default=""): cv.string, + vol.Optional("message", default=""): cv.string, + } + ), ) hass.services.async_register( @@ -1283,7 +1304,7 @@ async def handle_rebuild_badges(call: ServiceCall) -> None: DOMAIN, SERVICE_REJECT_REWARD, _parent(handle_reject_reward), - schema=vol.Schema({ vol.Required("claim_id"): cv.string }), + schema=vol.Schema({vol.Required("claim_id"): cv.string}), ) hass.services.async_register( @@ -1342,11 +1363,28 @@ async def handle_rebuild_badges(call: ServiceCall) -> None: _safe(handle_preview_sound), schema=vol.Schema( { - vol.Required(ATTR_SOUND): vol.In([ - "none", "coin", "levelup", "fanfare", "chime", "powerup", "undo", - "fart1", "fart2", "fart3", "fart4", "fart5", "fart6", "fart7", - "fart8", "fart9", "fart10", "fart_random", - ]), + vol.Required(ATTR_SOUND): vol.In( + [ + "none", + "coin", + "levelup", + "fanfare", + "chime", + "powerup", + "undo", + "fart1", + "fart2", + "fart3", + "fart4", + "fart5", + "fart6", + "fart7", + "fart8", + "fart9", + "fart10", + "fart_random", + ] + ), } ), ) @@ -1363,32 +1401,35 @@ async def handle_rebuild_badges(call: ServiceCall) -> None: ), ) - hass.services.async_register( DOMAIN, SERVICE_ADD_PENALTY, _admin(handle_add_penalty), - schema=vol.Schema({ - vol.Required(ATTR_PENALTY_NAME): cv.string, - vol.Required(ATTR_PENALTY_POINTS): cv.positive_int, - vol.Optional(ATTR_PENALTY_DESCRIPTION, default=""): cv.string, - vol.Optional(ATTR_PENALTY_ICON, default="mdi:alert-circle-outline"): cv.string, - vol.Optional(ATTR_PENALTY_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]), - }), + schema=vol.Schema( + { + vol.Required(ATTR_PENALTY_NAME): cv.string, + vol.Required(ATTR_PENALTY_POINTS): cv.positive_int, + vol.Optional(ATTR_PENALTY_DESCRIPTION, default=""): cv.string, + vol.Optional(ATTR_PENALTY_ICON, default="mdi:alert-circle-outline"): cv.string, + vol.Optional(ATTR_PENALTY_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]), + } + ), ) hass.services.async_register( DOMAIN, SERVICE_UPDATE_PENALTY, _admin(handle_update_penalty), - schema=vol.Schema({ - vol.Required(ATTR_PENALTY_ID): cv.string, - vol.Optional(ATTR_PENALTY_NAME): cv.string, - vol.Optional(ATTR_PENALTY_POINTS): cv.positive_int, - vol.Optional(ATTR_PENALTY_DESCRIPTION): cv.string, - vol.Optional(ATTR_PENALTY_ICON): cv.string, - vol.Optional(ATTR_PENALTY_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]), - }), + schema=vol.Schema( + { + vol.Required(ATTR_PENALTY_ID): cv.string, + vol.Optional(ATTR_PENALTY_NAME): cv.string, + vol.Optional(ATTR_PENALTY_POINTS): cv.positive_int, + vol.Optional(ATTR_PENALTY_DESCRIPTION): cv.string, + vol.Optional(ATTR_PENALTY_ICON): cv.string, + vol.Optional(ATTR_PENALTY_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]), + } + ), ) hass.services.async_register( @@ -1402,37 +1443,43 @@ async def handle_rebuild_badges(call: ServiceCall) -> None: DOMAIN, SERVICE_APPLY_PENALTY, _parent(handle_apply_penalty), - schema=vol.Schema({ - vol.Required(ATTR_PENALTY_ID): cv.string, - vol.Required(ATTR_CHILD_ID): cv.string, - }), + schema=vol.Schema( + { + vol.Required(ATTR_PENALTY_ID): cv.string, + vol.Required(ATTR_CHILD_ID): cv.string, + } + ), ) hass.services.async_register( DOMAIN, SERVICE_ADD_BONUS, _admin(handle_add_bonus), - schema=vol.Schema({ - vol.Required(ATTR_BONUS_NAME): cv.string, - vol.Required(ATTR_BONUS_POINTS): cv.positive_int, - vol.Optional(ATTR_BONUS_DESCRIPTION, default=""): cv.string, - vol.Optional(ATTR_BONUS_ICON, default="mdi:star-circle-outline"): cv.string, - vol.Optional(ATTR_BONUS_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]), - }), + schema=vol.Schema( + { + vol.Required(ATTR_BONUS_NAME): cv.string, + vol.Required(ATTR_BONUS_POINTS): cv.positive_int, + vol.Optional(ATTR_BONUS_DESCRIPTION, default=""): cv.string, + vol.Optional(ATTR_BONUS_ICON, default="mdi:star-circle-outline"): cv.string, + vol.Optional(ATTR_BONUS_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]), + } + ), ) hass.services.async_register( DOMAIN, SERVICE_UPDATE_BONUS, _admin(handle_update_bonus), - schema=vol.Schema({ - vol.Required(ATTR_BONUS_ID): cv.string, - vol.Optional(ATTR_BONUS_NAME): cv.string, - vol.Optional(ATTR_BONUS_POINTS): cv.positive_int, - vol.Optional(ATTR_BONUS_DESCRIPTION): cv.string, - vol.Optional(ATTR_BONUS_ICON): cv.string, - vol.Optional(ATTR_BONUS_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]), - }), + schema=vol.Schema( + { + vol.Required(ATTR_BONUS_ID): cv.string, + vol.Optional(ATTR_BONUS_NAME): cv.string, + vol.Optional(ATTR_BONUS_POINTS): cv.positive_int, + vol.Optional(ATTR_BONUS_DESCRIPTION): cv.string, + vol.Optional(ATTR_BONUS_ICON): cv.string, + vol.Optional(ATTR_BONUS_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]), + } + ), ) hass.services.async_register( @@ -1446,30 +1493,32 @@ async def handle_rebuild_badges(call: ServiceCall) -> None: DOMAIN, SERVICE_APPLY_BONUS, _parent(handle_apply_bonus), - schema=vol.Schema({ - vol.Required(ATTR_BONUS_ID): cv.string, - vol.Required(ATTR_CHILD_ID): cv.string, - }), + schema=vol.Schema( + { + vol.Required(ATTR_BONUS_ID): cv.string, + vol.Required(ATTR_CHILD_ID): cv.string, + } + ), ) hass.services.async_register( DOMAIN, SERVICE_ADD_CHORE, _admin(handle_add_chore), - schema=vol.Schema({ - vol.Required(ATTR_CHORE_NAME): cv.string, - vol.Optional(ATTR_CHORE_DESCRIPTION, default=""): cv.string, - vol.Optional(ATTR_CHORE_POINTS, default=10): cv.positive_int, - vol.Optional(ATTR_CHORE_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(ATTR_CHORE_TIME_CATEGORY, default="anytime"): vol.In(TIME_CATEGORIES), - vol.Optional("difficulty", default=DEFAULT_DIFFICULTY): vol.In(DIFFICULTY_TIERS), - vol.Optional(ATTR_CHORE_ONE_SHOT, default=False): cv.boolean, - vol.Optional(ATTR_CHORE_REQUIRES_APPROVAL, default=True): cv.boolean, - vol.Optional(ATTR_CHORE_EXPIRES_IN_MINUTES, default=0): vol.All( - cv.positive_int, vol.Range(max=10080) - ), - vol.Optional(ATTR_CHORE_SPEED_BONUS_POINTS, default=0): cv.positive_int, - }), + schema=vol.Schema( + { + vol.Required(ATTR_CHORE_NAME): cv.string, + vol.Optional(ATTR_CHORE_DESCRIPTION, default=""): cv.string, + vol.Optional(ATTR_CHORE_POINTS, default=10): cv.positive_int, + vol.Optional(ATTR_CHORE_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_CHORE_TIME_CATEGORY, default="anytime"): vol.In(TIME_CATEGORIES), + vol.Optional("difficulty", default=DEFAULT_DIFFICULTY): vol.In(DIFFICULTY_TIERS), + vol.Optional(ATTR_CHORE_ONE_SHOT, default=False): cv.boolean, + vol.Optional(ATTR_CHORE_REQUIRES_APPROVAL, default=True): cv.boolean, + vol.Optional(ATTR_CHORE_EXPIRES_IN_MINUTES, default=0): vol.All(cv.positive_int, vol.Range(max=10080)), + vol.Optional(ATTR_CHORE_SPEED_BONUS_POINTS, default=0): cv.positive_int, + } + ), ) hass.services.async_register( @@ -1483,33 +1532,39 @@ async def handle_rebuild_badges(call: ServiceCall) -> None: DOMAIN, SERVICE_SET_CHORE_MANUAL_START, _admin(handle_set_chore_manual_start), - schema=vol.Schema({ - vol.Required(ATTR_CHORE_ID): cv.string, - vol.Required(ATTR_CHILD_ID): cv.string, - }), + schema=vol.Schema( + { + vol.Required(ATTR_CHORE_ID): cv.string, + vol.Required(ATTR_CHILD_ID): cv.string, + } + ), ) hass.services.async_register( DOMAIN, SERVICE_ADD_TASK_GROUP, _admin(handle_add_task_group), - schema=vol.Schema({ - vol.Required(CONF_TASK_GROUP_NAME): cv.string, - vol.Required(CONF_TASK_GROUP_POLICY): vol.In(TASK_GROUP_POLICIES), - vol.Optional(CONF_TASK_GROUP_CHORE_IDS, default=[]): vol.All(cv.ensure_list, [cv.string]), - }), + schema=vol.Schema( + { + vol.Required(CONF_TASK_GROUP_NAME): cv.string, + vol.Required(CONF_TASK_GROUP_POLICY): vol.In(TASK_GROUP_POLICIES), + vol.Optional(CONF_TASK_GROUP_CHORE_IDS, default=[]): vol.All(cv.ensure_list, [cv.string]), + } + ), ) hass.services.async_register( DOMAIN, SERVICE_UPDATE_TASK_GROUP, _admin(handle_update_task_group), - schema=vol.Schema({ - vol.Required(CONF_TASK_GROUP_ID): cv.string, - vol.Optional(CONF_TASK_GROUP_NAME): cv.string, - vol.Optional(CONF_TASK_GROUP_POLICY): vol.In(TASK_GROUP_POLICIES), - vol.Optional(CONF_TASK_GROUP_CHORE_IDS): vol.All(cv.ensure_list, [cv.string]), - }), + schema=vol.Schema( + { + vol.Required(CONF_TASK_GROUP_ID): cv.string, + vol.Optional(CONF_TASK_GROUP_NAME): cv.string, + vol.Optional(CONF_TASK_GROUP_POLICY): vol.In(TASK_GROUP_POLICIES), + vol.Optional(CONF_TASK_GROUP_CHORE_IDS): vol.All(cv.ensure_list, [cv.string]), + } + ), ) hass.services.async_register( @@ -1523,36 +1578,40 @@ async def handle_rebuild_badges(call: ServiceCall) -> None: DOMAIN, "add_badge", _admin(handle_add_badge), - schema=vol.Schema({ - vol.Required(ATTR_BADGE_NAME): cv.string, - vol.Optional(ATTR_BADGE_DESCRIPTION, default=""): cv.string, - vol.Optional(ATTR_BADGE_ICON, default="mdi:trophy"): cv.string, - vol.Optional(ATTR_BADGE_TIER, default="bronze"): vol.In(["bronze", "silver", "gold", "platinum"]), - vol.Optional(ATTR_BADGE_POINT_BONUS, default=0): vol.Coerce(int), - vol.Optional(ATTR_BADGE_CRITERIA, default=[]): list, - vol.Optional(ATTR_BADGE_COMBINATOR, default="AND"): cv.string, - vol.Optional(ATTR_BADGE_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(ATTR_BADGE_NOTIFY_ON_EARN, default=True): cv.boolean, - }), + schema=vol.Schema( + { + vol.Required(ATTR_BADGE_NAME): cv.string, + vol.Optional(ATTR_BADGE_DESCRIPTION, default=""): cv.string, + vol.Optional(ATTR_BADGE_ICON, default="mdi:trophy"): cv.string, + vol.Optional(ATTR_BADGE_TIER, default="bronze"): vol.In(["bronze", "silver", "gold", "platinum"]), + vol.Optional(ATTR_BADGE_POINT_BONUS, default=0): vol.Coerce(int), + vol.Optional(ATTR_BADGE_CRITERIA, default=[]): list, + vol.Optional(ATTR_BADGE_COMBINATOR, default="AND"): cv.string, + vol.Optional(ATTR_BADGE_ASSIGNED_TO, default=[]): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_BADGE_NOTIFY_ON_EARN, default=True): cv.boolean, + } + ), ) hass.services.async_register( DOMAIN, "update_badge", _admin(handle_update_badge), - schema=vol.Schema({ - vol.Required(ATTR_BADGE_ID): cv.string, - vol.Optional(ATTR_BADGE_NAME): cv.string, - vol.Optional(ATTR_BADGE_DESCRIPTION): cv.string, - vol.Optional(ATTR_BADGE_ICON): cv.string, - vol.Optional(ATTR_BADGE_TIER): vol.In(["bronze", "silver", "gold", "platinum"]), - vol.Optional(ATTR_BADGE_POINT_BONUS): vol.Coerce(int), - vol.Optional(ATTR_BADGE_CRITERIA): list, - vol.Optional(ATTR_BADGE_COMBINATOR): cv.string, - vol.Optional(ATTR_BADGE_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(ATTR_BADGE_ENABLED): cv.boolean, - vol.Optional(ATTR_BADGE_NOTIFY_ON_EARN): cv.boolean, - }), + schema=vol.Schema( + { + vol.Required(ATTR_BADGE_ID): cv.string, + vol.Optional(ATTR_BADGE_NAME): cv.string, + vol.Optional(ATTR_BADGE_DESCRIPTION): cv.string, + vol.Optional(ATTR_BADGE_ICON): cv.string, + vol.Optional(ATTR_BADGE_TIER): vol.In(["bronze", "silver", "gold", "platinum"]), + vol.Optional(ATTR_BADGE_POINT_BONUS): vol.Coerce(int), + vol.Optional(ATTR_BADGE_CRITERIA): list, + vol.Optional(ATTR_BADGE_COMBINATOR): cv.string, + vol.Optional(ATTR_BADGE_ASSIGNED_TO): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_BADGE_ENABLED): cv.boolean, + vol.Optional(ATTR_BADGE_NOTIFY_ON_EARN): cv.boolean, + } + ), ) hass.services.async_register( @@ -1566,10 +1625,12 @@ async def handle_rebuild_badges(call: ServiceCall) -> None: DOMAIN, "award_badge_manually", _parent(handle_award_badge_manually), - schema=vol.Schema({ - vol.Required(ATTR_BADGE_ID): cv.string, - vol.Required(ATTR_CHILD_ID): cv.string, - }), + schema=vol.Schema( + { + vol.Required(ATTR_BADGE_ID): cv.string, + vol.Required(ATTR_CHILD_ID): cv.string, + } + ), ) hass.services.async_register( diff --git a/custom_components/taskmate/binary_sensor.py b/custom_components/taskmate/binary_sensor.py index b6ef334..888c4e9 100644 --- a/custom_components/taskmate/binary_sensor.py +++ b/custom_components/taskmate/binary_sensor.py @@ -1,4 +1,5 @@ """Binary sensor platform for TaskMate integration.""" + from __future__ import annotations from homeassistant.components.binary_sensor import BinarySensorEntity diff --git a/custom_components/taskmate/button.py b/custom_components/taskmate/button.py index fd3f596..039fe7b 100644 --- a/custom_components/taskmate/button.py +++ b/custom_components/taskmate/button.py @@ -1,4 +1,5 @@ """Button platform for TaskMate integration.""" + from __future__ import annotations import logging @@ -38,15 +39,11 @@ async def async_setup_entry( if getattr(chore, "assignment_mode", "everyone") == "unassigned": continue if not chore.assigned_to or child.id in chore.assigned_to: - entities.append( - CompleteChoreButton(coordinator, entry, child, chore) - ) + entities.append(CompleteChoreButton(coordinator, entry, child, chore)) # Reward claim buttons for reward in rewards: - entities.append( - ClaimRewardButton(coordinator, entry, child, reward) - ) + entities.append(ClaimRewardButton(coordinator, entry, child, reward)) # Track which entity combos already exist tracked_combos: set[str] = set() @@ -77,16 +74,12 @@ def async_update_entities() -> None: if not chore.assigned_to or child.id in chore.assigned_to: key = f"{child.id}_{chore.id}_complete" if key not in tracked_combos: - new_entities.append( - CompleteChoreButton(coordinator, entry, child, chore) - ) + new_entities.append(CompleteChoreButton(coordinator, entry, child, chore)) tracked_combos.add(key) for reward in current_rewards: key = f"{child.id}_{reward.id}_claim" if key not in tracked_combos: - new_entities.append( - ClaimRewardButton(coordinator, entry, child, reward) - ) + new_entities.append(ClaimRewardButton(coordinator, entry, child, reward)) tracked_combos.add(key) if new_entities: @@ -142,7 +135,7 @@ def icon(self) -> str: # Chores gained an optional icon in #683, defaulting to "". Fall back on # falsiness, not on the attribute being absent — otherwise every chore # without a picture gets a blank button icon. - return (getattr(chore, 'icon', "") or "mdi:check-circle") if chore else "mdi:check-circle" + return (getattr(chore, "icon", "") or "mdi:check-circle") if chore else "mdi:check-circle" @property def extra_state_attributes(self) -> dict: diff --git a/custom_components/taskmate/calendar.py b/custom_components/taskmate/calendar.py index 3451941..25a420c 100644 --- a/custom_components/taskmate/calendar.py +++ b/custom_components/taskmate/calendar.py @@ -15,6 +15,7 @@ Read-only for now: completing a chore from the calendar is intentionally not supported. """ + from __future__ import annotations import logging @@ -67,9 +68,7 @@ def _async_add_new() -> None: coordinator.async_add_listener(_async_add_new) -def _chore_applies_to_child( - coordinator: TaskMateCoordinator, chore: Chore, child_id: str, day: date -) -> bool: +def _chore_applies_to_child(coordinator: TaskMateCoordinator, chore: Chore, child_id: str, day: date) -> bool: """True if ``chore`` is scheduled for ``child_id`` on ``day``. Combines the recurrence schedule with the assignment engine so the calendar @@ -165,9 +164,7 @@ async def async_get_events( return [] return self._build_events(child, start_date.date(), end_date.date()) - def _build_events( - self, child: Child, start_day: date, end_day: date - ) -> list[CalendarEvent]: + def _build_events(self, child: Child, start_day: date, end_day: date) -> list[CalendarEvent]: coord = self.coordinator events: list[CalendarEvent] = [] @@ -197,25 +194,27 @@ def _build_events( for chore in chores: if not _chore_applies_to_child(coord, chore, child.id, day): continue - window = coord._time_category_window( - getattr(chore, "time_category", "anytime"), day - ) + window = coord._time_category_window(getattr(chore, "time_category", "anytime"), day) desc = _chore_description(chore) if window is None: - events.append(CalendarEvent( - start=day, - end=day + timedelta(days=1), - summary=chore.name, - description=desc, - )) + events.append( + CalendarEvent( + start=day, + end=day + timedelta(days=1), + summary=chore.name, + description=desc, + ) + ) else: start_dt, end_dt = window - events.append(CalendarEvent( - start=start_dt.replace(tzinfo=tz), - end=end_dt.replace(tzinfo=tz), - summary=chore.name, - description=desc, - )) + events.append( + CalendarEvent( + start=start_dt.replace(tzinfo=tz), + end=end_dt.replace(tzinfo=tz), + summary=chore.name, + description=desc, + ) + ) day += timedelta(days=1) return events diff --git a/custom_components/taskmate/config_flow.py b/custom_components/taskmate/config_flow.py index 10806a2..947682f 100644 --- a/custom_components/taskmate/config_flow.py +++ b/custom_components/taskmate/config_flow.py @@ -7,6 +7,7 @@ all of its functionality is available in the panel, and all data is stored in the integration's own ``Store`` rather than in ``config_entry.options``. """ + from __future__ import annotations from typing import Any @@ -24,9 +25,7 @@ class TaskMateConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult: """Handle the initial step.""" errors: dict[str, str] = {} diff --git a/custom_components/taskmate/const.py b/custom_components/taskmate/const.py index b8ba635..9cb6768 100644 --- a/custom_components/taskmate/const.py +++ b/custom_components/taskmate/const.py @@ -1,4 +1,5 @@ """Constants for TaskMate integration.""" + from typing import Final DOMAIN: Final = "taskmate" @@ -131,10 +132,10 @@ # always available) and never appears in this list. An empty label means # "use the translated built-in name for this id". DEFAULT_TIME_PERIODS: Final = [ - {"id": "morning", "label": "", "start": "06:00", "end": "12:00", "icon": "mdi:weather-sunny"}, + {"id": "morning", "label": "", "start": "06:00", "end": "12:00", "icon": "mdi:weather-sunny"}, {"id": "afternoon", "label": "", "start": "12:00", "end": "17:00", "icon": "mdi:white-balance-sunny"}, - {"id": "evening", "label": "", "start": "17:00", "end": "21:00", "icon": "mdi:weather-sunset"}, - {"id": "night", "label": "", "start": "21:00", "end": "23:59", "icon": "mdi:weather-night"}, + {"id": "evening", "label": "", "start": "17:00", "end": "21:00", "icon": "mdi:weather-sunset"}, + {"id": "night", "label": "", "start": "21:00", "end": "23:59", "icon": "mdi:weather-night"}, ] MAX_TIME_PERIODS: Final = 24 @@ -340,24 +341,24 @@ # Most sounds are synthesized via Web Audio API # Fart sounds are CC0 audio files from BigSoundBank.com and GfxSounds.com COMPLETION_SOUND_OPTIONS: Final = [ - "none", # No sound - "coin", # Coin collect sound - "levelup", # Level up / success sound - "fanfare", # Celebratory fanfare - "chime", # Simple chime - "powerup", # Power up sound - "undo", # Sad/descending "womp womp" for undo actions - "fart1", # Flatulence 1 (short) - "fart2", # Flatulence 2 (short) - "fart3", # Flatulence 3 (short) - "fart4", # Pony flatulence 2 (~3 sec) - "fart5", # Flatulence 4 - discreet (short) - "fart6", # Prout'cochons 1 - pig game sound (short) - "fart7", # Prout'cochons 2 - pig game sound (short) - "fart8", # Prout'cochons 3 - pig game sound (short) - "fart9", # Pony flatulence 1 (short) - "fart10", # Baby fart (short) - "fart_random", # Random fart - picks a random fart sound each time! + "none", # No sound + "coin", # Coin collect sound + "levelup", # Level up / success sound + "fanfare", # Celebratory fanfare + "chime", # Simple chime + "powerup", # Power up sound + "undo", # Sad/descending "womp womp" for undo actions + "fart1", # Flatulence 1 (short) + "fart2", # Flatulence 2 (short) + "fart3", # Flatulence 3 (short) + "fart4", # Pony flatulence 2 (~3 sec) + "fart5", # Flatulence 4 - discreet (short) + "fart6", # Prout'cochons 1 - pig game sound (short) + "fart7", # Prout'cochons 2 - pig game sound (short) + "fart8", # Prout'cochons 3 - pig game sound (short) + "fart9", # Pony flatulence 1 (short) + "fart10", # Baby fart (short) + "fart_random", # Random fart - picks a random fart sound each time! ] # Default completion sound @@ -377,22 +378,22 @@ DEFAULT_DIFFICULTY_MULTIPLIERS: Final = {"easy": 0.5, "medium": 1.0, "hard": 2.0} # --- Notification type IDs (v3.9.0) --- -NOTIF_TYPE_BEDTIME_REMINDER: Final = "bedtime_reminder" -NOTIF_TYPE_STREAK_AT_RISK: Final = "streak_at_risk" -NOTIF_TYPE_ALL_CHORES_DONE: Final = "all_chores_done" -NOTIF_TYPE_BADGE_EARNED: Final = "badge_earned" -NOTIF_TYPE_PENDING_CHORE_APPROVAL: Final = "pending_chore_approval" -NOTIF_TYPE_PENDING_REWARD_CLAIM: Final = "pending_reward_claim" -NOTIF_TYPE_STREAK_MILESTONE: Final = "streak_milestone" -NOTIF_TYPE_LEVEL_UP: Final = "level_up" -NOTIF_TYPE_WEEKLY_DIGEST: Final = "weekly_digest" -NOTIF_TYPE_CELEBRATION: Final = "celebration" -NOTIF_TYPE_MANDATORY_REMINDER: Final = "mandatory_reminder" -NOTIF_TYPE_MANDATORY_PARENT_ALERT: Final = "mandatory_parent_alert" -NOTIF_TYPE_MONTHLY_REPORT: Final = "monthly_report" -NOTIF_TYPE_SEASON_CHAMPION: Final = "season_champion" -NOTIF_TYPE_FAMILY_GOAL_REACHED: Final = "family_goal_reached" +NOTIF_TYPE_BEDTIME_REMINDER: Final = "bedtime_reminder" +NOTIF_TYPE_STREAK_AT_RISK: Final = "streak_at_risk" +NOTIF_TYPE_ALL_CHORES_DONE: Final = "all_chores_done" +NOTIF_TYPE_BADGE_EARNED: Final = "badge_earned" +NOTIF_TYPE_PENDING_CHORE_APPROVAL: Final = "pending_chore_approval" +NOTIF_TYPE_PENDING_REWARD_CLAIM: Final = "pending_reward_claim" +NOTIF_TYPE_STREAK_MILESTONE: Final = "streak_milestone" +NOTIF_TYPE_LEVEL_UP: Final = "level_up" +NOTIF_TYPE_WEEKLY_DIGEST: Final = "weekly_digest" +NOTIF_TYPE_CELEBRATION: Final = "celebration" +NOTIF_TYPE_MANDATORY_REMINDER: Final = "mandatory_reminder" +NOTIF_TYPE_MANDATORY_PARENT_ALERT: Final = "mandatory_parent_alert" +NOTIF_TYPE_MONTHLY_REPORT: Final = "monthly_report" +NOTIF_TYPE_SEASON_CHAMPION: Final = "season_champion" +NOTIF_TYPE_FAMILY_GOAL_REACHED: Final = "family_goal_reached" # Default notification tap target. Must match PANEL_URL_PATH in panel.py — # a bare /taskmate is the static-files prefix and returns 403, not the panel. -DEFAULT_NOTIFICATION_NAV_URL: Final = "/taskmate-admin" +DEFAULT_NOTIFICATION_NAV_URL: Final = "/taskmate-admin" diff --git a/custom_components/taskmate/coord_assignments.py b/custom_components/taskmate/coord_assignments.py index c3f0177..484facd 100644 --- a/custom_components/taskmate/coord_assignments.py +++ b/custom_components/taskmate/coord_assignments.py @@ -1,4 +1,5 @@ """Assignment operations mixin for TaskMateCoordinator.""" + from __future__ import annotations import asyncio @@ -118,9 +119,15 @@ async def _async_reevaluate_availability(self) -> None: await self.storage.async_save() await self.async_refresh() - _AVAILABLE_STATES: frozenset[str] = frozenset({ - "on", "home", "available", "present", "true", - }) + _AVAILABLE_STATES: frozenset[str] = frozenset( + { + "on", + "home", + "available", + "present", + "true", + } + ) def _is_visibility_entity_active( self, visibility_entity: str, visibility_state: str, visibility_operator: str = "equals" @@ -201,7 +208,7 @@ def _is_visibility_entity_active( return True # Check attributes for a matching value - if hasattr(state_obj, 'attributes') and state_obj.attributes: + if hasattr(state_obj, "attributes") and state_obj.attributes: for attr_value in state_obj.attributes.values(): if str(attr_value).lower() == parsed_state.lower(): return True @@ -232,7 +239,8 @@ def weather_block_reason(self, chore) -> str | None: if state_obj is None or state_obj.state in ("unavailable", "unknown", None, ""): _LOGGER.debug( "Weather entity '%s' unavailable, not blocking chore '%s'", - entity_id, getattr(chore, "name", ""), + entity_id, + getattr(chore, "name", ""), ) return None @@ -458,9 +466,7 @@ def _compute_daily_assignments(self, today: date | None = None) -> dict[str, str return result - def _apply_sticky_policy( - self, group, chore_by_id: dict[str, Chore], result: dict[str, str] - ) -> None: + def _apply_sticky_policy(self, group, chore_by_id: dict[str, Chore], result: dict[str, str]) -> None: """Force followers onto the leader chore's assignee (when in pool).""" leader_id = group.chore_ids[0] leader_child = result.get(leader_id) @@ -478,12 +484,12 @@ def _apply_sticky_policy( else: _LOGGER.debug( "STICKY fallback: leader %s assigned to %s not in follower %s pool", - leader_id, leader_child, follower_id, + leader_id, + leader_child, + follower_id, ) - def _apply_spread_policy( - self, group, chore_by_id: dict[str, Chore], result: dict[str, str] - ) -> None: + def _apply_spread_policy(self, group, chore_by_id: dict[str, Chore], result: dict[str, str]) -> None: """Assign group members to distinct children; wraps when pool < group size.""" used: set[str] = set() for chore_id in group.chore_ids: @@ -529,17 +535,18 @@ def _skip_unavailable(self, pool: list[str], start_idx: int, enabled: bool) -> s size = len(pool) # Cache per-call so the same child isn't queried twice in a scan. cache: dict[str, bool] = {} + def available(cid: str) -> bool: if cid not in cache: cache[cid] = self._is_child_available(cid) return cache[cid] + for step in range(size): cid = pool[(start_idx + step) % size] if available(cid): return cid _LOGGER.debug( - "Availability skip: no available child in pool %s for chore, " - "hiding chore (all children unavailable)", + "Availability skip: no available child in pool %s for chore, hiding chore (all children unavailable)", pool, ) return "" @@ -557,7 +564,7 @@ def _is_rotation_done_today(self, chore) -> bool: active child still has uncompleted bonus sub-tasks for today, keep the chore visible (return False) so they remain reachable. """ - if getattr(chore, 'assignment_mode', 'everyone') == 'everyone': + if getattr(chore, "assignment_mode", "everyone") == "everyone": return False # PERF-1: result depends only on the chore; memoize per availability build. cache = getattr(self, "_avail_cache", None) @@ -573,7 +580,7 @@ def _is_rotation_done_today_uncached(self, chore) -> bool: if not pool: return False today = dt_util.as_local(dt_util.now()).date() - active_child_id = getattr(chore, 'assignment_current_child_id', '') or '' + active_child_id = getattr(chore, "assignment_current_child_id", "") or "" completions_today = 0 completed_bonus_ids_today: set[str] = set() for comp in self._cached_completions(): @@ -581,14 +588,14 @@ def _is_rotation_done_today_uncached(self, chore) -> bool: continue comp_dt = comp.completed_at try: - if hasattr(comp_dt, 'astimezone'): + if hasattr(comp_dt, "astimezone"): comp_dt = dt_util.as_local(comp_dt) - comp_date = comp_dt.date() if hasattr(comp_dt, 'date') else None + comp_date = comp_dt.date() if hasattr(comp_dt, "date") else None except (AttributeError, TypeError, ValueError): continue if comp_date != today: continue - bonus_id = getattr(comp, 'bonus_subtask_id', None) + bonus_id = getattr(comp, "bonus_subtask_id", None) if bonus_id: # Bonus completions don't count toward the parent's daily # quota; track them only to decide whether the active child @@ -601,16 +608,16 @@ def _is_rotation_done_today_uncached(self, chore) -> bool: if comp.child_id in pool or comp.child_id == "__parent__": completions_today += 1 # first_come is a single-winner race: clamp any mis-configured quota to 1. - if getattr(chore, 'assignment_mode', 'everyone') == 'first_come': + if getattr(chore, "assignment_mode", "everyone") == "first_come": daily_limit = 1 else: - daily_limit = getattr(chore, 'daily_limit', 1) or 1 + daily_limit = getattr(chore, "daily_limit", 1) or 1 if completions_today < daily_limit: return False - bonus_subtasks = getattr(chore, 'bonus_subtasks', None) or [] + bonus_subtasks = getattr(chore, "bonus_subtasks", None) or [] if bonus_subtasks and active_child_id: for bst in bonus_subtasks: - bst_id = getattr(bst, 'id', None) + bst_id = getattr(bst, "id", None) if bst_id and bst_id not in completed_bonus_ids_today: return False return True diff --git a/custom_components/taskmate/coord_avatars.py b/custom_components/taskmate/coord_avatars.py index 4f59cae..ff0be77 100644 --- a/custom_components/taskmate/coord_avatars.py +++ b/custom_components/taskmate/coord_avatars.py @@ -5,6 +5,7 @@ available). Children unlock avatars by hitting those milestones and can switch to any avatar they've unlocked; parents can set any catalogue avatar. """ + from __future__ import annotations import logging @@ -14,14 +15,14 @@ # Shipped defaults so the feature is useful out of the box. Parents can replace # the whole list from the panel. DEFAULT_AVATAR_CATALOG: list[dict] = [ - {"id": "starter", "label": "Starter", "icon": "mdi:account-circle", "unlock_type": "free", "unlock_value": 0}, - {"id": "rocket", "label": "Rocket", "icon": "mdi:rocket-launch", "unlock_type": "level", "unlock_value": 3}, - {"id": "robot", "label": "Robot", "icon": "mdi:robot-happy", "unlock_type": "level", "unlock_value": 5}, - {"id": "ninja", "label": "Ninja", "icon": "mdi:ninja", "unlock_type": "level", "unlock_value": 10}, - {"id": "crown", "label": "Royalty", "icon": "mdi:crown", "unlock_type": "points", "unlock_value": 500}, - {"id": "trophy", "label": "Champion", "icon": "mdi:trophy", "unlock_type": "points", "unlock_value": 1000}, - {"id": "fire", "label": "On Fire", "icon": "mdi:fire", "unlock_type": "streak", "unlock_value": 7}, - {"id": "diamond", "label": "Diamond", "icon": "mdi:diamond-stone", "unlock_type": "streak", "unlock_value": 30}, + {"id": "starter", "label": "Starter", "icon": "mdi:account-circle", "unlock_type": "free", "unlock_value": 0}, + {"id": "rocket", "label": "Rocket", "icon": "mdi:rocket-launch", "unlock_type": "level", "unlock_value": 3}, + {"id": "robot", "label": "Robot", "icon": "mdi:robot-happy", "unlock_type": "level", "unlock_value": 5}, + {"id": "ninja", "label": "Ninja", "icon": "mdi:ninja", "unlock_type": "level", "unlock_value": 10}, + {"id": "crown", "label": "Royalty", "icon": "mdi:crown", "unlock_type": "points", "unlock_value": 500}, + {"id": "trophy", "label": "Champion", "icon": "mdi:trophy", "unlock_type": "points", "unlock_value": 1000}, + {"id": "fire", "label": "On Fire", "icon": "mdi:fire", "unlock_type": "streak", "unlock_value": 7}, + {"id": "diamond", "label": "Diamond", "icon": "mdi:diamond-stone", "unlock_type": "streak", "unlock_value": 30}, ] @@ -67,13 +68,15 @@ def avatar_options_for_child(self, child) -> list[dict]: req = f"{value}-day streak" else: req = "" - out.append({ - "id": entry.get("id", entry.get("icon")), - "label": entry.get("label", ""), - "icon": entry.get("icon"), - "unlocked": self._avatar_unlocked(entry, child), - "requirement": req, - }) + out.append( + { + "id": entry.get("id", entry.get("icon")), + "label": entry.get("label", ""), + "icon": entry.get("icon"), + "unlocked": self._avatar_unlocked(entry, child), + "requirement": req, + } + ) return out async def async_update_avatar_catalog(self, catalog: list[dict]) -> None: @@ -83,13 +86,15 @@ async def async_update_avatar_catalog(self, catalog: list[dict]) -> None: icon = (a.get("icon") or "").strip() if not icon: continue - cleaned.append({ - "id": (a.get("id") or icon).strip(), - "label": (a.get("label") or "").strip(), - "icon": icon, - "unlock_type": a.get("unlock_type", "free"), - "unlock_value": int(a.get("unlock_value", 0) or 0), - }) + cleaned.append( + { + "id": (a.get("id") or icon).strip(), + "label": (a.get("label") or "").strip(), + "icon": icon, + "unlock_type": a.get("unlock_type", "free"), + "unlock_value": int(a.get("unlock_value", 0) or 0), + } + ) self.storage.set_setting("avatar_catalog", cleaned) await self.storage.async_save() await self.async_refresh() diff --git a/custom_components/taskmate/coord_badges.py b/custom_components/taskmate/coord_badges.py index 9e64dba..4b7252d 100644 --- a/custom_components/taskmate/coord_badges.py +++ b/custom_components/taskmate/coord_badges.py @@ -1,4 +1,5 @@ """Badge evaluation engine and built-in catalogue.""" + from __future__ import annotations import logging @@ -8,8 +9,9 @@ _LOGGER = logging.getLogger(__name__) -def _b(id_suffix: str, name: str, description: str, icon: str, tier: str, - point_bonus: int, metric: str, value: int) -> Badge: +def _b( + id_suffix: str, name: str, description: str, icon: str, tier: str, point_bonus: int, metric: str, value: int +) -> Badge: """Helper to build a built-in badge.""" criteria = [BadgeCriterion(metric=metric, operator=">=", value=value)] if metric else [] badge = Badge( @@ -29,39 +31,116 @@ def _b(id_suffix: str, name: str, description: str, icon: str, tier: str, BUILTIN_CATALOGUE: list[Badge] = [ # Bronze - _b("first_chore", "First Chore", "Complete your very first chore", - "mdi:check-circle", "bronze", 0, "first_chore", 1), - _b("first_reward", "First Reward", "Claim your first reward", - "mdi:gift", "bronze", 0, "first_reward", 1), - _b("100_points", "100 Points", "Earn 100 lifetime points", - "mdi:star", "bronze", 0, "total_points", 100), - _b("10_chores", "10 Chores Completed", "Complete 10 chores", - "mdi:checkbox-marked-circle", "bronze", 0, "total_chores", 10), + _b( + "first_chore", + "First Chore", + "Complete your very first chore", + "mdi:check-circle", + "bronze", + 0, + "first_chore", + 1, + ), + _b("first_reward", "First Reward", "Claim your first reward", "mdi:gift", "bronze", 0, "first_reward", 1), + _b("100_points", "100 Points", "Earn 100 lifetime points", "mdi:star", "bronze", 0, "total_points", 100), + _b( + "10_chores", + "10 Chores Completed", + "Complete 10 chores", + "mdi:checkbox-marked-circle", + "bronze", + 0, + "total_chores", + 10, + ), # Silver - _b("500_points", "500 Points", "Earn 500 lifetime points", - "mdi:star-circle", "silver", 25, "total_points", 500), - _b("50_chores", "50 Chores Completed", "Complete 50 chores", - "mdi:checkbox-multiple-marked-circle", "silver", 25, "total_chores", 50), - _b("3_day_streak", "3-Day Streak", "Complete chores 3 days in a row", - "mdi:fire", "silver", 25, "current_streak", 3), - _b("first_perfect_week", "First Perfect Week", "Complete a perfect week", - "mdi:calendar-star", "silver", 50, "perfect_weeks", 1), + _b("500_points", "500 Points", "Earn 500 lifetime points", "mdi:star-circle", "silver", 25, "total_points", 500), + _b( + "50_chores", + "50 Chores Completed", + "Complete 50 chores", + "mdi:checkbox-multiple-marked-circle", + "silver", + 25, + "total_chores", + 50, + ), + _b( + "3_day_streak", "3-Day Streak", "Complete chores 3 days in a row", "mdi:fire", "silver", 25, "current_streak", 3 + ), + _b( + "first_perfect_week", + "First Perfect Week", + "Complete a perfect week", + "mdi:calendar-star", + "silver", + 50, + "perfect_weeks", + 1, + ), # Gold - _b("1000_points", "1000 Points", "Earn 1000 lifetime points", - "mdi:trophy", "gold", 100, "total_points", 1000), - _b("100_chores", "100 Chores Completed", "Complete 100 chores", - "mdi:trophy-variant", "gold", 100, "total_chores", 100), - _b("7_day_streak", "7-Day Streak", "Complete chores 7 days in a row", - "mdi:lightning-bolt", "gold", 50, "current_streak", 7), - _b("5_perfect_weeks", "5 Perfect Weeks", "Achieve 5 perfect weeks", - "mdi:calendar-multiple-check", "gold", 100, "perfect_weeks", 5), + _b("1000_points", "1000 Points", "Earn 1000 lifetime points", "mdi:trophy", "gold", 100, "total_points", 1000), + _b( + "100_chores", + "100 Chores Completed", + "Complete 100 chores", + "mdi:trophy-variant", + "gold", + 100, + "total_chores", + 100, + ), + _b( + "7_day_streak", + "7-Day Streak", + "Complete chores 7 days in a row", + "mdi:lightning-bolt", + "gold", + 50, + "current_streak", + 7, + ), + _b( + "5_perfect_weeks", + "5 Perfect Weeks", + "Achieve 5 perfect weeks", + "mdi:calendar-multiple-check", + "gold", + 100, + "perfect_weeks", + 5, + ), # Platinum - _b("5000_points", "5000 Points", "Earn 5000 lifetime points", - "mdi:diamond-stone", "platinum", 250, "total_points", 5000), - _b("30_day_streak", "30-Day Streak", "Complete chores 30 days in a row", - "mdi:crown", "platinum", 250, "current_streak", 30), - _b("10_perfect_weeks", "10 Perfect Weeks", "Achieve 10 perfect weeks", - "mdi:rainbow", "platinum", 250, "perfect_weeks", 10), + _b( + "5000_points", + "5000 Points", + "Earn 5000 lifetime points", + "mdi:diamond-stone", + "platinum", + 250, + "total_points", + 5000, + ), + _b( + "30_day_streak", + "30-Day Streak", + "Complete chores 30 days in a row", + "mdi:crown", + "platinum", + 250, + "current_streak", + 30, + ), + _b( + "10_perfect_weeks", + "10 Perfect Weeks", + "Achieve 10 perfect weeks", + "mdi:rainbow", + "platinum", + 250, + "perfect_weeks", + 10, + ), ] @@ -80,10 +159,7 @@ def resolve_metric(metric: str, child: Child, storage) -> int: if metric == "first_chore": return 1 if (child.total_chores_completed or 0) >= 1 else 0 if metric in ("total_rewards", "first_reward"): - approved_count = sum( - 1 for c in storage.get_reward_claims() - if c.child_id == child.id and c.approved - ) + approved_count = sum(1 for c in storage.get_reward_claims() if c.child_id == child.id and c.approved) if metric == "first_reward": return 1 if approved_count >= 1 else 0 return approved_count @@ -240,7 +316,9 @@ async def award_manually(self, child_id: str, badge_id: str): self.storage.add_awarded_badge(award) if bonus > 0: await self.points_coord.async_add_points( - child_id, bonus, reason=f"Badge: {badge.name}", + child_id, + bonus, + reason=f"Badge: {badge.name}", ) self.hass.bus.async_fire( "taskmate_badge_earned", @@ -260,9 +338,7 @@ async def award_manually(self, child_id: str, badge_id: str): async def revoke(self, awarded_id: str) -> bool: """Revoke an awarded badge; reverse bonus_credited if > 0.""" - matching = [ - a for a in self.storage.get_awarded_badges() if a.id == awarded_id - ] + matching = [a for a in self.storage.get_awarded_badges() if a.id == awarded_id] if not matching: return False award = matching[0] @@ -288,7 +364,9 @@ async def rebuild_all(self) -> int: total = 0 for child in self.storage.get_children(): new_awards = await self.evaluate_for_child( - child.id, "manual", silent=True, + child.id, + "manual", + silent=True, ) total += len(new_awards) return total diff --git a/custom_components/taskmate/coord_calendar.py b/custom_components/taskmate/coord_calendar.py index b9d2e79..8422549 100644 --- a/custom_components/taskmate/coord_calendar.py +++ b/custom_components/taskmate/coord_calendar.py @@ -1,4 +1,5 @@ """Calendar operations mixin for TaskMateCoordinator.""" + from __future__ import annotations import asyncio @@ -55,13 +56,15 @@ def get_time_periods(self) -> list[dict]: pid = str(entry.get("id") or "").strip() if not pid or pid == "anytime" or start is None or end is None: continue - periods.append({ - "id": pid, - "label": str(entry.get("label") or "").strip(), - "start": start.strftime("%H:%M"), - "end": end.strftime("%H:%M"), - "icon": str(entry.get("icon") or "") or TIME_CATEGORY_ICONS.get(pid, "mdi:clock-outline"), - }) + periods.append( + { + "id": pid, + "label": str(entry.get("label") or "").strip(), + "start": start.strftime("%H:%M"), + "end": end.strftime("%H:%M"), + "icon": str(entry.get("icon") or "") or TIME_CATEGORY_ICONS.get(pid, "mdi:clock-outline"), + } + ) if periods: return sorted(periods, key=lambda p: p["start"]) @@ -74,13 +77,15 @@ def get_time_periods(self) -> list[dict]: end_str = self.storage.get_setting(f"time_{pid}_end", default["end"]) start = self._parse_hhmm(start_str) or self._parse_hhmm(default["start"]) end = self._parse_hhmm(end_str) or self._parse_hhmm(default["end"]) - periods.append({ - "id": pid, - "label": "", - "start": start.strftime("%H:%M"), - "end": end.strftime("%H:%M"), - "icon": default["icon"], - }) + periods.append( + { + "id": pid, + "label": "", + "start": start.strftime("%H:%M"), + "end": end.strftime("%H:%M"), + "icon": default["icon"], + } + ) return sorted(periods, key=lambda p: p["start"]) def _get_time_boundaries(self) -> dict[str, tuple[time, time] | None]: @@ -109,9 +114,9 @@ def _chore_event_marker(self, chore: Chore) -> str: def _calendar_projection_days(self) -> int: """Return the configured projection horizon, clamped to the allowed range.""" try: - raw = int(float(self.storage.get_setting( - "calendar_projection_days", str(DEFAULT_CALENDAR_PROJECTION_DAYS) - ))) + raw = int( + float(self.storage.get_setting("calendar_projection_days", str(DEFAULT_CALENDAR_PROJECTION_DAYS))) + ) except (TypeError, ValueError): raw = DEFAULT_CALENDAR_PROJECTION_DAYS return max(MIN_CALENDAR_PROJECTION_DAYS, min(MAX_CALENDAR_PROJECTION_DAYS, raw)) @@ -204,9 +209,7 @@ def _is_chore_scheduled_for_date(self, chore: Chore, day: date) -> bool: def _build_event_payload(self, chore: Chore, day: date, summary: str) -> dict: """Build the calendar.create_event payload for one (chore, day).""" description = self._chore_event_marker(chore) - window = self._time_category_window( - getattr(chore, "time_category", "anytime"), day - ) + window = self._time_category_window(getattr(chore, "time_category", "anytime"), day) if window is None: return { "summary": summary, diff --git a/custom_components/taskmate/coord_challenges.py b/custom_components/taskmate/coord_challenges.py index 2ac7f2e..1563d7c 100644 --- a/custom_components/taskmate/coord_challenges.py +++ b/custom_components/taskmate/coord_challenges.py @@ -5,6 +5,7 @@ and the award reset automatically when the period rolls over (a new day or a new Monday-anchored week). """ + from __future__ import annotations import logging @@ -89,19 +90,21 @@ def challenge_progress_for_child(self, child_id: str) -> list[dict]: _, period_key = self._period_start_key(ch.scope) prog = self.storage.get_challenge_child_progress(ch.id, child_id) awarded = bool(prog.get("awarded")) and prog.get("period") == period_key - out.append({ - "challenge_id": ch.id, - "name": ch.name, - "icon": ch.icon, - "scope": ch.scope, - "metric": ch.metric, - "target": ch.target, - "progress": min(value, ch.target), - "value": value, - "bonus_points": ch.bonus_points, - "complete": value >= ch.target, - "awarded": awarded, - }) + out.append( + { + "challenge_id": ch.id, + "name": ch.name, + "icon": ch.icon, + "scope": ch.scope, + "metric": ch.metric, + "target": ch.target, + "progress": min(value, ch.target), + "value": value, + "bonus_points": ch.bonus_points, + "complete": value >= ch.target, + "awarded": awarded, + } + ) return out # ── Evaluation ─────────────────────────────────────────────────────── @@ -142,24 +145,36 @@ async def _award_challenge(self, challenge: Challenge, child) -> None: child.points += bonus child.total_points_earned += bonus child.career_score = child.total_points_earned - child.total_penalties_received - self.storage.add_points_transaction(PointsTransaction( - child_id=child.id, points=bonus, - reason=f"Challenge complete: {challenge.name}", created_at=dt_util.now(), - )) + self.storage.add_points_transaction( + PointsTransaction( + child_id=child.id, + points=bonus, + reason=f"Challenge complete: {challenge.name}", + created_at=dt_util.now(), + ) + ) if hasattr(self, "_maybe_level_up"): await self._maybe_level_up(child) self.storage.update_child(child) - self.hass.bus.async_fire("taskmate_challenge_completed", { - "child_id": child.id, "child_name": child.name, - "challenge_id": challenge.id, "challenge_name": challenge.name, - "scope": challenge.scope, "bonus": bonus, - "timestamp": dt_util.now().isoformat(), - }) + self.hass.bus.async_fire( + "taskmate_challenge_completed", + { + "child_id": child.id, + "child_name": child.name, + "challenge_id": challenge.id, + "challenge_name": challenge.name, + "scope": challenge.scope, + "bonus": bonus, + "timestamp": dt_util.now().isoformat(), + }, + ) if hasattr(self, "_celebrate"): await self._celebrate( - child, "challenge_completed", + child, + "challenge_completed", f"{child.name} completed the challenge '{challenge.name}'!", - tier=2, extra={"challenge_id": challenge.id, "bonus": bonus}, + tier=2, + extra={"challenge_id": challenge.id, "bonus": bonus}, ) _LOGGER.info("Challenge '%s' completed by %s (+%d)", challenge.name, child.name, bonus) diff --git a/custom_components/taskmate/coord_chores.py b/custom_components/taskmate/coord_chores.py index 218a60c..c2a9180 100644 --- a/custom_components/taskmate/coord_chores.py +++ b/custom_components/taskmate/coord_chores.py @@ -1,4 +1,5 @@ """Chore operations mixin for TaskMateCoordinator.""" + from __future__ import annotations import logging @@ -26,10 +27,15 @@ def _add_months(d: date, months: int) -> date: _DOW_MAP = { - 'monday': 0, 'tuesday': 1, 'wednesday': 2, 'thursday': 3, - 'friday': 4, 'saturday': 5, 'sunday': 6, + "monday": 0, + "tuesday": 1, + "wednesday": 2, + "thursday": 3, + "friday": 4, + "saturday": 5, + "sunday": 6, } -_MONTH_STEPS = {'monthly': 1, 'every_3_months': 3, 'every_6_months': 6} +_MONTH_STEPS = {"monthly": 1, "every_3_months": 3, "every_6_months": 6} class ChoresMixin: @@ -124,7 +130,9 @@ async def async_add_chore( # For random/balanced manual-start, override today's cached child so # the parent sees the chosen child immediately. if manual_start_child_id and resolved_mode in ("random", "balanced"): - resolved_pool = self._chore_assignment_pool(chore) if chore.assigned_to else [c.id for c in self.storage.get_children()] + resolved_pool = ( + self._chore_assignment_pool(chore) if chore.assigned_to else [c.id for c in self.storage.get_children()] + ) if manual_start_child_id in resolved_pool: chore.assignment_current_child_id = manual_start_child_id self.storage.add_chore(chore) @@ -138,6 +146,7 @@ async def async_add_chore( async def async_request_swap(self, chore_id: str, requester_id: str) -> str: """A child requests to take over today's rotation assignment of a chore.""" from .models import generate_id + chore = self.get_chore(chore_id) if not chore: raise ValueError(f"Chore {chore_id} not found") @@ -164,8 +173,10 @@ async def async_request_swap(self, chore_id: str, requester_id: str) -> str: async def async_approve_swap(self, req_id: str) -> None: """Approve a swap — reassign today's chore to the requester.""" - req = next((r for r in self.storage.get_swap_requests() - if r.get("id") == req_id and r.get("status") == "pending"), None) + req = next( + (r for r in self.storage.get_swap_requests() if r.get("id") == req_id and r.get("status") == "pending"), + None, + ) if not req: raise ValueError(f"Swap request {req_id} not found") chore = self.get_chore(req["chore_id"]) @@ -173,11 +184,15 @@ async def async_approve_swap(self, req_id: str) -> None: chore.assignment_current_child_id = req["requester_id"] self.storage.update_chore(chore) self.storage.update_swap_request(req_id, status="approved") - self.hass.bus.async_fire("taskmate_swap_approved", { - "chore_id": req["chore_id"], "requester_id": req["requester_id"], - "from_child_id": req.get("from_child_id", ""), - "timestamp": dt_util.now().isoformat(), - }) + self.hass.bus.async_fire( + "taskmate_swap_approved", + { + "chore_id": req["chore_id"], + "requester_id": req["requester_id"], + "from_child_id": req.get("from_child_id", ""), + "timestamp": dt_util.now().isoformat(), + }, + ) await self.storage.async_save() await self.async_refresh() @@ -197,7 +212,8 @@ def chore_deadline(self, chore) -> datetime | None: except (ValueError, TypeError): _LOGGER.warning( "Chore '%s' has an unparseable deadline_at %r — ignoring it", - getattr(chore, "name", ""), raw, + getattr(chore, "name", ""), + raw, ) return None # A naive value came from hand-edited storage; treat it as local time @@ -237,7 +253,8 @@ async def _async_expire_deadline_chores(self, refresh: bool = True) -> None: changed = True _LOGGER.info( "Reactive chore '%s' expired (deadline %s)", - chore.name, getattr(chore, "deadline_at", ""), + chore.name, + getattr(chore, "deadline_at", ""), ) self.hass.bus.async_fire( "taskmate_chore_expired", @@ -352,9 +369,7 @@ async def async_bulk_chore_action( await self.async_refresh() return count - async def async_approve_chores_bulk( - self, completion_ids: list[str] | None = None - ) -> int: + async def async_approve_chores_bulk(self, completion_ids: list[str] | None = None) -> int: """Approve several pending chore completions at once. Returns count approved. If completion_ids is given, only those (still-pending) completions are @@ -390,7 +405,7 @@ async def async_add_chores_bulk( visibility_entity: str = "", visibility_state: str = "on", visibility_operator: str = "equals", - ) -> list[Chore]: + ) -> list[Chore]: """Add multiple chores at once with shared settings.""" chores = [] for name in chore_names: @@ -412,7 +427,7 @@ async def async_add_chores_bulk( visibility_entity=visibility_entity, visibility_state=visibility_state, visibility_operator=visibility_operator, - ) + ) self.storage.add_chore(chore) chores.append(chore) @@ -453,7 +468,10 @@ async def async_update_chore(self, chore: Chore) -> None: extra_prefixes.append(f"{prev_name} — ") extra_prefixes.append(f"{chore.name} — ") await self._cleanup_chore_from_calendars( - chore, cleanup_entities, today, summary_prefixes=extra_prefixes, + chore, + cleanup_entities, + today, + summary_prefixes=extra_prefixes, ) self.storage.update_chore(chore) # Replacing or clearing the picture orphans the old file; delete it — @@ -464,9 +482,7 @@ async def async_update_chore(self, chore: Chore) -> None: await self.storage.async_save() await self.async_refresh() - async def _async_release_image( - self, image_url: str, *, excluding_chore_id: str = "" - ) -> None: + async def _async_release_image(self, image_url: str, *, excluding_chore_id: str = "") -> None: """Delete a chore image file, but only if nothing else still shows it. `async_clone_chore` copies `image_url` straight from the source, so two @@ -493,9 +509,7 @@ async def async_remove_chore(self, chore_id: str) -> None: # Nothing sweeps taskmate_images, so the file has to go with the chore — # unless a clone still shows it (#768). if existing is not None and getattr(existing, "image_url", ""): - await self._async_release_image( - existing.image_url, excluding_chore_id=chore_id - ) + await self._async_release_image(existing.image_url, excluding_chore_id=chore_id) self.storage.remove_chore(chore_id) self.storage.remove_completions_for_chore(chore_id) self.storage.remove_last_completed_for_chore(chore_id) @@ -533,9 +547,7 @@ async def async_skip_chore(self, chore_id: str) -> Chore: # Reject skipping a sticky group follower — the group would drift. group = self.storage.get_task_group_for_chore(chore_id) if group and group.policy == "sticky" and group.chore_ids and group.chore_ids[0] != chore_id: - raise ValueError( - "Cannot skip a sticky group follower; skip the leader chore instead" - ) + raise ValueError("Cannot skip a sticky group follower; skip the leader chore instead") pool = self._chore_assignment_pool(chore) if len(pool) <= 1: @@ -623,7 +635,9 @@ async def async_set_chore_manual_start(self, chore_id: str, child_id: str) -> Ch await self.async_refresh() return chore - async def async_complete_chore(self, chore_id: str, child_id: str, as_parent: bool = False, photo_url: str = "") -> ChoreCompletion | None: + async def async_complete_chore( + self, chore_id: str, child_id: str, as_parent: bool = False, photo_url: str = "" + ) -> ChoreCompletion | None: """Mark a chore as completed by a child. When ``as_parent`` is True the completion auto-approves (the parent is the @@ -664,8 +678,8 @@ async def async_complete_chore(self, chore_id: str, child_id: str, as_parent: bo # SINGLE daily quota across the whole pool. Enforce it here at completion # time, not just in the card UI — otherwise a caller can award every pool # member by completing the chore once per child_id (one call each). - assignment_mode = getattr(chore, 'assignment_mode', 'everyone') - if assignment_mode != 'everyone': + assignment_mode = getattr(chore, "assignment_mode", "everyone") + if assignment_mode != "everyone": if self._is_rotation_done_today(chore): _LOGGER.debug( "complete_chore no-op: '%s' already completed today (rotation quota filled)", @@ -676,16 +690,17 @@ async def async_complete_chore(self, chore_id: str, child_id: str, as_parent: bo # assignee. Parents (as_parent) may complete on behalf of any pool # member — e.g. ticking it off for the off-rotation child. first_come # keeps its competitive semantics (every pool member may race). - if not as_parent and assignment_mode != 'first_come': + if not as_parent and assignment_mode != "first_come": if child_id not in self._compute_active_children(chore): _LOGGER.debug( "complete_chore no-op: '%s' not assigned to %s today", - chore.name, child.name, + chore.name, + child.name, ) return None # Check recurrence window for Mode B chores - if getattr(chore, 'schedule_mode', 'specific_days') == 'recurring': + if getattr(chore, "schedule_mode", "specific_days") == "recurring": if not self.is_chore_available_for_child(chore, child_id): _LOGGER.debug( "complete_chore no-op: '%s' not available yet (recurrence window)", @@ -694,7 +709,7 @@ async def async_complete_chore(self, chore_id: str, child_id: str, as_parent: bo return None # Check availability for one-shot chores - if getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot': + if getattr(chore, "schedule_mode", "specific_days") == "one_shot": if not self.is_chore_available_for_child(chore, child_id): _LOGGER.debug( "complete_chore no-op: '%s' not available (one-shot done or expired)", @@ -718,11 +733,13 @@ async def async_complete_chore(self, chore_id: str, child_id: str, as_parent: bo if comp_dt.date() == today: todays_completions_count += 1 - daily_limit = getattr(chore, 'daily_limit', 1) + daily_limit = getattr(chore, "daily_limit", 1) if todays_completions_count >= daily_limit: _LOGGER.debug( "complete_chore no-op: daily limit reached for '%s' (%d/%d today)", - chore.name, todays_completions_count, daily_limit, + chore.name, + todays_completions_count, + daily_limit, ) return None @@ -781,7 +798,7 @@ async def async_complete_chore(self, chore_id: str, child_id: str, as_parent: bo self.storage.set_last_completed(chore_id, child_id, now.isoformat()) # One-shot: if auto-approved, disable for this child immediately - if getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot' and auto_approve: + if getattr(chore, "schedule_mode", "specific_days") == "one_shot" and auto_approve: if child_id not in chore.disabled_for: chore.disabled_for.append(child_id) self._check_one_shot_fully_disabled(chore) @@ -792,8 +809,11 @@ async def async_complete_chore(self, chore_id: str, child_id: str, as_parent: bo # Fire approval notification only if it stays pending if not auto_approve: await self._async_notify_pending_approval( - child.name, chore.name, chore.points, - completion_id=completion.id, photo_url=completion.photo_url, + child.name, + chore.name, + chore.points, + completion_id=completion.id, + photo_url=completion.photo_url, ) await self.async_refresh() @@ -819,19 +839,17 @@ async def async_parent_complete_chore(self, chore_id: str) -> ChoreCompletion: if not chore: raise ValueError(f"Chore {chore_id} not found") - if not getattr(chore, 'enabled', True): + if not getattr(chore, "enabled", True): raise ValueError(f"Chore '{chore.name}' is disabled") - schedule_mode = getattr(chore, 'schedule_mode', 'specific_days') - if schedule_mode == 'one_shot': - raise ValueError( - f"Chore '{chore.name}' is a one-shot chore and cannot be parent-completed" - ) + schedule_mode = getattr(chore, "schedule_mode", "specific_days") + if schedule_mode == "one_shot": + raise ValueError(f"Chore '{chore.name}' is a one-shot chore and cannot be parent-completed") now = dt_util.now() # Determine child pool — empty assigned_to means all children - assigned = getattr(chore, 'assigned_to', []) or [] + assigned = getattr(chore, "assigned_to", []) or [] if assigned: child_ids = list(assigned) else: @@ -912,9 +930,7 @@ async def async_complete_bonus_subtask( for c in all_completions ) if already_done: - raise ValueError( - f"Bonus sub-task '{subtask.name}' already completed today." - ) + raise ValueError(f"Bonus sub-task '{subtask.name}' already completed today.") completion = ChoreCompletion( chore_id=chore_id, @@ -960,19 +976,24 @@ async def async_approve_chore(self, completion_id: str) -> None: comp_date = dt_util.as_local(completion.completed_at).date() is_bonus = bool(completion.bonus_subtask_id) if is_bonus: - subtask = next( - (b for b in chore.bonus_subtasks if b.id == completion.bonus_subtask_id), None - ) + subtask = next((b for b in chore.bonus_subtasks if b.id == completion.bonus_subtask_id), None) pts = subtask.points if subtask else 0 elif completion.timed_duration_seconds > 0 and chore.task_type == "timed": rate_seconds = chore.timed_rate_minutes * 60 - pts = (completion.timed_duration_seconds // rate_seconds) * chore.timed_rate_points if rate_seconds > 0 else 0 + pts = ( + (completion.timed_duration_seconds // rate_seconds) * chore.timed_rate_points + if rate_seconds > 0 + else 0 + ) else: pts = self._apply_time_adjustment( chore, self.effective_chore_points(chore), completion.completed_at ) total_awarded = await self._award_points( - child, pts, completion_date=comp_date, skip_streak=is_bonus, + child, + pts, + completion_date=comp_date, + skip_streak=is_bonus, chore_id=completion.chore_id, ) completion.approved = True @@ -984,9 +1005,7 @@ async def async_approve_chore(self, completion_id: str) -> None: # reviewed (covers single approve AND "approve all", which # reuses this method per completion). if getattr(self, "notifications", None): - await self.notifications.clear_approval( - "pending_chore_approval", completion_id - ) + await self.notifications.clear_approval("pending_chore_approval", completion_id) self.hass.bus.async_fire( "taskmate_chore_approved", @@ -999,7 +1018,7 @@ async def async_approve_chore(self, completion_id: str) -> None: ) # One-shot: disable for this child on approval (parent completions only) - if not is_bonus and getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot': + if not is_bonus and getattr(chore, "schedule_mode", "specific_days") == "one_shot": if completion.child_id not in chore.disabled_for: chore.disabled_for.append(completion.child_id) self._check_one_shot_fully_disabled(chore) @@ -1031,13 +1050,17 @@ async def async_approve_chore(self, completion_id: str) -> None: {"child_name": child.name, "child_id": child.id}, ) await self._celebrate( - child, "all_chores_done", - f"{child.name} finished every chore today!", tier=1, + child, + "all_chores_done", + f"{child.name} finished every chore today!", + tier=1, ) else: _LOGGER.warning( "Cannot approve completion %s: chore (%s) or child (%s) not found", - completion_id, completion.chore_id, completion.child_id, + completion_id, + completion.chore_id, + completion.child_id, ) return _LOGGER.warning("Completion %s not found for approval", completion_id) @@ -1082,9 +1105,7 @@ def _reverse_completion_awards(self, target_completion, completions) -> list: and c.child_id == completion.child_id and not c.bonus_subtask_id ] - child.last_completion_date = ( - max(remaining).isoformat() if remaining else None - ) + child.last_completion_date = max(remaining).isoformat() if remaining else None # Reverse any streak milestones this completion unlocked. # Milestone bonuses are logged as separate transactions # (not part of points_awarded), so dropping the streak @@ -1094,24 +1115,15 @@ def _reverse_completion_awards(self, target_completion, completions) -> list: if lost: try: milestones = self.parse_milestone_setting( - self.storage.get_setting( - "streak_milestones", self.DEFAULT_STREAK_MILESTONES - ) + self.storage.get_setting("streak_milestones", self.DEFAULT_STREAK_MILESTONES) ) except ValueError: - milestones = self.parse_milestone_setting( - self.DEFAULT_STREAK_MILESTONES - ) + milestones = self.parse_milestone_setting(self.DEFAULT_STREAK_MILESTONES) refund = sum(milestones.get(d, 0) for d in lost) if refund > 0: child.points = max(0, child.points - refund) - child.total_points_earned = max( - 0, child.total_points_earned - refund - ) - child.career_score = ( - child.total_points_earned - - child.total_penalties_received - ) + child.total_points_earned = max(0, child.total_points_earned - refund) + child.career_score = child.total_points_earned - child.total_penalties_received self.storage.add_points_transaction( PointsTransaction( child_id=child.id, @@ -1120,9 +1132,7 @@ def _reverse_completion_awards(self, target_completion, completions) -> list: created_at=dt_util.now(), ) ) - child.streak_milestones_achieved = sorted( - d for d in achieved if d <= child.current_streak - ) + child.streak_milestones_achieved = sorted(d for d in achieved if d <= child.current_streak) self.storage.update_child(child) @@ -1133,7 +1143,8 @@ def _reverse_completion_awards(self, target_completion, completions) -> list: # chore/child on the same day (caller disposes of the records). comp_date = dt_util.as_local(target_completion.completed_at).date() bonus_completions = [ - c for c in completions + c + for c in completions if c.chore_id == target_completion.chore_id and c.child_id == target_completion.child_id and c.bonus_subtask_id @@ -1151,13 +1162,11 @@ def _reverse_completion_awards(self, target_completion, completions) -> list: self.storage.update_child(child) # Undo last_completed store so recurrence window resets correctly - self.storage.undo_last_completed( - target_completion.chore_id, target_completion.child_id - ) + self.storage.undo_last_completed(target_completion.chore_id, target_completion.child_id) # One-shot: re-enable for this child chore = self.get_chore(target_completion.chore_id) - if chore and getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot': + if chore and getattr(chore, "schedule_mode", "specific_days") == "one_shot": if target_completion.child_id in chore.disabled_for: chore.disabled_for.remove(target_completion.child_id) chore.enabled = True @@ -1168,14 +1177,10 @@ def _reverse_completion_awards(self, target_completion, completions) -> list: async def async_reject_chore(self, completion_id: str) -> None: """Reject a chore completion and fully reverse all awards if already granted.""" completions = self.storage.get_completions() - target_completion = next( - (c for c in completions if c.id == completion_id), None - ) + target_completion = next((c for c in completions if c.id == completion_id), None) if target_completion: - bonus_completions = self._reverse_completion_awards( - target_completion, completions - ) + bonus_completions = self._reverse_completion_awards(target_completion, completions) for bc in bonus_completions: self.storage.remove_completion(bc.id) @@ -1190,21 +1195,22 @@ async def async_reject_chore(self, completion_id: str) -> None: if target_completion: child = self.get_child(target_completion.child_id) chore = self.get_chore(target_completion.chore_id) - self.hass.bus.async_fire("taskmate_chore_rejected", { - "child_id": target_completion.child_id, - "child_name": getattr(child, "name", ""), - "chore_id": target_completion.chore_id, - "chore_name": getattr(chore, "name", ""), - "completion_id": completion_id, - "timestamp": dt_util.now().isoformat(), - }) + self.hass.bus.async_fire( + "taskmate_chore_rejected", + { + "child_id": target_completion.child_id, + "child_name": getattr(child, "name", ""), + "chore_id": target_completion.chore_id, + "chore_name": getattr(chore, "name", ""), + "completion_id": completion_id, + "timestamp": dt_util.now().isoformat(), + }, + ) # Dismiss the mobile approval push for this reviewed completion. Also # covers undoing an already-approved chore (whose push was cleared at # approval): re-clearing a stale tag is a harmless no-op. if getattr(self, "notifications", None): - await self.notifications.clear_approval( - "pending_chore_approval", completion_id - ) + await self.notifications.clear_approval("pending_chore_approval", completion_id) async def async_undo_chore_approval(self, completion_id: str) -> None: """Undo an accidental approval: reverse the awards and return the @@ -1240,14 +1246,17 @@ async def async_undo_chore_approval(self, completion_id: str) -> None: child = self.get_child(target.child_id) chore = self.get_chore(target.chore_id) - self.hass.bus.async_fire("taskmate_chore_approval_undone", { - "child_id": target.child_id, - "child_name": getattr(child, "name", ""), - "chore_id": target.chore_id, - "chore_name": getattr(chore, "name", ""), - "completion_id": completion_id, - "timestamp": dt_util.now().isoformat(), - }) + self.hass.bus.async_fire( + "taskmate_chore_approval_undone", + { + "child_id": target.child_id, + "child_name": getattr(child, "name", ""), + "chore_id": target.chore_id, + "chore_name": getattr(chore, "name", ""), + "completion_id": completion_id, + "timestamp": dt_util.now().isoformat(), + }, + ) def _check_one_shot_fully_disabled(self, chore) -> None: """Check if a one-shot chore should be fully disabled (all children done).""" @@ -1278,16 +1287,16 @@ def is_chore_available_for_child(self, chore, child_id: str) -> bool: return False # Check if chore is globally disabled (soft-disabled one-shot chores) - if not getattr(chore, 'enabled', True): + if not getattr(chore, "enabled", True): return False # Check per-child disabling (one-shot chores completed by this child) - disabled_for = getattr(chore, 'disabled_for', []) + disabled_for = getattr(chore, "disabled_for", []) if child_id in disabled_for: return False # Dynamic assignment — only the active child(ren) see alternating/random chores - if getattr(chore, 'assignment_mode', 'everyone') != 'everyone': + if getattr(chore, "assignment_mode", "everyone") != "everyone": active = self._compute_active_children(chore) if child_id not in active: return False @@ -1298,9 +1307,9 @@ def is_chore_available_for_child(self, chore, child_id: str) -> bool: return False # Check visibility entity first — if not visible, chore is not available - visibility_entity = getattr(chore, 'visibility_entity', '') - visibility_state = getattr(chore, 'visibility_state', 'on') - visibility_operator = getattr(chore, 'visibility_operator', 'equals') + visibility_entity = getattr(chore, "visibility_entity", "") + visibility_state = getattr(chore, "visibility_state", "on") + visibility_operator = getattr(chore, "visibility_operator", "equals") if not self._is_visibility_entity_active(visibility_entity, visibility_state, visibility_operator): return False @@ -1318,7 +1327,7 @@ def is_chore_available_for_child(self, chore, child_id: str) -> bool: # Chore dependencies (FEAT-1): this chore unlocks only once every chore # it depends on has an approved completion today by this same child. - depends_on = getattr(chore, 'depends_on', []) or [] + depends_on = getattr(chore, "depends_on", []) or [] if depends_on: dep_today = dt_util.as_local(dt_util.now()).date() completions = self._cached_completions() @@ -1327,18 +1336,18 @@ def is_chore_available_for_child(self, chore, child_id: str) -> bool: c.chore_id == dep_id and c.child_id == child_id and c.approved - and not getattr(c, 'bonus_subtask_id', '') + and not getattr(c, "bonus_subtask_id", "") and dt_util.as_local(c.completed_at).date() == dep_today for c in completions ) if not satisfied: return False - schedule_mode = getattr(chore, 'schedule_mode', 'specific_days') + schedule_mode = getattr(chore, "schedule_mode", "specific_days") # One-shot chores: only available on the day they were created - if schedule_mode == 'one_shot': - created_date = getattr(chore, 'created_date', '') + if schedule_mode == "one_shot": + created_date = getattr(chore, "created_date", "") if created_date: today = dt_util.as_local(dt_util.now()).date() try: @@ -1348,25 +1357,25 @@ def is_chore_available_for_child(self, chore, child_id: str) -> bool: pass return True - if schedule_mode != 'recurring': + if schedule_mode != "recurring": return True - recurrence = getattr(chore, 'recurrence', 'weekly') - first_occurrence_mode = getattr(chore, 'first_occurrence_mode', 'available_immediately') - recurrence_day = getattr(chore, 'recurrence_day', '') - recurrence_start = getattr(chore, 'recurrence_start', '') + recurrence = getattr(chore, "recurrence", "weekly") + first_occurrence_mode = getattr(chore, "first_occurrence_mode", "available_immediately") + recurrence_day = getattr(chore, "recurrence_day", "") + recurrence_start = getattr(chore, "recurrence_start", "") now = dt_util.now() today = dt_util.as_local(now).date() window_days = { - 'every_2_days': 2, - 'weekly': 7, - 'every_2_weeks': 14, + "every_2_days": 2, + "weekly": 7, + "every_2_weeks": 14, }.get(recurrence, 7) record = self.storage.get_last_completed(chore.id, child_id) - current_iso = record.get('current') + current_iso = record.get("current") if not current_iso: # Never completed — a future recurrence anchor always defers @@ -1377,7 +1386,7 @@ def is_chore_available_for_child(self, chore, child_id: str) -> bool: return False except ValueError: pass - if first_occurrence_mode == 'wait_for_first_occurrence' and recurrence_day: + if first_occurrence_mode == "wait_for_first_occurrence" and recurrence_day: target_dow = _DOW_MAP.get(recurrence_day.lower()) if target_dow is not None and today.weekday() != target_dow: return False @@ -1389,7 +1398,7 @@ def is_chore_available_for_child(self, chore, child_id: str) -> bool: return True # every_2_days with anchor — check alignment - if recurrence == 'every_2_days' and recurrence_start: + if recurrence == "every_2_days" and recurrence_start: try: anchor = date.fromisoformat(recurrence_start) days_since_anchor = (today - anchor).days @@ -1402,7 +1411,7 @@ def is_chore_available_for_child(self, chore, child_id: str) -> bool: pass # weekly/every_2_weeks with specific day — only available on that day - if recurrence_day and recurrence in ('weekly', 'every_2_weeks'): + if recurrence_day and recurrence in ("weekly", "every_2_weeks"): target_dow = _DOW_MAP.get(recurrence_day.lower()) if target_dow is not None and today.weekday() != target_dow: return False @@ -1477,7 +1486,9 @@ async def _async_expire_dated_chores(self) -> None: changed = True _LOGGER.info( "Chore '%s' expired (expires_on %s, today %s)", - chore.name, expires_on, today.isoformat(), + chore.name, + expires_on, + today.isoformat(), ) except ValueError: continue @@ -1492,11 +1503,11 @@ async def _async_expire_one_shot_chores(self) -> None: changed = False for chore in self.storage.get_chores(): - if getattr(chore, 'schedule_mode', 'specific_days') != 'one_shot': + if getattr(chore, "schedule_mode", "specific_days") != "one_shot": continue - if not getattr(chore, 'enabled', True): + if not getattr(chore, "enabled", True): continue - created_date = getattr(chore, 'created_date', '') + created_date = getattr(chore, "created_date", "") if not created_date: continue try: @@ -1506,7 +1517,9 @@ async def _async_expire_one_shot_chores(self) -> None: changed = True _LOGGER.info( "One-shot chore '%s' expired (created %s, today %s)", - chore.name, created_date, today.isoformat(), + chore.name, + created_date, + today.isoformat(), ) except ValueError: continue @@ -1563,18 +1576,15 @@ def _validate_task_group_members(self, chore_ids: list[str], exclude_group_id: s if not chore: raise ValueError(f"Unknown chore: {chore_id}") if getattr(chore, "assignment_mode", "everyone") == "everyone": - raise ValueError( - f"Chore '{chore.name}' uses 'everyone' mode and cannot join a group" - ) + raise ValueError(f"Chore '{chore.name}' uses 'everyone' mode and cannot join a group") existing_group = self.storage.get_task_group_for_chore(chore_id) if existing_group and existing_group.id != exclude_group_id: - raise ValueError( - f"Chore '{chore.name}' already belongs to group '{existing_group.name}'" - ) + raise ValueError(f"Chore '{chore.name}' already belongs to group '{existing_group.name}'") async def async_add_task_group(self, name: str, policy: str, chore_ids: list[str] | None = None): """Create a task group.""" from .models import TaskGroup + if policy not in ("sticky", "spread"): raise ValueError(f"Unknown task group policy: {policy}") chore_ids = list(chore_ids or []) diff --git a/custom_components/taskmate/coord_guests.py b/custom_components/taskmate/coord_guests.py index 68380ee..44eda51 100644 --- a/custom_components/taskmate/coord_guests.py +++ b/custom_components/taskmate/coord_guests.py @@ -7,6 +7,7 @@ "Archived" rather than deleted: the visit's completions stay in history, and next summer the same guest can be reactivated instead of rebuilt. """ + from __future__ import annotations import logging @@ -40,7 +41,8 @@ def guest_has_expired(self, child, on: date | None = None) -> bool: except (TypeError, ValueError): _LOGGER.warning( "Guest %s has an unparseable expiry %r — treating as open-ended", - getattr(child, "name", ""), raw, + getattr(child, "name", ""), + raw, ) return False return (on or dt_util.as_local(dt_util.now()).date()) > ends @@ -74,8 +76,7 @@ async def async_archive_expired_guests(self, refresh: bool = True) -> list[str]: child.pause_streak_when_unavailable = True self.storage.update_child(child) archived.append(child.name) - _LOGGER.info("Archived guest profile '%s' (stay ended %s)", - child.name, child.guest_expires_on) + _LOGGER.info("Archived guest profile '%s' (stay ended %s)", child.name, child.guest_expires_on) self.hass.bus.async_fire( "taskmate_guest_archived", { @@ -93,7 +94,10 @@ async def async_archive_expired_guests(self, refresh: bool = True) -> list[str]: return archived async def async_set_guest( - self, child_id: str, is_guest: bool, expires_on: str = "", + self, + child_id: str, + is_guest: bool, + expires_on: str = "", ) -> None: """Mark a child as a guest (or back to a family member).""" child = self.storage.get_child(child_id) diff --git a/custom_components/taskmate/coord_mandatory.py b/custom_components/taskmate/coord_mandatory.py index 3b4d24d..1afab6f 100644 --- a/custom_components/taskmate/coord_mandatory.py +++ b/custom_components/taskmate/coord_mandatory.py @@ -1,4 +1,5 @@ """Mandatory-chore detection, scheduling, and resolution (#532).""" + from __future__ import annotations import logging @@ -64,10 +65,7 @@ def _child_completed_today(self, chore_id: str, child_id: str, day: date) -> boo async def async_detect_mandatory_misses(self, period_id: str, day: date) -> int: """Create misses for due+incomplete mandatory chores in `period_id`.""" - existing = { - (m.chore_id, m.child_id, m.due_date) - for m in self.storage.get_mandatory_misses() - } + existing = {(m.chore_id, m.child_id, m.due_date) for m in self.storage.get_mandatory_misses()} created = 0 for chore in self.storage.get_chores(): if not getattr(chore, "mandatory", False): @@ -94,11 +92,17 @@ async def async_detect_mandatory_misses(self, period_id: str, day: date) -> int: ) self.storage.add_mandatory_miss(miss) created += 1 - self.hass.bus.async_fire("taskmate_mandatory_missed", { - "miss_id": miss.id, "chore_id": chore.id, "child_id": child_id, - "period_id": period_id, "penalty_points": miss.penalty_points, - "timestamp": dt_util.now().isoformat(), - }) + self.hass.bus.async_fire( + "taskmate_mandatory_missed", + { + "miss_id": miss.id, + "chore_id": chore.id, + "child_id": child_id, + "period_id": period_id, + "penalty_points": miss.penalty_points, + "timestamp": dt_util.now().isoformat(), + }, + ) if created: await self.storage.async_save() await self.async_refresh() @@ -132,15 +136,22 @@ async def async_apply_mandatory_penalty(self, miss_id: str) -> None: name = getattr(chore, "name", "chore") if miss.penalty_points > 0: await self.async_remove_points( - miss.child_id, miss.penalty_points, + miss.child_id, + miss.penalty_points, reason=f"Penalty: {name} (missed mandatory)", ) self.storage.remove_mandatory_miss(miss_id) await self.storage.async_save() - self.hass.bus.async_fire("taskmate_mandatory_penalty_applied", { - "miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id, - "points": miss.penalty_points, "timestamp": dt_util.now().isoformat(), - }) + self.hass.bus.async_fire( + "taskmate_mandatory_penalty_applied", + { + "miss_id": miss_id, + "chore_id": miss.chore_id, + "child_id": miss.child_id, + "points": miss.penalty_points, + "timestamp": dt_util.now().isoformat(), + }, + ) await self.async_refresh() async def async_postpone_mandatory_chore(self, miss_id: str) -> None: @@ -156,10 +167,16 @@ async def async_postpone_mandatory_chore(self, miss_id: str) -> None: # else: no window left today -> let normal scheduling resurface tomorrow self.storage.remove_mandatory_miss(miss_id) await self.storage.async_save() - self.hass.bus.async_fire("taskmate_mandatory_postponed", { - "miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id, - "next_period": nxt or "", "timestamp": dt_util.now().isoformat(), - }) + self.hass.bus.async_fire( + "taskmate_mandatory_postponed", + { + "miss_id": miss_id, + "chore_id": miss.chore_id, + "child_id": miss.child_id, + "next_period": nxt or "", + "timestamp": dt_util.now().isoformat(), + }, + ) await self.async_refresh() async def async_dismiss_mandatory_chore(self, miss_id: str) -> None: @@ -168,10 +185,15 @@ async def async_dismiss_mandatory_chore(self, miss_id: str) -> None: return self.storage.remove_mandatory_miss(miss_id) await self.storage.async_save() - self.hass.bus.async_fire("taskmate_mandatory_dismissed", { - "miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id, - "timestamp": dt_util.now().isoformat(), - }) + self.hass.bus.async_fire( + "taskmate_mandatory_dismissed", + { + "miss_id": miss_id, + "chore_id": miss.chore_id, + "child_id": miss.child_id, + "timestamp": dt_util.now().isoformat(), + }, + ) await self.async_refresh() # ---- escalation (FEAT-6) ---------------------------------------------- @@ -225,12 +247,14 @@ async def async_escalate_mandatory_misses(self, now: datetime | None = None) -> for stage in range(miss.escalation_stage + 1, target + 1): if stage in (1, 2): await self.notifications.fire( - NOTIF_TYPE_MANDATORY_REMINDER, ctx, + NOTIF_TYPE_MANDATORY_REMINDER, + ctx, only_recipients={f"child:{miss.child_id}"}, ) elif stage == 3: await self.notifications.fire( - NOTIF_TYPE_MANDATORY_PARENT_ALERT, ctx, + NOTIF_TYPE_MANDATORY_PARENT_ALERT, + ctx, ) miss.escalation_stage = target self.storage.update_mandatory_miss(miss) @@ -261,13 +285,17 @@ def arm_mandatory_schedules(self) -> None: unsub = async_track_time_change( self.hass, self._make_mandatory_period_cb(period_id), - hour=hour, minute=minute, second=10, + hour=hour, + minute=minute, + second=10, ) self._unsub_mandatory.append(unsub) # Reminder escalation ladder (FEAT-6) — re-evaluate open misses on a tick. self._unsub_mandatory.append( async_track_time_interval( - self.hass, self._escalation_tick, _ESCALATION_INTERVAL, + self.hass, + self._escalation_tick, + _ESCALATION_INTERVAL, ) ) @@ -278,9 +306,8 @@ def _escalation_tick(self, now: datetime) -> None: def _make_mandatory_period_cb(self, period_id: str): @callback def _cb(now: datetime) -> None: - self.hass.async_create_task( - self.async_detect_mandatory_misses(period_id, dt_util.now().date()) - ) + self.hass.async_create_task(self.async_detect_mandatory_misses(period_id, dt_util.now().date())) + return _cb def disarm_mandatory_schedules(self) -> None: diff --git a/custom_components/taskmate/coord_notifications.py b/custom_components/taskmate/coord_notifications.py index fbf7fb3..c8f64a7 100644 --- a/custom_components/taskmate/coord_notifications.py +++ b/custom_components/taskmate/coord_notifications.py @@ -9,6 +9,7 @@ Other coordinators MUST NOT call notify.* / persistent_notification directly once this module is in place. They call self.notifications.fire(...). """ + from __future__ import annotations import logging @@ -59,34 +60,32 @@ def _approval_tag(entry_id: str) -> str: @dataclass(frozen=True) class NotificationTypeMeta: id: str - audience: str # "child" | "parent" | "both" - time_gated: bool # has its own scheduled callback - per_recipient_time: bool # if True, route.time controls the schedule per recipient - actionable: bool # carries Approve/Reject mobile actions - default_enabled: bool # default master_enabled state at install + audience: str # "child" | "parent" | "both" + time_gated: bool # has its own scheduled callback + per_recipient_time: bool # if True, route.time controls the schedule per recipient + actionable: bool # carries Approve/Reject mobile actions + default_enabled: bool # default master_enabled state at install NOTIFICATION_TYPES: list[NotificationTypeMeta] = [ - NotificationTypeMeta(NOTIF_TYPE_BEDTIME_REMINDER, "child", True, True, False, False), - NotificationTypeMeta(NOTIF_TYPE_STREAK_AT_RISK, "child", True, False, False, False), - NotificationTypeMeta(NOTIF_TYPE_ALL_CHORES_DONE, "both", False, False, False, False), - NotificationTypeMeta(NOTIF_TYPE_BADGE_EARNED, "both", False, False, False, True), - NotificationTypeMeta(NOTIF_TYPE_PENDING_CHORE_APPROVAL, "parent", False, False, True, True), - NotificationTypeMeta(NOTIF_TYPE_PENDING_REWARD_CLAIM, "parent", False, False, True, True), - NotificationTypeMeta(NOTIF_TYPE_STREAK_MILESTONE, "both", False, False, False, False), - NotificationTypeMeta(NOTIF_TYPE_LEVEL_UP, "both", False, False, False, False), - NotificationTypeMeta(NOTIF_TYPE_WEEKLY_DIGEST, "parent", False, False, False, False), - NotificationTypeMeta(NOTIF_TYPE_CELEBRATION, "both", False, False, False, False), - NotificationTypeMeta(NOTIF_TYPE_MANDATORY_REMINDER, "child", False, False, False, False), + NotificationTypeMeta(NOTIF_TYPE_BEDTIME_REMINDER, "child", True, True, False, False), + NotificationTypeMeta(NOTIF_TYPE_STREAK_AT_RISK, "child", True, False, False, False), + NotificationTypeMeta(NOTIF_TYPE_ALL_CHORES_DONE, "both", False, False, False, False), + NotificationTypeMeta(NOTIF_TYPE_BADGE_EARNED, "both", False, False, False, True), + NotificationTypeMeta(NOTIF_TYPE_PENDING_CHORE_APPROVAL, "parent", False, False, True, True), + NotificationTypeMeta(NOTIF_TYPE_PENDING_REWARD_CLAIM, "parent", False, False, True, True), + NotificationTypeMeta(NOTIF_TYPE_STREAK_MILESTONE, "both", False, False, False, False), + NotificationTypeMeta(NOTIF_TYPE_LEVEL_UP, "both", False, False, False, False), + NotificationTypeMeta(NOTIF_TYPE_WEEKLY_DIGEST, "parent", False, False, False, False), + NotificationTypeMeta(NOTIF_TYPE_CELEBRATION, "both", False, False, False, False), + NotificationTypeMeta(NOTIF_TYPE_MANDATORY_REMINDER, "child", False, False, False, False), NotificationTypeMeta(NOTIF_TYPE_MANDATORY_PARENT_ALERT, "parent", False, False, False, False), - NotificationTypeMeta(NOTIF_TYPE_MONTHLY_REPORT, "parent", False, False, False, False), - NotificationTypeMeta(NOTIF_TYPE_SEASON_CHAMPION, "both", False, False, False, False), - NotificationTypeMeta(NOTIF_TYPE_FAMILY_GOAL_REACHED, "both", False, False, False, False), + NotificationTypeMeta(NOTIF_TYPE_MONTHLY_REPORT, "parent", False, False, False, False), + NotificationTypeMeta(NOTIF_TYPE_SEASON_CHAMPION, "both", False, False, False, False), + NotificationTypeMeta(NOTIF_TYPE_FAMILY_GOAL_REACHED, "both", False, False, False, False), ] -NOTIFICATION_TYPES_BY_ID: dict[str, NotificationTypeMeta] = { - t.id: t for t in NOTIFICATION_TYPES -} +NOTIFICATION_TYPES_BY_ID: dict[str, NotificationTypeMeta] = {t.id: t for t in NOTIFICATION_TYPES} def _validate_nav_url(value: str) -> str: @@ -101,11 +100,7 @@ def _validate_nav_url(value: str) -> str: return value if value.lower().startswith(("http://", "https://")): return value - if ( - value.startswith("/") - and not value.startswith("//") - and not any(ord(c) <= 32 or ord(c) == 127 for c in value) - ): + if value.startswith("/") and not value.startswith("//") and not any(ord(c) <= 32 or ord(c) == 127 for c in value): return value raise ValueError("nav_url must be a /path, an http(s) URL, or noAction") @@ -145,6 +140,7 @@ def _is_within_quiet_hours(start: str, end: str, now) -> bool: class _SafeDict(dict): """str.format_map dict that leaves missing keys as `{key}` literal.""" + def __missing__(self, key: str) -> str: return "{" + key + "}" @@ -155,11 +151,13 @@ class NotificationCoordinator: def __init__(self, hass: HomeAssistant, storage) -> None: self.hass = hass self.storage = storage - self._scheduled_unsubs: list = [] # cancellation handles for time triggers + self._scheduled_unsubs: list = [] # cancellation handles for time triggers self.coordinator: Any = None async def fire( - self, type_id: str, context: dict[str, Any], + self, + type_id: str, + context: dict[str, Any], only_recipients: set[str] | None = None, ) -> None: """Dispatch a notification of the given type with the given context. @@ -229,7 +227,8 @@ def _parent_is_home(self, recipient_id: str) -> bool: set one up shouldn't be silently excluded from every approval. """ parent = next( - (p for p in self.storage.get_parent_recipients() if p.id == recipient_id), None, + (p for p in self.storage.get_parent_recipients() if p.id == recipient_id), + None, ) entity_id = (getattr(parent, "presence_entity", "") or "").strip() if parent else "" if not entity_id: @@ -241,13 +240,16 @@ def _parent_is_home(self, recipient_id: str) -> bool: return str(state.state).lower() in ("home", "on", "true", "present") def _route_parents( - self, type_id: str, cfg, only_recipients: set[str] | None, + self, + type_id: str, + cfg, + only_recipients: set[str] | None, ) -> set[str]: """Which parent recipient ids should receive this notification.""" candidates = [ - rid for rid, route in cfg.routes.items() - if rid.startswith("parent:") and route.enabled - and (only_recipients is None or rid in only_recipients) + rid + for rid, route in cfg.routes.items() + if rid.startswith("parent:") and route.enabled and (only_recipients is None or rid in only_recipients) ] if not candidates: return set() @@ -328,20 +330,15 @@ def _child_in_quiet_hours(self, recipient_id: str) -> bool: if child is None: return False from homeassistant.util import dt as dt_util - return _is_within_quiet_hours( - child.quiet_hours_start, child.quiet_hours_end, dt_util.now() - ) + + return _is_within_quiet_hours(child.quiet_hours_start, child.quiet_hours_end, dt_util.now()) def _resolve_nav_url(self, cfg) -> str: """Tap target for this notification: per-type override, else global default.""" per_type = (getattr(cfg, "nav_url", "") or "").strip() if per_type: return per_type - return str( - self.storage.get_setting( - "notification_nav_url", DEFAULT_NOTIFICATION_NAV_URL - ) or "" - ).strip() + return str(self.storage.get_setting("notification_nav_url", DEFAULT_NOTIFICATION_NAV_URL) or "").strip() def _resolve_notify_service(self, recipient_id: str) -> str: if recipient_id.startswith("child:"): @@ -358,21 +355,21 @@ def _render_template(self, meta: "NotificationTypeMeta", context: dict[str, Any] # Built-in types use a baked-in default; will be replaced by translations # in a later task. For now use a safe English fallback so dispatch works. templates = { - NOTIF_TYPE_BEDTIME_REMINDER: "{child_name}, you still have chores to do before bedtime.", - NOTIF_TYPE_STREAK_AT_RISK: "{child_name}, complete a chore today to keep your {streak}-day streak!", - NOTIF_TYPE_ALL_CHORES_DONE: "{child_name} finished every chore today!", - NOTIF_TYPE_BADGE_EARNED: "{child_name} earned the {badge_name} badge!", + NOTIF_TYPE_BEDTIME_REMINDER: "{child_name}, you still have chores to do before bedtime.", + NOTIF_TYPE_STREAK_AT_RISK: "{child_name}, complete a chore today to keep your {streak}-day streak!", + NOTIF_TYPE_ALL_CHORES_DONE: "{child_name} finished every chore today!", + NOTIF_TYPE_BADGE_EARNED: "{child_name} earned the {badge_name} badge!", NOTIF_TYPE_PENDING_CHORE_APPROVAL: "{child_name} completed '{chore_name}' (+{points} {points_name}) — awaiting approval.", - NOTIF_TYPE_PENDING_REWARD_CLAIM: "{child_name} claimed '{reward_name}' ({cost} {points_name}) — awaiting approval.", - NOTIF_TYPE_STREAK_MILESTONE: "{child_name} hit a {days}-day streak — +{points} {points_name}!", - NOTIF_TYPE_LEVEL_UP: "{child_name} reached level {level}! 🎉", - NOTIF_TYPE_WEEKLY_DIGEST: "TaskMate weekly digest:\n{summary}", - NOTIF_TYPE_CELEBRATION: "🎉 {message}", - NOTIF_TYPE_MANDATORY_REMINDER: "{child_name}, you still need to do '{chore_name}'.", + NOTIF_TYPE_PENDING_REWARD_CLAIM: "{child_name} claimed '{reward_name}' ({cost} {points_name}) — awaiting approval.", + NOTIF_TYPE_STREAK_MILESTONE: "{child_name} hit a {days}-day streak — +{points} {points_name}!", + NOTIF_TYPE_LEVEL_UP: "{child_name} reached level {level}! 🎉", + NOTIF_TYPE_WEEKLY_DIGEST: "TaskMate weekly digest:\n{summary}", + NOTIF_TYPE_CELEBRATION: "🎉 {message}", + NOTIF_TYPE_MANDATORY_REMINDER: "{child_name}, you still need to do '{chore_name}'.", NOTIF_TYPE_MANDATORY_PARENT_ALERT: "{child_name} still hasn't done the mandatory chore '{chore_name}'.", - NOTIF_TYPE_MONTHLY_REPORT: "TaskMate {month} report:\n{summary}", - NOTIF_TYPE_SEASON_CHAMPION: "🏆 {child_name} won the {month} leaderboard with {points} {points_name}!", - NOTIF_TYPE_FAMILY_GOAL_REACHED: "🎉 Family goal reached: {goal_name}! Time for {goal_reward}.", + NOTIF_TYPE_MONTHLY_REPORT: "TaskMate {month} report:\n{summary}", + NOTIF_TYPE_SEASON_CHAMPION: "🏆 {child_name} won the {month} leaderboard with {points} {points_name}!", + NOTIF_TYPE_FAMILY_GOAL_REACHED: "🎉 Family goal reached: {goal_name}! Time for {goal_reward}.", } tpl = context.get("message_template") or templates.get(meta.id, "") try: @@ -383,13 +380,14 @@ def _render_template(self, meta: "NotificationTypeMeta", context: dict[str, Any] return tpl async def _send_to( - self, notify_service: str, message: str, - meta: "NotificationTypeMeta", context: dict[str, Any], nav_url: str = "", + self, + notify_service: str, + message: str, + meta: "NotificationTypeMeta", + context: dict[str, Any], + nav_url: str = "", ) -> None: - domain, service = ( - notify_service.split(".", 1) if "." in notify_service - else ("notify", notify_service) - ) + domain, service = notify_service.split(".", 1) if "." in notify_service else ("notify", notify_service) if domain != "notify": _LOGGER.warning("notify_service must be notify.*, got %s", notify_service) return @@ -419,7 +417,7 @@ async def _send_to( push["tag"] = _approval_tag(entry_id) push["actions"] = [ {"action": f"TASKMATE_APPROVE_{entry_id}", "title": "Approve"}, - {"action": f"TASKMATE_REJECT_{entry_id}", "title": "Reject"}, + {"action": f"TASKMATE_REJECT_{entry_id}", "title": "Reject"}, ] else: data["message"] = f"{message} {_APPROVE_IN_PANEL_HINT}" @@ -472,10 +470,7 @@ async def clear_approval(self, type_id: str, entry_id: str) -> None: notify_service = self._resolve_notify_service(recipient_id) if not notify_service: continue - domain, service = ( - notify_service.split(".", 1) if "." in notify_service - else ("notify", notify_service) - ) + domain, service = notify_service.split(".", 1) if "." in notify_service else ("notify", notify_service) if domain != "notify" or not service.startswith("mobile_app"): continue if service in cleared_services: @@ -483,7 +478,8 @@ async def clear_approval(self, type_id: str, entry_id: str) -> None: cleared_services.add(service) try: await self.hass.services.async_call( - "notify", service, + "notify", + service, {"message": "clear_notification", "data": {"tag": tag}}, blocking=False, ) @@ -492,7 +488,8 @@ async def clear_approval(self, type_id: str, entry_id: str) -> None: async def _fire_persistent_notification(self, type_id: str, message: str) -> None: await self.hass.services.async_call( - "persistent_notification", "create", + "persistent_notification", + "create", { "title": "TaskMate", "message": message, @@ -516,7 +513,7 @@ async def handle_mobile_action(self, event) -> None: return if action.startswith("TASKMATE_APPROVE_"): - entry_id = action[len("TASKMATE_APPROVE_"):] + entry_id = action[len("TASKMATE_APPROVE_") :] try: await coordinator.async_approve_chore(entry_id) return @@ -527,7 +524,7 @@ async def handle_mobile_action(self, event) -> None: except (ValueError, KeyError): _LOGGER.info("Mobile action %s — entry not found", action) elif action.startswith("TASKMATE_REJECT_"): - entry_id = action[len("TASKMATE_REJECT_"):] + entry_id = action[len("TASKMATE_REJECT_") :] try: await coordinator.async_reject_chore(entry_id) return @@ -591,7 +588,11 @@ def _register_at(self, hhmm: str, callback) -> None: _LOGGER.warning("Invalid time %r — skipping schedule", hhmm) return unsub = async_track_time_change( - self.hass, callback, hour=hour, minute=minute, second=0, + self.hass, + callback, + hour=hour, + minute=minute, + second=0, ) self._scheduled_unsubs.append(unsub) @@ -606,10 +607,12 @@ async def _cb(now): "bedtime_reminder", {"child_name": child.name, "child_id": child_id}, ) + return _cb async def _streak_at_risk_callback(self, now) -> None: from homeassistant.util import dt as dt_util + today = dt_util.now().date().isoformat() for child in self.storage.get_children(): if (child.current_streak or 0) < 2: @@ -628,6 +631,7 @@ async def _streak_at_risk_callback(self, now) -> None: def _make_custom_callback(self, custom_id: str): async def _cb(now): from homeassistant.util import dt as dt_util + n = next( (c for c in self.storage.get_custom_notifications() if c.id == custom_id), None, @@ -659,7 +663,8 @@ async def _cb(now): message = n.message_template service_name = notify_service.split(".", 1)[1] if "." in notify_service else notify_service await self.hass.services.async_call( - "notify", service_name, + "notify", + service_name, {"title": "TaskMate", "message": message}, blocking=False, ) @@ -667,6 +672,7 @@ async def _cb(now): "taskmate_custom_notification", {"id": n.id, "name": n.name, "recipients": n.recipient_ids}, ) + return _cb # ------------------------------------------------------------------ @@ -702,9 +708,7 @@ def ensure_parent_default_routes(self) -> bool: if self.storage.get_notification_config(meta.id).routes: continue # already configured — leave it alone for p in parents: - self.storage.set_notification_route( - meta.id, p.id, NotificationRoute(enabled=True) - ) + self.storage.set_notification_route(meta.id, p.id, NotificationRoute(enabled=True)) changed = True return changed @@ -750,13 +754,14 @@ def _has_outstanding_chores_today(self, child_id: str) -> bool: """Returns True if the child has at least one chore assigned today that has no approved/pending completion yet.""" from homeassistant.util import dt as dt_util + today = dt_util.now().date() chores = self.storage.get_chores() completions = self.storage.get_completions() completed_today = { - c.chore_id for c in completions - if c.child_id == child_id - and dt_util.as_local(c.completed_at).date() == today + c.chore_id + for c in completions + if c.child_id == child_id and dt_util.as_local(c.completed_at).date() == today } for chore in chores: if not chore.assigned_to or child_id not in chore.assigned_to: diff --git a/custom_components/taskmate/coord_points.py b/custom_components/taskmate/coord_points.py index 700706a..f4051eb 100644 --- a/custom_components/taskmate/coord_points.py +++ b/custom_components/taskmate/coord_points.py @@ -1,4 +1,5 @@ """Points operations mixin for TaskMateCoordinator.""" + from __future__ import annotations import logging @@ -72,7 +73,7 @@ def _required_dates(child_id: str) -> set[str]: return req for child in children: - awarded_weeks = list(getattr(child, 'awarded_perfect_weeks', None) or []) + awarded_weeks = list(getattr(child, "awarded_perfect_weeks", None) or []) # Skip if already awarded for this week if week_key in awarded_weeks: @@ -100,9 +101,7 @@ def _required_dates(child_id: str) -> set[str]: # counts only when EVERY chore due that day was done — not just one. if all_mode and derived_dates: satisfied = all( - self._all_due_chores_done( - child.id, date.fromisoformat(d), include_rotation=False - ) + self._all_due_chores_done(child.id, date.fromisoformat(d), include_rotation=False) for d in derived_dates ) else: @@ -113,9 +112,7 @@ def _required_dates(child_id: str) -> set[str]: child.points += perfect_week_bonus child.total_points_earned += perfect_week_bonus child.career_score = child.total_points_earned - child.total_penalties_received - self.storage.append_career_score_snapshot( - child.id, today.isoformat(), child.career_score - ) + self.storage.append_career_score_snapshot(child.id, today.isoformat(), child.career_score) self.storage.update_child(child) transaction = PointsTransaction( @@ -140,13 +137,17 @@ def _required_dates(child_id: str) -> set[str]: if getattr(self, "badges", None): await self.badges.evaluate_for_child(child.id, "perfect_week") await self._celebrate( - child, "perfect_week", + child, + "perfect_week", f"{child.name} earned a perfect week — +{perfect_week_bonus}!", - tier=3, extra={"bonus": perfect_week_bonus}, + tier=3, + extra={"bonus": perfect_week_bonus}, ) _LOGGER.info( "Perfect week bonus (%d pts) awarded to %s for week of %s", - perfect_week_bonus, child.name, week_key, + perfect_week_bonus, + child.name, + week_key, ) if changed: @@ -213,17 +214,13 @@ async def _async_check_streaks(self) -> None: if streak_mode == "pause" or getattr(child, "streak_paused", False): child.streak_paused = True _LOGGER.info( - "Streak paused for %s (last completion: %s, mode=%s)", - child.name, last_date_str, streak_mode + "Streak paused for %s (last completion: %s, mode=%s)", child.name, last_date_str, streak_mode ) else: # Default: reset to 0 child.current_streak = 0 child.streak_paused = False - _LOGGER.info( - "Streak reset for %s (last completion: %s, mode=reset)", - child.name, last_date_str - ) + _LOGGER.info("Streak reset for %s (last completion: %s, mode=reset)", child.name, last_date_str) self.storage.update_child(child) changed = True @@ -243,9 +240,7 @@ async def async_add_points(self, child_id: str, points: int, reason: str = "") - child.career_score = child.total_points_earned - child.total_penalties_received await self._maybe_level_up(child) self.storage.update_child(child) - self.storage.append_career_score_snapshot( - child_id, date.today().isoformat(), child.career_score - ) + self.storage.append_career_score_snapshot(child_id, date.today().isoformat(), child.career_score) # Log the manual transaction transaction = PointsTransaction( child_id=child_id, @@ -279,9 +274,7 @@ async def async_remove_points(self, child_id: str, points: int, reason: str = "" if reason.startswith("Penalty: "): child.total_penalties_received += actual_deducted child.career_score = child.total_points_earned - child.total_penalties_received - self.storage.append_career_score_snapshot( - child_id, date.today().isoformat(), child.career_score - ) + self.storage.append_career_score_snapshot(child_id, date.today().isoformat(), child.career_score) self.storage.update_child(child) # Log the manual transaction (negative points) transaction = PointsTransaction( @@ -338,9 +331,7 @@ async def async_undo_transaction(self, transaction_id: str) -> None: if reason.startswith("Gift to ") or reason.startswith("Gift from "): link_id = getattr(target, "link_id", "") or "" if not link_id: - raise ValueError( - "This gift predates undo support and can't be reversed automatically." - ) + raise ValueError("This gift predates undo support and can't be reversed automatically.") for leg in [t for t in txns if (getattr(t, "link_id", "") or "") == link_id]: leg_child = self.get_child(leg.child_id) if leg_child: @@ -368,9 +359,7 @@ async def async_undo_transaction(self, transaction_id: str) -> None: # points, so nothing else to reverse. child.career_score = child.total_points_earned - child.total_penalties_received self.storage.update_child(child) - self.storage.append_career_score_snapshot( - child.id, dt_util.now().date().isoformat(), child.career_score - ) + self.storage.append_career_score_snapshot(child.id, dt_util.now().date().isoformat(), child.career_score) self.storage.remove_points_transaction(transaction_id) await self.storage.async_save() await self.async_refresh() @@ -394,9 +383,7 @@ def level_info(self, child) -> dict: lvl = xp // step + 1 return {"level": lvl, "progress": xp - (lvl - 1) * step, "target": step} - async def _celebrate( - self, child, kind: str, message: str, tier: int = 1, extra: dict | None = None - ) -> None: + async def _celebrate(self, child, kind: str, message: str, tier: int = 1, extra: dict | None = None) -> None: """Central celebration funnel for notable moments. Always fires a single ``taskmate_celebration`` event carrying a ``tier`` @@ -452,17 +439,30 @@ async def _maybe_level_up(self, child) -> None: if new < old: return # earned total dropped (e.g. undo); resync quietly for lvl in range(old + 1, new + 1): - self.hass.bus.async_fire("taskmate_level_up", { - "child_id": child.id, "child_name": child.name, - "level": lvl, "timestamp": dt_util.now().isoformat(), - }) + self.hass.bus.async_fire( + "taskmate_level_up", + { + "child_id": child.id, + "child_name": child.name, + "level": lvl, + "timestamp": dt_util.now().isoformat(), + }, + ) if getattr(self, "notifications", None): - await self.notifications.fire("level_up", { - "child_name": child.name, "child_id": child.id, "level": lvl, - }) + await self.notifications.fire( + "level_up", + { + "child_name": child.name, + "child_id": child.id, + "level": lvl, + }, + ) await self._celebrate( - child, "level_up", f"{child.name} reached level {lvl}!", - tier=3 if lvl % 5 == 0 else 2, extra={"level": lvl}, + child, + "level_up", + f"{child.name} reached level {lvl}!", + tier=3 if lvl % 5 == 0 else 2, + extra={"level": lvl}, ) async def async_gift_points(self, from_child_id: str, to_child_id: str, points: int) -> None: @@ -481,9 +481,7 @@ async def async_gift_points(self, from_child_id: str, to_child_id: str, points: if not sender or not recipient: raise ValueError("Sender or recipient not found") if (sender.points or 0) < points: - raise ValueError( - f"Not enough points: {sender.name} has {sender.points}, gift {points}" - ) + raise ValueError(f"Not enough points: {sender.name} has {sender.points}, gift {points}") now = dt_util.now() sender.points -= points recipient.points += points @@ -491,19 +489,35 @@ async def async_gift_points(self, from_child_id: str, to_child_id: str, points: self.storage.update_child(recipient) # Shared link_id so undo can reverse both legs together. gift_link = generate_id() - self.storage.add_points_transaction(PointsTransaction( - child_id=sender.id, points=-points, - reason=f"Gift to {recipient.name}", created_at=now, link_id=gift_link, - )) - self.storage.add_points_transaction(PointsTransaction( - child_id=recipient.id, points=points, - reason=f"Gift from {sender.name}", created_at=now, link_id=gift_link, - )) - self.hass.bus.async_fire("taskmate_points_gifted", { - "from_child_id": sender.id, "from_child_name": sender.name, - "to_child_id": recipient.id, "to_child_name": recipient.name, - "points": points, "timestamp": now.isoformat(), - }) + self.storage.add_points_transaction( + PointsTransaction( + child_id=sender.id, + points=-points, + reason=f"Gift to {recipient.name}", + created_at=now, + link_id=gift_link, + ) + ) + self.storage.add_points_transaction( + PointsTransaction( + child_id=recipient.id, + points=points, + reason=f"Gift from {sender.name}", + created_at=now, + link_id=gift_link, + ) + ) + self.hass.bus.async_fire( + "taskmate_points_gifted", + { + "from_child_id": sender.id, + "from_child_name": sender.name, + "to_child_id": recipient.id, + "to_child_name": recipient.name, + "points": points, + "timestamp": now.isoformat(), + }, + ) await self.storage.async_save() await self.async_refresh() @@ -526,10 +540,7 @@ async def _async_decay_points(self) -> None: return period = self.storage.get_setting("points_decay_period", "monthly") today = dt_util.now().date() - due = ( - (period == "weekly" and today.weekday() == 0) - or (period == "monthly" and today.day == 1) - ) + due = (period == "weekly" and today.weekday() == 0) or (period == "monthly" and today.day == 1) if not due: return if self.storage.get_setting("points_decay_last", "") == today.isoformat(): @@ -544,14 +555,23 @@ async def _async_decay_points(self) -> None: continue child.points = max(0, child.points - loss) self.storage.update_child(child) - self.storage.add_points_transaction(PointsTransaction( - child_id=child.id, points=-loss, - reason=f"Points decay (-{pct:.0f}%)", created_at=now, - )) - self.hass.bus.async_fire("taskmate_points_decay", { - "child_id": child.id, "child_name": child.name, - "points": loss, "timestamp": now.isoformat(), - }) + self.storage.add_points_transaction( + PointsTransaction( + child_id=child.id, + points=-loss, + reason=f"Points decay (-{pct:.0f}%)", + created_at=now, + ) + ) + self.hass.bus.async_fire( + "taskmate_points_decay", + { + "child_id": child.id, + "child_name": child.name, + "points": loss, + "timestamp": now.isoformat(), + }, + ) changed = True self.storage.set_setting("points_decay_last", today.isoformat()) if changed: @@ -576,10 +596,7 @@ async def _async_apply_interest(self) -> None: return period = self.storage.get_setting("interest_period", "weekly") today = dt_util.now().date() - due = ( - (period == "weekly" and today.weekday() == 0) - or (period == "monthly" and today.day == 1) - ) + due = (period == "weekly" and today.weekday() == 0) or (period == "monthly" and today.day == 1) if not due: return if self.storage.get_setting("interest_last", "") == today.isoformat(): @@ -592,10 +609,15 @@ async def _async_apply_interest(self) -> None: if interest <= 0: continue await self.async_add_points(child.id, interest, reason=f"Savings interest (+{pct:.0f}%)") - self.hass.bus.async_fire("taskmate_interest_paid", { - "child_id": child.id, "child_name": child.name, - "points": interest, "timestamp": dt_util.now().isoformat(), - }) + self.hass.bus.async_fire( + "taskmate_interest_paid", + { + "child_id": child.id, + "child_name": child.name, + "points": interest, + "timestamp": dt_util.now().isoformat(), + }, + ) self.storage.set_setting("interest_last", today.isoformat()) await self.storage.async_save() @@ -613,17 +635,13 @@ def parse_milestone_setting(value: str) -> dict[int, int]: if not part: continue if ":" not in part: - raise ValueError( - f"Invalid format '{part}' — use 'days:points' pairs, e.g. '7:10, 14:20'" - ) + raise ValueError(f"Invalid format '{part}' — use 'days:points' pairs, e.g. '7:10, 14:20'") days_str, points_str = part.split(":", 1) try: days = int(days_str.strip()) points = int(points_str.strip()) except ValueError as err: - raise ValueError( - f"Invalid numbers in '{part}' — days and points must be whole numbers" - ) from err + raise ValueError(f"Invalid numbers in '{part}' — days and points must be whole numbers") from err if days < 1: raise ValueError(f"Days must be at least 1, got {days}") if points < 1: @@ -718,7 +736,7 @@ async def _award_points( today = now.date() effective_date = completion_date or today effective_date_str = effective_date.isoformat() - last_date_str = getattr(child, 'last_completion_date', None) + last_date_str = getattr(child, "last_completion_date", None) # ── Weekend multiplier ────────────────────────────────────────────── # Applied to base chore points only, based on completion date @@ -740,7 +758,10 @@ async def _award_points( if weekend_bonus > 0: _LOGGER.info( "Weekend multiplier (%.1fx) applied for %s: +%d bonus on top of %d", - multiplier, child.name, weekend_bonus, points, + multiplier, + child.name, + weekend_bonus, + points, ) # Log weekend bonus as a separate transaction for activity history transaction = PointsTransaction( @@ -758,9 +779,7 @@ async def _award_points( # day is done. The in-flight completion (chore_id) is counted as done # since it may not be persisted yet at this point in the flow. if advance_streak and self._setting_enabled("streak_requires_all_chores"): - if not self._all_due_chores_done( - child.id, effective_date, include_rotation=True, extra_done=chore_id - ): + if not self._all_due_chores_done(child.id, effective_date, include_rotation=True, extra_done=chore_id): advance_streak = False if advance_streak: streak_mode = self.storage.get_setting("streak_reset_mode", "reset") @@ -815,9 +834,7 @@ async def _award_points( milestones_enabled = self.storage.get_setting("streak_milestones_enabled", "true") == "true" if advance_streak and milestones_enabled and child.current_streak > 0: # Parse custom milestone config - milestone_setting = self.storage.get_setting( - "streak_milestones", self.DEFAULT_STREAK_MILESTONES - ) + milestone_setting = self.storage.get_setting("streak_milestones", self.DEFAULT_STREAK_MILESTONES) try: milestones = self.parse_milestone_setting(milestone_setting) except ValueError: @@ -836,7 +853,9 @@ async def _award_points( reached_milestones.append((days, bonus_pts)) _LOGGER.info( "Streak milestone %d days reached for %s: +%d bonus", - days, child.name, bonus_pts, + days, + child.name, + bonus_pts, ) child.streak_milestones_achieved = sorted(achieved) @@ -853,9 +872,7 @@ async def _award_points( ) self.storage.add_points_transaction(transaction) - self.storage.append_career_score_snapshot( - child.id, effective_date.isoformat(), child.career_score - ) + self.storage.append_career_score_snapshot(child.id, effective_date.isoformat(), child.career_score) await self._maybe_level_up(child) self.storage.update_child(child) @@ -877,9 +894,11 @@ async def _award_points( # A big streak is a celebration moment too — epic at 30+ days. for days, _bonus_pts in reached_milestones: await self._celebrate( - child, "streak_milestone", + child, + "streak_milestone", f"{child.name} hit a {days}-day streak!", - tier=3 if days >= 30 else 2, extra={"days": days}, + tier=3 if days >= 30 else 2, + extra={"days": days}, ) return total_points @@ -890,10 +909,7 @@ async def async_prune_history(self, days: int = 90) -> None: before = len(all_completions) # Keep completions newer than cutoff OR unapproved (pending) - to_keep = [ - c for c in all_completions - if c.completed_at >= cutoff or not c.approved - ] + to_keep = [c for c in all_completions if c.completed_at >= cutoff or not c.approved] if len(to_keep) < before: kept_ids = {c.id for c in to_keep} @@ -906,10 +922,7 @@ async def async_prune_history(self, days: int = 90) -> None: if c.id not in kept_ids and getattr(c, "photo_url", ""): await photos.async_delete_photo(self.hass, c.photo_url) await self.async_refresh() - _LOGGER.info( - "Pruned %d completions older than %d days", - before - len(to_keep), days - ) + _LOGGER.info("Pruned %d completions older than %d days", before - len(to_keep), days) # Penalty operations async def async_add_penalty( @@ -954,12 +967,17 @@ async def async_apply_penalty(self, penalty_id: str, child_id: str) -> None: if not child: raise ValueError(f"Child {child_id} not found") await self.async_remove_points(child_id, penalty.points, reason=f"Penalty: {penalty.name}") - self.hass.bus.async_fire("taskmate_penalty_applied", { - "child_id": child.id, "child_name": child.name, - "penalty_id": penalty.id, "penalty_name": penalty.name, - "points": penalty.points, - "timestamp": dt_util.now().isoformat(), - }) + self.hass.bus.async_fire( + "taskmate_penalty_applied", + { + "child_id": child.id, + "child_name": child.name, + "penalty_id": penalty.id, + "penalty_name": penalty.name, + "points": penalty.points, + "timestamp": dt_util.now().isoformat(), + }, + ) # Bonus operations async def async_add_bonus( @@ -1004,16 +1022,25 @@ async def async_apply_bonus(self, bonus_id: str, child_id: str) -> None: if not child: raise ValueError(f"Child {child_id} not found") await self.async_add_points(child_id, bonus.points, reason=f"Bonus: {bonus.name}") - self.hass.bus.async_fire("taskmate_bonus_applied", { - "child_id": child.id, "child_name": child.name, - "bonus_id": bonus.id, "bonus_name": bonus.name, - "points": bonus.points, - "timestamp": dt_util.now().isoformat(), - }) + self.hass.bus.async_fire( + "taskmate_bonus_applied", + { + "child_id": child.id, + "child_name": child.name, + "bonus_id": bonus.id, + "bonus_name": bonus.name, + "points": bonus.points, + "timestamp": dt_util.now().isoformat(), + }, + ) async def _async_notify_pending_approval( - self, child_name: str, chore_name: str, points: int, - completion_id: str | None = None, photo_url: str = "", + self, + child_name: str, + chore_name: str, + points: int, + completion_id: str | None = None, + photo_url: str = "", ) -> None: await self.notifications.fire( "pending_chore_approval", @@ -1030,7 +1057,10 @@ async def _async_notify_pending_approval( ) async def _async_notify_pending_reward_claim( - self, child_name: str, reward_name: str, cost: int, + self, + child_name: str, + reward_name: str, + cost: int, claim_id: str | None = None, ) -> None: await self.notifications.fire( diff --git a/custom_components/taskmate/coord_quests.py b/custom_components/taskmate/coord_quests.py index de20dad..ca57740 100644 --- a/custom_components/taskmate/coord_quests.py +++ b/custom_components/taskmate/coord_quests.py @@ -6,6 +6,7 @@ points, fires a ``taskmate_quest_completed`` event + celebration, and either resets progress (repeatable quests) or marks the quest complete for that child. """ + from __future__ import annotations import logging @@ -72,17 +73,19 @@ def quest_progress_for_child(self, child_id: str) -> list[dict]: step = int(prog.get("step", 0)) total = len(quest.steps) done = step >= total - out.append({ - "quest_id": quest.id, - "name": quest.name, - "icon": quest.icon, - "total_steps": total, - "step": min(step, total), - "done": done, - "times_completed": int(prog.get("completed_count", 0)), - "bonus_points": quest.bonus_points, - "next_chore_id": quest.steps[step] if not done and step < total else "", - }) + out.append( + { + "quest_id": quest.id, + "name": quest.name, + "icon": quest.icon, + "total_steps": total, + "step": min(step, total), + "done": done, + "times_completed": int(prog.get("completed_count", 0)), + "bonus_points": quest.bonus_points, + "next_chore_id": quest.steps[step] if not done and step < total else "", + } + ) return out # ── Progression ────────────────────────────────────────────────────── @@ -129,24 +132,36 @@ async def _complete_quest(self, quest: Quest, child, prog: dict) -> None: child.points += bonus child.total_points_earned += bonus child.career_score = child.total_points_earned - child.total_penalties_received - self.storage.add_points_transaction(PointsTransaction( - child_id=child.id, points=bonus, - reason=f"Quest complete: {quest.name}", created_at=dt_util.now(), - )) + self.storage.add_points_transaction( + PointsTransaction( + child_id=child.id, + points=bonus, + reason=f"Quest complete: {quest.name}", + created_at=dt_util.now(), + ) + ) if hasattr(self, "_maybe_level_up"): await self._maybe_level_up(child) self.storage.update_child(child) - self.hass.bus.async_fire("taskmate_quest_completed", { - "child_id": child.id, "child_name": child.name, - "quest_id": quest.id, "quest_name": quest.name, - "bonus": bonus, "timestamp": dt_util.now().isoformat(), - }) + self.hass.bus.async_fire( + "taskmate_quest_completed", + { + "child_id": child.id, + "child_name": child.name, + "quest_id": quest.id, + "quest_name": quest.name, + "bonus": bonus, + "timestamp": dt_util.now().isoformat(), + }, + ) if hasattr(self, "_celebrate"): await self._celebrate( - child, "quest_completed", + child, + "quest_completed", f"{child.name} completed the quest '{quest.name}'!", - tier=3, extra={"quest_id": quest.id, "bonus": bonus}, + tier=3, + extra={"quest_id": quest.id, "bonus": bonus}, ) # Repeatable quests start over; one-shot quests stay complete. diff --git a/custom_components/taskmate/coord_reports.py b/custom_components/taskmate/coord_reports.py index 5ee622f..8ae08c8 100644 --- a/custom_components/taskmate/coord_reports.py +++ b/custom_components/taskmate/coord_reports.py @@ -7,6 +7,7 @@ Fairness is the first; the shared window/aggregation helpers here are meant to carry the friction and projection reports too. """ + from __future__ import annotations import logging @@ -89,8 +90,7 @@ def fairness_report(self, days: int | None = None) -> dict[str, Any]: completions = self._completions_in_window(start, end) by_child: dict[str, dict[str, Any]] = { - c.id: {"id": c.id, "name": c.name, "completions": 0, "points": 0, "active_days": set()} - for c in children + c.id: {"id": c.id, "name": c.name, "completions": 0, "points": 0, "active_days": set()} for c in children } for comp in completions: entry = by_child.get(comp.child_id) @@ -106,9 +106,7 @@ def fairness_report(self, days: int | None = None) -> dict[str, Any]: rows = [] for entry in by_child.values(): - share_completions = ( - entry["completions"] / total_completions * 100 if total_completions else 0.0 - ) + share_completions = entry["completions"] / total_completions * 100 if total_completions else 0.0 share_points = entry["points"] / total_points * 100 if total_points else 0.0 # Judge on chore count: it's the closest proxy for "how much did # they actually have to do", independent of how a chore is priced. @@ -121,17 +119,19 @@ def fairness_report(self, days: int | None = None) -> dict[str, Any]: status = "under" else: status = "balanced" - rows.append({ - "id": entry["id"], - "name": entry["name"], - "completions": entry["completions"], - "points": entry["points"], - "share_completions": round(share_completions, 1), - "share_points": round(share_points, 1), - "delta": round(delta, 1), - "active_days": len(entry["active_days"]), - "status": status, - }) + rows.append( + { + "id": entry["id"], + "name": entry["name"], + "completions": entry["completions"], + "points": entry["points"], + "share_completions": round(share_completions, 1), + "share_points": round(share_points, 1), + "delta": round(delta, 1), + "active_days": len(entry["active_days"]), + "status": status, + } + ) rows.sort(key=lambda r: (-r["completions"], r["name"])) return { @@ -164,8 +164,12 @@ def _expected_occurrences(self, chore, start: date, end: date) -> int: if mode == "recurring": period_days = { - "every_2_days": 2, "weekly": 7, "every_2_weeks": 14, - "monthly": 30, "every_3_months": 91, "every_6_months": 182, + "every_2_days": 2, + "weekly": 7, + "every_2_weeks": 14, + "monthly": 30, + "every_3_months": 91, + "every_6_months": 182, }.get(getattr(chore, "recurrence", "weekly"), 7) return max(0, span // period_days) @@ -173,16 +177,18 @@ def _expected_occurrences(self, chore, start: date, end: date) -> int: if not due_days: return span # no restriction = every day wanted = { - "monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3, - "friday": 4, "saturday": 5, "sunday": 6, + "monday": 0, + "tuesday": 1, + "wednesday": 2, + "thursday": 3, + "friday": 4, + "saturday": 5, + "sunday": 6, } targets = {wanted[d] for d in due_days if d in wanted} if not targets: return span - return sum( - 1 for i in range(span) - if (start + timedelta(days=i)).weekday() in targets - ) + return sum(1 for i in range(span) if (start + timedelta(days=i)).weekday() in targets) def friction_report(self, days: int | None = None) -> dict[str, Any]: """Which chores are not working, and what to do about them. @@ -232,26 +238,26 @@ def friction_report(self, days: int | None = None) -> dict[str, Any]: else: verdict = "stalling" - rows.append({ - "id": chore.id, - "name": chore.name, - "points": int(getattr(chore, "points", 0) or 0), - "completed": done, - "expected": expected, - "rate": round(rate * 100, 1) if rate is not None else None, - "days_since": days_since, - "last_done": last_done.isoformat() if last_done else None, - "outstanding_misses": misses_by_chore.get(chore.id, 0), - "needed_chasing": chased_by_chore.get(chore.id, 0), - "verdict": verdict, - "suggestion": self._friction_suggestion(verdict, days_since, chore), - }) + rows.append( + { + "id": chore.id, + "name": chore.name, + "points": int(getattr(chore, "points", 0) or 0), + "completed": done, + "expected": expected, + "rate": round(rate * 100, 1) if rate is not None else None, + "days_since": days_since, + "last_done": last_done.isoformat() if last_done else None, + "outstanding_misses": misses_by_chore.get(chore.id, 0), + "needed_chasing": chased_by_chore.get(chore.id, 0), + "verdict": verdict, + "suggestion": self._friction_suggestion(verdict, days_since, chore), + } + ) # Worst first: never done, then lowest completion rate. order = {"never": 0, "stalling": 1, "struggling": 2, "unknown": 3, "fine": 4} - rows.sort(key=lambda r: (order.get(r["verdict"], 9), - r["rate"] if r["rate"] is not None else 0, - r["name"])) + rows.sort(key=lambda r: (order.get(r["verdict"], 9), r["rate"] if r["rate"] is not None else 0, r["name"])) return { "days": span, "start": start.isoformat(), @@ -325,8 +331,12 @@ def _chore_falls_on(self, chore, day: date) -> bool: if mode == "recurring": period_days = { - "every_2_days": 2, "weekly": 7, "every_2_weeks": 14, - "monthly": 30, "every_3_months": 91, "every_6_months": 182, + "every_2_days": 2, + "weekly": 7, + "every_2_weeks": 14, + "monthly": 30, + "every_3_months": 91, + "every_6_months": 182, }.get(getattr(chore, "recurrence", "weekly"), 7) anchor_raw = getattr(chore, "recurrence_start", "") or "" try: @@ -403,15 +413,16 @@ def projection_report(self, days: int | None = None) -> dict[str, Any]: totals[child_id]["chores"] += figures["chores"] unassigned_points += day_unassigned - day_rows.append({ - "date": day.isoformat(), - "weekday": day.strftime("%A").lower(), - "children": [ - {"id": cid, "points": f["points"], "chores": f["chores"]} - for cid, f in per_day.items() - ], - "unassigned_points": day_unassigned, - }) + day_rows.append( + { + "date": day.isoformat(), + "weekday": day.strftime("%A").lower(), + "children": [ + {"id": cid, "points": f["points"], "chores": f["chores"]} for cid, f in per_day.items() + ], + "unassigned_points": day_unassigned, + } + ) for child in children: entry = totals[child.id] @@ -454,48 +465,62 @@ def health_report(self) -> dict[str, Any]: issues: list[dict[str, Any]] = [] def add(severity: str, code: str, message: str, where: str, count: int = 1) -> None: - issues.append({ - "severity": severity, "code": code, - "message": message, "where": where, "count": count, - }) + issues.append( + { + "severity": severity, + "code": code, + "message": message, + "where": where, + "count": count, + } + ) # ── orphaned references ────────────────────────────────────────── orphan_assignees = [ - c.name for c in chores - if any(cid not in child_ids for cid in (getattr(c, "assigned_to", []) or [])) + c.name for c in chores if any(cid not in child_ids for cid in (getattr(c, "assigned_to", []) or [])) ] if orphan_assignees: - add("warning", "chore_orphan_assignee", + add( + "warning", + "chore_orphan_assignee", f"{len(orphan_assignees)} chore(s) are assigned to a child that no longer exists", - "chores", len(orphan_assignees)) + "chores", + len(orphan_assignees), + ) orphan_rewards = [ - r.name for r in rewards - if any(cid not in child_ids for cid in (getattr(r, "assigned_to", []) or [])) + r.name for r in rewards if any(cid not in child_ids for cid in (getattr(r, "assigned_to", []) or [])) ] if orphan_rewards: - add("warning", "reward_orphan_assignee", + add( + "warning", + "reward_orphan_assignee", f"{len(orphan_rewards)} reward(s) are assigned to a child that no longer exists", - "rewards", len(orphan_rewards)) + "rewards", + len(orphan_rewards), + ) orphan_deps = [ - c.name for c in chores - if any(dep not in chore_ids for dep in (getattr(c, "depends_on", []) or [])) + c.name for c in chores if any(dep not in chore_ids for dep in (getattr(c, "depends_on", []) or [])) ] if orphan_deps: - add("error", "chore_orphan_dependency", - f"{len(orphan_deps)} chore(s) depend on a chore that no longer exists, " - "so they can never unlock", - "chores", len(orphan_deps)) - - orphan_completions = sum( - 1 for c in completions - if c.chore_id not in chore_ids or c.child_id not in child_ids - ) + add( + "error", + "chore_orphan_dependency", + f"{len(orphan_deps)} chore(s) depend on a chore that no longer exists, so they can never unlock", + "chores", + len(orphan_deps), + ) + + orphan_completions = sum(1 for c in completions if c.chore_id not in chore_ids or c.child_id not in child_ids) if orphan_completions: - add("info", "completion_orphan", + add( + "info", + "completion_orphan", f"{orphan_completions} completion record(s) refer to a deleted chore or child", - "activity", orphan_completions) + "activity", + orphan_completions, + ) # ── configuration that can't work ──────────────────────────────── missing_entities = [] @@ -505,25 +530,38 @@ def add(severity: str, code: str, message: str, where: str, count: int = 1) -> N if entity_id and self.hass.states.get(entity_id) is None: missing_entities.append(f"{chore.name} → {entity_id}") if missing_entities: - add("warning", "chore_missing_entity", + add( + "warning", + "chore_missing_entity", f"{len(missing_entities)} chore(s) reference an entity that doesn't exist", - "chores", len(missing_entities)) + "chores", + len(missing_entities), + ) unlock_offlist = [ - r.name for r in rewards + r.name + for r in rewards if (getattr(r, "unlock_entity", "") or "") and not self.is_unlock_allowed(r.unlock_entity) ] if unlock_offlist: - add("warning", "reward_unlock_not_allowed", + add( + "warning", + "reward_unlock_not_allowed", f"{len(unlock_offlist)} reward(s) unlock an entity that is no longer on the " "allowlist, so nothing will happen when they're approved", - "settings", len(unlock_offlist)) + "settings", + len(unlock_offlist), + ) no_chores = [c.name for c in children if not self._child_has_any_chore(c.id, chores)] if no_chores: - add("info", "child_without_chores", + add( + "info", + "child_without_chores", f"{len(no_chores)} child/children have no chores assigned to them", - "children", len(no_chores)) + "children", + len(no_chores), + ) # ── size ───────────────────────────────────────────────────────── try: @@ -534,10 +572,14 @@ def add(severity: str, code: str, message: str, where: str, count: int = 1) -> N # The recorder refuses to store an attribute payload over 16KB, so a # large completion history is worth flagging before it bites. if len(completions) > 5000: - add("info", "large_history", + add( + "info", + "large_history", f"{len(completions)} completion records stored; history pruning keeps " "this in check but a large history slows every report", - "activity", len(completions)) + "activity", + len(completions), + ) severity_rank = {"error": 0, "warning": 1, "info": 2} issues.sort(key=lambda i: (severity_rank.get(i["severity"], 9), i["code"])) diff --git a/custom_components/taskmate/coord_rewards.py b/custom_components/taskmate/coord_rewards.py index 4443e14..fb6fad0 100644 --- a/custom_components/taskmate/coord_rewards.py +++ b/custom_components/taskmate/coord_rewards.py @@ -1,4 +1,5 @@ """Reward operations mixin for TaskMateCoordinator.""" + from __future__ import annotations import logging @@ -82,15 +83,11 @@ async def async_update_reward(self, reward: Reward) -> None: if old and reward.cost < old.cost: self._refund_pool_excess(reward, "Pool refund (reward cost reduced)") became_unavailable = ( - self._reward_is_unavailable(reward) - and old is not None - and not self._reward_is_unavailable(old) + self._reward_is_unavailable(reward) and old is not None and not self._reward_is_unavailable(old) ) if became_unavailable: reason = ( - "Pool refund (reward expired)" - if self._reward_is_expired(reward) - else "Pool refund (reward sold out)" + "Pool refund (reward expired)" if self._reward_is_expired(reward) else "Pool refund (reward sold out)" ) self._refund_all_pool_allocations(reward, reason) await self.storage.async_save() @@ -140,8 +137,7 @@ def _refund_all_pool_allocations(self, reward: Reward, reason: str) -> None: stays consistent with cost-reduction refunds. """ allocations = [ - a for a in self.storage.get_pool_allocations() - if a.reward_id == reward.id and a.allocated_points > 0 + a for a in self.storage.get_pool_allocations() if a.reward_id == reward.id and a.allocated_points > 0 ] for alloc in allocations: self._apply_pool_refund(alloc, alloc.allocated_points, reward, reason) @@ -154,8 +150,7 @@ def _refund_pool_excess(self, reward: Reward, reason: str) -> None: until the combined total matches the cost. """ allocations = [ - a for a in self.storage.get_pool_allocations() - if a.reward_id == reward.id and a.allocated_points > 0 + a for a in self.storage.get_pool_allocations() if a.reward_id == reward.id and a.allocated_points > 0 ] if not allocations: return @@ -173,13 +168,9 @@ def _refund_pool_excess(self, reward: Reward, reason: str) -> None: else: for alloc in allocations: if alloc.allocated_points > reward.cost: - self._apply_pool_refund( - alloc, alloc.allocated_points - reward.cost, reward, reason - ) + self._apply_pool_refund(alloc, alloc.allocated_points - reward.cost, reward, reason) - def _apply_pool_refund( - self, allocation: PoolAllocation, refund: int, reward: Reward, reason: str - ) -> None: + def _apply_pool_refund(self, allocation: PoolAllocation, refund: int, reward: Reward, reason: str) -> None: """Refund `refund` points from `allocation` back to the child's wallet. Updates or removes the allocation record and writes an audit transaction. @@ -196,19 +187,23 @@ def _apply_pool_refund( if remaining <= 0: self.storage.remove_pool_allocation(allocation.child_id, allocation.reward_id) else: - self.storage.upsert_pool_allocation(PoolAllocation( + self.storage.upsert_pool_allocation( + PoolAllocation( + child_id=allocation.child_id, + reward_id=allocation.reward_id, + allocated_points=remaining, + id=allocation.id, + ) + ) + + self.storage.add_points_transaction( + PointsTransaction( child_id=allocation.child_id, - reward_id=allocation.reward_id, - allocated_points=remaining, - id=allocation.id, - )) - - self.storage.add_points_transaction(PointsTransaction( - child_id=allocation.child_id, - points=refund, - reason=f"{reason}: {reward.name}", - created_at=dt_util.now(), - )) + points=refund, + reason=f"{reason}: {reward.name}", + created_at=dt_util.now(), + ) + ) async def async_claim_reward(self, reward_id: str, child_id: str) -> RewardClaim: """Child claims a reward — creates a pending claim awaiting parent approval. @@ -261,9 +256,7 @@ async def async_claim_reward(self, reward_id: str, child_id: str) -> RewardClaim available_points = child.points - committed if available_points < effective_cost: - raise ValueError( - f"Not enough points. Need {effective_cost}, have {available_points} available" - ) + raise ValueError(f"Not enough points. Need {effective_cost}, have {available_points} available") claim = RewardClaim( reward_id=reward_id, @@ -287,7 +280,10 @@ async def async_claim_reward(self, reward_id: str, child_id: str) -> RewardClaim await self.storage.async_save() await self.async_refresh() await self._async_notify_pending_reward_claim( - child.name, reward.name, reward.cost, claim_id=claim.id, + child.name, + reward.name, + reward.cost, + claim_id=claim.id, ) return claim @@ -297,6 +293,7 @@ def _spend_period_start(self) -> date: if period == "monthly": return today.replace(day=1) from datetime import timedelta + return today - timedelta(days=today.weekday()) # Monday of this week def _spent_in_period(self, child_id: str) -> int: @@ -324,9 +321,7 @@ def _enforce_spend_cap(self, child_id: str, cost: int) -> None: if cap <= 0: return if self._spent_in_period(child_id) + cost > cap: - raise ValueError( - f"Spending cap reached: {cap} per period already used" - ) + raise ValueError(f"Spending cap reached: {cap} per period already used") async def async_approve_reward(self, claim_id: str) -> None: """Approve a reward claim and deduct points from the child. @@ -371,7 +366,8 @@ async def async_approve_reward(self, claim_id: str) -> None: self._refund_pool_excess(reward, "Pool refund on redeem") if reward.is_jackpot: jackpot_allocs = [ - a for a in self.storage.get_pool_allocations() + a + for a in self.storage.get_pool_allocations() if a.reward_id == claim.reward_id and a.allocated_points > 0 ] for alloc in jackpot_allocs: @@ -381,9 +377,7 @@ async def async_approve_reward(self, claim_id: str) -> None: else: # Wallet mode: deduct directly from child.points if child.points < effective_cost: - raise ValueError( - f"Not enough points to approve. Need {effective_cost}, have {child.points}" - ) + raise ValueError(f"Not enough points to approve. Need {effective_cost}, have {child.points}") child.points -= effective_cost self.storage.update_child(child) @@ -393,9 +387,7 @@ async def async_approve_reward(self, claim_id: str) -> None: if reward.quantity == 0: # Last unit claimed — refund any points other children # still have earmarked for this reward's pool. - self._refund_all_pool_allocations( - reward, "Pool refund (reward sold out)" - ) + self._refund_all_pool_allocations(reward, "Pool refund (reward sold out)") claim.approved = True claim.approved_at = dt_util.now() @@ -405,20 +397,23 @@ async def async_approve_reward(self, claim_id: str) -> None: # Dismiss the mobile approval push now this claim is reviewed. if getattr(self, "notifications", None): - await self.notifications.clear_approval( - "pending_reward_claim", claim_id - ) + await self.notifications.clear_approval("pending_reward_claim", claim_id) # Timed unlock (#678): allowlisted entity on, auto-off later. await self.async_start_unlock(reward, child) - self.hass.bus.async_fire("taskmate_reward_approved", { - "child_id": child.id, "child_name": child.name, - "reward_id": reward.id, "reward_name": reward.name, - "claim_id": claim.id, - "cost": effective_cost, - "timestamp": dt_util.now().isoformat(), - }) + self.hass.bus.async_fire( + "taskmate_reward_approved", + { + "child_id": child.id, + "child_name": child.name, + "reward_id": reward.id, + "reward_name": reward.name, + "claim_id": claim.id, + "cost": effective_cost, + "timestamp": dt_util.now().isoformat(), + }, + ) if getattr(self, "badges", None): await self.badges.evaluate_for_child(claim.child_id, "reward_redeemed") @@ -435,23 +430,22 @@ async def async_reject_reward(self, claim_id: str) -> None: if claim: reward = self.get_reward(claim.reward_id) child = self.get_child(claim.child_id) - self.hass.bus.async_fire("taskmate_reward_rejected", { - "child_id": claim.child_id, - "child_name": getattr(child, "name", ""), - "reward_id": claim.reward_id, - "reward_name": getattr(reward, "name", ""), - "claim_id": claim.id, - "timestamp": dt_util.now().isoformat(), - }) + self.hass.bus.async_fire( + "taskmate_reward_rejected", + { + "child_id": claim.child_id, + "child_name": getattr(child, "name", ""), + "reward_id": claim.reward_id, + "reward_name": getattr(reward, "name", ""), + "claim_id": claim.id, + "timestamp": dt_util.now().isoformat(), + }, + ) # Dismiss the mobile approval push for this reviewed claim. if getattr(self, "notifications", None): - await self.notifications.clear_approval( - "pending_reward_claim", claim_id - ) + await self.notifications.clear_approval("pending_reward_claim", claim_id) - async def async_allocate_points_to_pool( - self, child_id: str, reward_id: str, points: int - ) -> PoolAllocation: + async def async_allocate_points_to_pool(self, child_id: str, reward_id: str, points: int) -> PoolAllocation: """Move `points` from a child's spendable balance into a reward pool. Deducts immediately from child.points so the visible balance reflects the @@ -539,6 +533,7 @@ async def _async_restock_rewards(self) -> None: ``restock_last`` stamp guards against restocking twice in a day. """ from homeassistant.util import dt as dt_util + today = dt_util.now().date() today_iso = today.isoformat() changed = False @@ -577,8 +572,7 @@ async def _async_expire_rewards(self) -> None: if not self._reward_is_expired(reward): continue allocations_before = [ - a for a in self.storage.get_pool_allocations() - if a.reward_id == reward.id and a.allocated_points > 0 + a for a in self.storage.get_pool_allocations() if a.reward_id == reward.id and a.allocated_points > 0 ] if not allocations_before: continue @@ -586,7 +580,9 @@ async def _async_expire_rewards(self) -> None: changed = True _LOGGER.info( "Reward '%s' expired on %s — refunded %d pool allocation(s)", - reward.name, reward.expires_at, len(allocations_before), + reward.name, + reward.expires_at, + len(allocations_before), ) if changed: diff --git a/custom_components/taskmate/coord_roulette.py b/custom_components/taskmate/coord_roulette.py index 2420572..26c4dc4 100644 --- a/custom_components/taskmate/coord_roulette.py +++ b/custom_components/taskmate/coord_roulette.py @@ -8,6 +8,7 @@ applied at completion time, next to the difficulty multiplier and the reactive-chore speed bonus. """ + from __future__ import annotations import logging @@ -73,15 +74,16 @@ def _roulette_candidates(self, child_id: str) -> list: """ today = dt_util.as_local(dt_util.now()).date() done_today = { - c.chore_id for c in self.storage.get_completions() + c.chore_id + for c in self.storage.get_completions() if c.child_id == child_id and not getattr(c, "bonus_subtask_id", "") and dt_util.as_local(c.completed_at).date() == today } return [ - chore for chore in self.storage.get_chores() - if chore.id not in done_today - and self.is_chore_available_for_child(chore, child_id) + chore + for chore in self.storage.get_chores() + if chore.id not in done_today and self.is_chore_available_for_child(chore, child_id) ] async def async_spin_roulette(self, child_id: str) -> dict[str, Any]: @@ -154,8 +156,7 @@ async def async_prune_roulette_state(self, refresh: bool = True) -> int: """Drop selections from previous days. Returns how many were cleared.""" today = dt_util.as_local(dt_util.now()).date().isoformat() state = self._roulette_state() - keep = {cid: entry for cid, entry in state.items() - if isinstance(entry, dict) and entry.get("date") == today} + keep = {cid: entry for cid, entry in state.items() if isinstance(entry, dict) and entry.get("date") == today} removed = len(state) - len(keep) if removed: self.storage.set_setting("roulette_state", keep) diff --git a/custom_components/taskmate/coord_scheduled.py b/custom_components/taskmate/coord_scheduled.py index 7798ac8..31aa3b5 100644 --- a/custom_components/taskmate/coord_scheduled.py +++ b/custom_components/taskmate/coord_scheduled.py @@ -8,6 +8,7 @@ and when — a config change that happens silently is worse than one that doesn't happen at all. """ + from __future__ import annotations import logging @@ -69,7 +70,11 @@ def get_scheduled_changes(self, chore_id: str = "") -> list[ScheduledChange]: return sorted(changes, key=lambda c: (c.apply_on, c.created_at)) async def async_add_scheduled_change( - self, chore_id: str, apply_on: str, changes: dict[str, Any], note: str = "", + self, + chore_id: str, + apply_on: str, + changes: dict[str, Any], + note: str = "", ) -> ScheduledChange: """Queue a change. Validates the chore, the date and every field now.""" if not self.storage.get_chore(chore_id): @@ -87,7 +92,10 @@ async def async_add_scheduled_change( coerced = {f: coerce_scheduled_value(f, v) for f, v in changes.items()} change = ScheduledChange( - chore_id=chore_id, apply_on=apply_on, changes=coerced, note=note, + chore_id=chore_id, + apply_on=apply_on, + changes=coerced, + note=note, ) self.storage.add_scheduled_change(change) await self.storage.async_save() @@ -119,7 +127,8 @@ async def async_apply_due_scheduled_changes(self, refresh: bool = True) -> int: except (TypeError, ValueError): _LOGGER.warning( "Scheduled change %s has an unparseable date %r — skipping", - change.id, change.apply_on, + change.id, + change.apply_on, ) continue @@ -138,7 +147,8 @@ async def async_apply_due_scheduled_changes(self, refresh: bool = True) -> int: if field_name not in SCHEDULED_CHANGE_FIELDS: _LOGGER.warning( "Scheduled change %s targets unknown field '%s' — skipping it", - change.id, field_name, + change.id, + field_name, ) continue setattr(chore, field_name, value) @@ -151,7 +161,8 @@ async def async_apply_due_scheduled_changes(self, refresh: bool = True) -> int: _LOGGER.info( "Applied scheduled change to '%s': %s", - chore.name, ", ".join(f"{k}={v}" for k, v in change.changes.items()), + chore.name, + ", ".join(f"{k}={v}" for k, v in change.changes.items()), ) self.hass.bus.async_fire( "taskmate_scheduled_change_applied", diff --git a/custom_components/taskmate/coord_templates.py b/custom_components/taskmate/coord_templates.py index 0255c7d..a5be535 100644 --- a/custom_components/taskmate/coord_templates.py +++ b/custom_components/taskmate/coord_templates.py @@ -1,4 +1,5 @@ """Template operations mixin for TaskMateCoordinator.""" + from __future__ import annotations import logging @@ -70,9 +71,7 @@ async def async_apply_template(self, chores: list[dict]) -> list[str]: await self.async_refresh() return created_ids - async def async_save_template_from_chores( - self, chore_ids: list[str], name: str, icon: str - ) -> str: + async def async_save_template_from_chores(self, chore_ids: list[str], name: str, icon: str) -> str: """Save existing chores as a custom template pack.""" if not chore_ids: raise ValueError("At least one chore must be selected") @@ -97,9 +96,7 @@ async def async_save_template_from_chores( await self.storage.async_save() return tpl_id - async def async_create_template( - self, name: str, icon: str, chores: list[dict] - ) -> str: + async def async_create_template(self, name: str, icon: str, chores: list[dict]) -> str: """Create a new custom template from scratch.""" if not chores: raise ValueError("Template must have at least one chore") @@ -156,16 +153,19 @@ def export_templates(self, template_ids: list[str] | None = None) -> dict: for tpl in self.storage.get_custom_templates(): if wanted and tpl.get("id") not in wanted: continue - packed.append({ - "name": tpl.get("name", ""), - "icon": tpl.get("icon", "mdi:clipboard-list-outline"), - "chores": [ - {k: v for k, v in chore.items() if k in TEMPLATE_CHORE_FIELDS} - for chore in tpl.get("chores", []) - ], - }) + packed.append( + { + "name": tpl.get("name", ""), + "icon": tpl.get("icon", "mdi:clipboard-list-outline"), + "chores": [ + {k: v for k, v in chore.items() if k in TEMPLATE_CHORE_FIELDS} + for chore in tpl.get("chores", []) + ], + } + ) from homeassistant.util import dt as dt_util + return { "format": self.PACK_FORMAT, "version": self.PACK_VERSION, @@ -191,8 +191,7 @@ def _validate_pack(self, pack: dict) -> list[dict]: raise ValueError("Pack version is not a number") from err if version > self.PACK_VERSION: raise ValueError( - f"This pack needs a newer TaskMate (pack version {version}, " - f"this one understands {self.PACK_VERSION})" + f"This pack needs a newer TaskMate (pack version {version}, this one understands {self.PACK_VERSION})" ) templates = pack.get("templates") @@ -227,11 +226,13 @@ def _validate_pack(self, pack: dict) -> list[dict]: cleaned["name"] = chore_name[:200] chores.append(cleaned) - clean.append({ - "name": name[:120], - "icon": str(entry.get("icon", "") or "mdi:clipboard-list-outline"), - "chores": chores, - }) + clean.append( + { + "name": name[:120], + "icon": str(entry.get("icon", "") or "mdi:clipboard-list-outline"), + "chores": chores, + } + ) return clean async def async_import_pack(self, pack: dict) -> dict: diff --git a/custom_components/taskmate/coord_timed.py b/custom_components/taskmate/coord_timed.py index 70aa3a5..bccc105 100644 --- a/custom_components/taskmate/coord_timed.py +++ b/custom_components/taskmate/coord_timed.py @@ -1,4 +1,5 @@ """Timed task operations mixin for TaskMateCoordinator.""" + from __future__ import annotations import logging @@ -41,9 +42,7 @@ async def async_start_timed_task(self, chore_id: str, child_id: str) -> None: # Check daily cap before resuming if chore.timed_max_daily_minutes > 0: if existing.total_seconds_today >= chore.timed_max_daily_minutes * 60: - raise ValueError( - f"Daily cap reached ({chore.timed_max_daily_minutes} min)" - ) + raise ValueError(f"Daily cap reached ({chore.timed_max_daily_minutes} min)") existing.state = "running" existing.segments.append({"start": now.isoformat(), "end": None}) self.storage.save_timed_session(existing) @@ -52,9 +51,7 @@ async def async_start_timed_task(self, chore_id: str, child_id: str) -> None: if chore.timed_max_daily_minutes > 0: old_session = self.storage.get_timed_session(chore_id, child_id, today) if old_session and old_session.total_seconds_today >= chore.timed_max_daily_minutes * 60: - raise ValueError( - f"Daily cap reached ({chore.timed_max_daily_minutes} min)" - ) + raise ValueError(f"Daily cap reached ({chore.timed_max_daily_minutes} min)") session = TimedSession( chore_id=chore_id, child_id=child_id, @@ -139,7 +136,10 @@ async def async_stop_timed_task(self, chore_id: str, child_id: str) -> None: if chore.requires_approval: await self._async_notify_pending_approval( - child.name, chore.name, pts, completion_id=completion.id, + child.name, + chore.name, + pts, + completion_id=completion.id, ) await self.async_refresh() diff --git a/custom_components/taskmate/coord_tts.py b/custom_components/taskmate/coord_tts.py index 7ec64e4..a10a9c9 100644 --- a/custom_components/taskmate/coord_tts.py +++ b/custom_components/taskmate/coord_tts.py @@ -8,6 +8,7 @@ well want wording that isn't any of the eight shipped languages — so the templates are settings, documented with their placeholders. """ + from __future__ import annotations import logging @@ -29,7 +30,7 @@ def _tts_setting(self, key: str, default: str) -> str: return str(value).strip() or default def _join_chore_names(self, names: list[str]) -> str: - """"a, b and c" — spoken lists need a conjunction, not commas.""" + """ "a, b and c" — spoken lists need a conjunction, not commas.""" joiner = self._tts_setting("read_aloud_joiner", DEFAULT_JOINER) if not names: return "" @@ -55,7 +56,9 @@ def build_read_aloud_message(self, child_id: str) -> str: try: return template.format( - name=child.name, count=len(names), chores=self._join_chore_names(names), + name=child.name, + count=len(names), + chores=self._join_chore_names(names), ) except (KeyError, IndexError, ValueError): # A parent-edited template with a bad placeholder must not silence @@ -65,12 +68,12 @@ def build_read_aloud_message(self, child_id: str) -> str: template, ) fallback = ( - DEFAULT_DONE_TEMPLATE if not names - else DEFAULT_ONE_TEMPLATE if len(names) == 1 - else DEFAULT_TEMPLATE + DEFAULT_DONE_TEMPLATE if not names else DEFAULT_ONE_TEMPLATE if len(names) == 1 else DEFAULT_TEMPLATE ) return fallback.format( - name=child.name, count=len(names), chores=self._join_chore_names(names), + name=child.name, + count=len(names), + chores=self._join_chore_names(names), ) def _resolve_tts_entity(self, explicit: str = "") -> str: @@ -82,27 +85,24 @@ def _resolve_tts_entity(self, explicit: str = "") -> str: return configured # Single-TTS households are the common case; picking the only one # beats making them configure it. - candidates = sorted( - state.entity_id for state in self.hass.states.async_all("tts") - ) + candidates = sorted(state.entity_id for state in self.hass.states.async_all("tts")) return candidates[0] if candidates else "" async def async_read_aloud( - self, child_id: str, media_player: str = "", tts_entity: str = "", + self, + child_id: str, + media_player: str = "", + tts_entity: str = "", message: str = "", ) -> str: """Speak a child's outstanding chores. Returns what was said.""" target = media_player or self._tts_setting("read_aloud_media_player", "") if not target: - raise ValueError( - "No media player given, and no default set in Settings" - ) + raise ValueError("No media player given, and no default set in Settings") speaker = self._resolve_tts_entity(tts_entity) if not speaker: - raise ValueError( - "No text-to-speech entity found. Set one in Settings or pass tts_entity." - ) + raise ValueError("No text-to-speech entity found. Set one in Settings or pass tts_entity.") text = message.strip() or self.build_read_aloud_message(child_id) @@ -110,7 +110,8 @@ async def async_read_aloud( # caller. This is a service a parent invokes deliberately; "it silently # did nothing" is the worst possible answer. await self.hass.services.async_call( - "tts", "speak", + "tts", + "speak", { "entity_id": speaker, "media_player_entity_id": target, diff --git a/custom_components/taskmate/coord_unlocks.py b/custom_components/taskmate/coord_unlocks.py index 9ed2d98..e2288c2 100644 --- a/custom_components/taskmate/coord_unlocks.py +++ b/custom_components/taskmate/coord_unlocks.py @@ -16,6 +16,7 @@ strand the television on: anything already past due is reverted at startup and anything still running is re-armed. """ + from __future__ import annotations import logging @@ -68,8 +69,7 @@ def validate_unlock(self, entity_id: str, minutes: Any) -> tuple[str, int]: return "", 0 if not self.is_unlock_allowed(entity_id): raise ValueError( - f"'{entity_id}' is not on the unlock allowlist. " - "Add it in Settings before using it as a reward." + f"'{entity_id}' is not on the unlock allowlist. Add it in Settings before using it as a reward." ) try: mins = int(minutes or 0) @@ -102,15 +102,18 @@ async def async_start_unlock(self, reward, child) -> dict[str, Any] | None: if not self.is_unlock_allowed(entity_id): _LOGGER.warning( - "Reward '%s' wanted to unlock '%s', which is no longer on the " - "allowlist — skipping", - reward.name, entity_id, + "Reward '%s' wanted to unlock '%s', which is no longer on the allowlist — skipping", + reward.name, + entity_id, ) return None minutes = int(getattr(reward, "unlock_minutes", 0) or 0) await self.hass.services.async_call( - "homeassistant", "turn_on", {"entity_id": entity_id}, blocking=False, + "homeassistant", + "turn_on", + {"entity_id": entity_id}, + blocking=False, ) record: dict[str, Any] = { @@ -134,7 +137,10 @@ async def async_start_unlock(self, reward, child) -> dict[str, Any] | None: _LOGGER.info( "Unlocked %s for %s (%s) for %s minutes", - entity_id, child.name, reward.name, minutes or "no auto-revert", + entity_id, + child.name, + reward.name, + minutes or "no auto-revert", ) self.hass.bus.async_fire("taskmate_unlock_started", dict(record)) return record @@ -143,23 +149,24 @@ def _schedule_revert(self, record: dict[str, Any], when: datetime) -> None: async def _revert(_now) -> None: await self.async_revert_unlock(record) - self._unlock_timers.append( - async_track_point_in_time(self.hass, _revert, when) - ) + self._unlock_timers.append(async_track_point_in_time(self.hass, _revert, when)) async def async_revert_unlock(self, record: dict[str, Any]) -> None: """Turn the entity back off and drop the record.""" entity_id = record.get("entity_id", "") if entity_id: await self.hass.services.async_call( - "homeassistant", "turn_off", {"entity_id": entity_id}, blocking=False, + "homeassistant", + "turn_off", + {"entity_id": entity_id}, + blocking=False, ) _LOGGER.info("Re-locked %s", entity_id) remaining = [ - u for u in self.active_unlocks() - if not (u.get("entity_id") == entity_id - and u.get("revert_at") == record.get("revert_at")) + u + for u in self.active_unlocks() + if not (u.get("entity_id") == entity_id and u.get("revert_at") == record.get("revert_at")) ] self._store_unlocks(remaining) await self.storage.async_save() @@ -197,7 +204,10 @@ async def async_resume_unlocks(self) -> int: entity_id = record.get("entity_id", "") if entity_id: await self.hass.services.async_call( - "homeassistant", "turn_off", {"entity_id": entity_id}, blocking=False, + "homeassistant", + "turn_off", + {"entity_id": entity_id}, + blocking=False, ) _LOGGER.info("Re-locked %s after restart (unlock had expired)", entity_id) diff --git a/custom_components/taskmate/coordinator.py b/custom_components/taskmate/coordinator.py index e902814..b0f9f45 100644 --- a/custom_components/taskmate/coordinator.py +++ b/custom_components/taskmate/coordinator.py @@ -1,4 +1,5 @@ """Data coordinator for TaskMate integration.""" + from __future__ import annotations import logging @@ -107,11 +108,7 @@ def difficulty_multiplier(self, tier: str) -> float: resolved = tier if tier in DEFAULT_DIFFICULTY_MULTIPLIERS else DEFAULT_DIFFICULTY default = DEFAULT_DIFFICULTY_MULTIPLIERS[resolved] try: - return float( - self.storage.get_setting( - f"difficulty_multiplier_{resolved}", str(default) - ) - ) + return float(self.storage.get_setting(f"difficulty_multiplier_{resolved}", str(default))) except (ValueError, TypeError): return default @@ -145,12 +142,14 @@ def get_vacation_periods(self) -> list[dict]: continue if end < start: start, end = end, start - periods.append({ - "id": str(entry.get("id") or "").strip() or start.isoformat(), - "name": str(entry.get("name") or "").strip(), - "start": start.isoformat(), - "end": end.isoformat(), - }) + periods.append( + { + "id": str(entry.get("id") or "").strip() or start.isoformat(), + "name": str(entry.get("name") or "").strip(), + "start": start.isoformat(), + "end": end.isoformat(), + } + ) return sorted(periods, key=lambda p: p["start"]) def active_vacation(self, on: date | None = None) -> dict | None: @@ -253,20 +252,20 @@ async def async_import_config(self, payload: dict) -> None: await self.async_refresh() # ── Admin audit log ────────────────────────────────────────────────── - async def async_record_audit( - self, user_id: str, user_name: str, action: str, target: str = "" - ) -> None: + async def async_record_audit(self, user_id: str, user_name: str, action: str, target: str = "") -> None: """Record an admin config action in the audit log and persist it.""" from .models import generate_id - self.storage.add_audit_entry({ - "id": generate_id(), - "ts": dt_util.now().isoformat(), - "user_id": user_id or "", - "user_name": user_name or "", - "action": action, - "target": target or "", - }) + self.storage.add_audit_entry( + { + "id": generate_id(), + "ts": dt_util.now().isoformat(), + "user_id": user_id or "", + "user_name": user_name or "", + "action": action, + "target": target or "", + } + ) await self.storage.async_save() async def async_initialize(self) -> None: @@ -296,16 +295,12 @@ async def async_initialize(self) -> None: self.hass, self._async_midnight_streak_check, hour=0, minute=0, second=5 ) # Schedule daily history pruning at 00:01:00 - self._unsub_prune = async_track_time_change( - self.hass, self._async_scheduled_prune, hour=0, minute=1, second=0 - ) + self._unsub_prune = async_track_time_change(self.hass, self._async_scheduled_prune, hour=0, minute=1, second=0) # Re-evaluate availability-aware chore assignments when any HA entity # state changes. The callback filters cheaply on entity id so only # relevant flips trigger a recompute. self._refresh_tracked_availability_entities() - self._unsub_availability = self.hass.bus.async_listen( - "state_changed", self._availability_state_changed - ) + self._unsub_availability = self.hass.bus.async_listen("state_changed", self._availability_state_changed) # Surprise-bonus daily roll at 16:00 (opt-in; no-op unless enabled) self._unsub_surprise = async_track_time_change( self.hass, self._async_surprise_bonus_check, hour=16, minute=0, second=0 @@ -353,10 +348,7 @@ def _build_weekly_digest(self) -> str: done[comp.child_id] = done.get(comp.child_id, 0) + 1 earned[comp.child_id] = earned.get(comp.child_id, 0) + (comp.points_awarded or 0) pts = self.storage.get_points_name() - lines = [ - f"• {c.name}: {done.get(c.id, 0)} chores, {earned.get(c.id, 0)} {pts} earned" - for c in children - ] + lines = [f"• {c.name}: {done.get(c.id, 0)} chores, {earned.get(c.id, 0)} {pts} earned" for c in children] return "\n".join(lines) async def _async_send_monthly_report(self) -> None: @@ -367,10 +359,13 @@ async def _async_send_monthly_report(self) -> None: summary = self._build_monthly_report(month_start, month_end) if not summary: return - await self.notifications.fire("monthly_report", { - "summary": summary, - "month": month_start.strftime("%B %Y"), - }) + await self.notifications.fire( + "monthly_report", + { + "summary": summary, + "month": month_start.strftime("%B %Y"), + }, + ) def _build_monthly_report(self, month_start: date, month_end: date) -> str: """Per-child recap for [month_start, month_end]: chores, points, level, best streak.""" @@ -403,8 +398,7 @@ def get_season_standings(self, ym: str | None = None) -> list[dict]: ym = dt_util.now().strftime("%Y-%m") pts = self.storage.get_season_points(ym) rows = [ - {"child_id": c.id, "name": c.name, "points": int(pts.get(c.id, 0))} - for c in self.storage.get_children() + {"child_id": c.id, "name": c.name, "points": int(pts.get(c.id, 0))} for c in self.storage.get_children() ] rows.sort(key=lambda r: (-r["points"], r["name"].lower())) for i, r in enumerate(rows): @@ -434,13 +428,22 @@ async def _async_check_family_goal(self) -> None: await self.storage.async_save() name = str(self.storage.get_setting("family_goal_name", "") or "Family goal") reward = str(self.storage.get_setting("family_goal_reward", "") or "a treat") - self.hass.bus.async_fire("taskmate_family_goal_reached", { - "goal_name": name, "goal_reward": reward, "target": target, - "timestamp": dt_util.now().isoformat(), - }) - await self.notifications.fire("family_goal_reached", { - "goal_name": name, "goal_reward": reward, - }) + self.hass.bus.async_fire( + "taskmate_family_goal_reached", + { + "goal_name": name, + "goal_reward": reward, + "target": target, + "timestamp": dt_util.now().isoformat(), + }, + ) + await self.notifications.fire( + "family_goal_reached", + { + "goal_name": name, + "goal_reward": reward, + }, + ) # ── Allowance payout ledger (FEAT-3) ───────────────────────────────── async def async_record_allowance_payout(self, child_id: str, points: int) -> dict: @@ -469,6 +472,7 @@ async def async_record_allowance_payout(self, child_id: str, points: int) -> dic await self.async_remove_points(child_id, points, reason="Allowance payout") from .models import generate_id + entry = { "id": generate_id(), "child_id": child_id, @@ -490,6 +494,7 @@ async def async_get_or_create_ics_token(self) -> str: token = self.storage.get_setting("ics_token", "") if not token: import secrets + token = secrets.token_urlsafe(24) self.storage.set_setting("ics_token", token) await self.storage.async_save() @@ -498,6 +503,7 @@ async def async_get_or_create_ics_token(self) -> str: async def async_regenerate_ics_token(self) -> str: """Rotate the ICS feed token (invalidates existing subscriptions).""" import secrets + token = secrets.token_urlsafe(24) self.storage.set_setting("ics_token", token) await self.storage.async_save() @@ -514,22 +520,34 @@ async def _async_finalize_season(self) -> None: if not winners: return top = winners[0] - self.storage.add_season_champion({ - "month": ym, - "child_id": top["child_id"], - "child_name": top["name"], - "points": top["points"], - }) + self.storage.add_season_champion( + { + "month": ym, + "child_id": top["child_id"], + "child_name": top["name"], + "points": top["points"], + } + ) await self.storage.async_save() - self.hass.bus.async_fire("taskmate_season_champion", { - "month": ym, "child_id": top["child_id"], "child_name": top["name"], - "points": top["points"], "timestamp": now.isoformat(), - }) - await self.notifications.fire("season_champion", { - "child_name": top["name"], "points": top["points"], - "month": prev_end.strftime("%B %Y"), - "points_name": self.storage.get_points_name(), - }) + self.hass.bus.async_fire( + "taskmate_season_champion", + { + "month": ym, + "child_id": top["child_id"], + "child_name": top["name"], + "points": top["points"], + "timestamp": now.isoformat(), + }, + ) + await self.notifications.fire( + "season_champion", + { + "child_name": top["name"], + "points": top["points"], + "month": prev_end.strftime("%B %Y"), + "points_name": self.storage.get_points_name(), + }, + ) @callback def _async_surprise_bonus_check(self, now: datetime) -> None: @@ -565,10 +583,15 @@ async def _async_run_surprise_bonus(self) -> None: if pts <= 0: continue await self.async_add_points(child.id, pts, reason="Surprise bonus 🎉") - self.hass.bus.async_fire("taskmate_surprise_bonus", { - "child_id": child.id, "child_name": child.name, - "points": pts, "timestamp": dt_util.now().isoformat(), - }) + self.hass.bus.async_fire( + "taskmate_surprise_bonus", + { + "child_id": child.id, + "child_name": child.name, + "points": pts, + "timestamp": dt_util.now().isoformat(), + }, + ) async def _async_backfill_career_history(self) -> None: """Backfill career_score_history from completions and transactions. @@ -618,13 +641,12 @@ async def _async_backfill_career_history(self) -> None: running = start_score for day in sorted_days: running += daily_net[day] - self.storage.append_career_score_snapshot( - child.id, day, running - ) + self.storage.append_career_score_snapshot(child.id, day, running) needs_save = True _LOGGER.info( "Backfilled %d career history entries for %s", - len(sorted_days), child.name, + len(sorted_days), + child.name, ) if needs_save: @@ -701,9 +723,7 @@ async def _async_run_midnight_maintenance(self, now: datetime) -> None: async def _async_sweep_orphan_photos(self) -> None: """Delete evidence photos not referenced by any completion (SEC-2).""" referenced = [ - getattr(c, "photo_url", "") - for c in self.storage.get_completions() - if getattr(c, "photo_url", "") + getattr(c, "photo_url", "") for c in self.storage.get_completions() if getattr(c, "photo_url", "") ] removed = await photos.async_sweep_orphan_photos(self.hass, referenced) if removed: diff --git a/custom_components/taskmate/frontend.py b/custom_components/taskmate/frontend.py index d79179b..8b46df9 100644 --- a/custom_components/taskmate/frontend.py +++ b/custom_components/taskmate/frontend.py @@ -1,4 +1,5 @@ """Frontend registration for TaskMate custom cards.""" + from __future__ import annotations import json @@ -56,8 +57,8 @@ # why blanket stale-cleanup was removed from async_register_cards). RETIRED_CARDS: Final = [ "taskmate-task-groups-card.js", # removed #452 - "taskmate-templates-card.js", # removed #448 - "taskmate-reminders-card.js", # removed #450 + "taskmate-templates-card.js", # removed #448 + "taskmate-reminders-card.js", # removed #450 ] # JS modules loaded on every HA frontend page (config flow sound preview). @@ -86,9 +87,7 @@ async def _async_get_version(hass: HomeAssistant) -> str: """Get version from manifest.json for cache busting (async-safe).""" manifest_path = Path(__file__).parent / "manifest.json" try: - content = await hass.async_add_executor_job( - manifest_path.read_text, "utf-8" - ) + content = await hass.async_add_executor_job(manifest_path.read_text, "utf-8") return json.loads(content).get("version", "1.0.0") except (OSError, json.JSONDecodeError, AttributeError): return "1.0.0" @@ -108,22 +107,23 @@ async def async_register_frontend(hass: HomeAssistant) -> None: return # Register the www folder as a static path - await hass.http.async_register_static_paths( - [StaticPathConfig(URL_BASE, str(www_path), False)] - ) + await hass.http.async_register_static_paths([StaticPathConfig(URL_BASE, str(www_path), False)]) _LOGGER.debug("Registered static path: %s -> %s", URL_BASE, www_path) # Authenticated upload/serve endpoints for chore evidence photos. from .http_photos import async_register_photo_views + async_register_photo_views(hass) # Admin-gated upload / authenticated serve for chore pictures (#750). from .http_images import async_register_image_views + async_register_image_views(hass) # Token-gated ICS calendar feed (FEAT-10). from .http_calendar import async_register_calendar_view + async_register_calendar_view(hass) # Register global JS modules (loaded on all pages, including config flow) @@ -205,9 +205,7 @@ async def async_register_cards(hass: HomeAssistant) -> None: if card_url not in existing: # Card not registered yet — add it - await resources.async_create_item( - {"url": versioned_url, "res_type": "module"} - ) + await resources.async_create_item({"url": versioned_url, "res_type": "module"}) _LOGGER.info("TaskMate: added resource: %s", versioned_url) else: item = existing[card_url] @@ -220,7 +218,8 @@ async def async_register_cards(hass: HomeAssistant) -> None: ) _LOGGER.info( "TaskMate: updated resource: %s -> %s", - current_url, versioned_url, + current_url, + versioned_url, ) else: _LOGGER.debug("TaskMate: resource up to date: %s", versioned_url) @@ -237,13 +236,12 @@ async def async_register_cards(hass: HomeAssistant) -> None: continue try: await resources.async_delete_item(item["id"]) - _LOGGER.info( - "TaskMate: removed retired resource: %s", item.get("url") - ) + _LOGGER.info("TaskMate: removed retired resource: %s", item.get("url")) except (AttributeError, KeyError, TypeError, OSError) as err: _LOGGER.warning( "TaskMate: could not remove retired resource %s: %s", - item.get("url"), err, + item.get("url"), + err, ) except (AttributeError, KeyError, TypeError, OSError) as err: diff --git a/custom_components/taskmate/http_calendar.py b/custom_components/taskmate/http_calendar.py index 6ebb115..6f900af 100644 --- a/custom_components/taskmate/http_calendar.py +++ b/custom_components/taskmate/http_calendar.py @@ -5,6 +5,7 @@ ``?token=`` query param (compared in constant time). It serves a read-only feed of upcoming chores; no mutation is possible. """ + from __future__ import annotations import hmac @@ -28,6 +29,7 @@ def _get_coordinator(hass: HomeAssistant): from .coordinator import TaskMateCoordinator + for value in hass.data.get(DOMAIN, {}).values(): if isinstance(value, TaskMateCoordinator): return value diff --git a/custom_components/taskmate/http_images.py b/custom_components/taskmate/http_images.py index 92bcd87..4c2a6c3 100644 --- a/custom_components/taskmate/http_images.py +++ b/custom_components/taskmate/http_images.py @@ -6,6 +6,7 @@ write files to disk would weaken the existing posture for no benefit. Serving stays plain-authenticated, matching photos. """ + from __future__ import annotations import logging @@ -40,9 +41,7 @@ async def post(self, request: web.Request) -> web.Response: # Cheap pre-check on the declared length before reading the body. if request.content_length and request.content_length > images.MAX_UPLOAD_BYTES: - return self.json_message( - "File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE - ) + return self.json_message("File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE) try: reader = await request.multipart() @@ -63,23 +62,15 @@ async def post(self, request: web.Request) -> web.Response: break data.extend(chunk) if len(data) > images.MAX_UPLOAD_BYTES: - return self.json_message( - "File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE - ) + return self.json_message("File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE) ext = images.detect_allowed_ext(bytes(data)) if ext is None: - return self.json_message( - "Not a supported image (JPEG, PNG or WebP)", HTTPStatus.BAD_REQUEST - ) + return self.json_message("Not a supported image (JPEG, PNG or WebP)", HTTPStatus.BAD_REQUEST) - used = await self.hass.async_add_executor_job( - images.total_images_bytes, self.hass - ) + used = await self.hass.async_add_executor_job(images.total_images_bytes, self.hass) if used + len(data) > images.MAX_TOTAL_BYTES: - return self.json_message( - "Image storage full", HTTPStatus.INSUFFICIENT_STORAGE - ) + return self.json_message("Image storage full", HTTPStatus.INSUFFICIENT_STORAGE) name = f"{uuid.uuid4().hex}.{ext}" directory = images.images_path(self.hass) @@ -93,9 +84,7 @@ def _write() -> None: await self.hass.async_add_executor_job(_write) except OSError as err: _LOGGER.error("Failed to store chore image: %s", err) - return self.json_message( - "Could not store image", HTTPStatus.INTERNAL_SERVER_ERROR - ) + return self.json_message("Could not store image", HTTPStatus.INTERNAL_SERVER_ERROR) return self.json({"image_url": f"{images.URL_PREFIX}/{name}"}) diff --git a/custom_components/taskmate/http_photos.py b/custom_components/taskmate/http_photos.py index 3073ce9..1d9f8d7 100644 --- a/custom_components/taskmate/http_photos.py +++ b/custom_components/taskmate/http_photos.py @@ -8,6 +8,7 @@ Pure path/validation logic lives in :mod:`.photos` (unit-tested); this module is the thin aiohttp wrapper, verified on the dev HA instance. """ + from __future__ import annotations import logging @@ -37,9 +38,7 @@ def __init__(self, hass: HomeAssistant) -> None: async def post(self, request: web.Request) -> web.Response: # Cheap pre-check on the declared length before reading the body. if request.content_length and request.content_length > photos.MAX_UPLOAD_BYTES: - return self.json_message( - "File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE - ) + return self.json_message("File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE) try: reader = await request.multipart() @@ -61,22 +60,16 @@ async def post(self, request: web.Request) -> web.Response: break data.extend(chunk) if len(data) > photos.MAX_UPLOAD_BYTES: - return self.json_message( - "File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE - ) + return self.json_message("File too large", HTTPStatus.REQUEST_ENTITY_TOO_LARGE) ext = photos.detect_image_ext(bytes(data)) if ext is None: return self.json_message("Not a valid image", HTTPStatus.BAD_REQUEST) # DoS guard: reject if the photo store is already at its disk budget. - used = await self.hass.async_add_executor_job( - photos.total_photos_bytes, self.hass - ) + used = await self.hass.async_add_executor_job(photos.total_photos_bytes, self.hass) if used + len(data) > photos.MAX_TOTAL_BYTES: - return self.json_message( - "Photo storage full", HTTPStatus.INSUFFICIENT_STORAGE - ) + return self.json_message("Photo storage full", HTTPStatus.INSUFFICIENT_STORAGE) name = f"{uuid.uuid4().hex}.{ext}" directory = photos.photos_path(self.hass) @@ -90,9 +83,7 @@ def _write() -> None: await self.hass.async_add_executor_job(_write) except OSError as err: _LOGGER.error("Failed to store evidence photo: %s", err) - return self.json_message( - "Could not store photo", HTTPStatus.INTERNAL_SERVER_ERROR - ) + return self.json_message("Could not store photo", HTTPStatus.INTERNAL_SERVER_ERROR) return self.json({"photo_url": f"{photos.URL_PREFIX}/{name}"}) diff --git a/custom_components/taskmate/ics.py b/custom_components/taskmate/ics.py index 94c3e95..6521179 100644 --- a/custom_components/taskmate/ics.py +++ b/custom_components/taskmate/ics.py @@ -4,6 +4,7 @@ calendar app (Google/Apple/Outlook) can subscribe to. Token auth + the HTTP view live in ``http_calendar.py``; this module only builds text. """ + from __future__ import annotations import hashlib @@ -14,13 +15,7 @@ def _escape(text: str) -> str: """Escape a value per RFC 5545 (backslash, comma, semicolon, newline).""" - return ( - str(text) - .replace("\\", "\\\\") - .replace("\n", "\\n") - .replace(",", "\\,") - .replace(";", "\\;") - ) + return str(text).replace("\\", "\\\\").replace("\n", "\\n").replace(",", "\\,").replace(";", "\\;") def _fold(line: str) -> str: @@ -97,31 +92,39 @@ def build_chore_events(coordinator, start_day: date, end_day: date) -> list[dict for chore in chores: if not _chore_applies_to_child(coordinator, chore, child.id, day): continue - window = coordinator._time_category_window( - getattr(chore, "time_category", "anytime"), day - ) + window = coordinator._time_category_window(getattr(chore, "time_category", "anytime"), day) summary = f"{chore.name} — {child.name}" desc = _chore_description(chore) if window is None: - events.append({ - "uid": make_uid(chore.id, child.id, day.isoformat(), "allday"), - "summary": summary, "description": desc, - "start": day, "end": day + timedelta(days=1), "all_day": True, - }) + events.append( + { + "uid": make_uid(chore.id, child.id, day.isoformat(), "allday"), + "summary": summary, + "description": desc, + "start": day, + "end": day + timedelta(days=1), + "all_day": True, + } + ) else: start_dt, end_dt = window - events.append({ - "uid": make_uid(chore.id, child.id, day.isoformat(), "timed"), - "summary": summary, "description": desc, - "start": _ensure_aware(start_dt), - "end": _ensure_aware(end_dt), "all_day": False, - }) + events.append( + { + "uid": make_uid(chore.id, child.id, day.isoformat(), "timed"), + "summary": summary, + "description": desc, + "start": _ensure_aware(start_dt), + "end": _ensure_aware(end_dt), + "all_day": False, + } + ) day += timedelta(days=1) return events def _ensure_aware(dt: datetime) -> datetime: from homeassistant.util import dt as dt_util + if dt.tzinfo is None: return dt.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) return dt diff --git a/custom_components/taskmate/images.py b/custom_components/taskmate/images.py index 8162e66..886bb0e 100644 --- a/custom_components/taskmate/images.py +++ b/custom_components/taskmate/images.py @@ -14,6 +14,7 @@ Images are stored as ``<32 hex>.`` under ``/taskmate_images`` and served (auth-gated) at ``/api/taskmate/image/``. """ + from __future__ import annotations import logging @@ -46,10 +47,20 @@ # Re-exported so http_images.py has a single import site for its serve view. __all__ = [ - "ALLOWED_EXTS", "FILENAME_RE", "IMAGES_DIR", "MAX_TOTAL_BYTES", - "MAX_UPLOAD_BYTES", "URL_PREFIX", "async_delete_image", "content_type_for", - "detect_allowed_ext", "image_file_for_url", "images_path", - "is_taskmate_image_url", "sign_image_url", "total_images_bytes", + "ALLOWED_EXTS", + "FILENAME_RE", + "IMAGES_DIR", + "MAX_TOTAL_BYTES", + "MAX_UPLOAD_BYTES", + "URL_PREFIX", + "async_delete_image", + "content_type_for", + "detect_allowed_ext", + "image_file_for_url", + "images_path", + "is_taskmate_image_url", + "sign_image_url", + "total_images_bytes", ] @@ -76,14 +87,14 @@ def is_taskmate_image_url(image_url: str) -> bool: prefix = URL_PREFIX + "/" if not image_url.startswith(prefix): return False - return bool(FILENAME_RE.match(image_url[len(prefix):])) + return bool(FILENAME_RE.match(image_url[len(prefix) :])) def image_file_for_url(hass, image_url: str) -> Path | None: """Map a ``/api/taskmate/image/`` URL to its path, or None.""" if not is_taskmate_image_url(image_url): return None - return images_path(hass) / image_url[len(URL_PREFIX) + 1:] + return images_path(hass) / image_url[len(URL_PREFIX) + 1 :] def sign_image_url(hass, image_url: str, expiration_hours: int = 24) -> str: diff --git a/custom_components/taskmate/intents.py b/custom_components/taskmate/intents.py index 8a84ada..3f9dd33 100644 --- a/custom_components/taskmate/intents.py +++ b/custom_components/taskmate/intents.py @@ -8,6 +8,7 @@ config (see custom_sentences/README.md). The speech-building logic is kept in pure helpers so it is unit-testable without the conversation stack. """ + from __future__ import annotations import logging @@ -27,6 +28,7 @@ def _get_coordinator(hass: HomeAssistant): from .coordinator import TaskMateCoordinator + for value in hass.data.get(DOMAIN, {}).values(): if isinstance(value, TaskMateCoordinator): return value diff --git a/custom_components/taskmate/models.py b/custom_components/taskmate/models.py index 752056c..5da598d 100644 --- a/custom_components/taskmate/models.py +++ b/custom_components/taskmate/models.py @@ -1,4 +1,5 @@ """Data models for TaskMate integration.""" + from __future__ import annotations import logging @@ -24,6 +25,7 @@ def generate_id() -> str: def dt_util_now_iso() -> str: """Current local time as an ISO string (module-level so dataclass defaults can use it).""" from homeassistant.util import dt as dt_util + return dt_util.now().isoformat() @@ -172,7 +174,7 @@ class Child: notify_service: str | None = None linked_user_id: str = "" # HA user id; when set, only that user (or an admin) may self-serve as this child quiet_hours_start: str = "" # "HH:MM" — start of do-not-disturb window; empty = no quiet hours - quiet_hours_end: str = "" # "HH:MM" — end of do-not-disturb window; start>end means overnight + quiet_hours_end: str = "" # "HH:MM" — end of do-not-disturb window; start>end means overnight level: int = 1 # cached XP level (derived from total_points_earned) # Guest profiles (#690): a visiting cousin gets a temporary child that # expires on its own and stays out of the family leaderboard. @@ -265,7 +267,9 @@ class Chore: # Optional uploaded photograph (#750). Takes precedence over `icon` at # every render site; stored as a /api/taskmate/image/ URL. image_url: str = "" - difficulty: str = "medium" # easy | medium | hard — scales awarded points by the tier multiplier (medium = ×1.0 baseline) + difficulty: str = ( + "medium" # easy | medium | hard — scales awarded points by the tier multiplier (medium = ×1.0 baseline) + ) # Scheduling # schedule_mode: "specific_days" = show on selected days of week (Mode A) # "recurring" = rolling window recurrence (Mode B) @@ -273,7 +277,7 @@ class Chore: due_days: list[str] = field(default_factory=list) # Mode A: days to show chore # Mode B fields recurrence: str = "weekly" # every_2_days | weekly | every_2_weeks | monthly | every_3_months | every_6_months - recurrence_day: str = "" # optional: which day of week for weekly/every_2_weeks + recurrence_day: str = "" # optional: which day of week for weekly/every_2_weeks recurrence_start: str = "" # optional: ISO date anchor for every_2_days first_occurrence_mode: str = "available_immediately" # available_immediately | wait_for_first_occurrence # Dynamic visibility @@ -293,7 +297,9 @@ class Chore: # One-shot chore fields enabled: bool = True # False = soft-disabled (completed or expired) disabled_for: list[str] = field(default_factory=list) # Child IDs this chore is disabled for - depends_on: list[str] = field(default_factory=list) # Chore IDs that must be approved-completed today before this is available + depends_on: list[str] = field( + default_factory=list + ) # Chore IDs that must be approved-completed today before this is available created_date: str = "" # ISO date for one-shot expiry, e.g. "2026-04-16" expires_on: str = "" # optional ISO end date; chore auto-disables the day after # Reactive chores (#674): a short-lived chore raised by an automation, e.g. @@ -316,7 +322,9 @@ class Chore: # Dynamic assignment (sibling rotation) assignment_mode: str = "everyone" # everyone | alternating | random assignment_rotation_anchor: str = "" # ISO date; day-0 of the rotation for alternating - assignment_current_child_id: str = "" # cached active child ID for today (computed at midnight and on create/update) + assignment_current_child_id: str = ( + "" # cached active child ID for today (computed at midnight and on create/update) + ) require_availability: bool = False # When True, skip children whose availability entity says they're unavailable # Skip state (ephemeral: cleared at midnight when skip_date != today) skip_date: str = "" # ISO date the skip applies to ("" = no active skip) @@ -561,7 +569,7 @@ class Quest: name: str description: str = "" icon: str = "mdi:map-marker-path" - steps: list[str] = field(default_factory=list) # ordered chore IDs + steps: list[str] = field(default_factory=list) # ordered chore IDs bonus_points: int = 25 assigned_to: list[str] = field(default_factory=list) # child IDs; empty = all repeatable: bool = False @@ -609,8 +617,8 @@ class Challenge: name: str description: str = "" icon: str = "mdi:trophy-outline" - scope: str = "daily" # daily | weekly - metric: str = "chores" # chores | points + scope: str = "daily" # daily | weekly + metric: str = "chores" # chores | points target: int = 3 bonus_points: int = 15 assigned_to: list[str] = field(default_factory=list) # child IDs; empty = all @@ -708,8 +716,8 @@ class MandatoryMiss: chore_id: str child_id: str - due_date: str # ISO date the chore was missed - period_id: str # the window that closed ("anytime" for all-day) + due_date: str # ISO date the chore was missed + period_id: str # the window that closed ("anytime" for all-day) penalty_points: int = 0 postpone_count: int = 0 escalation_stage: int = 0 # 0=none 1=nudged 2=reminded 3=parent-alerted (FEAT-6) @@ -1207,10 +1215,7 @@ def from_dict(cls, data: dict[str, Any]) -> NotificationConfig: return cls( type_id=data.get("type_id", ""), master_enabled=bool(data.get("master_enabled", False)), - routes={ - rid: NotificationRoute.from_dict(rdata) - for rid, rdata in raw_routes.items() - }, + routes={rid: NotificationRoute.from_dict(rdata) for rid, rdata in raw_routes.items()}, nav_url=data.get("nav_url", "") or "", ) @@ -1231,8 +1236,8 @@ class CustomNotification: name: str message_template: str - time: str # "HH:MM" - day_mask: int = 0b1111111 # bit0=Mon … bit6=Sun + time: str # "HH:MM" + day_mask: int = 0b1111111 # bit0=Mon … bit6=Sun recipient_ids: list[str] = field(default_factory=list) enabled: bool = True id: str = field(default_factory=generate_id) diff --git a/custom_components/taskmate/number.py b/custom_components/taskmate/number.py index cf0194a..a7e7bed 100644 --- a/custom_components/taskmate/number.py +++ b/custom_components/taskmate/number.py @@ -4,6 +4,7 @@ the HA UI without a service call. Values are persisted through the same settings store the panel uses, so panel and entity stay in sync. """ + from __future__ import annotations from homeassistant.components.number import NumberEntity, NumberMode @@ -23,9 +24,7 @@ ] -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: """Set up the TaskMate setting-number entities.""" coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities(TaskMateSettingNumber(coordinator, entry, *cfg) for cfg in _NUMBERS) diff --git a/custom_components/taskmate/panel.py b/custom_components/taskmate/panel.py index 400a163..0d5d139 100644 --- a/custom_components/taskmate/panel.py +++ b/custom_components/taskmate/panel.py @@ -1,4 +1,5 @@ """Sidebar panel registration for the TaskMate admin UI.""" + from __future__ import annotations import json diff --git a/custom_components/taskmate/photos.py b/custom_components/taskmate/photos.py index 53c8e98..3fe314c 100644 --- a/custom_components/taskmate/photos.py +++ b/custom_components/taskmate/photos.py @@ -7,6 +7,7 @@ Photos are stored as ``<32 hex>.`` under ``/taskmate_photos`` and served (auth-gated) at ``/api/taskmate/photo/``. """ + from __future__ import annotations import logging @@ -86,7 +87,7 @@ def is_taskmate_photo_url(photo_url: str) -> bool: prefix = URL_PREFIX + "/" if not photo_url.startswith(prefix): return False - return bool(FILENAME_RE.match(photo_url[len(prefix):])) + return bool(FILENAME_RE.match(photo_url[len(prefix) :])) def photo_file_for_url(hass, photo_url: str) -> Path | None: @@ -101,7 +102,7 @@ def photo_file_for_url(hass, photo_url: str) -> Path | None: prefix = URL_PREFIX + "/" if not photo_url.startswith(prefix): return None - name = photo_url[len(prefix):] + name = photo_url[len(prefix) :] if not FILENAME_RE.match(name): return None return photos_path(hass) / name @@ -181,10 +182,7 @@ async def async_sweep_orphan_photos(hass, referenced_urls, max_age_hours: int = Returns the number of files deleted. """ prefix = URL_PREFIX + "/" - referenced = { - url[len(prefix):] for url in referenced_urls - if url and url.startswith(prefix) - } + referenced = {url[len(prefix) :] for url in referenced_urls if url and url.startswith(prefix)} directory = photos_path(hass) def _sweep() -> int: diff --git a/custom_components/taskmate/printable.py b/custom_components/taskmate/printable.py index ced6a63..3fd7cc0 100644 --- a/custom_components/taskmate/printable.py +++ b/custom_components/taskmate/printable.py @@ -8,6 +8,7 @@ Pure string building with no HA imports, so the layout is unit-testable without a Home Assistant install. """ + from __future__ import annotations from datetime import date, timedelta @@ -60,10 +61,7 @@ def build_chart( for child in children: child_id = str(child.get("id", "")) # A chore with an empty assigned_to belongs to everyone. - mine = [ - c for c in chores - if not (c.get("assigned_to") or []) or child_id in (c.get("assigned_to") or []) - ] + mine = [c for c in chores if not (c.get("assigned_to") or []) or child_id in (c.get("assigned_to") or [])] if not mine: continue @@ -75,21 +73,14 @@ def build_chart( f'{escape(str(c.get("name", "")))}' for c in todays ) - cells.append(f'{boxes or " "}') - rows.append( - f'{escape(str(child.get("name", "")))}' - + "".join(cells) + "" - ) + cells.append(f"{boxes or ' '}") + rows.append(f'{escape(str(child.get("name", "")))}' + "".join(cells) + "") header = "".join( - f'{DAY_NAMES[d.weekday()][:3]}' - f'{d.day}' - for d in days + f'{DAY_NAMES[d.weekday()][:3]}{d.day}' for d in days ) week_label = f"{start.strftime('%d %b')} – {(start + timedelta(days=6)).strftime('%d %b %Y')}" - body = "".join(rows) or ( - 'No chores to show for this week.' - ) + body = "".join(rows) or ('No chores to show for this week.') return f""" diff --git a/custom_components/taskmate/select.py b/custom_components/taskmate/select.py index 796df45..098bd80 100644 --- a/custom_components/taskmate/select.py +++ b/custom_components/taskmate/select.py @@ -1,4 +1,5 @@ """Select platform — expose key choice TaskMate settings as entities (FEAT-9).""" + from __future__ import annotations from homeassistant.components.select import SelectEntity @@ -14,13 +15,17 @@ # (setting_key, translation_key, options, default, icon) _SELECTS = [ ("streak_reset_mode", "streak_reset_mode", ["reset", "pause"], "reset", "mdi:restart"), - ("card_design", "card_design", ["classic", "playroom", "console", "cleanpro", "accessible"], "classic", "mdi:palette"), + ( + "card_design", + "card_design", + ["classic", "playroom", "console", "cleanpro", "accessible"], + "classic", + "mdi:palette", + ), ] -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: """Set up the TaskMate setting-select entities.""" coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities(TaskMateSettingSelect(coordinator, entry, *cfg) for cfg in _SELECTS) diff --git a/custom_components/taskmate/sensor.py b/custom_components/taskmate/sensor.py index 47afb78..1284d6a 100644 --- a/custom_components/taskmate/sensor.py +++ b/custom_components/taskmate/sensor.py @@ -1,4 +1,5 @@ """Sensor platform for TaskMate integration.""" + from __future__ import annotations import logging @@ -85,9 +86,7 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict: for comp in pending_completions: chore = chore_lookup.get(comp.chore_id) if chore: - pending_points_by_child[comp.child_id] = ( - pending_points_by_child.get(comp.child_id, 0) + chore.points - ) + pending_points_by_child[comp.child_id] = pending_points_by_child.get(comp.child_id, 0) + chore.points # Committed points per child (reward claims awaiting approval = points reserved). # Pool-mode pending claims are skipped because their cost was already deducted @@ -98,9 +97,7 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict: continue reward = reward_lookup.get(rc.reward_id) if reward: - committed_points_by_child[rc.child_id] = ( - committed_points_by_child.get(rc.child_id, 0) + reward.cost - ) + committed_points_by_child[rc.child_id] = committed_points_by_child.get(rc.child_id, 0) + reward.cost # Pool allocation lookups for v3.0 pool mode. pool_by_child_reward: dict[str, dict[str, int]] = {} @@ -108,12 +105,8 @@ def _compute_common(coordinator: TaskMateCoordinator) -> dict: total_allocated_by_child: dict[str, int] = {} for pa in pool_alloc_objs: pool_by_child_reward.setdefault(pa.child_id, {})[pa.reward_id] = pa.allocated_points - pool_total_by_reward[pa.reward_id] = ( - pool_total_by_reward.get(pa.reward_id, 0) + pa.allocated_points - ) - total_allocated_by_child[pa.child_id] = ( - total_allocated_by_child.get(pa.child_id, 0) + pa.allocated_points - ) + pool_total_by_reward[pa.reward_id] = pool_total_by_reward.get(pa.reward_id, 0) + pa.allocated_points + total_allocated_by_child[pa.child_id] = total_allocated_by_child.get(pa.child_id, 0) + pa.allocated_points common = { "data_id": data_id, @@ -151,51 +144,57 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l for c in children: committed_amount = committed.get(c.id, 0) lvl = coordinator.level_info(c) - summary.append({ - "level": lvl["level"], - "level_progress": lvl["progress"], - "level_target": lvl["target"], - "id": c.id, - "name": c.name, - "points": c.points, - "pending_points": pending.get(c.id, 0), - # Guest profiles (#690): cards filter these out of competitive views. - **({"is_guest": True, "guest_expires_on": getattr(c, "guest_expires_on", "")} - if getattr(c, "is_guest", False) else {}), - # Chore roulette (#677): today's pick + spins left, so the card can - # show the result and disable the button once the allowance is used. - **( - { - "roulette": { - **(coordinator.roulette_selection(c.id) or {}), - "spins_left": coordinator.roulette_spins_left(c.id), + summary.append( + { + "level": lvl["level"], + "level_progress": lvl["progress"], + "level_target": lvl["target"], + "id": c.id, + "name": c.name, + "points": c.points, + "pending_points": pending.get(c.id, 0), + # Guest profiles (#690): cards filter these out of competitive views. + **( + {"is_guest": True, "guest_expires_on": getattr(c, "guest_expires_on", "")} + if getattr(c, "is_guest", False) + else {} + ), + # Chore roulette (#677): today's pick + spins left, so the card can + # show the result and disable the button once the allowance is used. + **( + { + "roulette": { + **(coordinator.roulette_selection(c.id) or {}), + "spins_left": coordinator.roulette_spins_left(c.id), + } } - } - if coordinator.roulette_enabled() else {} - ), - "committed_points": committed_amount, - "allocated_points": allocated.get(c.id, 0), - # Allocations were deducted from child.points already, so spendable - # only needs to account for pending-claim commitments. - "spendable_balance": max(0, c.points - committed_amount), - "chore_order": c.chore_order, - "current_streak": getattr(c, 'current_streak', 0) or 0, - "best_streak": getattr(c, 'best_streak', 0) or 0, - "season_points": int(season.get(c.id, 0)), - "total_points_earned": getattr(c, 'total_points_earned', 0) or 0, - "total_chores_completed": getattr(c, 'total_chores_completed', 0) or 0, - "avatar": getattr(c, 'avatar', 'mdi:account-circle') or 'mdi:account-circle', - "last_completion_date": getattr(c, 'last_completion_date', None), - "streak_paused": getattr(c, 'streak_paused', False), - "on_vacation": coordinator._is_child_on_vacation(c), - "streak_milestones_achieved": getattr(c, 'streak_milestones_achieved', None) or [], - "awarded_perfect_weeks": getattr(c, 'awarded_perfect_weeks', None) or [], - "career_score": getattr(c, 'career_score', 0) or 0, - "total_penalties_received": getattr(c, 'total_penalties_received', 0) or 0, - "quests": coordinator.quest_progress_for_child(c.id), - "avatar_options": coordinator.avatar_options_for_child(c), - "challenges": coordinator.challenge_progress_for_child(c.id), - }) + if coordinator.roulette_enabled() + else {} + ), + "committed_points": committed_amount, + "allocated_points": allocated.get(c.id, 0), + # Allocations were deducted from child.points already, so spendable + # only needs to account for pending-claim commitments. + "spendable_balance": max(0, c.points - committed_amount), + "chore_order": c.chore_order, + "current_streak": getattr(c, "current_streak", 0) or 0, + "best_streak": getattr(c, "best_streak", 0) or 0, + "season_points": int(season.get(c.id, 0)), + "total_points_earned": getattr(c, "total_points_earned", 0) or 0, + "total_chores_completed": getattr(c, "total_chores_completed", 0) or 0, + "avatar": getattr(c, "avatar", "mdi:account-circle") or "mdi:account-circle", + "last_completion_date": getattr(c, "last_completion_date", None), + "streak_paused": getattr(c, "streak_paused", False), + "on_vacation": coordinator._is_child_on_vacation(c), + "streak_milestones_achieved": getattr(c, "streak_milestones_achieved", None) or [], + "awarded_perfect_weeks": getattr(c, "awarded_perfect_weeks", None) or [], + "career_score": getattr(c, "career_score", 0) or 0, + "total_penalties_received": getattr(c, "total_penalties_received", 0) or 0, + "quests": coordinator.quest_progress_for_child(c.id), + "avatar_options": coordinator.avatar_options_for_child(c), + "challenges": coordinator.challenge_progress_for_child(c.id), + } + ) return summary @@ -223,61 +222,61 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d "time_category": c.time_category, "assigned_to": assigned_to, "depends_on": depends_on, - "schedule_mode": getattr(c, 'schedule_mode', 'specific_days'), - "enabled": getattr(c, 'enabled', True), - "assignment_mode": getattr(c, 'assignment_mode', 'everyone'), + "schedule_mode": getattr(c, "schedule_mode", "specific_days"), + "enabled": getattr(c, "enabled", True), + "assignment_mode": getattr(c, "assignment_mode", "everyone"), } # Difficulty tier + the points it actually awards. Emitted only when # non-default (medium / ×1.0) so simple chores stay compact. - difficulty = getattr(c, 'difficulty', 'medium') or 'medium' - if difficulty != 'medium': + difficulty = getattr(c, "difficulty", "medium") or "medium" + if difficulty != "medium": record["difficulty"] = difficulty effective_points = coordinator.effective_chore_points(c) if effective_points != c.points: record["effective_points"] = effective_points # Optional fields — emit only when non-default to save bytes. - description = getattr(c, 'description', '') or '' + description = getattr(c, "description", "") or "" if description: record["description"] = description - daily_limit = getattr(c, 'daily_limit', 1) + daily_limit = getattr(c, "daily_limit", 1) if daily_limit != 1: record["daily_limit"] = daily_limit - claim_allowance_minutes = getattr(c, 'claim_allowance_minutes', 0) or 0 + claim_allowance_minutes = getattr(c, "claim_allowance_minutes", 0) or 0 if claim_allowance_minutes: record["claim_allowance_minutes"] = claim_allowance_minutes - due_days = getattr(c, 'due_days', []) or [] + due_days = getattr(c, "due_days", []) or [] if due_days: record["due_days"] = due_days - requires_approval = getattr(c, 'requires_approval', True) + requires_approval = getattr(c, "requires_approval", True) if not requires_approval: record["requires_approval"] = False # Mandatory chores (#532): emit only when set so the child card can # show the mandatory styling/badge. penalty rides along when non-zero. - if getattr(c, 'mandatory', False): + if getattr(c, "mandatory", False): record["mandatory"] = True - penalty = getattr(c, 'mandatory_penalty_points', 0) or 0 + penalty = getattr(c, "mandatory_penalty_points", 0) or 0 if penalty: record["mandatory_penalty_points"] = penalty - if getattr(c, 'require_photo', False): + if getattr(c, "require_photo", False): record["require_photo"] = True - recurrence = getattr(c, 'recurrence', 'weekly') - if recurrence != 'weekly': + recurrence = getattr(c, "recurrence", "weekly") + if recurrence != "weekly": record["recurrence"] = recurrence - recurrence_day = getattr(c, 'recurrence_day', '') + recurrence_day = getattr(c, "recurrence_day", "") if recurrence_day: record["recurrence_day"] = recurrence_day - recurrence_start = getattr(c, 'recurrence_start', '') + recurrence_start = getattr(c, "recurrence_start", "") if recurrence_start: record["recurrence_start"] = recurrence_start - visibility_entity = getattr(c, 'visibility_entity', '') + visibility_entity = getattr(c, "visibility_entity", "") if visibility_entity: record["visibility_entity"] = visibility_entity - record["visibility_operator"] = getattr(c, 'visibility_operator', 'equals') - record["visibility_state"] = getattr(c, 'visibility_state', 'on') - weather_entity = getattr(c, 'weather_entity', '') + record["visibility_operator"] = getattr(c, "visibility_operator", "equals") + record["visibility_state"] = getattr(c, "visibility_state", "on") + weather_entity = getattr(c, "weather_entity", "") if weather_entity: record["weather_entity"] = weather_entity - record["weather_block_conditions"] = list(getattr(c, 'weather_block_conditions', []) or []) + record["weather_block_conditions"] = list(getattr(c, "weather_block_conditions", []) or []) for limit in ("weather_temp_min", "weather_temp_max", "weather_wind_max"): value = getattr(c, limit, None) if value is not None: @@ -287,44 +286,41 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d reason = coordinator.weather_block_reason(c) if reason: record["weather_blocked"] = reason - deadline_at = getattr(c, 'deadline_at', '') + deadline_at = getattr(c, "deadline_at", "") if deadline_at: record["deadline_at"] = deadline_at - speed_bonus = getattr(c, 'speed_bonus_points', 0) + speed_bonus = getattr(c, "speed_bonus_points", 0) if speed_bonus: record["speed_bonus_points"] = speed_bonus - disabled_for = getattr(c, 'disabled_for', []) + disabled_for = getattr(c, "disabled_for", []) if disabled_for: record["disabled_for"] = disabled_for - created_date = getattr(c, 'created_date', '') + created_date = getattr(c, "created_date", "") if created_date: record["created_date"] = created_date - assignment_current_child_id = getattr(c, 'assignment_current_child_id', '') + assignment_current_child_id = getattr(c, "assignment_current_child_id", "") if assignment_current_child_id: record["assignment_current_child_id"] = assignment_current_child_id - icon = getattr(c, 'icon', '') + icon = getattr(c, "icon", "") if icon: record["icon"] = icon # Signed so the card's loads; emitted only when set, matching # `icon` above, to keep records under the 16KB recorder limit. - image_url = getattr(c, 'image_url', '') + image_url = getattr(c, "image_url", "") if image_url: record["image_url"] = images.sign_image_url(common["hass"], image_url) - completion_sound = getattr(c, 'completion_sound', 'coin') - if completion_sound and completion_sound != 'coin': + completion_sound = getattr(c, "completion_sound", "coin") + if completion_sound and completion_sound != "coin": record["completion_sound"] = completion_sound - task_type = getattr(c, 'task_type', 'standard') + task_type = getattr(c, "task_type", "standard") if task_type == "timed": record["task_type"] = "timed" - record["timed_rate_points"] = getattr(c, 'timed_rate_points', 10) - record["timed_rate_minutes"] = getattr(c, 'timed_rate_minutes', 5) - record["timed_max_daily_minutes"] = getattr(c, 'timed_max_daily_minutes', 0) - bonus_subtasks = getattr(c, 'bonus_subtasks', []) + record["timed_rate_points"] = getattr(c, "timed_rate_points", 10) + record["timed_rate_minutes"] = getattr(c, "timed_rate_minutes", 5) + record["timed_max_daily_minutes"] = getattr(c, "timed_max_daily_minutes", 0) + bonus_subtasks = getattr(c, "bonus_subtasks", []) if bonus_subtasks: - record["bonus_subtasks"] = [ - {"id": b.id, "name": b.name, "points": b.points} - for b in bonus_subtasks - ] + record["bonus_subtasks"] = [{"id": b.id, "name": b.name, "points": b.points} for b in bonus_subtasks] chores_list.append(record) return chores_list @@ -356,9 +352,9 @@ def _build_todays_completions(common: dict) -> list[dict]: out = [] for comp in common["all_completions"]: comp_dt = comp.completed_at - if hasattr(comp_dt, 'astimezone'): + if hasattr(comp_dt, "astimezone"): comp_dt = dt_util.as_local(comp_dt) - comp_date = comp_dt.date() if hasattr(comp_dt, 'date') else comp_dt + comp_date = comp_dt.date() if hasattr(comp_dt, "date") else comp_dt if comp_date != today: continue matched_chore = chore_lookup.get(comp.chore_id) @@ -379,11 +375,15 @@ def _build_todays_completions(common: dict) -> list[dict]: "completion_id": comp.id, "chore_id": comp.chore_id, "child_id": comp.child_id, - "child_name": "Parent" if comp.child_id == "__parent__" else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""), + "child_name": "Parent" + if comp.child_id == "__parent__" + else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""), "chore_name": display_name, "points": display_points, "approved": comp.approved, - "completed_at": comp.completed_at.isoformat() if hasattr(comp.completed_at, 'isoformat') else str(comp.completed_at), + "completed_at": comp.completed_at.isoformat() + if hasattr(comp.completed_at, "isoformat") + else str(comp.completed_at), "bonus_subtask_id": bonus_subtask_id, } if timed_secs > 0: @@ -402,7 +402,7 @@ def _build_active_timed_sessions(coordinator: TaskMateCoordinator) -> list[dict] sessions = coordinator.data.get("timed_sessions", []) out = [] for s in sessions: - if hasattr(s, 'state'): + if hasattr(s, "state"): state = s.state if state not in ("running", "paused"): continue @@ -412,13 +412,15 @@ def _build_active_timed_sessions(coordinator: TaskMateCoordinator) -> list[dict] last_seg = segments[-1] if isinstance(last_seg, dict) and last_seg.get("end") is None: current_segment_start = last_seg.get("start", "") - out.append({ - "chore_id": s.chore_id, - "child_id": s.child_id, - "state": state, - "total_seconds_today": s.total_seconds_today, - "current_segment_start": current_segment_start, - }) + out.append( + { + "chore_id": s.chore_id, + "child_id": s.child_id, + "state": state, + "total_seconds_today": s.total_seconds_today, + "current_segment_start": current_segment_start, + } + ) else: state = s.get("state", "stopped") if isinstance(s, dict) else "stopped" if state not in ("running", "paused"): @@ -429,13 +431,15 @@ def _build_active_timed_sessions(coordinator: TaskMateCoordinator) -> list[dict] last_seg = segments[-1] if isinstance(last_seg, dict) and last_seg.get("end") is None: current_segment_start = last_seg.get("start", "") - out.append({ - "chore_id": s.get("chore_id", "") if isinstance(s, dict) else "", - "child_id": s.get("child_id", "") if isinstance(s, dict) else "", - "state": state, - "total_seconds_today": s.get("total_seconds_today", 0) if isinstance(s, dict) else 0, - "current_segment_start": current_segment_start, - }) + out.append( + { + "chore_id": s.get("chore_id", "") if isinstance(s, dict) else "", + "child_id": s.get("child_id", "") if isinstance(s, dict) else "", + "state": state, + "total_seconds_today": s.get("total_seconds_today", 0) if isinstance(s, dict) else 0, + "current_segment_start": current_segment_start, + } + ) return out @@ -448,20 +452,12 @@ def _build_rewards_list(common: dict) -> list[dict]: today = dt_util.now().date() out = [] for r in rewards: - assigned = ( - r.assigned_to - if isinstance(r.assigned_to, list) and r.assigned_to - else [c.id for c in children] - ) + assigned = r.assigned_to if isinstance(r.assigned_to, list) and r.assigned_to else [c.id for c in children] calculated_costs = {child_id: r.cost for child_id in assigned} - reward_pool_allocations = { - cid: pool_by_child_reward.get(cid, {}).get(r.id, 0) for cid in assigned - } - jackpot_pool_total = ( - pool_total_by_reward.get(r.id, 0) if getattr(r, 'is_jackpot', False) else None - ) - quantity = getattr(r, 'quantity', None) - expires_at = getattr(r, 'expires_at', None) + reward_pool_allocations = {cid: pool_by_child_reward.get(cid, {}).get(r.id, 0) for cid in assigned} + jackpot_pool_total = pool_total_by_reward.get(r.id, 0) if getattr(r, "is_jackpot", False) else None + quantity = getattr(r, "quantity", None) + expires_at = getattr(r, "expires_at", None) is_sold_out = quantity is not None and quantity <= 0 is_expired = False days_until_expiry: int | None = None @@ -472,25 +468,27 @@ def _build_rewards_list(common: dict) -> list[dict]: days_until_expiry = (deadline - today).days except (TypeError, ValueError): pass - out.append({ - "id": r.id, - "name": r.name, - "cost": r.cost, - "description": getattr(r, 'description', ''), - "icon": r.icon, - "assigned_to": r.assigned_to if isinstance(r.assigned_to, list) else [], - "is_jackpot": getattr(r, 'is_jackpot', False), - "pool_enabled": getattr(r, 'pool_enabled', False), - "calculated_costs": calculated_costs, - "pool_allocations": reward_pool_allocations, - "jackpot_pool_total": jackpot_pool_total, - "quantity": quantity, - "expires_at": expires_at, - "is_sold_out": is_sold_out, - "is_expired": is_expired, - "is_available": not (is_sold_out or is_expired), - "days_until_expiry": days_until_expiry, - }) + out.append( + { + "id": r.id, + "name": r.name, + "cost": r.cost, + "description": getattr(r, "description", ""), + "icon": r.icon, + "assigned_to": r.assigned_to if isinstance(r.assigned_to, list) else [], + "is_jackpot": getattr(r, "is_jackpot", False), + "pool_enabled": getattr(r, "pool_enabled", False), + "calculated_costs": calculated_costs, + "pool_allocations": reward_pool_allocations, + "jackpot_pool_total": jackpot_pool_total, + "quantity": quantity, + "expires_at": expires_at, + "is_sold_out": is_sold_out, + "is_expired": is_expired, + "is_available": not (is_sold_out or is_expired), + "days_until_expiry": days_until_expiry, + } + ) return out @@ -504,17 +502,19 @@ def _build_pending_reward_claims(common: dict) -> list[dict]: child = child_lookup.get(rc.child_id) if not reward or not child: continue - out.append({ - "claim_id": rc.id, - "reward_id": rc.reward_id, - "child_id": rc.child_id, - "child_name": child.name, - "child_avatar": getattr(child, 'avatar', 'mdi:account-circle') or 'mdi:account-circle', - "reward_name": reward.name, - "reward_icon": reward.icon or 'mdi:gift', - "cost": reward.cost, - "claimed_at": rc.claimed_at.isoformat() if hasattr(rc.claimed_at, 'isoformat') else str(rc.claimed_at), - }) + out.append( + { + "claim_id": rc.id, + "reward_id": rc.reward_id, + "child_id": rc.child_id, + "child_name": child.name, + "child_avatar": getattr(child, "avatar", "mdi:account-circle") or "mdi:account-circle", + "reward_name": reward.name, + "reward_icon": reward.icon or "mdi:gift", + "cost": reward.cost, + "claimed_at": rc.claimed_at.isoformat() if hasattr(rc.claimed_at, "isoformat") else str(rc.claimed_at), + } + ) return out @@ -536,16 +536,22 @@ def _build_recent_completions(common: dict, limit: int = 35) -> list[dict]: rate_seconds = matched_chore.timed_rate_minutes * 60 if rate_seconds > 0: display_points = (timed_secs // rate_seconds) * matched_chore.timed_rate_points - out.append({ - "completion_id": comp.id, - "chore_id": comp.chore_id, - "child_id": comp.child_id, - "child_name": "Parent" if comp.child_id == "__parent__" else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""), - "chore_name": matched_chore.name if matched_chore else "", - "points": display_points, - "approved": comp.approved, - "completed_at": comp.completed_at.isoformat() if hasattr(comp.completed_at, 'isoformat') else str(comp.completed_at), - }) + out.append( + { + "completion_id": comp.id, + "chore_id": comp.chore_id, + "child_id": comp.child_id, + "child_name": "Parent" + if comp.child_id == "__parent__" + else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""), + "chore_name": matched_chore.name if matched_chore else "", + "points": display_points, + "approved": comp.approved, + "completed_at": comp.completed_at.isoformat() + if hasattr(comp.completed_at, "isoformat") + else str(comp.completed_at), + } + ) return out @@ -558,21 +564,23 @@ def _build_photo_gallery(common: dict, limit: int = 40) -> list[dict]: """ child_lookup = common["child_lookup"] chore_lookup = common["chore_lookup"] - with_photos = [ - c for c in common["all_completions"] if getattr(c, "photo_url", "") - ] + with_photos = [c for c in common["all_completions"] if getattr(c, "photo_url", "")] recent = sorted(with_photos, key=lambda c: c.completed_at, reverse=True)[:limit] out = [] for comp in recent: chore = chore_lookup.get(comp.chore_id) - out.append({ - "completion_id": comp.id, - "child_name": child_lookup[comp.child_id].name if comp.child_id in child_lookup else "", - "chore_name": chore.name if chore else "", - "approved": comp.approved, - "completed_at": comp.completed_at.isoformat() if hasattr(comp.completed_at, "isoformat") else str(comp.completed_at), - "photo_url": comp.photo_url, - }) + out.append( + { + "completion_id": comp.id, + "child_name": child_lookup[comp.child_id].name if comp.child_id in child_lookup else "", + "chore_name": chore.name if chore else "", + "approved": comp.approved, + "completed_at": comp.completed_at.isoformat() + if hasattr(comp.completed_at, "isoformat") + else str(comp.completed_at), + "photo_url": comp.photo_url, + } + ) return out @@ -593,15 +601,17 @@ def _build_recent_transactions(common: dict, limit: int = 20) -> list[dict]: child = child_lookup.get(t.child_id) if not child: continue - events.append({ - "transaction_id": t.id, - "type": "points_added" if t.points > 0 else "points_removed", - "child_id": t.child_id, - "child_name": child.name, - "points": t.points, - "reason": t.reason or "", - "created_at": t.created_at.isoformat() if hasattr(t.created_at, 'isoformat') else str(t.created_at), - }) + events.append( + { + "transaction_id": t.id, + "type": "points_added" if t.points > 0 else "points_removed", + "child_id": t.child_id, + "child_name": child.name, + "points": t.points, + "reason": t.reason or "", + "created_at": t.created_at.isoformat() if hasattr(t.created_at, "isoformat") else str(t.created_at), + } + ) for rc in all_reward_claims: child = child_lookup.get(rc.child_id) @@ -610,43 +620,51 @@ def _build_recent_transactions(common: dict, limit: int = 20) -> list[dict]: continue event_type = "reward_approved" if rc.approved else "reward_claimed" timestamp = rc.approved_at if rc.approved and rc.approved_at else rc.claimed_at - events.append({ - "transaction_id": rc.id, - "type": event_type, - "child_id": rc.child_id, - "child_name": child.name, - "reward_id": rc.reward_id, - "reward_name": reward.name, - "reward_icon": reward.icon or "mdi:gift", - "points": -reward.cost, - "approved": rc.approved, - "created_at": timestamp.isoformat() if hasattr(timestamp, 'isoformat') else str(timestamp), - }) + events.append( + { + "transaction_id": rc.id, + "type": event_type, + "child_id": rc.child_id, + "child_name": child.name, + "reward_id": rc.reward_id, + "reward_name": reward.name, + "reward_icon": reward.icon or "mdi:gift", + "points": -reward.cost, + "approved": rc.approved, + "created_at": timestamp.isoformat() if hasattr(timestamp, "isoformat") else str(timestamp), + } + ) events.sort(key=lambda e: e["created_at"], reverse=True) return events[:limit] def _build_penalties_list(common: dict) -> list[dict]: - return [{ - "id": p.id, - "name": p.name, - "points": p.points, - "description": p.description, - "icon": p.icon, - "assigned_to": p.assigned_to or [], - } for p in common["data"].get("penalties", [])] + return [ + { + "id": p.id, + "name": p.name, + "points": p.points, + "description": p.description, + "icon": p.icon, + "assigned_to": p.assigned_to or [], + } + for p in common["data"].get("penalties", []) + ] def _build_bonuses_list(common: dict) -> list[dict]: - return [{ - "id": b.id, - "name": b.name, - "points": b.points, - "description": b.description, - "icon": b.icon, - "assigned_to": b.assigned_to or [], - } for b in common["data"].get("bonuses", [])] + return [ + { + "id": b.id, + "name": b.name, + "points": b.points, + "description": b.description, + "icon": b.icon, + "assigned_to": b.assigned_to or [], + } + for b in common["data"].get("bonuses", []) + ] async def async_setup_entry( @@ -795,8 +813,10 @@ def _build_attributes(self) -> dict: # Legacy shape kept for cards that still read the four fixed keys. legacy_defaults = { - "morning": ("06:00", "12:00"), "afternoon": ("12:00", "17:00"), - "evening": ("17:00", "21:00"), "night": ("21:00", "23:59"), + "morning": ("06:00", "12:00"), + "afternoon": ("12:00", "17:00"), + "evening": ("17:00", "21:00"), + "night": ("21:00", "23:59"), } time_boundaries = {} for cat, (def_start, def_end) in legacy_defaults.items(): @@ -813,7 +833,8 @@ def _build_attributes(self) -> dict: "perfect_week_enabled": settings.get("perfect_week_enabled", "true") == "true", "perfect_week_bonus": _safe_int(settings.get("perfect_week_bonus"), 50), "streak_requires_all_chores": settings.get("streak_requires_all_chores", "false") in (True, "true"), - "perfect_week_requires_all_chores": settings.get("perfect_week_requires_all_chores", "false") in (True, "true"), + "perfect_week_requires_all_chores": settings.get("perfect_week_requires_all_chores", "false") + in (True, "true"), "total_children": len(children), "total_chores": len(chores), "total_rewards": len(rewards), @@ -1117,6 +1138,7 @@ def extra_state_attributes(self) -> dict: # drop it once any pool member has completed it today (so a parent # crediting the off-rotation child clears the chore for everyone). chores = self.coordinator.data.get("chores", []) + def _included(c): if not (child.id in c.assigned_to or not c.assigned_to): return False @@ -1128,6 +1150,7 @@ def _included(c): if self.coordinator._is_rotation_done_today(c): return False return True + assigned_chores = [c for c in chores if _included(c)] return { @@ -1141,7 +1164,10 @@ def _included(c): "best_streak": child.best_streak, "career_score": child.career_score, "total_penalties_received": child.total_penalties_received, - "assigned_chores": [{"id": c.id, "name": c.name, "points": c.points, "time_category": c.time_category} for c in assigned_chores], + "assigned_chores": [ + {"id": c.id, "name": c.name, "points": c.points, "time_category": c.time_category} + for c in assigned_chores + ], "chore_order": child.chore_order, } @@ -1166,9 +1192,7 @@ def __init__( @property def native_value(self) -> int: """Number of badges earned by this child.""" - return len( - self.coordinator.storage.get_awarded_badges_for_child(self.child_id) - ) + return len(self.coordinator.storage.get_awarded_badges_for_child(self.child_id)) @property def extra_state_attributes(self) -> dict: @@ -1180,10 +1204,7 @@ def extra_state_attributes(self) -> dict: return {"earned": [], "available": [], "total_badges": 0} all_badges = [b for b in storage.get_badges() if b.enabled] - applicable = [ - b for b in all_badges - if not b.assigned_to or self.child_id in b.assigned_to - ] + applicable = [b for b in all_badges if not b.assigned_to or self.child_id in b.assigned_to] awarded_records = storage.get_awarded_badges_for_child(self.child_id) record_by_id = {a.badge_id: a for a in awarded_records} @@ -1193,15 +1214,17 @@ def extra_state_attributes(self) -> dict: for b in applicable: if b.id in record_by_id: rec = record_by_id[b.id] - earned.append({ - "badge_id": b.id, - "name": b.name, - "icon": b.icon, - "tier": b.tier, - "earned_at": rec.earned_at.isoformat() if rec.earned_at else None, - "manually_awarded": rec.manually_awarded, - "silent": rec.silent, - }) + earned.append( + { + "badge_id": b.id, + "name": b.name, + "icon": b.icon, + "tier": b.tier, + "earned_at": rec.earned_at.isoformat() if rec.earned_at else None, + "manually_awarded": rec.manually_awarded, + "silent": rec.silent, + } + ) else: if not b.criteria: progress_pct = 0 @@ -1212,16 +1235,16 @@ def extra_state_attributes(self) -> dict: target = max(c.value, 1) pcts.append(min(100, int(100 * cur / target))) progress_pct = min(pcts) if pcts else 0 - available.append({ - "badge_id": b.id, - "name": b.name, - "icon": b.icon, - "tier": b.tier, - "progress_pct": progress_pct, - "criteria_summary": ", ".join( - f"{c.metric} >= {c.value}" for c in b.criteria - ), - }) + available.append( + { + "badge_id": b.id, + "name": b.name, + "icon": b.icon, + "tier": b.tier, + "progress_pct": progress_pct, + "criteria_summary": ", ".join(f"{c.metric} >= {c.value}" for c in b.criteria), + } + ) earned.sort(key=lambda e: e.get("earned_at") or "", reverse=True) @@ -1274,9 +1297,7 @@ def extra_state_attributes(self) -> dict: # list render them identically. bonus_subtask_id = getattr(comp, "bonus_subtask_id", "") or "" if bonus_subtask_id: - subtask = next( - (b for b in chore.bonus_subtasks if b.id == bonus_subtask_id), None - ) + subtask = next((b for b in chore.bonus_subtasks if b.id == bonus_subtask_id), None) chore_name = f"{chore.name} › {subtask.name}" if subtask else chore.name pts = subtask.points if subtask else 0 else: @@ -1303,9 +1324,7 @@ def extra_state_attributes(self) -> dict: detail["timed_duration_seconds"] = timed_secs photo = getattr(comp, "photo_url", "") or "" if photo: - detail["photo_url"] = photos.sign_photo_url( - self.coordinator.hass, photo - ) + detail["photo_url"] = photos.sign_photo_url(self.coordinator.hass, photo) completion_details.append(detail) reward_details = [] @@ -1313,16 +1332,18 @@ def extra_state_attributes(self) -> dict: child = self.coordinator.get_child(claim.child_id) reward = self.coordinator.get_reward(claim.reward_id) if child and reward: - reward_details.append({ - "claim_id": claim.id, - "type": "reward", - "child_name": child.name, - "child_id": child.id, - "reward_name": reward.name, - "reward_id": reward.id, - "cost": reward.cost, - "claimed_at": claim.claimed_at.isoformat(), - }) + reward_details.append( + { + "claim_id": claim.id, + "type": "reward", + "child_name": child.name, + "child_id": child.id, + "reward_name": reward.name, + "reward_id": reward.id, + "cost": reward.cost, + "claimed_at": claim.claimed_at.isoformat(), + } + ) mandatory_misses = self.coordinator.mandatory_misses_state() return { diff --git a/custom_components/taskmate/storage.py b/custom_components/taskmate/storage.py index 5e6c64d..ab0b44c 100644 --- a/custom_components/taskmate/storage.py +++ b/custom_components/taskmate/storage.py @@ -1,4 +1,5 @@ """Storage management for TaskMate integration.""" + from __future__ import annotations import logging @@ -182,8 +183,8 @@ async def _migrate_pool_allocations_v2(self) -> None: self._data["_pool_semantics_version"] = 2 if adjusted: _LOGGER.info( - "TaskMate: migrated %d pool allocation(s) to beta2 semantics " - "(points now deducted at allocation time)", adjusted + "TaskMate: migrated %d pool allocation(s) to beta2 semantics (points now deducted at allocation time)", + adjusted, ) await self.async_save() @@ -232,15 +233,13 @@ async def _migrate_assigned_to_child_ids(self) -> None: "Migrating chore '%s' assigned_to: '%s' -> '%s' (name to ID)", chore.get("name", "unknown"), assignment, - name_to_id[assignment] + name_to_id[assignment], ) else: # Unknown value, keep it but log a warning new_assigned_to.append(assignment) _LOGGER.warning( - "Chore '%s' has unknown assigned_to value: '%s'", - chore.get("name", "unknown"), - assignment + "Chore '%s' has unknown assigned_to value: '%s'", chore.get("name", "unknown"), assignment ) if chore_modified: @@ -269,10 +268,7 @@ async def _migrate_career_score(self) -> None: self._data["_career_score_initialized"] = True if children: - _LOGGER.info( - "TaskMate: initialized career_score for %d child(ren) " - "from total_points_earned", len(children) - ) + _LOGGER.info("TaskMate: initialized career_score for %d child(ren) from total_points_earned", len(children)) await self.async_save() async def async_save(self) -> None: @@ -336,9 +332,7 @@ def update_child(self, child: Child) -> None: def remove_child(self, child_id: str) -> None: """Remove a child and cascade-delete their awarded badges.""" - self._data["children"] = [ - c for c in self._data.get("children", []) if c.get("id") != child_id - ] + self._data["children"] = [c for c in self._data.get("children", []) if c.get("id") != child_id] self.remove_awards_for_child(child_id) # Chores management @@ -370,9 +364,7 @@ def update_chore(self, chore: Chore) -> None: def remove_chore(self, chore_id: str) -> None: """Remove a chore.""" - self._data["chores"] = [ - c for c in self._data.get("chores", []) if c.get("id") != chore_id - ] + self._data["chores"] = [c for c in self._data.get("chores", []) if c.get("id") != chore_id] order = self._data.get("chore_display_order", []) if chore_id in order: order.remove(chore_id) @@ -414,9 +406,7 @@ def update_reward(self, reward: Reward) -> None: def remove_reward(self, reward_id: str) -> None: """Remove a reward.""" - self._data["rewards"] = [ - r for r in self._data.get("rewards", []) if r.get("id") != reward_id - ] + self._data["rewards"] = [r for r in self._data.get("rewards", []) if r.get("id") != reward_id] # Completions management def get_completions(self) -> list[ChoreCompletion]: @@ -447,9 +437,7 @@ def update_completion(self, completion: ChoreCompletion) -> None: def remove_completion(self, completion_id: str) -> None: """Remove a completion record.""" - self._data["completions"] = [ - c for c in self._data.get("completions", []) if c.get("id") != completion_id - ] + self._data["completions"] = [c for c in self._data.get("completions", []) if c.get("id") != completion_id] # Mandatory-miss management (#532) def get_mandatory_misses(self) -> list[MandatoryMiss]: @@ -470,9 +458,7 @@ def update_mandatory_miss(self, miss: MandatoryMiss) -> None: def remove_mandatory_miss(self, miss_id: str) -> None: """Remove a mandatory-miss item by id.""" - self._data["mandatory_misses"] = [ - m for m in self._data.get("mandatory_misses", []) if m.get("id") != miss_id - ] + self._data["mandatory_misses"] = [m for m in self._data.get("mandatory_misses", []) if m.get("id") != miss_id] def replace_mandatory_misses(self, misses: list[MandatoryMiss]) -> None: """Replace the whole mandatory-miss collection.""" @@ -507,9 +493,7 @@ def update_reward_claim(self, claim: RewardClaim) -> None: def remove_reward_claim(self, claim_id: str) -> None: """Remove a reward claim.""" - self._data["reward_claims"] = [ - c for c in self._data.get("reward_claims", []) if c.get("id") != claim_id - ] + self._data["reward_claims"] = [c for c in self._data.get("reward_claims", []) if c.get("id") != claim_id] # Penalties management def get_penalties(self) -> list[Penalty]: @@ -538,9 +522,7 @@ def update_penalty(self, penalty) -> None: def remove_penalty(self, penalty_id: str) -> None: """Remove a penalty.""" - self._data["penalties"] = [ - p for p in self._data.get("penalties", []) if p.get("id") != penalty_id - ] + self._data["penalties"] = [p for p in self._data.get("penalties", []) if p.get("id") != penalty_id] # Bonuses management def get_bonuses(self) -> list[Bonus]: @@ -569,9 +551,7 @@ def update_bonus(self, bonus) -> None: def remove_bonus(self, bonus_id: str) -> None: """Remove a bonus.""" - self._data["bonuses"] = [ - b for b in self._data.get("bonuses", []) if b.get("id") != bonus_id - ] + self._data["bonuses"] = [b for b in self._data.get("bonuses", []) if b.get("id") != bonus_id] # Badges management def get_badges(self) -> list[Badge]: @@ -600,9 +580,7 @@ def update_badge(self, badge: Badge) -> None: def remove_badge(self, badge_id: str) -> None: """Remove a badge and cascade-delete its awards.""" - self._data["badges"] = [ - b for b in self._data.get("badges", []) if b.get("id") != badge_id - ] + self._data["badges"] = [b for b in self._data.get("badges", []) if b.get("id") != badge_id] self.remove_awards_for_badge(badge_id) # Awarded badges management @@ -620,9 +598,7 @@ def add_awarded_badge(self, awarded: AwardedBadge) -> None: def remove_awarded_badge(self, awarded_id: str) -> None: """Remove an awarded-badge record by id.""" - self._data["awarded_badges"] = [ - a for a in self._data.get("awarded_badges", []) if a.get("id") != awarded_id - ] + self._data["awarded_badges"] = [a for a in self._data.get("awarded_badges", []) if a.get("id") != awarded_id] def remove_awards_for_badge(self, badge_id: str) -> None: """Cascade-delete all awards referencing a badge id.""" @@ -705,9 +681,7 @@ def _run_notifications_migration(self) -> None: cfg = NotificationConfig( type_id=tid, master_enabled=True, - routes={ - seeded_parent_id: NotificationRoute(enabled=True) - } if seeded_parent_id else {}, + routes={seeded_parent_id: NotificationRoute(enabled=True)} if seeded_parent_id else {}, ) nc[tid] = cfg.to_dict() @@ -728,8 +702,7 @@ def upsert_parent_recipient(self, p: ParentRecipient) -> None: def delete_parent_recipient(self, parent_id: str) -> None: self._data["parent_recipients"] = [ - r for r in self._data.get("parent_recipients", []) - if r.get("id") != parent_id + r for r in self._data.get("parent_recipients", []) if r.get("id") != parent_id ] # --- notification config --- @@ -749,9 +722,7 @@ def set_notification_nav_url(self, type_id: str, nav_url: str) -> None: cfg.nav_url = nav_url self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict() - def set_notification_route( - self, type_id: str, recipient_id: str, route: NotificationRoute - ) -> None: + def set_notification_route(self, type_id: str, recipient_id: str, route: NotificationRoute) -> None: cfg = self.get_notification_config(type_id) cfg.routes[recipient_id] = route self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict() @@ -764,10 +735,7 @@ def get_all_notification_configs(self) -> dict[str, NotificationConfig]: # --- custom notifications --- def get_custom_notifications(self) -> list[CustomNotification]: - return [ - CustomNotification.from_dict(d) - for d in self._data.get("custom_notifications", []) - ] + return [CustomNotification.from_dict(d) for d in self._data.get("custom_notifications", [])] def upsert_custom_notification(self, n: CustomNotification) -> None: rows = self._data.setdefault("custom_notifications", []) @@ -779,15 +747,12 @@ def upsert_custom_notification(self, n: CustomNotification) -> None: def delete_custom_notification(self, custom_id: str) -> None: self._data["custom_notifications"] = [ - r for r in self._data.get("custom_notifications", []) - if r.get("id") != custom_id + r for r in self._data.get("custom_notifications", []) if r.get("id") != custom_id ] # --- streak-at-risk cutoff --- def get_streak_at_risk_cutoff(self) -> str: - return (self._data.get("settings", {}) or {}).get( - "streak_at_risk_cutoff_time", "20:00" - ) + return (self._data.get("settings", {}) or {}).get("streak_at_risk_cutoff_time", "20:00") def set_streak_at_risk_cutoff(self, hhmm: str) -> None: self._data.setdefault("settings", {})["streak_at_risk_cutoff_time"] = hhmm @@ -811,16 +776,14 @@ def set_parent_user_ids(self, ids: list[str]) -> None: def get_escalation_reminder_minutes(self) -> int: """Minutes after a mandatory miss before the child reminder escalates.""" try: - return max(1, int((self._data.get("settings", {}) or {}).get( - "mandatory_escalation_reminder_minutes", 30))) + return max(1, int((self._data.get("settings", {}) or {}).get("mandatory_escalation_reminder_minutes", 30))) except (TypeError, ValueError): return 30 def get_escalation_parent_minutes(self) -> int: """Minutes after a mandatory miss before the parent alert escalates.""" try: - return max(1, int((self._data.get("settings", {}) or {}).get( - "mandatory_escalation_parent_minutes", 120))) + return max(1, int((self._data.get("settings", {}) or {}).get("mandatory_escalation_parent_minutes", 120))) except (TypeError, ValueError): return 120 @@ -863,9 +826,7 @@ def update_task_group(self, group: TaskGroup) -> None: def remove_task_group(self, group_id: str) -> None: """Remove a task group.""" - self._data["task_groups"] = [ - g for g in self._data.get("task_groups", []) if g.get("id") != group_id - ] + self._data["task_groups"] = [g for g in self._data.get("task_groups", []) if g.get("id") != group_id] def remove_chore_from_task_groups(self, chore_id: str) -> None: """Strip a chore ID from every group (used on chore delete).""" @@ -921,9 +882,7 @@ def add_points_transaction(self, transaction: PointsTransaction) -> None: # the single choke point all awards flow through — the rolling 200-cap on # transactions makes them unreliable for a monthly total (FEAT-2). if transaction.points > 0: - self.record_season_points( - transaction.child_id, transaction.points, transaction.created_at - ) + self.record_season_points(transaction.child_id, transaction.points, transaction.created_at) # Keep only the last 200 transactions to avoid unbounded storage growth if len(self._data["points_transactions"]) > 200: @@ -1041,9 +1000,7 @@ def update_quest(self, quest: Quest) -> None: self.add_quest(quest) def remove_quest(self, quest_id: str) -> None: - self._data["quests"] = [ - q for q in self._data.get("quests", []) if q.get("id") != quest_id - ] + self._data["quests"] = [q for q in self._data.get("quests", []) if q.get("id") != quest_id] # Drop any progress tracked for this quest prog = self._data.get("quest_progress", {}) prog.pop(quest_id, None) @@ -1084,9 +1041,7 @@ def update_challenge(self, challenge: Challenge) -> None: self.add_challenge(challenge) def remove_challenge(self, challenge_id: str) -> None: - self._data["challenges"] = [ - c for c in self._data.get("challenges", []) if c.get("id") != challenge_id - ] + self._data["challenges"] = [c for c in self._data.get("challenges", []) if c.get("id") != challenge_id] self._data.get("challenge_progress", {}).pop(challenge_id, None) def get_challenge_progress(self) -> dict: @@ -1107,6 +1062,7 @@ def remove_challenge_progress_for_child(self, child_id: str) -> None: def export_data(self) -> dict: """Return a deep copy of the full stored data (for backup/export).""" import copy + return copy.deepcopy(self._data) def import_data(self, data: dict) -> None: @@ -1116,14 +1072,29 @@ def import_data(self, data: dict) -> None: a partial import. """ import copy + if not isinstance(data, dict): raise ValueError("import data must be an object") self._data = copy.deepcopy(data) list_keys = ( - "children", "chores", "rewards", "penalties", "bonuses", - "task_groups", "completions", "mandatory_misses", "reward_claims", "points_transactions", - "pool_allocations", "badges", "awarded_badges", "parent_recipients", - "audit_log", "timed_sessions", "quests", "challenges", + "children", + "chores", + "rewards", + "penalties", + "bonuses", + "task_groups", + "completions", + "mandatory_misses", + "reward_claims", + "points_transactions", + "pool_allocations", + "badges", + "awarded_badges", + "parent_recipients", + "audit_log", + "timed_sessions", + "quests", + "challenges", ) for k in list_keys: if not isinstance(self._data.get(k), list): @@ -1146,6 +1117,7 @@ def _sanitize_imported_records(self) -> None: well-formed photo URLs so the panel never renders a foreign/dangerous one. """ from .photos import is_taskmate_photo_url + for comp in self._data.get("completions", []): if not isinstance(comp, dict): continue @@ -1163,21 +1135,15 @@ def replace_completions(self, completions: list[ChoreCompletion]) -> None: def remove_completions_for_child(self, child_id: str) -> None: """Remove all completions for a given child.""" - self._data["completions"] = [ - c for c in self._data.get("completions", []) if c.get("child_id") != child_id - ] + self._data["completions"] = [c for c in self._data.get("completions", []) if c.get("child_id") != child_id] def remove_completions_for_chore(self, chore_id: str) -> None: """Remove all completions for a given chore.""" - self._data["completions"] = [ - c for c in self._data.get("completions", []) if c.get("chore_id") != chore_id - ] + self._data["completions"] = [c for c in self._data.get("completions", []) if c.get("chore_id") != chore_id] def remove_reward_claims_for_child(self, child_id: str) -> None: """Remove all reward claims for a given child.""" - self._data["reward_claims"] = [ - c for c in self._data.get("reward_claims", []) if c.get("child_id") != child_id - ] + self._data["reward_claims"] = [c for c in self._data.get("reward_claims", []) if c.get("child_id") != child_id] def remove_reward_claims_for_reward(self, reward_id: str) -> None: """Remove all reward claims for a given reward.""" @@ -1209,7 +1175,8 @@ def upsert_pool_allocation(self, allocation: PoolAllocation) -> None: def remove_pool_allocation(self, child_id: str, reward_id: str) -> None: """Remove a pool allocation for a specific (child, reward) pair.""" self._data["pool_allocations"] = [ - a for a in self._data.get("pool_allocations", []) + a + for a in self._data.get("pool_allocations", []) if not (a.get("child_id") == child_id and a.get("reward_id") == reward_id) ] @@ -1309,18 +1276,22 @@ def get_timed_sessions(self) -> list[TimedSession]: def get_timed_session(self, chore_id: str, child_id: str, session_date: str) -> TimedSession | None: """Get a timed session for a specific chore/child/date.""" for s in self._data.get("timed_sessions", []): - if (s.get("chore_id") == chore_id - and s.get("child_id") == child_id - and s.get("session_date") == session_date): + if ( + s.get("chore_id") == chore_id + and s.get("child_id") == child_id + and s.get("session_date") == session_date + ): return TimedSession.from_dict(s) return None def get_active_timed_session(self, chore_id: str, child_id: str) -> TimedSession | None: """Get a running or paused session for a chore/child pair.""" for s in self._data.get("timed_sessions", []): - if (s.get("chore_id") == chore_id - and s.get("child_id") == child_id - and s.get("state") in ("running", "paused")): + if ( + s.get("chore_id") == chore_id + and s.get("child_id") == child_id + and s.get("state") in ("running", "paused") + ): return TimedSession.from_dict(s) return None @@ -1335,10 +1306,7 @@ def save_timed_session(self, session: TimedSession) -> None: def remove_timed_session(self, session_id: str) -> None: """Remove a timed session.""" - self._data["timed_sessions"] = [ - s for s in self._data.get("timed_sessions", []) - if s.get("id") != session_id - ] + self._data["timed_sessions"] = [s for s in self._data.get("timed_sessions", []) if s.get("id") != session_id] # Generic settings def get_setting(self, key: str, default: Any = "") -> Any: diff --git a/custom_components/taskmate/templates.py b/custom_components/taskmate/templates.py index 846d8e2..921b051 100644 --- a/custom_components/taskmate/templates.py +++ b/custom_components/taskmate/templates.py @@ -1,15 +1,35 @@ """Built-in chore template packs for TaskMate.""" + from __future__ import annotations TEMPLATE_CHORE_FIELDS = ( - "name", "points", "description", "requires_approval", "time_category", - "daily_limit", "completion_sound", "schedule_mode", "due_days", - "recurrence", "recurrence_day", "recurrence_start", "first_occurrence_mode", - "assignment_mode", "require_availability", "visibility_entity", - "visibility_state", "visibility_operator", - "weather_entity", "weather_block_conditions", "weather_temp_min", - "weather_temp_max", "weather_wind_max", "task_type", - "timed_rate_points", "timed_rate_minutes", "timed_max_daily_minutes", + "name", + "points", + "description", + "requires_approval", + "time_category", + "daily_limit", + "completion_sound", + "schedule_mode", + "due_days", + "recurrence", + "recurrence_day", + "recurrence_start", + "first_occurrence_mode", + "assignment_mode", + "require_availability", + "visibility_entity", + "visibility_state", + "visibility_operator", + "weather_entity", + "weather_block_conditions", + "weather_temp_min", + "weather_temp_max", + "weather_wind_max", + "task_type", + "timed_rate_points", + "timed_rate_minutes", + "timed_max_daily_minutes", ) _WEEKDAYS = ["monday", "tuesday", "wednesday", "thursday", "friday"] @@ -22,10 +42,50 @@ "icon": "mdi:weather-sunny", "builtin": True, "chores": [ - {"name": "Make bed", "points": 2, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, - {"name": "Brush teeth", "points": 1, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, - {"name": "Get dressed", "points": 1, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, - {"name": "Pack school bag", "points": 2, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, + { + "name": "Make bed", + "points": 2, + "time_category": "morning", + "schedule_mode": "specific_days", + "due_days": list(_WEEKDAYS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, + { + "name": "Brush teeth", + "points": 1, + "time_category": "morning", + "schedule_mode": "specific_days", + "due_days": list(_WEEKDAYS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, + { + "name": "Get dressed", + "points": 1, + "time_category": "morning", + "schedule_mode": "specific_days", + "due_days": list(_WEEKDAYS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, + { + "name": "Pack school bag", + "points": 2, + "time_category": "morning", + "schedule_mode": "specific_days", + "due_days": list(_WEEKDAYS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, ], }, { @@ -34,10 +94,50 @@ "icon": "mdi:weather-night", "builtin": True, "chores": [ - {"name": "Brush teeth", "points": 1, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, - {"name": "Put on pyjamas", "points": 1, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, - {"name": "Tidy room", "points": 2, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, - {"name": "Set out clothes for tomorrow", "points": 1, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, + { + "name": "Brush teeth", + "points": 1, + "time_category": "evening", + "schedule_mode": "specific_days", + "due_days": list(_WEEKDAYS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, + { + "name": "Put on pyjamas", + "points": 1, + "time_category": "evening", + "schedule_mode": "specific_days", + "due_days": list(_WEEKDAYS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, + { + "name": "Tidy room", + "points": 2, + "time_category": "evening", + "schedule_mode": "specific_days", + "due_days": list(_WEEKDAYS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, + { + "name": "Set out clothes for tomorrow", + "points": 1, + "time_category": "evening", + "schedule_mode": "specific_days", + "due_days": list(_WEEKDAYS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, ], }, { @@ -46,10 +146,50 @@ "icon": "mdi:silverware-fork-knife", "builtin": True, "chores": [ - {"name": "Set table", "points": 2, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, - {"name": "Clear plates", "points": 2, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, - {"name": "Load dishwasher", "points": 3, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, - {"name": "Wipe counters", "points": 2, "time_category": "evening", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, + { + "name": "Set table", + "points": 2, + "time_category": "evening", + "schedule_mode": "specific_days", + "due_days": list(_WEEKDAYS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, + { + "name": "Clear plates", + "points": 2, + "time_category": "evening", + "schedule_mode": "specific_days", + "due_days": list(_WEEKDAYS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, + { + "name": "Load dishwasher", + "points": 3, + "time_category": "evening", + "schedule_mode": "specific_days", + "due_days": list(_WEEKDAYS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, + { + "name": "Wipe counters", + "points": 2, + "time_category": "evening", + "schedule_mode": "specific_days", + "due_days": list(_WEEKDAYS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, ], }, { @@ -58,10 +198,50 @@ "icon": "mdi:broom", "builtin": True, "chores": [ - {"name": "Tidy bedroom", "points": 3, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKENDS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, - {"name": "Hoover", "points": 4, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKENDS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, - {"name": "Help with laundry", "points": 3, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKENDS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, - {"name": "Take bins out", "points": 2, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKENDS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, + { + "name": "Tidy bedroom", + "points": 3, + "time_category": "anytime", + "schedule_mode": "specific_days", + "due_days": list(_WEEKENDS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, + { + "name": "Hoover", + "points": 4, + "time_category": "anytime", + "schedule_mode": "specific_days", + "due_days": list(_WEEKENDS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, + { + "name": "Help with laundry", + "points": 3, + "time_category": "anytime", + "schedule_mode": "specific_days", + "due_days": list(_WEEKENDS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, + { + "name": "Take bins out", + "points": 2, + "time_category": "anytime", + "schedule_mode": "specific_days", + "due_days": list(_WEEKENDS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, ], }, { @@ -70,10 +250,50 @@ "icon": "mdi:paw", "builtin": True, "chores": [ - {"name": "Feed pet", "points": 2, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, - {"name": "Fill water bowl", "points": 1, "time_category": "morning", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, - {"name": "Walk dog", "points": 3, "time_category": "afternoon", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, - {"name": "Clean litter tray", "points": 3, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, + { + "name": "Feed pet", + "points": 2, + "time_category": "morning", + "schedule_mode": "specific_days", + "due_days": list(_WEEKDAYS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, + { + "name": "Fill water bowl", + "points": 1, + "time_category": "morning", + "schedule_mode": "specific_days", + "due_days": list(_WEEKDAYS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, + { + "name": "Walk dog", + "points": 3, + "time_category": "afternoon", + "schedule_mode": "specific_days", + "due_days": list(_WEEKDAYS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, + { + "name": "Clean litter tray", + "points": 3, + "time_category": "anytime", + "schedule_mode": "specific_days", + "due_days": list(_WEEKDAYS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, ], }, { @@ -82,9 +302,39 @@ "icon": "mdi:book-open-variant", "builtin": True, "chores": [ - {"name": "Do homework", "points": 3, "time_category": "afternoon", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, - {"name": "Read for 20 minutes", "points": 2, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, - {"name": "Practice instrument", "points": 2, "time_category": "anytime", "schedule_mode": "specific_days", "due_days": list(_WEEKDAYS), "requires_approval": False, "assignment_mode": "everyone", "daily_limit": 1, "completion_sound": "coin"}, + { + "name": "Do homework", + "points": 3, + "time_category": "afternoon", + "schedule_mode": "specific_days", + "due_days": list(_WEEKDAYS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, + { + "name": "Read for 20 minutes", + "points": 2, + "time_category": "anytime", + "schedule_mode": "specific_days", + "due_days": list(_WEEKDAYS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, + { + "name": "Practice instrument", + "points": 2, + "time_category": "anytime", + "schedule_mode": "specific_days", + "due_days": list(_WEEKDAYS), + "requires_approval": False, + "assignment_mode": "everyone", + "daily_limit": 1, + "completion_sound": "coin", + }, ], }, ] diff --git a/custom_components/taskmate/todo.py b/custom_components/taskmate/todo.py index b3b612f..9f24532 100644 --- a/custom_components/taskmate/todo.py +++ b/custom_components/taskmate/todo.py @@ -5,6 +5,7 @@ the chore, so the native HA to-do card and voice assistants can drive TaskMate without the custom cards. """ + from __future__ import annotations from homeassistant.components.todo import ( @@ -23,9 +24,7 @@ from .coordinator import TaskMateCoordinator -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: """Set up a to-do list per child, adding new children as they appear.""" coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id] tracked: set[str] = set() diff --git a/custom_components/taskmate/websocket.py b/custom_components/taskmate/websocket.py index 73b87a1..b7c06ff 100644 --- a/custom_components/taskmate/websocket.py +++ b/custom_components/taskmate/websocket.py @@ -44,6 +44,7 @@ All commands require admin. Mutations write through coordinator methods so TaskMate's existing business logic (refunds, cleanup, recompute) runs. """ + from __future__ import annotations import logging @@ -72,102 +73,102 @@ WS_REGISTERED: Final = "ws_registered" # --- command names --------------------------------------------------------- -WS_GET_STATE: Final = "taskmate/get_state" - -WS_ADD_CHILD: Final = "taskmate/add_child" -WS_UPDATE_CHILD: Final = "taskmate/update_child" -WS_REMOVE_CHILD: Final = "taskmate/remove_child" -WS_LIST_HA_USERS: Final = "taskmate/list_ha_users" - -WS_ADD_CHORE: Final = "taskmate/add_chore" -WS_UPDATE_CHORE: Final = "taskmate/update_chore" -WS_REMOVE_CHORE: Final = "taskmate/remove_chore" - -WS_REPORT_FAIRNESS: Final = "taskmate/reports/fairness" -WS_REPORT_FRICTION: Final = "taskmate/reports/friction" -WS_REPORT_PROJECTION: Final = "taskmate/reports/projection" -WS_REPORT_HEALTH: Final = "taskmate/reports/health" -WS_SCHEDULED_LIST: Final = "taskmate/scheduled/list" -WS_SCHEDULED_ADD: Final = "taskmate/scheduled/add" -WS_SCHEDULED_REMOVE: Final = "taskmate/scheduled/remove" - -WS_ADD_REWARD: Final = "taskmate/add_reward" -WS_UPDATE_REWARD: Final = "taskmate/update_reward" -WS_REMOVE_REWARD: Final = "taskmate/remove_reward" - -WS_ADD_PENALTY: Final = "taskmate/add_penalty" -WS_UPDATE_PENALTY: Final = "taskmate/update_penalty" -WS_REMOVE_PENALTY: Final = "taskmate/remove_penalty" -WS_APPLY_PENALTY: Final = "taskmate/apply_penalty" - -WS_ADD_BONUS: Final = "taskmate/add_bonus" -WS_UPDATE_BONUS: Final = "taskmate/update_bonus" -WS_REMOVE_BONUS: Final = "taskmate/remove_bonus" -WS_APPLY_BONUS: Final = "taskmate/apply_bonus" - -WS_CREATE_QUEST: Final = "taskmate/create_quest" -WS_UPDATE_QUEST: Final = "taskmate/update_quest" -WS_DELETE_QUEST: Final = "taskmate/delete_quest" +WS_GET_STATE: Final = "taskmate/get_state" + +WS_ADD_CHILD: Final = "taskmate/add_child" +WS_UPDATE_CHILD: Final = "taskmate/update_child" +WS_REMOVE_CHILD: Final = "taskmate/remove_child" +WS_LIST_HA_USERS: Final = "taskmate/list_ha_users" + +WS_ADD_CHORE: Final = "taskmate/add_chore" +WS_UPDATE_CHORE: Final = "taskmate/update_chore" +WS_REMOVE_CHORE: Final = "taskmate/remove_chore" + +WS_REPORT_FAIRNESS: Final = "taskmate/reports/fairness" +WS_REPORT_FRICTION: Final = "taskmate/reports/friction" +WS_REPORT_PROJECTION: Final = "taskmate/reports/projection" +WS_REPORT_HEALTH: Final = "taskmate/reports/health" +WS_SCHEDULED_LIST: Final = "taskmate/scheduled/list" +WS_SCHEDULED_ADD: Final = "taskmate/scheduled/add" +WS_SCHEDULED_REMOVE: Final = "taskmate/scheduled/remove" + +WS_ADD_REWARD: Final = "taskmate/add_reward" +WS_UPDATE_REWARD: Final = "taskmate/update_reward" +WS_REMOVE_REWARD: Final = "taskmate/remove_reward" + +WS_ADD_PENALTY: Final = "taskmate/add_penalty" +WS_UPDATE_PENALTY: Final = "taskmate/update_penalty" +WS_REMOVE_PENALTY: Final = "taskmate/remove_penalty" +WS_APPLY_PENALTY: Final = "taskmate/apply_penalty" + +WS_ADD_BONUS: Final = "taskmate/add_bonus" +WS_UPDATE_BONUS: Final = "taskmate/update_bonus" +WS_REMOVE_BONUS: Final = "taskmate/remove_bonus" +WS_APPLY_BONUS: Final = "taskmate/apply_bonus" + +WS_CREATE_QUEST: Final = "taskmate/create_quest" +WS_UPDATE_QUEST: Final = "taskmate/update_quest" +WS_DELETE_QUEST: Final = "taskmate/delete_quest" WS_UPDATE_AVATAR_CATALOG: Final = "taskmate/update_avatar_catalog" -WS_SET_CHILD_AVATAR: Final = "taskmate/set_child_avatar" +WS_SET_CHILD_AVATAR: Final = "taskmate/set_child_avatar" -WS_CREATE_CHALLENGE: Final = "taskmate/create_challenge" -WS_UPDATE_CHALLENGE: Final = "taskmate/update_challenge" -WS_DELETE_CHALLENGE: Final = "taskmate/delete_challenge" +WS_CREATE_CHALLENGE: Final = "taskmate/create_challenge" +WS_UPDATE_CHALLENGE: Final = "taskmate/update_challenge" +WS_DELETE_CHALLENGE: Final = "taskmate/delete_challenge" -WS_ADD_TASK_GROUP: Final = "taskmate/add_task_group" -WS_UPDATE_TASK_GROUP: Final = "taskmate/update_task_group" -WS_REMOVE_TASK_GROUP: Final = "taskmate/remove_task_group" +WS_ADD_TASK_GROUP: Final = "taskmate/add_task_group" +WS_UPDATE_TASK_GROUP: Final = "taskmate/update_task_group" +WS_REMOVE_TASK_GROUP: Final = "taskmate/remove_task_group" -WS_UPDATE_SETTINGS: Final = "taskmate/update_settings" +WS_UPDATE_SETTINGS: Final = "taskmate/update_settings" # Operational WS_COMPLETE_BONUS_SUBTASK: Final = "taskmate/complete_bonus_subtask" -WS_APPROVE_CHORE: Final = "taskmate/approve_chore" -WS_APPROVE_ALL_CHORES: Final = "taskmate/approve_all_chores" -WS_REJECT_CHORE: Final = "taskmate/reject_chore" -WS_APPROVE_REWARD: Final = "taskmate/approve_reward" -WS_REJECT_REWARD: Final = "taskmate/reject_reward" -WS_SET_CHORE_ORDER: Final = "taskmate/set_chore_order" +WS_APPROVE_CHORE: Final = "taskmate/approve_chore" +WS_APPROVE_ALL_CHORES: Final = "taskmate/approve_all_chores" +WS_REJECT_CHORE: Final = "taskmate/reject_chore" +WS_APPROVE_REWARD: Final = "taskmate/approve_reward" +WS_REJECT_REWARD: Final = "taskmate/reject_reward" +WS_SET_CHORE_ORDER: Final = "taskmate/set_chore_order" WS_SET_GLOBAL_CHORE_ORDER: Final = "taskmate/set_global_chore_order" -WS_ADD_CHORES_BULK: Final = "taskmate/add_chores_bulk" +WS_ADD_CHORES_BULK: Final = "taskmate/add_chores_bulk" WS_PARENT_COMPLETE_CHORE: Final = "taskmate/parent_complete_chore" # Templates -WS_TEMPLATES_LIST: Final = "taskmate/templates/list" -WS_TEMPLATES_GET: Final = "taskmate/templates/get" -WS_TEMPLATES_APPLY: Final = "taskmate/templates/apply" -WS_TEMPLATES_SAVE_FROM: Final = "taskmate/templates/save_from_chores" -WS_TEMPLATES_CREATE: Final = "taskmate/templates/create" -WS_TEMPLATES_UPDATE: Final = "taskmate/templates/update" -WS_TEMPLATES_EXPORT: Final = "taskmate/templates/export" -WS_TEMPLATES_IMPORT: Final = "taskmate/templates/import" -WS_PRINT_CHART: Final = "taskmate/print/weekly_chart" -WS_TEMPLATES_DELETE: Final = "taskmate/templates/delete" +WS_TEMPLATES_LIST: Final = "taskmate/templates/list" +WS_TEMPLATES_GET: Final = "taskmate/templates/get" +WS_TEMPLATES_APPLY: Final = "taskmate/templates/apply" +WS_TEMPLATES_SAVE_FROM: Final = "taskmate/templates/save_from_chores" +WS_TEMPLATES_CREATE: Final = "taskmate/templates/create" +WS_TEMPLATES_UPDATE: Final = "taskmate/templates/update" +WS_TEMPLATES_EXPORT: Final = "taskmate/templates/export" +WS_TEMPLATES_IMPORT: Final = "taskmate/templates/import" +WS_PRINT_CHART: Final = "taskmate/print/weekly_chart" +WS_TEMPLATES_DELETE: Final = "taskmate/templates/delete" # Notifications -WS_NOTIF_GET_STATE: Final = "taskmate/notifications/get_state" -WS_NOTIF_SET_MASTER: Final = "taskmate/notifications/set_master_enabled" -WS_NOTIF_SET_ROUTE: Final = "taskmate/notifications/set_route" -WS_NOTIF_SET_CHILD_NOTIFY: Final = "taskmate/notifications/set_child_notify" -WS_NOTIF_SET_CHILD_QUIET: Final = "taskmate/notifications/set_child_quiet" -WS_NOTIF_UPSERT_PARENT: Final = "taskmate/notifications/upsert_parent" -WS_NOTIF_DELETE_PARENT: Final = "taskmate/notifications/delete_parent" -WS_NOTIF_UPSERT_CUSTOM: Final = "taskmate/notifications/upsert_custom" -WS_NOTIF_DELETE_CUSTOM: Final = "taskmate/notifications/delete_custom" -WS_NOTIF_LIST_NOTIFY: Final = "taskmate/notifications/list_notify_services" -WS_NOTIF_SET_STREAK_CUTOFF: Final = "taskmate/notifications/set_streak_cutoff" -WS_NOTIF_SET_ESCALATION: Final = "taskmate/notifications/set_escalation" -WS_NOTIF_SEND_TEST: Final = "taskmate/notifications/send_test" -WS_NOTIF_SET_NAV_URL: Final = "taskmate/notifications/set_nav_url" +WS_NOTIF_GET_STATE: Final = "taskmate/notifications/get_state" +WS_NOTIF_SET_MASTER: Final = "taskmate/notifications/set_master_enabled" +WS_NOTIF_SET_ROUTE: Final = "taskmate/notifications/set_route" +WS_NOTIF_SET_CHILD_NOTIFY: Final = "taskmate/notifications/set_child_notify" +WS_NOTIF_SET_CHILD_QUIET: Final = "taskmate/notifications/set_child_quiet" +WS_NOTIF_UPSERT_PARENT: Final = "taskmate/notifications/upsert_parent" +WS_NOTIF_DELETE_PARENT: Final = "taskmate/notifications/delete_parent" +WS_NOTIF_UPSERT_CUSTOM: Final = "taskmate/notifications/upsert_custom" +WS_NOTIF_DELETE_CUSTOM: Final = "taskmate/notifications/delete_custom" +WS_NOTIF_LIST_NOTIFY: Final = "taskmate/notifications/list_notify_services" +WS_NOTIF_SET_STREAK_CUTOFF: Final = "taskmate/notifications/set_streak_cutoff" +WS_NOTIF_SET_ESCALATION: Final = "taskmate/notifications/set_escalation" +WS_NOTIF_SEND_TEST: Final = "taskmate/notifications/send_test" +WS_NOTIF_SET_NAV_URL: Final = "taskmate/notifications/set_nav_url" # Calendar ICS feed (FEAT-10) -WS_CAL_GET_URL: Final = "taskmate/calendar/get_ics_url" +WS_CAL_GET_URL: Final = "taskmate/calendar/get_ics_url" WS_CAL_REGEN_TOKEN: Final = "taskmate/calendar/regenerate_ics_token" # Admin audit log -WS_AUDIT_LIST: Final = "taskmate/audit/list" +WS_AUDIT_LIST: Final = "taskmate/audit/list" WS_AUDIT_CLEAR: Final = "taskmate/audit/clear" # Undo / retract @@ -194,10 +195,21 @@ # Read-only / audit-management commands that should NOT themselves be audited. # Everything else routed through @_admin_only mutates state and is logged. _AUDIT_EXCLUDE: Final = { - WS_GET_STATE, WS_NOTIF_GET_STATE, WS_NOTIF_LIST_NOTIFY, - WS_TEMPLATES_LIST, WS_TEMPLATES_GET, WS_TEMPLATES_EXPORT, WS_PRINT_CHART, - WS_AUDIT_LIST, WS_AUDIT_CLEAR, - WS_CONFIG_EXPORT, WS_SCHEDULED_LIST, WS_REPORT_FAIRNESS, WS_REPORT_FRICTION, WS_REPORT_PROJECTION, WS_REPORT_HEALTH, + WS_GET_STATE, + WS_NOTIF_GET_STATE, + WS_NOTIF_LIST_NOTIFY, + WS_TEMPLATES_LIST, + WS_TEMPLATES_GET, + WS_TEMPLATES_EXPORT, + WS_PRINT_CHART, + WS_AUDIT_LIST, + WS_AUDIT_CLEAR, + WS_CONFIG_EXPORT, + WS_SCHEDULED_LIST, + WS_REPORT_FAIRNESS, + WS_REPORT_FRICTION, + WS_REPORT_PROJECTION, + WS_REPORT_HEALTH, } @@ -216,9 +228,18 @@ def _audit_target(coordinator, msg: dict) -> str: obj = getter(val) return getattr(obj, "name", None) or str(val) for key in ( - "penalty_id", "bonus_id", "badge_id", "group_id", "template_id", - "completion_id", "claim_id", "parent_id", "custom_id", - "awarded_badge_id", "type_id", "transaction_id", + "penalty_id", + "bonus_id", + "badge_id", + "group_id", + "template_id", + "completion_id", + "claim_id", + "parent_id", + "custom_id", + "awarded_badge_id", + "type_id", + "transaction_id", ): if msg.get(key): return str(msg[key]) @@ -234,6 +255,7 @@ def _get_coordinator(hass: HomeAssistant) -> TaskMateCoordinator | None: def _admin_only(handler): """Enforce admin + coordinator availability + uniform error reporting.""" + @wraps(handler) async def wrapper(hass, connection, msg): if not connection.user.is_admin: @@ -266,6 +288,7 @@ async def wrapper(hass, connection, msg): ) except Exception: # noqa: BLE001 _LOGGER.debug("audit record failed for %s", msg.get("type"), exc_info=True) + return wrapper @@ -273,6 +296,7 @@ async def wrapper(hass, connection, msg): # Validators / coercers # --------------------------------------------------------------------------- + def _opt_str(v: Any) -> str: """Coerce optional string field to stripped str (or empty).""" if v is None: @@ -284,6 +308,7 @@ def _opt_str(v: Any) -> str: # State snapshot # --------------------------------------------------------------------------- + def _build_state_snapshot(coordinator: TaskMateCoordinator) -> dict[str, Any]: data = coordinator.storage.data completions = list(data.get("completions", [])) @@ -292,8 +317,7 @@ def _build_state_snapshot(coordinator: TaskMateCoordinator) -> dict[str, Any]: # bearer token, so the auth-gated serve view would 401. Copy each dict so # the expiring signed URL is never written back into storage. completions = [ - {**c, "photo_url": photos.sign_photo_url(coordinator.hass, c["photo_url"])} - if c.get("photo_url") else c + {**c, "photo_url": photos.sign_photo_url(coordinator.hass, c["photo_url"])} if c.get("photo_url") else c for c in completions ] reward_claims = list(data.get("reward_claims", [])) @@ -310,47 +334,46 @@ def _build_state_snapshot(coordinator: TaskMateCoordinator) -> dict[str, Any]: return { "version": "2", - "children": list(data.get("children", [])), + "children": list(data.get("children", [])), # Sign picture URLs so the panel's loads — browsers do not # send the bearer token on image requests, so a bare URL 401s. "chores": [ - {**c, "image_url": images.sign_image_url(coordinator.hass, c["image_url"])} - if c.get("image_url") else c + {**c, "image_url": images.sign_image_url(coordinator.hass, c["image_url"])} if c.get("image_url") else c for c in data.get("chores", []) ], "chore_display_order": list(data.get("chore_display_order", [])), "scheduled_changes": list(data.get("scheduled_changes", [])), - "rewards": list(data.get("rewards", [])), - "penalties": list(data.get("penalties", [])), - "bonuses": list(data.get("bonuses", [])), - "task_groups": list(data.get("task_groups", [])), - "quests": list(data.get("quests", [])), - "quest_progress": dict(data.get("quest_progress", {}) or {}), - "avatar_catalog": coordinator.avatar_catalog(), - "challenges": list(data.get("challenges", [])), + "rewards": list(data.get("rewards", [])), + "penalties": list(data.get("penalties", [])), + "bonuses": list(data.get("bonuses", [])), + "task_groups": list(data.get("task_groups", [])), + "quests": list(data.get("quests", [])), + "quest_progress": dict(data.get("quest_progress", {}) or {}), + "avatar_catalog": coordinator.avatar_catalog(), + "challenges": list(data.get("challenges", [])), "pool_allocations": list(data.get("pool_allocations", [])), - "timed_sessions": list(data.get("timed_sessions", [])), - "templates": coordinator.get_all_templates(), + "timed_sessions": list(data.get("timed_sessions", [])), + "templates": coordinator.get_all_templates(), # Operational state — used by the panel's Activity tab + approval banner - "completions": completions, # all (panel slices for display) - "pending_completions": [c for c in completions if not c.get("approved")], - "reward_claims": reward_claims, + "completions": completions, # all (panel slices for display) + "pending_completions": [c for c in completions if not c.get("approved")], + "reward_claims": reward_claims, "pending_reward_claims": [c for c in reward_claims if not c.get("approved")], - "mandatory_misses": coordinator.mandatory_misses_state(), # missed mandatory chores awaiting review (#532) - "points_transactions": transactions[-100:], # most recent 100 for audit log - "badges": list(data.get("badges", [])), + "mandatory_misses": coordinator.mandatory_misses_state(), # missed mandatory chores awaiting review (#532) + "points_transactions": transactions[-100:], # most recent 100 for audit log + "badges": list(data.get("badges", [])), "awarded_badges": list(data.get("awarded_badges", [])), - "audit_log": coordinator.storage.get_audit_log()[:100], # newest 100 for the panel + "audit_log": coordinator.storage.get_audit_log()[:100], # newest 100 for the panel "swap_requests": [r for r in coordinator.storage.get_swap_requests() if r.get("status") == "pending"], "allowance_payouts": list(reversed(coordinator.storage.get_allowance_payouts()))[:50], # newest first (FEAT-3) "settings": { - "points_name": data.get("points_name", "Stars"), - "points_icon": data.get("points_icon", "mdi:star"), - "card_design": "classic", + "points_name": data.get("points_name", "Stars"), + "points_icon": data.get("points_icon", "mdi:star"), + "card_design": "classic", # Difficulty multiplier defaults; overridden by stored values below. - "difficulty_multiplier_easy": 0.5, + "difficulty_multiplier_easy": 0.5, "difficulty_multiplier_medium": 1.0, - "difficulty_multiplier_hard": 2.0, + "difficulty_multiplier_hard": 2.0, **(data.get("settings", {}) or {}), }, "parent_completable": parent_completable, @@ -368,16 +391,19 @@ async def _ws_get_state(hass, connection, msg, coordinator): # Children # --------------------------------------------------------------------------- -@websocket_api.websocket_command({ - vol.Required("type"): WS_ADD_CHILD, - vol.Required("name"): vol.All(str, vol.Length(min=1, max=120)), - vol.Optional("avatar", default="mdi:account-circle"): str, - vol.Optional("availability_entity", default=""): str, - vol.Optional("availability_inverted", default=False): bool, - vol.Optional("unavailability_entity", default=""): str, - vol.Optional("pause_streak_when_unavailable", default=False): bool, - vol.Optional("linked_user_id", default=""): str, -}) + +@websocket_api.websocket_command( + { + vol.Required("type"): WS_ADD_CHILD, + vol.Required("name"): vol.All(str, vol.Length(min=1, max=120)), + vol.Optional("avatar", default="mdi:account-circle"): str, + vol.Optional("availability_entity", default=""): str, + vol.Optional("availability_inverted", default=False): bool, + vol.Optional("unavailability_entity", default=""): str, + vol.Optional("pause_streak_when_unavailable", default=False): bool, + vol.Optional("linked_user_id", default=""): str, + } +) @websocket_api.async_response @_admin_only async def _ws_add_child(hass, connection, msg, coordinator): @@ -393,19 +419,21 @@ async def _ws_add_child(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": child.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_UPDATE_CHILD, - vol.Required("child_id"): str, - vol.Optional("name"): vol.All(str, vol.Length(min=1, max=120)), - vol.Optional("avatar"): str, - vol.Optional("availability_entity"): str, - vol.Optional("availability_inverted"): bool, - vol.Optional("unavailability_entity"): str, - vol.Optional("pause_streak_when_unavailable"): bool, - vol.Optional("linked_user_id"): str, - vol.Optional("is_guest"): bool, - vol.Optional("guest_expires_on"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_UPDATE_CHILD, + vol.Required("child_id"): str, + vol.Optional("name"): vol.All(str, vol.Length(min=1, max=120)), + vol.Optional("avatar"): str, + vol.Optional("availability_entity"): str, + vol.Optional("availability_inverted"): bool, + vol.Optional("unavailability_entity"): str, + vol.Optional("pause_streak_when_unavailable"): bool, + vol.Optional("linked_user_id"): str, + vol.Optional("is_guest"): bool, + vol.Optional("guest_expires_on"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_update_child(hass, connection, msg, coordinator): @@ -444,10 +472,12 @@ async def _ws_update_child(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": existing.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REMOVE_CHILD, - vol.Required("child_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REMOVE_CHILD, + vol.Required("child_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_remove_child(hass, connection, msg, coordinator): @@ -458,9 +488,11 @@ async def _ws_remove_child(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": msg["child_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_LIST_HA_USERS, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_LIST_HA_USERS, + } +) @websocket_api.async_response @_admin_only async def _ws_list_ha_users(hass, connection, msg, coordinator): @@ -483,6 +515,7 @@ async def _ws_list_ha_users(hass, connection, msg, coordinator): # Chores # --------------------------------------------------------------------------- + def _image_url_or_blank(value): """Accept a blank string (clears the picture) or one of our image URLs. @@ -500,21 +533,52 @@ def _image_url_or_blank(value): # assignment_current_child_id, publish_calendar_published_dates, etc.) is # coordinator-managed runtime state and intentionally not exposed. _CHORE_EDITABLE_FIELDS = { - "name", "description", "points", "assigned_to", "depends_on", "requires_approval", - "time_category", "claim_allowance_minutes", "daily_limit", "completion_sound", - "icon", "image_url", "difficulty", - "schedule_mode", "due_days", "recurrence", "recurrence_day", - "recurrence_start", "first_occurrence_mode", "visibility_entity", - "visibility_state", "visibility_operator", - "weather_entity", "weather_block_conditions", "weather_temp_min", - "weather_temp_max", "weather_wind_max", - "deadline_at", "speed_bonus_points", - "enabled", "expires_on", - "due_time", "early_bonus", "late_penalty", "require_photo", - "mandatory", "mandatory_penalty_points", - "assignment_mode", "assignment_rotation_anchor", "require_availability", - "publish_calendar_entities", "bonus_subtasks", - "task_type", "timed_rate_points", "timed_rate_minutes", "timed_max_daily_minutes", + "name", + "description", + "points", + "assigned_to", + "depends_on", + "requires_approval", + "time_category", + "claim_allowance_minutes", + "daily_limit", + "completion_sound", + "icon", + "image_url", + "difficulty", + "schedule_mode", + "due_days", + "recurrence", + "recurrence_day", + "recurrence_start", + "first_occurrence_mode", + "visibility_entity", + "visibility_state", + "visibility_operator", + "weather_entity", + "weather_block_conditions", + "weather_temp_min", + "weather_temp_max", + "weather_wind_max", + "deadline_at", + "speed_bonus_points", + "enabled", + "expires_on", + "due_time", + "early_bonus", + "late_penalty", + "require_photo", + "mandatory", + "mandatory_penalty_points", + "assignment_mode", + "assignment_rotation_anchor", + "require_availability", + "publish_calendar_entities", + "bonus_subtasks", + "task_type", + "timed_rate_points", + "timed_rate_minutes", + "timed_max_daily_minutes", } @@ -560,16 +624,20 @@ def _chore_payload_schema(*, require_name: bool): vol.Optional("mandatory"): bool, vol.Optional("mandatory_penalty_points"): vol.All(int, vol.Range(min=0)), vol.Optional("require_photo"): bool, - vol.Optional("assignment_mode"): vol.In(["everyone", "alternating", "random", "balanced", "first_come", "unassigned"]), + vol.Optional("assignment_mode"): vol.In( + ["everyone", "alternating", "random", "balanced", "first_come", "unassigned"] + ), vol.Optional("assignment_rotation_anchor"): str, vol.Optional("require_availability"): bool, vol.Optional("publish_calendar_entities"): [str], - vol.Optional("bonus_subtasks"): [{ - vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("points"): vol.All(int, vol.Range(min=0)), - vol.Optional("description"): str, - vol.Optional("id"): str, - }], + vol.Optional("bonus_subtasks"): [ + { + vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("points"): vol.All(int, vol.Range(min=0)), + vol.Optional("description"): str, + vol.Optional("id"): str, + } + ], vol.Optional("task_type"): vol.In(["standard", "timed"]), vol.Optional("timed_rate_points"): vol.All(int, vol.Range(min=1)), vol.Optional("timed_rate_minutes"): vol.All(int, vol.Range(min=1)), @@ -592,11 +660,13 @@ async def _maybe_apply_manual_start(coordinator, chore_id: str, child_id: str | _LOGGER.debug("manual start ignored for %s: %s", chore_id, err) -@websocket_api.websocket_command({ - vol.Required("type"): WS_ADD_CHORE, - vol.Optional("manual_start_child_id"): vol.Any(str, None), - **_chore_payload_schema(require_name=True), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_ADD_CHORE, + vol.Optional("manual_start_child_id"): vol.Any(str, None), + **_chore_payload_schema(require_name=True), + } +) @websocket_api.async_response @_admin_only async def _ws_add_chore(hass, connection, msg, coordinator): @@ -613,9 +683,16 @@ async def _ws_add_chore(hass, connection, msg, coordinator): schedule_mode=msg.get("schedule_mode", "specific_days"), ) extra_fields = (set(msg.keys()) & _CHORE_EDITABLE_FIELDS) - { - "name", "points", "description", "assigned_to", "requires_approval", - "time_category", "claim_allowance_minutes", "daily_limit", - "completion_sound", "schedule_mode", + "name", + "points", + "description", + "assigned_to", + "requires_approval", + "time_category", + "claim_allowance_minutes", + "daily_limit", + "completion_sound", + "schedule_mode", } if extra_fields: for f in extra_fields: @@ -628,12 +705,14 @@ async def _ws_add_chore(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": chore.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_UPDATE_CHORE, - vol.Required("chore_id"): str, - vol.Optional("manual_start_child_id"): vol.Any(str, None), - **_chore_payload_schema(require_name=False), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_UPDATE_CHORE, + vol.Required("chore_id"): str, + vol.Optional("manual_start_child_id"): vol.Any(str, None), + **_chore_payload_schema(require_name=False), + } +) @websocket_api.async_response @_admin_only async def _ws_update_chore(hass, connection, msg, coordinator): @@ -656,10 +735,12 @@ async def _ws_update_chore(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": existing.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REMOVE_CHORE, - vol.Required("chore_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REMOVE_CHORE, + vol.Required("chore_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_remove_chore(hass, connection, msg, coordinator): @@ -670,55 +751,65 @@ async def _ws_remove_chore(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": msg["chore_id"]}) - # --------------------------------------------------------------------------- # Insight reports (#679) # --------------------------------------------------------------------------- -@websocket_api.websocket_command({ - vol.Required("type"): WS_REPORT_FAIRNESS, - vol.Optional("days"): vol.All(int, vol.Range(min=1, max=90)), -}) + +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REPORT_FAIRNESS, + vol.Optional("days"): vol.All(int, vol.Range(min=1, max=90)), + } +) @websocket_api.async_response @_admin_only async def _ws_report_fairness(hass, connection, msg, coordinator): connection.send_result(msg["id"], coordinator.fairness_report(msg.get("days"))) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REPORT_FRICTION, - vol.Optional("days"): vol.All(int, vol.Range(min=1, max=90)), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REPORT_FRICTION, + vol.Optional("days"): vol.All(int, vol.Range(min=1, max=90)), + } +) @websocket_api.async_response @_admin_only async def _ws_report_friction(hass, connection, msg, coordinator): connection.send_result(msg["id"], coordinator.friction_report(msg.get("days"))) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REPORT_PROJECTION, - vol.Optional("days"): vol.All(int, vol.Range(min=1, max=28)), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REPORT_PROJECTION, + vol.Optional("days"): vol.All(int, vol.Range(min=1, max=28)), + } +) @websocket_api.async_response @_admin_only async def _ws_report_projection(hass, connection, msg, coordinator): connection.send_result(msg["id"], coordinator.projection_report(msg.get("days"))) -@websocket_api.websocket_command({ - vol.Required("type"): WS_TEMPLATES_EXPORT, - vol.Optional("template_ids"): [str], -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_TEMPLATES_EXPORT, + vol.Optional("template_ids"): [str], + } +) @websocket_api.async_response @_admin_only async def _ws_templates_export(hass, connection, msg, coordinator): connection.send_result(msg["id"], coordinator.export_templates(msg.get("template_ids"))) -@websocket_api.websocket_command({ - vol.Required("type"): WS_TEMPLATES_IMPORT, - vol.Required("pack"): dict, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_TEMPLATES_IMPORT, + vol.Required("pack"): dict, + } +) @websocket_api.async_response @_admin_only async def _ws_templates_import(hass, connection, msg, coordinator): @@ -730,12 +821,14 @@ async def _ws_templates_import(hass, connection, msg, coordinator): connection.send_result(msg["id"], result) -@websocket_api.websocket_command({ - vol.Required("type"): WS_PRINT_CHART, - vol.Optional("orientation", default="portrait"): vol.In(["portrait", "landscape"]), - vol.Optional("week_start"): str, - vol.Optional("title"): vol.All(str, vol.Length(max=80)), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_PRINT_CHART, + vol.Optional("orientation", default="portrait"): vol.In(["portrait", "landscape"]), + vol.Optional("week_start"): str, + vol.Optional("title"): vol.All(str, vol.Length(max=80)), + } +) @websocket_api.async_response @_admin_only async def _ws_print_chart(hass, connection, msg, coordinator): @@ -775,10 +868,13 @@ async def _ws_report_health(hass, connection, msg, coordinator): # Scheduled config changes (#675) # --------------------------------------------------------------------------- -@websocket_api.websocket_command({ - vol.Required("type"): WS_SCHEDULED_LIST, - vol.Optional("chore_id"): str, -}) + +@websocket_api.websocket_command( + { + vol.Required("type"): WS_SCHEDULED_LIST, + vol.Optional("chore_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_scheduled_list(hass, connection, msg, coordinator): @@ -786,13 +882,15 @@ async def _ws_scheduled_list(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"changes": [c.to_dict() for c in changes]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_SCHEDULED_ADD, - vol.Required("chore_id"): str, - vol.Required("apply_on"): str, - vol.Required("changes"): dict, - vol.Optional("note", default=""): vol.All(str, vol.Length(max=200)), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_SCHEDULED_ADD, + vol.Required("chore_id"): str, + vol.Required("apply_on"): str, + vol.Required("changes"): dict, + vol.Optional("note", default=""): vol.All(str, vol.Length(max=200)), + } +) @websocket_api.async_response @_admin_only async def _ws_scheduled_add(hass, connection, msg, coordinator): @@ -809,10 +907,12 @@ async def _ws_scheduled_add(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": change.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_SCHEDULED_REMOVE, - vol.Required("change_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_SCHEDULED_REMOVE, + vol.Required("change_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_scheduled_remove(hass, connection, msg, coordinator): @@ -828,10 +928,22 @@ async def _ws_scheduled_remove(hass, connection, msg, coordinator): # Rewards # --------------------------------------------------------------------------- -_REWARD_FIELDS = {"name", "cost", "description", "icon", "assigned_to", - "is_jackpot", "pool_enabled", "quantity", "expires_at", - "restock_enabled", "restock_amount", "restock_period", - "unlock_entity", "unlock_minutes"} +_REWARD_FIELDS = { + "name", + "cost", + "description", + "icon", + "assigned_to", + "is_jackpot", + "pool_enabled", + "quantity", + "expires_at", + "restock_enabled", + "restock_amount", + "restock_period", + "unlock_entity", + "unlock_minutes", +} def _reward_payload_schema(*, require_name: bool): @@ -855,10 +967,12 @@ def _reward_payload_schema(*, require_name: bool): } -@websocket_api.websocket_command({ - vol.Required("type"): WS_ADD_REWARD, - **_reward_payload_schema(require_name=True), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_ADD_REWARD, + **_reward_payload_schema(require_name=True), + } +) @websocket_api.async_response @_admin_only async def _ws_add_reward(hass, connection, msg, coordinator): @@ -866,7 +980,8 @@ async def _ws_add_reward(hass, connection, msg, coordinator): # allowlist, at save time, with a message the panel can show. try: unlock_entity, unlock_minutes = coordinator.validate_unlock( - msg.get("unlock_entity", ""), msg.get("unlock_minutes", 0), + msg.get("unlock_entity", ""), + msg.get("unlock_minutes", 0), ) except ValueError as err: connection.send_error(msg["id"], "invalid_format", str(err)) @@ -896,11 +1011,13 @@ async def _ws_add_reward(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": reward.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_UPDATE_REWARD, - vol.Required("reward_id"): str, - **_reward_payload_schema(require_name=False), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_UPDATE_REWARD, + vol.Required("reward_id"): str, + **_reward_payload_schema(require_name=False), + } +) @websocket_api.async_response @_admin_only async def _ws_update_reward(hass, connection, msg, coordinator): @@ -931,10 +1048,12 @@ async def _ws_update_reward(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": existing.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REMOVE_REWARD, - vol.Required("reward_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REMOVE_REWARD, + vol.Required("reward_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_remove_reward(hass, connection, msg, coordinator): @@ -949,17 +1068,20 @@ async def _ws_remove_reward(hass, connection, msg, coordinator): # Quests (chore chains) # --------------------------------------------------------------------------- -@websocket_api.websocket_command({ - vol.Required("type"): WS_CREATE_QUEST, - vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("description", default=""): str, - vol.Optional("icon", default="mdi:map-marker-path"): str, - vol.Required("steps"): vol.All([str], vol.Length(min=1)), - vol.Optional("bonus_points", default=25): vol.All(int, vol.Range(min=0, max=1000000)), - vol.Optional("assigned_to", default=[]): [str], - vol.Optional("repeatable", default=False): bool, - vol.Optional("active", default=True): bool, -}) + +@websocket_api.websocket_command( + { + vol.Required("type"): WS_CREATE_QUEST, + vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("description", default=""): str, + vol.Optional("icon", default="mdi:map-marker-path"): str, + vol.Required("steps"): vol.All([str], vol.Length(min=1)), + vol.Optional("bonus_points", default=25): vol.All(int, vol.Range(min=0, max=1000000)), + vol.Optional("assigned_to", default=[]): [str], + vol.Optional("repeatable", default=False): bool, + vol.Optional("active", default=True): bool, + } +) @websocket_api.async_response @_admin_only async def _ws_create_quest(hass, connection, msg, coordinator): @@ -976,18 +1098,20 @@ async def _ws_create_quest(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": quest_id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_UPDATE_QUEST, - vol.Required("quest_id"): str, - vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("description"): str, - vol.Optional("icon"): str, - vol.Optional("steps"): vol.All([str], vol.Length(min=1)), - vol.Optional("bonus_points"): vol.All(int, vol.Range(min=0, max=1000000)), - vol.Optional("assigned_to"): [str], - vol.Optional("repeatable"): bool, - vol.Optional("active"): bool, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_UPDATE_QUEST, + vol.Required("quest_id"): str, + vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("description"): str, + vol.Optional("icon"): str, + vol.Optional("steps"): vol.All([str], vol.Length(min=1)), + vol.Optional("bonus_points"): vol.All(int, vol.Range(min=0, max=1000000)), + vol.Optional("assigned_to"): [str], + vol.Optional("repeatable"): bool, + vol.Optional("active"): bool, + } +) @websocket_api.async_response @_admin_only async def _ws_update_quest(hass, connection, msg, coordinator): @@ -1002,10 +1126,12 @@ async def _ws_update_quest(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": msg["quest_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_DELETE_QUEST, - vol.Required("quest_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_DELETE_QUEST, + vol.Required("quest_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_delete_quest(hass, connection, msg, coordinator): @@ -1021,16 +1147,21 @@ async def _ws_delete_quest(hass, connection, msg, coordinator): # Avatar unlockables # --------------------------------------------------------------------------- -@websocket_api.websocket_command({ - vol.Required("type"): WS_UPDATE_AVATAR_CATALOG, - vol.Required("catalog"): [{ - vol.Optional("id"): str, - vol.Optional("label"): str, - vol.Required("icon"): str, - vol.Optional("unlock_type"): vol.In(["free", "level", "points", "streak"]), - vol.Optional("unlock_value"): vol.All(vol.Coerce(int), vol.Range(min=0, max=1000000)), - }], -}) + +@websocket_api.websocket_command( + { + vol.Required("type"): WS_UPDATE_AVATAR_CATALOG, + vol.Required("catalog"): [ + { + vol.Optional("id"): str, + vol.Optional("label"): str, + vol.Required("icon"): str, + vol.Optional("unlock_type"): vol.In(["free", "level", "points", "streak"]), + vol.Optional("unlock_value"): vol.All(vol.Coerce(int), vol.Range(min=0, max=1000000)), + } + ], + } +) @websocket_api.async_response @_admin_only async def _ws_update_avatar_catalog(hass, connection, msg, coordinator): @@ -1038,11 +1169,13 @@ async def _ws_update_avatar_catalog(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"count": len(msg["catalog"])}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_SET_CHILD_AVATAR, - vol.Required("child_id"): str, - vol.Required("icon"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_SET_CHILD_AVATAR, + vol.Required("child_id"): str, + vol.Required("icon"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_set_child_avatar(hass, connection, msg, coordinator): @@ -1059,18 +1192,21 @@ async def _ws_set_child_avatar(hass, connection, msg, coordinator): # Challenges (daily / weekly) # --------------------------------------------------------------------------- -@websocket_api.websocket_command({ - vol.Required("type"): WS_CREATE_CHALLENGE, - vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("description", default=""): str, - vol.Optional("icon", default="mdi:trophy-outline"): str, - vol.Optional("scope", default="daily"): vol.In(["daily", "weekly"]), - vol.Optional("metric", default="chores"): vol.In(["chores", "points"]), - vol.Required("target"): vol.All(int, vol.Range(min=1, max=1000000)), - vol.Optional("bonus_points", default=15): vol.All(int, vol.Range(min=0, max=1000000)), - vol.Optional("assigned_to", default=[]): [str], - vol.Optional("active", default=True): bool, -}) + +@websocket_api.websocket_command( + { + vol.Required("type"): WS_CREATE_CHALLENGE, + vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("description", default=""): str, + vol.Optional("icon", default="mdi:trophy-outline"): str, + vol.Optional("scope", default="daily"): vol.In(["daily", "weekly"]), + vol.Optional("metric", default="chores"): vol.In(["chores", "points"]), + vol.Required("target"): vol.All(int, vol.Range(min=1, max=1000000)), + vol.Optional("bonus_points", default=15): vol.All(int, vol.Range(min=0, max=1000000)), + vol.Optional("assigned_to", default=[]): [str], + vol.Optional("active", default=True): bool, + } +) @websocket_api.async_response @_admin_only async def _ws_create_challenge(hass, connection, msg, coordinator): @@ -1088,19 +1224,21 @@ async def _ws_create_challenge(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": challenge_id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_UPDATE_CHALLENGE, - vol.Required("challenge_id"): str, - vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("description"): str, - vol.Optional("icon"): str, - vol.Optional("scope"): vol.In(["daily", "weekly"]), - vol.Optional("metric"): vol.In(["chores", "points"]), - vol.Optional("target"): vol.All(int, vol.Range(min=1, max=1000000)), - vol.Optional("bonus_points"): vol.All(int, vol.Range(min=0, max=1000000)), - vol.Optional("assigned_to"): [str], - vol.Optional("active"): bool, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_UPDATE_CHALLENGE, + vol.Required("challenge_id"): str, + vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("description"): str, + vol.Optional("icon"): str, + vol.Optional("scope"): vol.In(["daily", "weekly"]), + vol.Optional("metric"): vol.In(["chores", "points"]), + vol.Optional("target"): vol.All(int, vol.Range(min=1, max=1000000)), + vol.Optional("bonus_points"): vol.All(int, vol.Range(min=0, max=1000000)), + vol.Optional("assigned_to"): [str], + vol.Optional("active"): bool, + } +) @websocket_api.async_response @_admin_only async def _ws_update_challenge(hass, connection, msg, coordinator): @@ -1115,10 +1253,12 @@ async def _ws_update_challenge(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": msg["challenge_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_DELETE_CHALLENGE, - vol.Required("challenge_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_DELETE_CHALLENGE, + vol.Required("challenge_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_delete_challenge(hass, connection, msg, coordinator): @@ -1134,14 +1274,17 @@ async def _ws_delete_challenge(hass, connection, msg, coordinator): # Penalties # --------------------------------------------------------------------------- -@websocket_api.websocket_command({ - vol.Required("type"): WS_ADD_PENALTY, - vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Required("points"): vol.All(int, vol.Range(min=1)), - vol.Optional("description", default=""): str, - vol.Optional("icon", default="mdi:alert-circle-outline"): str, - vol.Optional("assigned_to", default=[]): [str], -}) + +@websocket_api.websocket_command( + { + vol.Required("type"): WS_ADD_PENALTY, + vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Required("points"): vol.All(int, vol.Range(min=1)), + vol.Optional("description", default=""): str, + vol.Optional("icon", default="mdi:alert-circle-outline"): str, + vol.Optional("assigned_to", default=[]): [str], + } +) @websocket_api.async_response @_admin_only async def _ws_add_penalty(hass, connection, msg, coordinator): @@ -1155,15 +1298,17 @@ async def _ws_add_penalty(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": pen.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_UPDATE_PENALTY, - vol.Required("penalty_id"): str, - vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("points"): vol.All(int, vol.Range(min=1)), - vol.Optional("description"): str, - vol.Optional("icon"): str, - vol.Optional("assigned_to"): [str], -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_UPDATE_PENALTY, + vol.Required("penalty_id"): str, + vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("points"): vol.All(int, vol.Range(min=1)), + vol.Optional("description"): str, + vol.Optional("icon"): str, + vol.Optional("assigned_to"): [str], + } +) @websocket_api.async_response @_admin_only async def _ws_update_penalty(hass, connection, msg, coordinator): @@ -1183,10 +1328,12 @@ async def _ws_update_penalty(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": existing.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REMOVE_PENALTY, - vol.Required("penalty_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REMOVE_PENALTY, + vol.Required("penalty_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_remove_penalty(hass, connection, msg, coordinator): @@ -1197,17 +1344,17 @@ async def _ws_remove_penalty(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": msg["penalty_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_APPLY_PENALTY, - vol.Required("penalty_id"): str, - vol.Required("child_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_APPLY_PENALTY, + vol.Required("penalty_id"): str, + vol.Required("child_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_apply_penalty(hass, connection, msg, coordinator): - await coordinator.async_apply_penalty( - penalty_id=msg["penalty_id"], child_id=msg["child_id"] - ) + await coordinator.async_apply_penalty(penalty_id=msg["penalty_id"], child_id=msg["child_id"]) connection.send_result(msg["id"], {"penalty_id": msg["penalty_id"], "child_id": msg["child_id"]}) @@ -1215,14 +1362,17 @@ async def _ws_apply_penalty(hass, connection, msg, coordinator): # Bonuses (mirror of penalties) # --------------------------------------------------------------------------- -@websocket_api.websocket_command({ - vol.Required("type"): WS_ADD_BONUS, - vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Required("points"): vol.All(int, vol.Range(min=1)), - vol.Optional("description", default=""): str, - vol.Optional("icon", default="mdi:star-circle-outline"): str, - vol.Optional("assigned_to", default=[]): [str], -}) + +@websocket_api.websocket_command( + { + vol.Required("type"): WS_ADD_BONUS, + vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Required("points"): vol.All(int, vol.Range(min=1)), + vol.Optional("description", default=""): str, + vol.Optional("icon", default="mdi:star-circle-outline"): str, + vol.Optional("assigned_to", default=[]): [str], + } +) @websocket_api.async_response @_admin_only async def _ws_add_bonus(hass, connection, msg, coordinator): @@ -1236,15 +1386,17 @@ async def _ws_add_bonus(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": b.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_UPDATE_BONUS, - vol.Required("bonus_id"): str, - vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("points"): vol.All(int, vol.Range(min=1)), - vol.Optional("description"): str, - vol.Optional("icon"): str, - vol.Optional("assigned_to"): [str], -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_UPDATE_BONUS, + vol.Required("bonus_id"): str, + vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("points"): vol.All(int, vol.Range(min=1)), + vol.Optional("description"): str, + vol.Optional("icon"): str, + vol.Optional("assigned_to"): [str], + } +) @websocket_api.async_response @_admin_only async def _ws_update_bonus(hass, connection, msg, coordinator): @@ -1264,10 +1416,12 @@ async def _ws_update_bonus(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": existing.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REMOVE_BONUS, - vol.Required("bonus_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REMOVE_BONUS, + vol.Required("bonus_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_remove_bonus(hass, connection, msg, coordinator): @@ -1278,11 +1432,13 @@ async def _ws_remove_bonus(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": msg["bonus_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_APPLY_BONUS, - vol.Required("bonus_id"): str, - vol.Required("child_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_APPLY_BONUS, + vol.Required("bonus_id"): str, + vol.Required("child_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_apply_bonus(hass, connection, msg, coordinator): @@ -1294,12 +1450,15 @@ async def _ws_apply_bonus(hass, connection, msg, coordinator): # Task groups # --------------------------------------------------------------------------- -@websocket_api.websocket_command({ - vol.Required("type"): WS_ADD_TASK_GROUP, - vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Required("policy"): vol.In(["sticky", "spread"]), - vol.Optional("chore_ids", default=[]): [str], -}) + +@websocket_api.websocket_command( + { + vol.Required("type"): WS_ADD_TASK_GROUP, + vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Required("policy"): vol.In(["sticky", "spread"]), + vol.Optional("chore_ids", default=[]): [str], + } +) @websocket_api.async_response @_admin_only async def _ws_add_task_group(hass, connection, msg, coordinator): @@ -1311,13 +1470,15 @@ async def _ws_add_task_group(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": g.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_UPDATE_TASK_GROUP, - vol.Required("group_id"): str, - vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("policy"): vol.In(["sticky", "spread"]), - vol.Optional("chore_ids"): [str], -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_UPDATE_TASK_GROUP, + vol.Required("group_id"): str, + vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("policy"): vol.In(["sticky", "spread"]), + vol.Optional("chore_ids"): [str], + } +) @websocket_api.async_response @_admin_only async def _ws_update_task_group(hass, connection, msg, coordinator): @@ -1330,10 +1491,12 @@ async def _ws_update_task_group(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": g.id if g else msg["group_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REMOVE_TASK_GROUP, - vol.Required("group_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REMOVE_TASK_GROUP, + vol.Required("group_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_remove_task_group(hass, connection, msg, coordinator): @@ -1354,30 +1517,67 @@ async def _ws_remove_task_group(hass, connection, msg, coordinator): _ALLOWED_CARD_DESIGNS = {"classic", "playroom", "console", "cleanpro", "accessible"} # Settings stored under storage._data["settings"][key] _SUBKEY_SETTINGS = { - "history_days", "streak_reset_mode", "card_design", - "weekend_multiplier", "streak_milestones_enabled", "perfect_week_enabled", - "perfect_week_bonus", "streak_milestones", "quick_point_amounts", - "streak_requires_all_chores", "perfect_week_requires_all_chores", - "difficulty_multiplier_easy", "difficulty_multiplier_medium", "difficulty_multiplier_hard", - "unlock_allowlist", "parent_routing", - "read_aloud_media_player", "read_aloud_tts_entity", "read_aloud_template", - "read_aloud_one_template", "read_aloud_done_template", "read_aloud_joiner", - "roulette_enabled", "roulette_multiplier", "roulette_daily_spins", - "surprise_bonus_enabled", "surprise_bonus_chance", "surprise_bonus_min", "surprise_bonus_max", - "points_decay_enabled", "points_decay_period", "points_decay_percent", + "history_days", + "streak_reset_mode", + "card_design", + "weekend_multiplier", + "streak_milestones_enabled", + "perfect_week_enabled", + "perfect_week_bonus", + "streak_milestones", + "quick_point_amounts", + "streak_requires_all_chores", + "perfect_week_requires_all_chores", + "difficulty_multiplier_easy", + "difficulty_multiplier_medium", + "difficulty_multiplier_hard", + "unlock_allowlist", + "parent_routing", + "read_aloud_media_player", + "read_aloud_tts_entity", + "read_aloud_template", + "read_aloud_one_template", + "read_aloud_done_template", + "read_aloud_joiner", + "roulette_enabled", + "roulette_multiplier", + "roulette_daily_spins", + "surprise_bonus_enabled", + "surprise_bonus_chance", + "surprise_bonus_min", + "surprise_bonus_max", + "points_decay_enabled", + "points_decay_period", + "points_decay_percent", "level_xp_step", - "spend_cap_enabled", "spend_cap_period", "spend_cap_amount", - "interest_enabled", "interest_period", "interest_percent", - "celebration_notify", "celebration_notify_min_tier", + "spend_cap_enabled", + "spend_cap_period", + "spend_cap_amount", + "interest_enabled", + "interest_period", + "interest_percent", + "celebration_notify", + "celebration_notify_min_tier", "allow_negative_balance", - "allowance_enabled", "allowance_rate", "allowance_currency", - "family_goal_enabled", "family_goal_name", "family_goal_target", "family_goal_reward", - "notify_service", "calendar_projection_days", "skip_confirmation_enabled", + "allowance_enabled", + "allowance_rate", + "allowance_currency", + "family_goal_enabled", + "family_goal_name", + "family_goal_target", + "family_goal_reward", + "notify_service", + "calendar_projection_days", + "skip_confirmation_enabled", "vacation_calendar", - "time_morning_start", "time_morning_end", - "time_afternoon_start", "time_afternoon_end", - "time_evening_start", "time_evening_end", - "time_night_start", "time_night_end", + "time_morning_start", + "time_morning_end", + "time_afternoon_start", + "time_afternoon_end", + "time_evening_start", + "time_evening_end", + "time_night_start", + "time_night_end", } @@ -1430,30 +1630,28 @@ def _validate_time_periods(raw: list, coordinator) -> tuple[list[dict] | None, s if start >= end: return None, f"period '{label or pid}' must start before it ends" ids.add(pid) - periods.append({ - "id": pid, - "label": label, - "start": start, - "end": end, - "icon": str(entry.get("icon") or "").strip()[:120] - or TIME_CATEGORY_ICONS.get(pid, "mdi:clock-outline"), - }) + periods.append( + { + "id": pid, + "label": label, + "start": start, + "end": end, + "icon": str(entry.get("icon") or "").strip()[:120] or TIME_CATEGORY_ICONS.get(pid, "mdi:clock-outline"), + } + ) periods.sort(key=lambda p: p["start"]) for prev, cur in zip(periods, periods[1:], strict=False): if cur["start"] < prev["end"]: return None, ( - f"'{cur['label'] or cur['id']}' overlaps " - f"'{prev['label'] or prev['id']}' — periods cannot overlap" + f"'{cur['label'] or cur['id']}' overlaps '{prev['label'] or prev['id']}' — periods cannot overlap" ) removed = {p["id"] for p in coordinator.get_time_periods()} - {p["id"] for p in periods} if removed: - in_use = sorted({ - chore.name or chore.id - for chore in coordinator.storage.get_chores() - if chore.time_category in removed - }) + in_use = sorted( + {chore.name or chore.id for chore in coordinator.storage.get_chores() if chore.time_category in removed} + ) if in_use: return None, ( "cannot delete a period still used by chores: " @@ -1495,14 +1693,17 @@ def _validate_vacation_periods(raw: list) -> tuple[list[dict] | None, str | None if not pid or pid in taken: pid = f"{start.isoformat()}_{len(periods)}" taken.add(pid) - periods.append({ - "id": pid, - "name": str(entry.get("name") or "").strip(), - "start": start.isoformat(), - "end": end.isoformat(), - }) + periods.append( + { + "id": pid, + "name": str(entry.get("name") or "").strip(), + "start": start.isoformat(), + "end": end.isoformat(), + } + ) return sorted(periods, key=lambda p: p["start"]), None + # Extracted to a module constant so the accepted settings keys can be unit-tested # (the websocket_command decorator does not expose the compiled schema). Every key # accepted here must also be routed in _ws_update_settings below. @@ -1635,12 +1836,15 @@ async def _ws_update_settings(hass, connection, msg, coordinator): # Operational — approval / rejection / reorder / bulk add # --------------------------------------------------------------------------- -@websocket_api.websocket_command({ - vol.Required("type"): WS_COMPLETE_BONUS_SUBTASK, - vol.Required("chore_id"): str, - vol.Required("bonus_subtask_id"): str, - vol.Required("child_id"): str, -}) + +@websocket_api.websocket_command( + { + vol.Required("type"): WS_COMPLETE_BONUS_SUBTASK, + vol.Required("chore_id"): str, + vol.Required("bonus_subtask_id"): str, + vol.Required("child_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_complete_bonus_subtask(hass, connection, msg, coordinator): @@ -1650,10 +1854,12 @@ async def _ws_complete_bonus_subtask(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": completion.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_APPROVE_CHORE, - vol.Required("completion_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_APPROVE_CHORE, + vol.Required("completion_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_approve_chore(hass, connection, msg, coordinator): @@ -1661,10 +1867,12 @@ async def _ws_approve_chore(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"completion_id": msg["completion_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_APPROVE_ALL_CHORES, - vol.Optional("completion_ids"): [str], -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_APPROVE_ALL_CHORES, + vol.Optional("completion_ids"): [str], + } +) @websocket_api.async_response @_admin_only async def _ws_approve_all_chores(hass, connection, msg, coordinator): @@ -1672,10 +1880,12 @@ async def _ws_approve_all_chores(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"count": count}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REJECT_CHORE, - vol.Required("completion_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REJECT_CHORE, + vol.Required("completion_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_reject_chore(hass, connection, msg, coordinator): @@ -1683,10 +1893,12 @@ async def _ws_reject_chore(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"completion_id": msg["completion_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_APPROVE_REWARD, - vol.Required("claim_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_APPROVE_REWARD, + vol.Required("claim_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_approve_reward(hass, connection, msg, coordinator): @@ -1694,10 +1906,12 @@ async def _ws_approve_reward(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"claim_id": msg["claim_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REJECT_REWARD, - vol.Required("claim_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REJECT_REWARD, + vol.Required("claim_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_reject_reward(hass, connection, msg, coordinator): @@ -1705,10 +1919,12 @@ async def _ws_reject_reward(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"claim_id": msg["claim_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_PARENT_COMPLETE_CHORE, - vol.Required("chore_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_PARENT_COMPLETE_CHORE, + vol.Required("chore_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_parent_complete_chore(hass, connection, msg, coordinator): @@ -1716,11 +1932,13 @@ async def _ws_parent_complete_chore(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"ok": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_SET_CHORE_ORDER, - vol.Required("child_id"): str, - vol.Required("chore_order"): [str], -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_SET_CHORE_ORDER, + vol.Required("child_id"): str, + vol.Required("chore_order"): [str], + } +) @websocket_api.async_response @_admin_only async def _ws_set_chore_order(hass, connection, msg, coordinator): @@ -1728,10 +1946,12 @@ async def _ws_set_chore_order(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"child_id": msg["child_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_SET_GLOBAL_CHORE_ORDER, - vol.Required("chore_order"): [str], -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_SET_GLOBAL_CHORE_ORDER, + vol.Required("chore_order"): [str], + } +) @websocket_api.async_response @_admin_only async def _ws_set_global_chore_order(hass, connection, msg, coordinator): @@ -1739,18 +1959,20 @@ async def _ws_set_global_chore_order(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"ok": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_ADD_CHORES_BULK, - vol.Required("chore_names"): [str], - vol.Optional("points"): vol.All(int, vol.Range(min=0)), - vol.Optional("assigned_to"): [str], - vol.Optional("requires_approval"): bool, - vol.Optional("time_category"): str, - vol.Optional("schedule_mode"): vol.In(["specific_days", "recurring", "one_shot"]), - vol.Optional("due_days"): [str], - vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)), - vol.Optional("completion_sound"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_ADD_CHORES_BULK, + vol.Required("chore_names"): [str], + vol.Optional("points"): vol.All(int, vol.Range(min=0)), + vol.Optional("assigned_to"): [str], + vol.Optional("requires_approval"): bool, + vol.Optional("time_category"): str, + vol.Optional("schedule_mode"): vol.In(["specific_days", "recurring", "one_shot"]), + vol.Optional("due_days"): [str], + vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)), + vol.Optional("completion_sound"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_add_chores_bulk(hass, connection, msg, coordinator): @@ -1776,6 +1998,7 @@ async def _ws_add_chores_bulk(hass, connection, msg, coordinator): # Templates # --------------------------------------------------------------------------- + @websocket_api.websocket_command({vol.Required("type"): WS_TEMPLATES_LIST}) @websocket_api.async_response @_admin_only @@ -1783,10 +2006,12 @@ async def _ws_templates_list(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"templates": coordinator.get_all_templates()}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_TEMPLATES_GET, - vol.Required("template_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_TEMPLATES_GET, + vol.Required("template_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_templates_get(hass, connection, msg, coordinator): @@ -1797,34 +2022,40 @@ async def _ws_templates_get(hass, connection, msg, coordinator): connection.send_result(msg["id"], tpl) -@websocket_api.websocket_command({ - vol.Required("type"): WS_TEMPLATES_APPLY, - vol.Required("chores"): [{ - vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("points"): vol.All(int, vol.Range(min=0)), - vol.Optional("description"): str, - vol.Optional("assigned_to"): [str], - vol.Optional("requires_approval"): bool, - vol.Optional("time_category"): str, - vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)), - vol.Optional("completion_sound"): str, - vol.Optional("schedule_mode"): vol.In(["specific_days", "recurring", "one_shot"]), - vol.Optional("due_days"): [str], - vol.Optional("recurrence"): str, - vol.Optional("recurrence_day"): str, - vol.Optional("recurrence_start"): str, - vol.Optional("first_occurrence_mode"): str, - vol.Optional("assignment_mode"): vol.In(["everyone", "alternating", "random", "balanced", "first_come", "unassigned"]), - vol.Optional("require_availability"): bool, - vol.Optional("visibility_entity"): str, - vol.Optional("visibility_state"): str, - vol.Optional("visibility_operator"): str, - vol.Optional("task_type"): vol.In(["standard", "timed"]), - vol.Optional("timed_rate_points"): vol.All(int, vol.Range(min=1)), - vol.Optional("timed_rate_minutes"): vol.All(int, vol.Range(min=1)), - vol.Optional("timed_max_daily_minutes"): vol.All(int, vol.Range(min=0)), - }], -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_TEMPLATES_APPLY, + vol.Required("chores"): [ + { + vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("points"): vol.All(int, vol.Range(min=0)), + vol.Optional("description"): str, + vol.Optional("assigned_to"): [str], + vol.Optional("requires_approval"): bool, + vol.Optional("time_category"): str, + vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)), + vol.Optional("completion_sound"): str, + vol.Optional("schedule_mode"): vol.In(["specific_days", "recurring", "one_shot"]), + vol.Optional("due_days"): [str], + vol.Optional("recurrence"): str, + vol.Optional("recurrence_day"): str, + vol.Optional("recurrence_start"): str, + vol.Optional("first_occurrence_mode"): str, + vol.Optional("assignment_mode"): vol.In( + ["everyone", "alternating", "random", "balanced", "first_come", "unassigned"] + ), + vol.Optional("require_availability"): bool, + vol.Optional("visibility_entity"): str, + vol.Optional("visibility_state"): str, + vol.Optional("visibility_operator"): str, + vol.Optional("task_type"): vol.In(["standard", "timed"]), + vol.Optional("timed_rate_points"): vol.All(int, vol.Range(min=1)), + vol.Optional("timed_rate_minutes"): vol.All(int, vol.Range(min=1)), + vol.Optional("timed_max_daily_minutes"): vol.All(int, vol.Range(min=0)), + } + ], + } +) @websocket_api.async_response @_admin_only async def _ws_templates_apply(hass, connection, msg, coordinator): @@ -1832,12 +2063,14 @@ async def _ws_templates_apply(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"created_ids": created_ids}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_TEMPLATES_SAVE_FROM, - vol.Required("chore_ids"): vol.All([str], vol.Length(min=1)), - vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("icon", default="mdi:clipboard-list"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_TEMPLATES_SAVE_FROM, + vol.Required("chore_ids"): vol.All([str], vol.Length(min=1)), + vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("icon", default="mdi:clipboard-list"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_templates_save_from(hass, connection, msg, coordinator): @@ -1849,12 +2082,14 @@ async def _ws_templates_save_from(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"template_id": tpl_id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_TEMPLATES_CREATE, - vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("icon", default="mdi:clipboard-list"): str, - vol.Required("chores"): vol.All(list, vol.Length(min=1)), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_TEMPLATES_CREATE, + vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("icon", default="mdi:clipboard-list"): str, + vol.Required("chores"): vol.All(list, vol.Length(min=1)), + } +) @websocket_api.async_response @_admin_only async def _ws_templates_create(hass, connection, msg, coordinator): @@ -1866,13 +2101,15 @@ async def _ws_templates_create(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"template_id": tpl_id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_TEMPLATES_UPDATE, - vol.Required("template_id"): str, - vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), - vol.Optional("icon"): str, - vol.Optional("chores"): vol.All(list, vol.Length(min=1)), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_TEMPLATES_UPDATE, + vol.Required("template_id"): str, + vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)), + vol.Optional("icon"): str, + vol.Optional("chores"): vol.All(list, vol.Length(min=1)), + } +) @websocket_api.async_response @_admin_only async def _ws_templates_update(hass, connection, msg, coordinator): @@ -1885,10 +2122,12 @@ async def _ws_templates_update(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"success": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_TEMPLATES_DELETE, - vol.Required("template_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_TEMPLATES_DELETE, + vol.Required("template_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_templates_delete(hass, connection, msg, coordinator): @@ -1900,11 +2139,13 @@ async def _ws_templates_delete(hass, connection, msg, coordinator): # Notifications # --------------------------------------------------------------------------- + @websocket_api.websocket_command({vol.Required("type"): WS_NOTIF_GET_STATE}) @websocket_api.async_response @_admin_only async def ws_notif_get_state(hass, connection, msg, coordinator): from .coord_notifications import NOTIFICATION_TYPES + c = coordinator state = { "recipients": { @@ -1931,28 +2172,25 @@ async def ws_notif_get_state(hass, connection, msg, coordinator): } for t in NOTIFICATION_TYPES ], - "config": { - tid: cfg.to_dict() - for tid, cfg in c.storage.get_all_notification_configs().items() - }, + "config": {tid: cfg.to_dict() for tid, cfg in c.storage.get_all_notification_configs().items()}, "custom": [n.to_dict() for n in c.storage.get_custom_notifications()], "settings": { "streak_at_risk_cutoff_time": c.storage.get_streak_at_risk_cutoff(), "mandatory_escalation_reminder_minutes": c.storage.get_escalation_reminder_minutes(), "mandatory_escalation_parent_minutes": c.storage.get_escalation_parent_minutes(), - "notification_nav_url": c.storage.get_setting( - "notification_nav_url", DEFAULT_NOTIFICATION_NAV_URL - ), + "notification_nav_url": c.storage.get_setting("notification_nav_url", DEFAULT_NOTIFICATION_NAV_URL), }, } connection.send_result(msg["id"], state) -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_SET_MASTER, - vol.Required("type_id"): str, - vol.Required("enabled"): bool, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_SET_MASTER, + vol.Required("type_id"): str, + vol.Required("enabled"): bool, + } +) @websocket_api.async_response @_admin_only async def ws_notif_set_master(hass, connection, msg, coordinator): @@ -1960,27 +2198,32 @@ async def ws_notif_set_master(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"ok": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_SET_ROUTE, - vol.Required("type_id"): str, - vol.Required("recipient_id"): str, - vol.Required("enabled"): bool, - vol.Optional("time"): vol.Any(str, None), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_SET_ROUTE, + vol.Required("type_id"): str, + vol.Required("recipient_id"): str, + vol.Required("enabled"): bool, + vol.Optional("time"): vol.Any(str, None), + } +) @websocket_api.async_response @_admin_only async def ws_notif_set_route(hass, connection, msg, coordinator): from .models import NotificationRoute + route = NotificationRoute(enabled=msg["enabled"], time=msg.get("time")) await coordinator.notifications.set_route(msg["type_id"], msg["recipient_id"], route) connection.send_result(msg["id"], {"ok": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_SET_CHILD_NOTIFY, - vol.Required("child_id"): str, - vol.Required("notify_service"): vol.Any(str, None), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_SET_CHILD_NOTIFY, + vol.Required("child_id"): str, + vol.Required("notify_service"): vol.Any(str, None), + } +) @websocket_api.async_response @_admin_only async def ws_notif_set_child_notify(hass, connection, msg, coordinator): @@ -2011,12 +2254,14 @@ def _validate_hhmm_or_empty(value): return f"{hour:02d}:{minute:02d}" -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_SET_CHILD_QUIET, - vol.Required("child_id"): str, - vol.Required("quiet_hours_start"): _validate_hhmm_or_empty, - vol.Required("quiet_hours_end"): _validate_hhmm_or_empty, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_SET_CHILD_QUIET, + vol.Required("child_id"): str, + vol.Required("quiet_hours_start"): _validate_hhmm_or_empty, + vol.Required("quiet_hours_end"): _validate_hhmm_or_empty, + } +) @websocket_api.async_response @_admin_only async def ws_notif_set_child_quiet(hass, connection, msg, coordinator): @@ -2032,24 +2277,25 @@ async def ws_notif_set_child_quiet(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"ok": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_UPSERT_PARENT, - vol.Optional("parent_id"): str, - vol.Required("name"): str, - vol.Required("notify_service"): str, - vol.Optional("enabled", default=True): bool, - vol.Optional("presence_entity", default=""): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_UPSERT_PARENT, + vol.Optional("parent_id"): str, + vol.Required("name"): str, + vol.Required("notify_service"): str, + vol.Optional("enabled", default=True): bool, + vol.Optional("presence_entity", default=""): str, + } +) @websocket_api.async_response @_admin_only async def ws_notif_upsert_parent(hass, connection, msg, coordinator): from .models import ParentRecipient + c = coordinator p_id = msg.get("parent_id") if p_id: - existing = next( - (p for p in c.storage.get_parent_recipients() if p.id == p_id), None - ) + existing = next((p for p in c.storage.get_parent_recipients() if p.id == p_id), None) if existing is None: connection.send_error(msg["id"], "not_found", "Parent not found") return @@ -2070,10 +2316,12 @@ async def ws_notif_upsert_parent(hass, connection, msg, coordinator): connection.send_result(msg["id"], p.to_dict()) -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_DELETE_PARENT, - vol.Required("parent_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_DELETE_PARENT, + vol.Required("parent_id"): str, + } +) @websocket_api.async_response @_admin_only async def ws_notif_delete_parent(hass, connection, msg, coordinator): @@ -2081,38 +2329,45 @@ async def ws_notif_delete_parent(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"ok": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_UPSERT_CUSTOM, - vol.Optional("custom_id"): str, - vol.Required("name"): str, - vol.Required("message_template"): str, - vol.Required("time"): str, - vol.Optional("day_mask", default=0b1111111): int, - vol.Optional("recipient_ids", default=list): list, - vol.Optional("enabled", default=True): bool, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_UPSERT_CUSTOM, + vol.Optional("custom_id"): str, + vol.Required("name"): str, + vol.Required("message_template"): str, + vol.Required("time"): str, + vol.Optional("day_mask", default=0b1111111): int, + vol.Optional("recipient_ids", default=list): list, + vol.Optional("enabled", default=True): bool, + } +) @websocket_api.async_response @_admin_only async def ws_notif_upsert_custom(hass, connection, msg, coordinator): from .models import CustomNotification + c = coordinator - n = CustomNotification.from_dict({ - "id": msg.get("custom_id"), - "name": msg["name"], - "message_template": msg["message_template"], - "time": msg["time"], - "day_mask": int(msg["day_mask"]), - "recipient_ids": list(msg["recipient_ids"]), - "enabled": bool(msg["enabled"]), - }) + n = CustomNotification.from_dict( + { + "id": msg.get("custom_id"), + "name": msg["name"], + "message_template": msg["message_template"], + "time": msg["time"], + "day_mask": int(msg["day_mask"]), + "recipient_ids": list(msg["recipient_ids"]), + "enabled": bool(msg["enabled"]), + } + ) await c.notifications.upsert_custom(n) connection.send_result(msg["id"], n.to_dict()) -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_DELETE_CUSTOM, - vol.Required("custom_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_DELETE_CUSTOM, + vol.Required("custom_id"): str, + } +) @websocket_api.async_response @_admin_only async def ws_notif_delete_custom(hass, connection, msg, coordinator): @@ -2124,18 +2379,17 @@ async def ws_notif_delete_custom(hass, connection, msg, coordinator): @websocket_api.async_response @_admin_only async def ws_notif_list_notify(hass, connection, msg, coordinator): - services = [ - f"notify.{name}" - for name in hass.services.async_services().get("notify", {}) - ] + services = [f"notify.{name}" for name in hass.services.async_services().get("notify", {})] services.sort() connection.send_result(msg["id"], services) -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_SET_STREAK_CUTOFF, - vol.Required("time"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_SET_STREAK_CUTOFF, + vol.Required("time"): str, + } +) @websocket_api.async_response @_admin_only async def ws_notif_set_streak_cutoff(hass, connection, msg, coordinator): @@ -2143,25 +2397,27 @@ async def ws_notif_set_streak_cutoff(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"ok": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_SET_ESCALATION, - vol.Required("reminder_minutes"): vol.All(vol.Coerce(int), vol.Range(min=1, max=1440)), - vol.Required("parent_minutes"): vol.All(vol.Coerce(int), vol.Range(min=1, max=1440)), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_SET_ESCALATION, + vol.Required("reminder_minutes"): vol.All(vol.Coerce(int), vol.Range(min=1, max=1440)), + vol.Required("parent_minutes"): vol.All(vol.Coerce(int), vol.Range(min=1, max=1440)), + } +) @websocket_api.async_response @_admin_only async def ws_notif_set_escalation(hass, connection, msg, coordinator): - coordinator.storage.set_escalation_minutes( - msg["reminder_minutes"], msg["parent_minutes"] - ) + coordinator.storage.set_escalation_minutes(msg["reminder_minutes"], msg["parent_minutes"]) await coordinator.storage.async_save() connection.send_result(msg["id"], {"ok": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_SEND_TEST, - vol.Required("type_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_SEND_TEST, + vol.Required("type_id"): str, + } +) @websocket_api.async_response @_admin_only async def ws_notif_send_test(hass, connection, msg, coordinator): @@ -2169,11 +2425,13 @@ async def ws_notif_send_test(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"sent": sent}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_NOTIF_SET_NAV_URL, - vol.Optional("type_id"): vol.Any(str, None), - vol.Required("nav_url"): vol.All(str, vol.Length(max=200)), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_NOTIF_SET_NAV_URL, + vol.Optional("type_id"): vol.Any(str, None), + vol.Required("nav_url"): vol.All(str, vol.Length(max=200)), + } +) @websocket_api.async_response @_admin_only async def ws_notif_set_nav_url(hass, connection, msg, coordinator): @@ -2185,11 +2443,14 @@ async def ws_notif_set_nav_url(hass, connection, msg, coordinator): # Calendar ICS feed (FEAT-10) # --------------------------------------------------------------------------- + def _build_ics_url(hass, token: str) -> str: from .http_calendar import CALENDAR_URL + base = "" try: from homeassistant.helpers.network import get_url + base = get_url(hass, prefer_external=True) except Exception: # noqa: BLE001 - no configured URL yet base = "" @@ -2216,6 +2477,7 @@ async def ws_cal_regen_token(hass, connection, msg, coordinator): # Admin audit log # --------------------------------------------------------------------------- + @websocket_api.websocket_command({vol.Required("type"): WS_AUDIT_LIST}) @websocket_api.async_response @_admin_only @@ -2232,10 +2494,12 @@ async def _ws_audit_clear(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"cleared": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_UNDO_TRANSACTION, - vol.Required("transaction_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_UNDO_TRANSACTION, + vol.Required("transaction_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_undo_transaction(hass, connection, msg, coordinator): @@ -2243,10 +2507,12 @@ async def _ws_undo_transaction(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"undone": msg["transaction_id"]}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_CLONE_CHORE, - vol.Required("chore_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_CLONE_CHORE, + vol.Required("chore_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_clone_chore(hass, connection, msg, coordinator): @@ -2254,27 +2520,33 @@ async def _ws_clone_chore(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": clone.id}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_BULK_CHORE_ACTION, - vol.Required("action"): vol.In(["delete", "enable", "disable", "reassign"]), - vol.Required("chore_ids"): [str], - vol.Optional("assigned_to"): [str], -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_BULK_CHORE_ACTION, + vol.Required("action"): vol.In(["delete", "enable", "disable", "reassign"]), + vol.Required("chore_ids"): [str], + vol.Optional("assigned_to"): [str], + } +) @websocket_api.async_response @_admin_only async def _ws_bulk_chore_action(hass, connection, msg, coordinator): count = await coordinator.async_bulk_chore_action( - msg["action"], msg["chore_ids"], msg.get("assigned_to"), + msg["action"], + msg["chore_ids"], + msg.get("assigned_to"), ) connection.send_result(msg["id"], {"count": count}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_GIFT_POINTS, - vol.Required("from_child_id"): str, - vol.Required("to_child_id"): str, - vol.Required("points"): vol.All(int, vol.Range(min=1)), -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_GIFT_POINTS, + vol.Required("from_child_id"): str, + vol.Required("to_child_id"): str, + vol.Required("points"): vol.All(int, vol.Range(min=1)), + } +) @websocket_api.async_response @_admin_only async def _ws_gift_points(hass, connection, msg, coordinator): @@ -2282,11 +2554,13 @@ async def _ws_gift_points(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"ok": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REQUEST_SWAP, - vol.Required("chore_id"): str, - vol.Required("requester_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REQUEST_SWAP, + vol.Required("chore_id"): str, + vol.Required("requester_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_request_swap(hass, connection, msg, coordinator): @@ -2294,10 +2568,12 @@ async def _ws_request_swap(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": rid}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_APPROVE_SWAP, - vol.Required("request_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_APPROVE_SWAP, + vol.Required("request_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_approve_swap(hass, connection, msg, coordinator): @@ -2305,10 +2581,12 @@ async def _ws_approve_swap(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"ok": True}) -@websocket_api.websocket_command({ - vol.Required("type"): WS_REJECT_SWAP, - vol.Required("request_id"): str, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_REJECT_SWAP, + vol.Required("request_id"): str, + } +) @websocket_api.async_response @_admin_only async def _ws_reject_swap(hass, connection, msg, coordinator): @@ -2323,10 +2601,12 @@ async def _ws_config_export(hass, connection, msg, coordinator): connection.send_result(msg["id"], coordinator.export_config()) -@websocket_api.websocket_command({ - vol.Required("type"): WS_CONFIG_IMPORT, - vol.Required("payload"): dict, -}) +@websocket_api.websocket_command( + { + vol.Required("type"): WS_CONFIG_IMPORT, + vol.Required("payload"): dict, + } +) @websocket_api.async_response @_admin_only async def _ws_config_import(hass, connection, msg, coordinator): @@ -2340,37 +2620,90 @@ async def _ws_config_import(hass, connection, msg, coordinator): _COMMANDS = ( _ws_get_state, - _ws_audit_list, _ws_audit_clear, _ws_undo_transaction, - _ws_add_child, _ws_update_child, _ws_remove_child, _ws_list_ha_users, - _ws_add_chore, _ws_update_chore, _ws_remove_chore, _ws_clone_chore, - _ws_scheduled_list, _ws_scheduled_add, _ws_scheduled_remove, - _ws_report_fairness, _ws_report_friction, _ws_report_projection, _ws_report_health, - _ws_templates_export, _ws_templates_import, _ws_print_chart, - _ws_bulk_chore_action, _ws_gift_points, - _ws_request_swap, _ws_approve_swap, _ws_reject_swap, - _ws_config_export, _ws_config_import, - _ws_add_reward, _ws_update_reward, _ws_remove_reward, - _ws_create_quest, _ws_update_quest, _ws_delete_quest, - _ws_update_avatar_catalog, _ws_set_child_avatar, - _ws_create_challenge, _ws_update_challenge, _ws_delete_challenge, - _ws_add_penalty, _ws_update_penalty, _ws_remove_penalty, _ws_apply_penalty, - _ws_add_bonus, _ws_update_bonus, _ws_remove_bonus, _ws_apply_bonus, - _ws_add_task_group, _ws_update_task_group, _ws_remove_task_group, + _ws_audit_list, + _ws_audit_clear, + _ws_undo_transaction, + _ws_add_child, + _ws_update_child, + _ws_remove_child, + _ws_list_ha_users, + _ws_add_chore, + _ws_update_chore, + _ws_remove_chore, + _ws_clone_chore, + _ws_scheduled_list, + _ws_scheduled_add, + _ws_scheduled_remove, + _ws_report_fairness, + _ws_report_friction, + _ws_report_projection, + _ws_report_health, + _ws_templates_export, + _ws_templates_import, + _ws_print_chart, + _ws_bulk_chore_action, + _ws_gift_points, + _ws_request_swap, + _ws_approve_swap, + _ws_reject_swap, + _ws_config_export, + _ws_config_import, + _ws_add_reward, + _ws_update_reward, + _ws_remove_reward, + _ws_create_quest, + _ws_update_quest, + _ws_delete_quest, + _ws_update_avatar_catalog, + _ws_set_child_avatar, + _ws_create_challenge, + _ws_update_challenge, + _ws_delete_challenge, + _ws_add_penalty, + _ws_update_penalty, + _ws_remove_penalty, + _ws_apply_penalty, + _ws_add_bonus, + _ws_update_bonus, + _ws_remove_bonus, + _ws_apply_bonus, + _ws_add_task_group, + _ws_update_task_group, + _ws_remove_task_group, _ws_update_settings, _ws_complete_bonus_subtask, - _ws_approve_chore, _ws_approve_all_chores, _ws_reject_chore, _ws_approve_reward, _ws_reject_reward, + _ws_approve_chore, + _ws_approve_all_chores, + _ws_reject_chore, + _ws_approve_reward, + _ws_reject_reward, _ws_parent_complete_chore, - _ws_set_chore_order, _ws_set_global_chore_order, _ws_add_chores_bulk, - _ws_templates_list, _ws_templates_get, _ws_templates_apply, - _ws_templates_save_from, _ws_templates_create, _ws_templates_update, + _ws_set_chore_order, + _ws_set_global_chore_order, + _ws_add_chores_bulk, + _ws_templates_list, + _ws_templates_get, + _ws_templates_apply, + _ws_templates_save_from, + _ws_templates_create, + _ws_templates_update, _ws_templates_delete, - ws_notif_get_state, ws_notif_set_master, ws_notif_set_route, - ws_notif_set_child_notify, ws_notif_set_child_quiet, - ws_notif_upsert_parent, ws_notif_delete_parent, - ws_notif_upsert_custom, ws_notif_delete_custom, - ws_notif_list_notify, ws_notif_set_streak_cutoff, ws_notif_send_test, - ws_notif_set_escalation, ws_notif_set_nav_url, - ws_cal_get_url, ws_cal_regen_token, + ws_notif_get_state, + ws_notif_set_master, + ws_notif_set_route, + ws_notif_set_child_notify, + ws_notif_set_child_quiet, + ws_notif_upsert_parent, + ws_notif_delete_parent, + ws_notif_upsert_custom, + ws_notif_delete_custom, + ws_notif_list_notify, + ws_notif_set_streak_cutoff, + ws_notif_send_test, + ws_notif_set_escalation, + ws_notif_set_nav_url, + ws_cal_get_url, + ws_cal_regen_token, ) diff --git a/scripts/check_data_files.py b/scripts/check_data_files.py index eb57cfc..cb1b4b7 100644 --- a/scripts/check_data_files.py +++ b/scripts/check_data_files.py @@ -88,7 +88,9 @@ def check_metadata(problems: list[str]) -> None: # HACS is configured for zip_release — release-zip.yml must keep producing # this exact filename or the integration becomes uninstallable. if hacs.get("zip_release") and hacs.get("filename") != "taskmate.zip": - problems.append(f"hacs.json: zip_release is on but filename is '{hacs.get('filename')}', expected 'taskmate.zip'") + problems.append( + f"hacs.json: zip_release is on but filename is '{hacs.get('filename')}', expected 'taskmate.zip'" + ) print(f" OK manifest version {manifest.get('version')}, hacs filename {hacs.get('filename')}") diff --git a/tests/conftest.py b/tests/conftest.py index 467f73e..75dd8d5 100755 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ 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 @@ -23,6 +24,7 @@ # ── homeassistant.core ────────────────────────────────────────────────────── + class FakeHass: """Minimal mock of HomeAssistant.""" @@ -40,12 +42,13 @@ def async_create_task(self, coro): _ha_core = MagicMock() _ha_core.HomeAssistant = FakeHass -_ha_core.callback = lambda f: f # pass-through decorator +_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.""" @@ -71,6 +74,7 @@ def __init__(self, coordinator): # ── homeassistant.helpers.storage ─────────────────────────────────────────── + class FakeStore: """In-memory Store substitute that avoids the filesystem.""" @@ -95,6 +99,7 @@ def async_delay_save(self, data_func, delay=0): # ── homeassistant.components.websocket_api ─────────────────────────────────── # Decorators must be pass-throughs so handler functions remain awaitable in tests. + def _ws_command_decorator(schema): """Return the handler unchanged — schema is ignored in tests.""" return lambda f: f @@ -117,6 +122,7 @@ def _ws_async_response(f): # ── homeassistant.exceptions ──────────────────────────────────────────────── + class FakeUnauthorized(Exception): """Stand-in for homeassistant.exceptions.Unauthorized.""" @@ -147,6 +153,7 @@ def __init__(self, *args, **kwargs): # ever touched for type hints and base-class inheritance; a MagicMock class # suffices for unit tests. + class _FakeSensorEntity: pass @@ -362,6 +369,7 @@ def as_local(dt: _dt.datetime) -> _dt.datetime: # Pytest fixtures # --------------------------------------------------------------------------- + @pytest.fixture def hass(): """Return a fresh FakeHass instance.""" diff --git a/tests/test_all_cards_use_design_system.py b/tests/test_all_cards_use_design_system.py index 98fea9d..5e01a4d 100644 --- a/tests/test_all_cards_use_design_system.py +++ b/tests/test_all_cards_use_design_system.py @@ -10,6 +10,7 @@ to a functional test — a card that ignores the design tokens renders perfectly, just always in the classic look. """ + from __future__ import annotations import pathlib @@ -57,12 +58,7 @@ def test_cards_that_name_the_design_layer_actually_use_it(): src = f.read_text(encoding="utf-8") if "__taskmate_design" not in src: continue - uses_it = ( - ".styles()" in src - or "editorOptions" in src - or ".apply(" in src - or ".resolve(" in src - ) + uses_it = ".styles()" in src or "editorOptions" in src or ".apply(" in src or ".resolve(" in src if not uses_it: offenders.append(f.name) assert offenders == [], f"cards that name the design layer but never use it: {offenders}" diff --git a/tests/test_allowance_ledger.py b/tests/test_allowance_ledger.py index d9c7676..39abc83 100644 --- a/tests/test_allowance_ledger.py +++ b/tests/test_allowance_ledger.py @@ -1,4 +1,5 @@ """Tests for the allowance payout ledger (FEAT-3).""" + from __future__ import annotations from unittest.mock import AsyncMock, MagicMock diff --git a/tests/test_approval_photo_push.py b/tests/test_approval_photo_push.py index d43508a..1b81cd8 100644 --- a/tests/test_approval_photo_push.py +++ b/tests/test_approval_photo_push.py @@ -4,6 +4,7 @@ the room. The photo is signed before it reaches the notifier, because the companion app fetches attachments without the user's bearer token. """ + from __future__ import annotations from unittest.mock import AsyncMock, MagicMock @@ -91,14 +92,18 @@ def test_context_signs_the_photo_url(self): before the notifier sees it.""" source = ( __import__("pathlib").Path(__file__).resolve().parent.parent - / "custom_components" / "taskmate" / "coord_points.py" + / "custom_components" + / "taskmate" + / "coord_points.py" ).read_text(encoding="utf-8") assert "photos.sign_photo_url(self.hass, photo_url)" in source def test_completion_photo_is_passed_to_the_notifier(self): source = ( __import__("pathlib").Path(__file__).resolve().parent.parent - / "custom_components" / "taskmate" / "coord_chores.py" + / "custom_components" + / "taskmate" + / "coord_chores.py" ).read_text(encoding="utf-8") assert "photo_url=completion.photo_url" in source @@ -110,17 +115,21 @@ def test_signing_import_is_inside_the_try(self): its "never break delivery" contract exists to prevent.""" source = ( __import__("pathlib").Path(__file__).resolve().parent.parent - / "custom_components" / "taskmate" / "photos.py" + / "custom_components" + / "taskmate" + / "photos.py" ).read_text(encoding="utf-8") - body = source[source.index("def sign_photo_url"):source.index("async def async_delete_photo")] + body = source[source.index("def sign_photo_url") : source.index("async def async_delete_photo")] try_at = body.index("try:") import_at = body.index("from homeassistant.components.http.auth import async_sign_path") assert try_at < import_at, "the HA import must sit inside the try block" def test_unsignable_url_falls_back_to_the_original(self): from custom_components.taskmate import photos + assert photos.sign_photo_url(None, "/api/taskmate/photo/x.jpg") == "/api/taskmate/photo/x.jpg" def test_foreign_urls_are_returned_untouched(self): from custom_components.taskmate import photos + assert photos.sign_photo_url(None, "https://evil.example/x.jpg") == "https://evil.example/x.jpg" diff --git a/tests/test_approve_all_chores.py b/tests/test_approve_all_chores.py index 5fc6672..35ed390 100644 --- a/tests/test_approve_all_chores.py +++ b/tests/test_approve_all_chores.py @@ -1,4 +1,5 @@ """Tests for bulk chore approval (Approve All).""" + from __future__ import annotations import asyncio @@ -19,8 +20,11 @@ def run(coro): def _completion(cid, approved=False): return ChoreCompletion( - chore_id="ch1", child_id="k1", - completed_at=datetime.now(timezone.utc), approved=approved, id=cid, + chore_id="ch1", + child_id="k1", + completed_at=datetime.now(timezone.utc), + approved=approved, + id=cid, ) diff --git a/tests/test_assignment_modes.py b/tests/test_assignment_modes.py index 1d93215..c53fd59 100644 --- a/tests/test_assignment_modes.py +++ b/tests/test_assignment_modes.py @@ -8,6 +8,7 @@ - Scale smoke test: 50 chores x 5 children x 3 calendars run concurrently. - Storage round-trip keeps legacy chores backwards-compatible. """ + from __future__ import annotations import datetime as dt @@ -58,9 +59,11 @@ def _get_chore(cid): storage.get_chore = MagicMock(side_effect=_get_chore) storage.add_chore = MagicMock(side_effect=stored_chores.append) - storage.update_chore = MagicMock(side_effect=lambda chore: stored_chores.__setitem__( - next(i for i, c in enumerate(stored_chores) if c.id == chore.id), chore - )) + storage.update_chore = MagicMock( + side_effect=lambda chore: stored_chores.__setitem__( + next(i for i, c in enumerate(stored_chores) if c.id == chore.id), chore + ) + ) storage.async_save = AsyncMock() # Task group stubs — tests that don't touch groups still work because @@ -94,7 +97,8 @@ def _update_task_group(group): storage.remove_chore_from_task_groups = MagicMock( side_effect=lambda cid: [ setattr(g, "chore_ids", [c for c in g.chore_ids if c != cid]) - for g in stored_task_groups if cid in (g.chore_ids or []) + for g in stored_task_groups + if cid in (g.chore_ids or []) ] ) @@ -126,8 +130,12 @@ def test_compute_active_alternating_rotates_day_by_day(): # Three-child rotation wraps correctly c = Child(name="C") coord2 = _coord([a, b, c]) - chore2 = Chore(name="Table", assigned_to=[a.id, b.id, c.id], assignment_mode="alternating", - assignment_rotation_anchor=anchor.isoformat()) + chore2 = Chore( + name="Table", + assigned_to=[a.id, b.id, c.id], + assignment_mode="alternating", + assignment_rotation_anchor=anchor.isoformat(), + ) picks = [coord2._compute_active_children(chore2, anchor + dt.timedelta(days=i))[0] for i in range(4)] assert picks == [a.id, b.id, c.id, a.id] @@ -158,8 +166,7 @@ def test_compute_active_random_varies_by_date(): kids = [Child(name=f"K{i}") for i in range(5)] coord = _coord(kids) chore = Chore(name="Roulette", assigned_to=[c.id for c in kids], assignment_mode="random") - picks = {coord._compute_active_children(chore, date(2026, 4, 20) + dt.timedelta(days=i))[0] - for i in range(14)} + picks = {coord._compute_active_children(chore, date(2026, 4, 20) + dt.timedelta(days=i))[0] for i in range(14)} assert len(picks) >= 2 @@ -206,8 +213,10 @@ def test_rotation_chore_clears_for_pool_when_off_rotation_child_completes(): # Parent credits B (off-rotation) — completion lands on B today. completion = ChoreCompletion( - chore_id=chore.id, child_id=b.id, - completed_at=dt_util_mock.now(), approved=True, + chore_id=chore.id, + child_id=b.id, + completed_at=dt_util_mock.now(), + approved=True, ) coord.storage.get_completions = MagicMock(return_value=[completion]) @@ -251,8 +260,10 @@ def test_rotation_chore_with_pending_bonus_subtasks_stays_visible(): # A completes the parent — daily_limit is filled (1/1) but the bonus # sub-task is still pending, so the chore must stay visible. parent_completion = ChoreCompletion( - chore_id=chore.id, child_id=a.id, - completed_at=dt_util_mock.now(), approved=True, + chore_id=chore.id, + child_id=a.id, + completed_at=dt_util_mock.now(), + approved=True, ) coord.storage.get_completions = MagicMock(return_value=[parent_completion]) assert coord._is_rotation_done_today(chore) is False @@ -260,13 +271,13 @@ def test_rotation_chore_with_pending_bonus_subtasks_stays_visible(): # Once A completes the bonus sub-task, the chore is fully done and hides. bonus_completion = ChoreCompletion( - chore_id=chore.id, child_id=a.id, - completed_at=dt_util_mock.now(), approved=True, + chore_id=chore.id, + child_id=a.id, + completed_at=dt_util_mock.now(), + approved=True, bonus_subtask_id=bonus.id, ) - coord.storage.get_completions = MagicMock( - return_value=[parent_completion, bonus_completion] - ) + coord.storage.get_completions = MagicMock(return_value=[parent_completion, bonus_completion]) assert coord._is_rotation_done_today(chore) is True assert coord.is_chore_available_for_child(chore, a.id) is False @@ -281,8 +292,10 @@ def test_rotation_chore_with_pending_bonus_subtasks_stays_visible(): ) chore_no_bonus.assignment_current_child_id = a.id plain_completion = ChoreCompletion( - chore_id=chore_no_bonus.id, child_id=a.id, - completed_at=dt_util_mock.now(), approved=True, + chore_id=chore_no_bonus.id, + child_id=a.id, + completed_at=dt_util_mock.now(), + approved=True, ) coord.storage.get_completions = MagicMock(return_value=[plain_completion]) assert coord._is_rotation_done_today(chore_no_bonus) is True @@ -300,8 +313,10 @@ def test_everyone_mode_unaffected_by_rotation_done_helper(): dt_util_mock._now = dt.datetime.combine(today, dt.time(12, 0), tzinfo=UTC) chore = Chore(name="Brush teeth", assigned_to=[a.id, b.id]) # default everyone completion = ChoreCompletion( - chore_id=chore.id, child_id=a.id, - completed_at=dt_util_mock.now(), approved=True, + chore_id=chore.id, + child_id=a.id, + completed_at=dt_util_mock.now(), + approved=True, ) coord.storage.get_completions = MagicMock(return_value=[completion]) assert coord._is_rotation_done_today(chore) is False @@ -346,13 +361,15 @@ def test_async_add_chore_publishes_immediately(): a, b = Child(name="A"), Child(name="B") coord = _coord([a, b]) dt_util_mock._now = dt.datetime(2026, 4, 20, 10, 0, tzinfo=UTC) - chore = run_async(coord.async_add_chore( - name="Trash day", - assigned_to=[a.id, b.id], - assignment_mode="alternating", - assignment_rotation_anchor="2026-04-20", - publish_calendar_entities=["calendar.kids", "calendar.family"], - )) + chore = run_async( + coord.async_add_chore( + name="Trash day", + assigned_to=[a.id, b.id], + assignment_mode="alternating", + assignment_rotation_anchor="2026-04-20", + publish_calendar_entities=["calendar.kids", "calendar.family"], + ) + ) assert coord.hass.services.async_call.await_count == 2 assert chore.publish_calendar_published_dates == ["2026-04-20"] assert chore.assignment_current_child_id == a.id @@ -362,11 +379,13 @@ def test_async_update_chore_republishes_on_name_change(): a = Child(name="A") coord = _coord([a]) dt_util_mock._now = dt.datetime(2026, 4, 20, 10, 0, tzinfo=UTC) - chore = run_async(coord.async_add_chore( - name="Old", - assigned_to=[a.id], - publish_calendar_entities=["calendar.x"], - )) + chore = run_async( + coord.async_add_chore( + name="Old", + assigned_to=[a.id], + publish_calendar_entities=["calendar.x"], + ) + ) # One create_event from the initial add services = [c.args[1] for c in coord.hass.services.async_call.await_args_list] assert services == ["create_event"] @@ -515,11 +534,13 @@ def test_update_chore_cleans_up_old_events_before_republishing(): coord = _coord([a]) dt_util_mock._now = dt.datetime(2026, 4, 20, 10, 0, tzinfo=UTC) - chore = run_async(coord.async_add_chore( - name="Old Name", - assigned_to=[a.id], - publish_calendar_entities=["calendar.x"], - )) + chore = run_async( + coord.async_add_chore( + name="Old Name", + assigned_to=[a.id], + publish_calendar_entities=["calendar.x"], + ) + ) marker = coord._chore_event_marker(chore) # 1 publish from create. assert coord.hass.services.async_call.await_count == 1 @@ -527,11 +548,16 @@ def test_update_chore_cleans_up_old_events_before_republishing(): # Mock calendar.get_events to return one event that carries our marker. async def _service_side_effect(domain, service, data, *args, **kwargs): if service == "get_events": - return {data["entity_id"]: {"events": [ - {"uid": "evt-abc", "summary": "Old Name — A", "description": marker}, - {"uid": "evt-foreign", "summary": "Unrelated", "description": ""}, - ]}} + return { + data["entity_id"]: { + "events": [ + {"uid": "evt-abc", "summary": "Old Name — A", "description": marker}, + {"uid": "evt-foreign", "summary": "Unrelated", "description": ""}, + ] + } + } return None + coord.hass.services.async_call.side_effect = _service_side_effect edited = coord.storage.get_chore(chore.id) @@ -552,20 +578,27 @@ def test_remove_chore_cleans_up_events(): a = Child(name="A") coord = _coord([a]) dt_util_mock._now = dt.datetime(2026, 4, 20, 10, 0, tzinfo=UTC) - chore = run_async(coord.async_add_chore( - name="Trash", - assigned_to=[a.id], - publish_calendar_entities=["calendar.family"], - )) + chore = run_async( + coord.async_add_chore( + name="Trash", + assigned_to=[a.id], + publish_calendar_entities=["calendar.family"], + ) + ) marker = coord._chore_event_marker(chore) # Fake a stored event we previously published. async def _service_side_effect(domain, service, data, *args, **kwargs): if service == "get_events": - return {data["entity_id"]: {"events": [ - {"uid": "evt-purge-me", "summary": "Trash — A", "description": marker}, - ]}} + return { + data["entity_id"]: { + "events": [ + {"uid": "evt-purge-me", "summary": "Trash — A", "description": marker}, + ] + } + } return None + coord.hass.services.async_call.side_effect = _service_side_effect # Storage needs these mocks for async_remove_chore's cleanup pass coord.storage.remove_chore = MagicMock() @@ -618,7 +651,7 @@ def __init__(self, entity_id: str) -> None: def _states_lookup(mapping: dict[str, str]): """Build a hass.states.get(entity_id) stub from an {entity_id: state} dict.""" - return MagicMock(side_effect=lambda eid: (_FakeState(mapping[eid]) if eid in mapping else None)) + return MagicMock(side_effect=lambda eid: _FakeState(mapping[eid]) if eid in mapping else None) class TestAvailabilityAwareAssignment: @@ -631,7 +664,8 @@ def test_require_availability_off_ignores_entity(self): coord.hass.states.get = _states_lookup({"binary_sensor.a": "off"}) anchor = date(2026, 4, 20) chore = Chore( - name="X", assigned_to=[a.id, b.id], + name="X", + assigned_to=[a.id, b.id], assignment_mode="alternating", assignment_rotation_anchor=anchor.isoformat(), ) @@ -645,7 +679,8 @@ def test_alternating_skips_unavailable_child(self): coord.hass.states.get = _states_lookup({"binary_sensor.a": "off"}) anchor = date(2026, 4, 20) chore = Chore( - name="X", assigned_to=[a.id, b.id], + name="X", + assigned_to=[a.id, b.id], assignment_mode="alternating", assignment_rotation_anchor=anchor.isoformat(), require_availability=True, @@ -666,14 +701,18 @@ def test_random_skips_unavailable_child(self): coord.hass.states.get = _states_lookup({"binary_sensor.a": "on"}) today = date(2026, 4, 20) base = Chore( - name="R", assigned_to=[a.id, b.id], - assignment_mode="random", require_availability=True, + name="R", + assigned_to=[a.id, b.id], + assignment_mode="random", + require_availability=True, id="reward_alpha", ) original_pick = coord._compute_active_children(base, today)[0] - coord.hass.states.get = _states_lookup({ - "binary_sensor.a": "off" if original_pick == a.id else "on", - }) + coord.hass.states.get = _states_lookup( + { + "binary_sensor.a": "off" if original_pick == a.id else "on", + } + ) # Only flip the originally picked child away — if it was A, A's sensor # is off; if it was B, A stays on and we'd expect no change. result = coord._compute_active_children(base, today)[0] @@ -689,9 +728,13 @@ def test_balanced_skips_unavailable_child(self): coord.hass.states.get = _states_lookup({"binary_sensor.a": "off"}) today = date(2026, 4, 20) chores = [ - Chore(name=f"C{i}", assigned_to=[a.id, b.id], - assignment_mode="balanced", require_availability=True, - id=f"c{i}") + Chore( + name=f"C{i}", + assigned_to=[a.id, b.id], + assignment_mode="balanced", + require_availability=True, + id=f"c{i}", + ) for i in range(4) ] for c in chores: @@ -713,12 +756,11 @@ def test_all_unavailable_hides_chore(self): a = Child(name="A", availability_entity="binary_sensor.a", id="kidA") b = Child(name="B", availability_entity="binary_sensor.b", id="kidB") coord = _coord([a, b]) - coord.hass.states.get = _states_lookup( - {"binary_sensor.a": "off", "binary_sensor.b": "off"} - ) + coord.hass.states.get = _states_lookup({"binary_sensor.a": "off", "binary_sensor.b": "off"}) anchor = date(2026, 4, 20) chore = Chore( - name="X", assigned_to=[a.id, b.id], + name="X", + assigned_to=[a.id, b.id], assignment_mode="alternating", assignment_rotation_anchor=anchor.isoformat(), require_availability=True, @@ -732,7 +774,8 @@ def test_missing_entity_treated_as_available(self): coord.hass.states.get = _states_lookup({}) # nothing registered anchor = date(2026, 4, 20) chore = Chore( - name="X", assigned_to=[a.id, b.id], + name="X", + assigned_to=[a.id, b.id], assignment_mode="alternating", assignment_rotation_anchor=anchor.isoformat(), require_availability=True, @@ -747,7 +790,8 @@ def test_unknown_state_treated_as_available(self): coord.hass.states.get = _states_lookup({"binary_sensor.a": "unknown"}) anchor = date(2026, 4, 20) chore = Chore( - name="X", assigned_to=[a.id, b.id], + name="X", + assigned_to=[a.id, b.id], assignment_mode="alternating", assignment_rotation_anchor=anchor.isoformat(), require_availability=True, @@ -761,7 +805,8 @@ def test_child_without_availability_entity_always_available(self): coord.hass.states.get = MagicMock(return_value=None) anchor = date(2026, 4, 20) chore = Chore( - name="X", assigned_to=[a.id, b.id], + name="X", + assigned_to=[a.id, b.id], assignment_mode="alternating", assignment_rotation_anchor=anchor.isoformat(), require_availability=True, @@ -776,7 +821,8 @@ def test_state_change_event_reassigns_chore(self): # Seed: A is home, chore picks A. coord.hass.states.get = _states_lookup({"binary_sensor.a": "on"}) chore = Chore( - name="X", assigned_to=[a.id, b.id], + name="X", + assigned_to=[a.id, b.id], assignment_mode="alternating", assignment_rotation_anchor=anchor.isoformat(), require_availability=True, @@ -798,7 +844,8 @@ def test_state_change_event_skips_completed_chore(self): anchor = date(2026, 4, 20) coord.hass.states.get = _states_lookup({"binary_sensor.a": "off"}) chore = Chore( - name="X", assigned_to=[a.id, b.id], + name="X", + assigned_to=[a.id, b.id], assignment_mode="alternating", assignment_rotation_anchor=anchor.isoformat(), require_availability=True, @@ -809,10 +856,13 @@ def test_state_change_event_skips_completed_chore(self): # Today in dt_util_mock is 2024-03-20 (see conftest). from custom_components.taskmate.models import ChoreCompletion + today_dt = dt_util_mock.now() completion = ChoreCompletion( - chore_id="chore1", child_id=a.id, - completed_at=today_dt, approved=True, + chore_id="chore1", + child_id=a.id, + completed_at=today_dt, + approved=True, ) coord.storage.get_completions = MagicMock(return_value=[completion]) @@ -833,9 +883,11 @@ def test_state_change_for_unrelated_entity_is_ignored(self): # short-circuits before scheduling). called = [] original = coord.hass.async_create_task + def _capture(coro): called.append(coro) return original(coro) if callable(original) else None + coord.hass.async_create_task = _capture coord._availability_state_changed(event) assert called == [] @@ -845,11 +897,13 @@ def _capture(coro): # Skip / Manual-start / Task group coverage # --------------------------------------------------------------------------- + class TestSkipChore: """Skip advances today's rotation pointer; tomorrow resumes original schedule.""" def test_skip_advances_alternating_pointer_today_only(self): from custom_components.taskmate.models import TaskGroup # noqa: F401 (ensure importable) + a, b, c = Child(name="A"), Child(name="B"), Child(name="C") coord = _coord([a, b, c]) anchor = date(2026, 4, 20) @@ -991,13 +1045,15 @@ def test_manual_start_alternating_reorders_pool(self): coord = _coord([a, b, c]) anchor = date(2026, 4, 20) dt_util_mock._now = dt.datetime.combine(anchor, dt.time(12, 0), tzinfo=UTC) - chore = run_async(coord.async_add_chore( - name="Bins", - assigned_to=[a.id, b.id, c.id], - assignment_mode="alternating", - assignment_rotation_anchor=anchor.isoformat(), - manual_start_child_id=b.id, - )) + chore = run_async( + coord.async_add_chore( + name="Bins", + assigned_to=[a.id, b.id, c.id], + assignment_mode="alternating", + assignment_rotation_anchor=anchor.isoformat(), + manual_start_child_id=b.id, + ) + ) # Pool should now start with B (today's active child). assert chore.assigned_to[0] == b.id assert chore.assignment_current_child_id == b.id @@ -1010,12 +1066,14 @@ def test_manual_start_random_pins_today_only(self): coord = _coord([a, b]) today = date(2026, 4, 20) dt_util_mock._now = dt.datetime.combine(today, dt.time(12, 0), tzinfo=UTC) - chore = run_async(coord.async_add_chore( - name="Roulette", - assigned_to=[a.id, b.id], - assignment_mode="random", - manual_start_child_id=a.id, - )) + chore = run_async( + coord.async_add_chore( + name="Roulette", + assigned_to=[a.id, b.id], + assignment_mode="random", + manual_start_child_id=a.id, + ) + ) assert chore.assignment_current_child_id == a.id @@ -1077,10 +1135,18 @@ def test_spread_gives_distinct_children(self): a, b = Child(name="A"), Child(name="B") coord = _coord([a, b]) today = date(2026, 4, 20) - c1 = Chore(name="AM", assigned_to=[a.id, b.id], assignment_mode="alternating", - assignment_rotation_anchor=today.isoformat()) - c2 = Chore(name="PM", assigned_to=[a.id, b.id], assignment_mode="alternating", - assignment_rotation_anchor=today.isoformat()) + c1 = Chore( + name="AM", + assigned_to=[a.id, b.id], + assignment_mode="alternating", + assignment_rotation_anchor=today.isoformat(), + ) + c2 = Chore( + name="PM", + assigned_to=[a.id, b.id], + assignment_mode="alternating", + assignment_rotation_anchor=today.isoformat(), + ) coord.storage.add_chore(c1) coord.storage.add_chore(c2) run_async(coord.async_add_task_group(name="Cat litter", policy="spread", chore_ids=[c1.id, c2.id])) @@ -1102,9 +1168,7 @@ def test_spread_wraps_when_group_larger_than_pool(self): ) coord.storage.add_chore(ch) chores.append(ch) - run_async(coord.async_add_task_group( - name="Big", policy="spread", chore_ids=[c.id for c in chores] - )) + run_async(coord.async_add_task_group(name="Big", policy="spread", chore_ids=[c.id for c in chores])) daily = coord._compute_daily_assignments(today) picks = [daily[c.id] for c in chores] @@ -1121,9 +1185,7 @@ def test_everyone_mode_chore_cannot_join_group(self): chore = Chore(name="Brush", assigned_to=[a.id, b.id]) # everyone coord.storage.add_chore(chore) try: - run_async(coord.async_add_task_group( - name="Bad", policy="sticky", chore_ids=[chore.id] - )) + run_async(coord.async_add_task_group(name="Bad", policy="sticky", chore_ids=[chore.id])) except ValueError as err: assert "everyone" in str(err).lower() return @@ -1132,17 +1194,17 @@ def test_everyone_mode_chore_cannot_join_group(self): def test_chore_cannot_belong_to_two_groups(self): a, b = Child(name="A"), Child(name="B") coord = _coord([a, b]) - c1 = Chore(name="C1", assigned_to=[a.id, b.id], assignment_mode="alternating", - assignment_rotation_anchor="2026-04-20") - c2 = Chore(name="C2", assigned_to=[a.id, b.id], assignment_mode="alternating", - assignment_rotation_anchor="2026-04-20") + c1 = Chore( + name="C1", assigned_to=[a.id, b.id], assignment_mode="alternating", assignment_rotation_anchor="2026-04-20" + ) + c2 = Chore( + name="C2", assigned_to=[a.id, b.id], assignment_mode="alternating", assignment_rotation_anchor="2026-04-20" + ) coord.storage.add_chore(c1) coord.storage.add_chore(c2) run_async(coord.async_add_task_group(name="G1", policy="sticky", chore_ids=[c1.id, c2.id])) try: - run_async(coord.async_add_task_group( - name="G2", policy="spread", chore_ids=[c1.id] - )) + run_async(coord.async_add_task_group(name="G2", policy="spread", chore_ids=[c1.id])) except ValueError as err: assert "group" in str(err).lower() return @@ -1151,10 +1213,12 @@ def test_chore_cannot_belong_to_two_groups(self): def test_skip_on_sticky_follower_rejected(self): a, b = Child(name="A"), Child(name="B") coord = _coord([a, b]) - leader = Chore(name="L", assigned_to=[a.id, b.id], assignment_mode="alternating", - assignment_rotation_anchor="2026-04-20") - follower = Chore(name="F", assigned_to=[a.id, b.id], assignment_mode="alternating", - assignment_rotation_anchor="2026-04-20") + leader = Chore( + name="L", assigned_to=[a.id, b.id], assignment_mode="alternating", assignment_rotation_anchor="2026-04-20" + ) + follower = Chore( + name="F", assigned_to=[a.id, b.id], assignment_mode="alternating", assignment_rotation_anchor="2026-04-20" + ) coord.storage.add_chore(leader) coord.storage.add_chore(follower) run_async(coord.async_add_task_group(name="G", policy="sticky", chore_ids=[leader.id, follower.id])) @@ -1171,8 +1235,12 @@ def test_skip_on_sticky_leader_propagates_to_followers(self): a, b = Child(name="A"), Child(name="B") coord = _coord([a, b]) anchor = date(2026, 4, 20) - leader = Chore(name="L", assigned_to=[a.id, b.id], assignment_mode="alternating", - assignment_rotation_anchor=anchor.isoformat()) + leader = Chore( + name="L", + assigned_to=[a.id, b.id], + assignment_mode="alternating", + assignment_rotation_anchor=anchor.isoformat(), + ) follower = Chore(name="F", assigned_to=[a.id, b.id], assignment_mode="random") coord.storage.add_chore(leader) coord.storage.add_chore(follower) @@ -1198,10 +1266,12 @@ class TestRemoveChoreFromGroups: def test_remove_chore_strips_from_group(self): a, b = Child(name="A"), Child(name="B") coord = _coord([a, b]) - c1 = Chore(name="C1", assigned_to=[a.id, b.id], assignment_mode="alternating", - assignment_rotation_anchor="2026-04-20") - c2 = Chore(name="C2", assigned_to=[a.id, b.id], assignment_mode="alternating", - assignment_rotation_anchor="2026-04-20") + c1 = Chore( + name="C1", assigned_to=[a.id, b.id], assignment_mode="alternating", assignment_rotation_anchor="2026-04-20" + ) + c2 = Chore( + name="C2", assigned_to=[a.id, b.id], assignment_mode="alternating", assignment_rotation_anchor="2026-04-20" + ) coord.storage.add_chore(c1) coord.storage.add_chore(c2) run_async(coord.async_add_task_group(name="G", policy="sticky", chore_ids=[c1.id, c2.id])) @@ -1226,8 +1296,7 @@ def test_add_chore_accepts_first_come_mode(): coord.storage.get_completions = MagicMock(return_value=[]) dt_util_mock._now = dt.datetime.combine(date(2026, 4, 20), dt.time(9, 0), tzinfo=UTC) - chore = run_async(coord.async_add_chore(name="Feed cat", assigned_to=[a.id, b.id], - assignment_mode="first_come")) + chore = run_async(coord.async_add_chore(name="Feed cat", assigned_to=[a.id, b.id], assignment_mode="first_come")) assert chore.assignment_mode == "first_come" # first_come has no single active child cached. assert chore.assignment_current_child_id == "" @@ -1270,8 +1339,7 @@ def test_first_come_first_claim_hides_for_others_and_reopens_on_reject(): chore = Chore(name="Feed cat", assigned_to=[a.id, b.id], assignment_mode="first_come") # A claims (pending approval -- approved=False still fills the quota). - claim = ChoreCompletion(chore_id=chore.id, child_id=a.id, - completed_at=dt_util_mock.now(), approved=False) + claim = ChoreCompletion(chore_id=chore.id, child_id=a.id, completed_at=dt_util_mock.now(), approved=False) coord.storage.get_completions = MagicMock(return_value=[claim]) assert coord._is_rotation_done_today(chore) is True assert coord.is_chore_available_for_child(chore, a.id) is False @@ -1293,10 +1361,8 @@ def test_first_come_clamps_quota_to_one_winner(): today = date(2026, 4, 20) dt_util_mock._now = dt.datetime.combine(today, dt.time(9, 0), tzinfo=UTC) # Even if daily_limit is mis-set to 2, first_come allows only one winner. - chore = Chore(name="Feed cat", assigned_to=[a.id, b.id], - assignment_mode="first_come", daily_limit=2) - claim = ChoreCompletion(chore_id=chore.id, child_id=a.id, - completed_at=dt_util_mock.now(), approved=True) + chore = Chore(name="Feed cat", assigned_to=[a.id, b.id], assignment_mode="first_come", daily_limit=2) + claim = ChoreCompletion(chore_id=chore.id, child_id=a.id, completed_at=dt_util_mock.now(), approved=True) coord.storage.get_completions = MagicMock(return_value=[claim]) assert coord._is_rotation_done_today(chore) is True @@ -1325,13 +1391,11 @@ def test_first_come_loser_completion_is_rejected(): coord.badges = None today = date(2026, 4, 20) dt_util_mock._now = dt.datetime.combine(today, dt.time(9, 0), tzinfo=UTC) - chore = Chore(name="Feed cat", assigned_to=[a.id, b.id], - assignment_mode="first_come", requires_approval=False) + chore = Chore(name="Feed cat", assigned_to=[a.id, b.id], assignment_mode="first_come", requires_approval=False) coord.storage.add_chore(chore) # A already won (completion on record). - winning = ChoreCompletion(chore_id=chore.id, child_id=a.id, - completed_at=dt_util_mock.now(), approved=True) + winning = ChoreCompletion(chore_id=chore.id, child_id=a.id, completed_at=dt_util_mock.now(), approved=True) coord.storage.get_completions = MagicMock(return_value=[winning]) # The race loser is a soft rejection: silent no-op (returns None), no points diff --git a/tests/test_audit_log.py b/tests/test_audit_log.py index 2fe07de..b438f1b 100644 --- a/tests/test_audit_log.py +++ b/tests/test_audit_log.py @@ -1,4 +1,5 @@ """Tests for the admin audit log.""" + from __future__ import annotations from unittest.mock import AsyncMock, MagicMock @@ -19,9 +20,9 @@ async def test_storage_append_order_and_cap(hass): for i in range(550): storage.add_audit_entry({"id": str(i), "action": "x", "target": str(i)}) log = storage.get_audit_log() - assert len(log) == 500 # capped - assert log[0]["id"] == "549" # newest first - assert log[-1]["id"] == "50" # oldest 50 dropped + assert len(log) == 500 # capped + assert log[0]["id"] == "549" # newest first + assert log[-1]["id"] == "50" # oldest 50 dropped @pytest.mark.asyncio diff --git a/tests/test_availability_cache.py b/tests/test_availability_cache.py index 4ef3a1e..9720d54 100644 --- a/tests/test_availability_cache.py +++ b/tests/test_availability_cache.py @@ -1,4 +1,5 @@ """PERF-1: availability_build_scope memoizes per-chore work and completions.""" + from __future__ import annotations import datetime as dt @@ -28,14 +29,13 @@ def _coord(chores, completions=None): def test_scope_fetches_completions_once_across_many_lookups(): # depends_on forces is_chore_available_for_child to read completions. - chores = [ - Chore(name=f"c{i}", assigned_to=["k1"], depends_on=["dep"], id=f"id{i}") - for i in range(5) - ] + chores = [Chore(name=f"c{i}", assigned_to=["k1"], depends_on=["dep"], id=f"id{i}") for i in range(5)] coord = _coord(chores) - with patch("custom_components.taskmate.coord_chores.dt_util.now", return_value=NOW), \ - patch("custom_components.taskmate.coord_assignments.dt_util.now", return_value=NOW): + with ( + patch("custom_components.taskmate.coord_chores.dt_util.now", return_value=NOW), + patch("custom_components.taskmate.coord_assignments.dt_util.now", return_value=NOW), + ): with coord.availability_build_scope(): for c in chores: for kid in ("k1", "k2", "k3"): @@ -51,8 +51,10 @@ def test_rotation_done_memoized_per_chore(): coord._is_rotation_done_today_uncached = MagicMock(return_value=False) coord._compute_active_children_uncached = MagicMock(return_value=["k1", "k2"]) - with patch("custom_components.taskmate.coord_chores.dt_util.now", return_value=NOW), \ - patch("custom_components.taskmate.coord_assignments.dt_util.now", return_value=NOW): + with ( + patch("custom_components.taskmate.coord_chores.dt_util.now", return_value=NOW), + patch("custom_components.taskmate.coord_assignments.dt_util.now", return_value=NOW), + ): with coord.availability_build_scope(): for kid in ("k1", "k2"): coord.is_chore_available_for_child(chore, kid) @@ -65,8 +67,10 @@ def test_rotation_done_memoized_per_chore(): def test_no_scope_means_no_cache_state_leaks(): chore = Chore(name="x", assigned_to=["k1"], depends_on=["dep"], id="x1") coord = _coord([chore]) - with patch("custom_components.taskmate.coord_chores.dt_util.now", return_value=NOW), \ - patch("custom_components.taskmate.coord_assignments.dt_util.now", return_value=NOW): + with ( + patch("custom_components.taskmate.coord_chores.dt_util.now", return_value=NOW), + patch("custom_components.taskmate.coord_assignments.dt_util.now", return_value=NOW), + ): coord.is_chore_available_for_child(chore, "k1") # Outside a scope the cache is never created; storage is queried directly. assert getattr(coord, "_avail_cache", None) is None diff --git a/tests/test_avatars.py b/tests/test_avatars.py index 7141709..8fda9c3 100644 --- a/tests/test_avatars.py +++ b/tests/test_avatars.py @@ -1,4 +1,5 @@ """Tests for avatar unlockables.""" + from __future__ import annotations import asyncio @@ -47,14 +48,14 @@ def test_unlock_by_level_points_streak(): child = Child(name="A", id="a", total_points_earned=500, best_streak=7) coord = _coord(child) # level = 500//100+1 = 6 opts = {o["icon"]: o for o in coord.avatar_options_for_child(child)} - assert opts["mdi:account-circle"]["unlocked"] # free - assert opts["mdi:rocket-launch"]["unlocked"] # level 3 - assert opts["mdi:robot-happy"]["unlocked"] # level 5 - assert not opts["mdi:ninja"]["unlocked"] # level 10 - assert opts["mdi:crown"]["unlocked"] # 500 points - assert not opts["mdi:trophy"]["unlocked"] # 1000 points - assert opts["mdi:fire"]["unlocked"] # 7-day streak - assert not opts["mdi:diamond-stone"]["unlocked"] # 30-day streak + assert opts["mdi:account-circle"]["unlocked"] # free + assert opts["mdi:rocket-launch"]["unlocked"] # level 3 + assert opts["mdi:robot-happy"]["unlocked"] # level 5 + assert not opts["mdi:ninja"]["unlocked"] # level 10 + assert opts["mdi:crown"]["unlocked"] # 500 points + assert not opts["mdi:trophy"]["unlocked"] # 1000 points + assert opts["mdi:fire"]["unlocked"] # 7-day streak + assert not opts["mdi:diamond-stone"]["unlocked"] # 30-day streak def test_child_cannot_select_locked_avatar(): @@ -87,10 +88,14 @@ def test_set_unknown_avatar_rejected(): def test_update_catalog_filters_iconless_rows(): coord = _coord(Child(name="A", id="a")) - run(coord.async_update_avatar_catalog([ - {"label": "No icon", "icon": "", "unlock_type": "free"}, - {"label": "Good", "icon": "mdi:star", "unlock_type": "level", "unlock_value": "4"}, - ])) + run( + coord.async_update_avatar_catalog( + [ + {"label": "No icon", "icon": "", "unlock_type": "free"}, + {"label": "Good", "icon": "mdi:star", "unlock_type": "level", "unlock_value": "4"}, + ] + ) + ) cat = coord._settings["avatar_catalog"] assert len(cat) == 1 assert cat[0]["icon"] == "mdi:star" @@ -111,7 +116,10 @@ class TestAvatarPickerOnEveryDesign: SOURCE = ( _pathlib.Path(__file__).resolve().parent.parent - / "custom_components" / "taskmate" / "www" / "taskmate-child-card.js" + / "custom_components" + / "taskmate" + / "www" + / "taskmate-child-card.js" ).read_text(encoding="utf-8") def _designed_region(self) -> str: @@ -120,15 +128,14 @@ def _designed_region(self) -> str: return self.SOURCE[start:end] def test_classic_opens_the_picker(self): - classic = self.SOURCE[self.SOURCE.index(" render() {"):self.SOURCE.index("_renderDesigned(design) {")] + classic = self.SOURCE[self.SOURCE.index(" render() {") : self.SOURCE.index("_renderDesigned(design) {")] assert "_toggleAvatarPicker()" in classic assert "_renderAvatarPicker(" in classic def test_designed_header_opens_the_picker(self): region = self._designed_region() assert "_toggleAvatarPicker()" in region, ( - "the designed header never wires the avatar to the picker, so it " - "does nothing on any style but classic" + "the designed header never wires the avatar to the picker, so it does nothing on any style but classic" ) def test_designed_header_renders_the_picker(self): diff --git a/tests/test_badge_highlight_without_events.py b/tests/test_badge_highlight_without_events.py index 718f784..cf8b28e 100644 --- a/tests/test_badge_highlight_without_events.py +++ b/tests/test_badge_highlight_without_events.py @@ -18,6 +18,7 @@ Structural (grep over source) because neither defect is visible to a functional test: the cards render perfectly either way. """ + from __future__ import annotations import pathlib @@ -49,9 +50,7 @@ def test_no_card_subscribes_to_the_badge_event(): def test_badge_id_helper_exists(): src = _src("taskmate-attr-resolver.js") - assert "window.__taskmate_badge_id" in src, ( - "the shared badge-id helper is gone; the cards depend on it" - ) + assert "window.__taskmate_badge_id" in src, "the shared badge-id helper is gone; the cards depend on it" def test_badge_cards_read_the_id_through_the_helper(): @@ -78,7 +77,8 @@ def test_badge_cards_do_not_compare_against_the_undefined_id_field(): def test_both_child_card_render_paths_highlight_the_new_badge(): """Classic and designed paths each need the highlight (the recurring bug).""" hits = [ - line for line in _src("taskmate-child-card.js").splitlines() + line + for line in _src("taskmate-child-card.js").splitlines() if "just-earned" in line and "__taskmate_badge_id" in line ] assert len(hits) >= 2, ( diff --git a/tests/test_badge_models.py b/tests/test_badge_models.py index 97a8c57..5ad6b12 100644 --- a/tests/test_badge_models.py +++ b/tests/test_badge_models.py @@ -1,4 +1,5 @@ """Tests for badge dataclasses.""" + from __future__ import annotations from custom_components.taskmate.models import AwardedBadge, Badge, BadgeCriterion diff --git a/tests/test_badge_services.py b/tests/test_badge_services.py index a1be54a..c39c7c9 100644 --- a/tests/test_badge_services.py +++ b/tests/test_badge_services.py @@ -1,4 +1,5 @@ """Tests for badge service handlers (via coordinator methods).""" + from __future__ import annotations from unittest.mock import AsyncMock, MagicMock diff --git a/tests/test_badge_storage.py b/tests/test_badge_storage.py index 6db9c83..3912341 100644 --- a/tests/test_badge_storage.py +++ b/tests/test_badge_storage.py @@ -1,4 +1,5 @@ """Tests for badge storage layer.""" + from __future__ import annotations from unittest.mock import MagicMock diff --git a/tests/test_binary_sensor.py b/tests/test_binary_sensor.py index 12a3a4b..f65adcc 100644 --- a/tests/test_binary_sensor.py +++ b/tests/test_binary_sensor.py @@ -1,4 +1,5 @@ """Tests for the pending-approvals binary sensor.""" + from __future__ import annotations from unittest.mock import MagicMock diff --git a/tests/test_bulk_chore_action.py b/tests/test_bulk_chore_action.py index 7def71e..e99ab99 100644 --- a/tests/test_bulk_chore_action.py +++ b/tests/test_bulk_chore_action.py @@ -1,4 +1,5 @@ """Tests for bulk chore actions.""" + from __future__ import annotations import asyncio diff --git a/tests/test_button.py b/tests/test_button.py index f786168..2b27ce8 100644 --- a/tests/test_button.py +++ b/tests/test_button.py @@ -4,6 +4,7 @@ (unique_id/name), icon resolution, attributes, and that a press dispatches the right coordinator call. A stubbed coordinator avoids any HA runtime. """ + from __future__ import annotations import asyncio diff --git a/tests/test_calendar_platform.py b/tests/test_calendar_platform.py index e9d5794..a387fca 100644 --- a/tests/test_calendar_platform.py +++ b/tests/test_calendar_platform.py @@ -5,6 +5,7 @@ real coordinator (mocked storage) so the actual scheduling logic runs, and exercise the calendar's event builder directly. """ + from __future__ import annotations from datetime import date, datetime diff --git a/tests/test_calendar_projection.py b/tests/test_calendar_projection.py index cc67715..30c57bb 100644 --- a/tests/test_calendar_projection.py +++ b/tests/test_calendar_projection.py @@ -5,6 +5,7 @@ setting, default 14). Days are filtered by the chore's schedule so the HA calendar matches the in-card schedule view. """ + from __future__ import annotations import datetime as dt @@ -61,9 +62,7 @@ def test_schedule_helper_every_2_days_respects_anchor(): assert coord._is_chore_scheduled_for_date(chore, date(2026, 4, 20) + dt.timedelta(days=offset)) # Off-cycle days for offset in (1, 3, 5, 7): - assert not coord._is_chore_scheduled_for_date( - chore, date(2026, 4, 20) + dt.timedelta(days=offset) - ) + assert not coord._is_chore_scheduled_for_date(chore, date(2026, 4, 20) + dt.timedelta(days=offset)) def test_schedule_helper_weekly_recurrence_day_filters_to_single_weekday(): @@ -198,6 +197,7 @@ def test_projection_horizon_honors_settings_clamp(): # ERR-2: interval recurrences fall back to created_date when no recurrence_start # --------------------------------------------------------------------------- + def test_schedule_helper_monthly_without_start_uses_created_date(): coord = _coord([]) chore = Chore( @@ -223,9 +223,9 @@ def test_schedule_helper_quarterly_without_start_projects(): created_date="2026-01-15", ) assert coord._is_chore_scheduled_for_date(chore, date(2026, 1, 15)) is True - assert coord._is_chore_scheduled_for_date(chore, date(2026, 4, 15)) is True # +3 months + assert coord._is_chore_scheduled_for_date(chore, date(2026, 4, 15)) is True # +3 months assert coord._is_chore_scheduled_for_date(chore, date(2026, 2, 15)) is False # +1 month - assert coord._is_chore_scheduled_for_date(chore, date(2026, 7, 15)) is True # +6 months + assert coord._is_chore_scheduled_for_date(chore, date(2026, 7, 15)) is True # +6 months def test_schedule_helper_every_2_days_without_start_uses_created_date(): diff --git a/tests/test_card_design_setting.py b/tests/test_card_design_setting.py index bc29624..4c91635 100644 --- a/tests/test_card_design_setting.py +++ b/tests/test_card_design_setting.py @@ -1,4 +1,5 @@ """Tests for the global default card-design setting (per-card design styles).""" + from __future__ import annotations from custom_components.taskmate.models import Child @@ -68,6 +69,7 @@ def _settings_schema(): import voluptuous as vol from custom_components.taskmate.websocket import _UPDATE_SETTINGS_SCHEMA, WS_UPDATE_SETTINGS + return vol.Schema(_UPDATE_SETTINGS_SCHEMA, extra=vol.ALLOW_EXTRA), WS_UPDATE_SETTINGS @@ -82,6 +84,7 @@ def test_update_settings_schema_accepts_card_design(): def test_update_settings_schema_rejects_bad_card_design(): import pytest import voluptuous as vol + schema, cmd = _settings_schema() with pytest.raises(vol.Invalid): schema({"type": cmd, "id": 1, "card_design": "bogus"}) @@ -96,6 +99,7 @@ def test_every_routed_setting_is_in_the_schema(): _TOP_LEVEL_SETTINGS, _UPDATE_SETTINGS_SCHEMA, ) + schema_keys = {str(k) for k in _UPDATE_SETTINGS_SCHEMA} routed = _SUBKEY_SETTINGS | _TOP_LEVEL_SETTINGS missing = routed - schema_keys diff --git a/tests/test_celebration.py b/tests/test_celebration.py index d1201a3..88a6303 100644 --- a/tests/test_celebration.py +++ b/tests/test_celebration.py @@ -1,4 +1,5 @@ """Tests for the celebration funnel (bigger celebration moments).""" + from __future__ import annotations import asyncio @@ -31,8 +32,7 @@ def _coord(settings=None): def _fired_event(coord): - calls = [c for c in coord.hass.bus.async_fire.call_args_list - if c[0][0] == "taskmate_celebration"] + calls = [c for c in coord.hass.bus.async_fire.call_args_list if c[0][0] == "taskmate_celebration"] return calls diff --git a/tests/test_challenges.py b/tests/test_challenges.py index 458f457..0ed2007 100644 --- a/tests/test_challenges.py +++ b/tests/test_challenges.py @@ -1,4 +1,5 @@ """Tests for daily / weekly challenges.""" + from __future__ import annotations import asyncio @@ -31,9 +32,7 @@ def _coord(challenges, child, completions): storage.get_challenges = MagicMock(return_value=challenges) storage.get_challenge = MagicMock(side_effect=lambda cid: next((c for c in challenges if c.id == cid), None)) storage.get_completions = MagicMock(return_value=completions) - storage.get_challenge_child_progress = MagicMock( - side_effect=lambda cid, kid: progress.get(cid, {}).get(kid, {}) - ) + storage.get_challenge_child_progress = MagicMock(side_effect=lambda cid, kid: progress.get(cid, {}).get(kid, {})) storage.set_challenge_child_progress = MagicMock( side_effect=lambda cid, kid, p: progress.setdefault(cid, {}).__setitem__(kid, p) ) @@ -51,16 +50,24 @@ def _coord(challenges, child, completions): def _comp(child_id, when, approved=True, pts=10, bonus=""): - return ChoreCompletion(chore_id="x", child_id=child_id, completed_at=when, - approved=approved, points_awarded=pts, bonus_subtask_id=bonus) + return ChoreCompletion( + chore_id="x", + child_id=child_id, + completed_at=when, + approved=approved, + points_awarded=pts, + bonus_subtask_id=bonus, + ) NOW = dt.datetime(2026, 6, 17, 10, tzinfo=UTC) # a Wednesday def _patched(fn): - with patch("homeassistant.util.dt.now", return_value=NOW), \ - patch("homeassistant.util.dt.as_local", side_effect=lambda d: d): + with ( + patch("homeassistant.util.dt.now", return_value=NOW), + patch("homeassistant.util.dt.as_local", side_effect=lambda d: d), + ): return fn() @@ -101,8 +108,11 @@ def test_points_metric_weekly(): # Monday of NOW's week is 2026-06-15 monday = dt.datetime(2026, 6, 15, 9, tzinfo=UTC) last_week = dt.datetime(2026, 6, 8, 9, tzinfo=UTC) - comps = [_comp("kid", monday, pts=30), _comp("kid", NOW, pts=25), - _comp("kid", last_week, pts=999)] # last week excluded + comps = [ + _comp("kid", monday, pts=30), + _comp("kid", NOW, pts=25), + _comp("kid", last_week, pts=999), + ] # last week excluded coord = _coord([ch], child, comps) _patched(lambda: run(coord._async_evaluate_challenges("kid"))) assert child.points == 20 # 30+25=55 >= 50 diff --git a/tests/test_child_vacation.py b/tests/test_child_vacation.py index 9ade237..3213d4d 100644 --- a/tests/test_child_vacation.py +++ b/tests/test_child_vacation.py @@ -11,6 +11,7 @@ While away: the streak is frozen (resumes intact on return, in BOTH reset and pause modes) and chores are hidden for that child only. """ + from __future__ import annotations import asyncio @@ -33,8 +34,7 @@ def _make_coord(settings=None, children=None, states=None) -> TaskMateCoordinato hass = MagicMock() hass.states.get = MagicMock( - side_effect=lambda eid: SimpleNamespace(state=_states[eid], attributes={}) - if eid in _states else None + side_effect=lambda eid: SimpleNamespace(state=_states[eid], attributes={}) if eid in _states else None ) coord.hass = hass @@ -59,6 +59,7 @@ def run(coro): def _run_check(coord, now_dt): import custom_components.taskmate.coord_points as _mod + with patch.object(_mod.dt_util, "now", return_value=now_dt): run(coord._async_check_streaks()) @@ -75,7 +76,8 @@ def test_false_when_nothing_configured(self): def test_global_calendar_active_freezes_everyone(self): child = Child(name="A") # not opted in coord = _make_coord( - {"vacation_calendar": "calendar.family"}, [child], + {"vacation_calendar": "calendar.family"}, + [child], {"calendar.family": "on"}, ) assert coord._is_child_on_vacation(child) is True @@ -83,7 +85,8 @@ def test_global_calendar_active_freezes_everyone(self): def test_global_calendar_off_does_not_freeze(self): child = Child(name="A") coord = _make_coord( - {"vacation_calendar": "calendar.family"}, [child], + {"vacation_calendar": "calendar.family"}, + [child], {"calendar.family": "off"}, ) assert coord._is_child_on_vacation(child) is False @@ -91,7 +94,8 @@ def test_global_calendar_off_does_not_freeze(self): def test_broken_calendar_fails_open(self): child = Child(name="A") coord = _make_coord( - {"vacation_calendar": "calendar.family"}, [child], + {"vacation_calendar": "calendar.family"}, + [child], {"calendar.family": "unavailable"}, ) assert coord._is_child_on_vacation(child) is False @@ -99,7 +103,8 @@ def test_broken_calendar_fails_open(self): def test_per_child_calendar_via_unavailability(self): # Opted-in child whose unavailability entity is a calendar; "on" = away. child = Child( - name="A", pause_streak_when_unavailable=True, + name="A", + pause_streak_when_unavailable=True, unavailability_entity="calendar.alice_trips", ) coord = _make_coord(children=[child], states={"calendar.alice_trips": "on"}) @@ -109,7 +114,8 @@ def test_per_child_ignored_when_not_opted_in(self): # Same entity, flag off -> availability still gates assignment elsewhere, # but it must NOT freeze the streak / hide chores. child = Child( - name="A", pause_streak_when_unavailable=False, + name="A", + pause_streak_when_unavailable=False, unavailability_entity="calendar.alice_trips", ) coord = _make_coord(children=[child], states={"calendar.alice_trips": "on"}) @@ -117,11 +123,13 @@ def test_per_child_ignored_when_not_opted_in(self): def test_per_child_only_affects_that_child(self): away = Child( - name="Away", pause_streak_when_unavailable=True, + name="Away", + pause_streak_when_unavailable=True, unavailability_entity="calendar.away", ) home = Child( - name="Home", pause_streak_when_unavailable=True, + name="Home", + pause_streak_when_unavailable=True, unavailability_entity="calendar.home", ) coord = _make_coord( @@ -133,7 +141,8 @@ def test_per_child_only_affects_that_child(self): def test_none_child_uses_global_only(self): coord = _make_coord( - {"vacation_calendar": "calendar.family"}, [], + {"vacation_calendar": "calendar.family"}, + [], {"calendar.family": "on"}, ) assert coord._is_child_on_vacation(None) is True @@ -142,19 +151,25 @@ def test_none_child_uses_global_only(self): class TestStreakFreezeOnAway: def test_optin_away_freezes_and_marks_paused_reset_mode(self): child = Child( - name="A", current_streak=5, last_completion_date="2026-07-05", - pause_streak_when_unavailable=True, unavailability_entity="calendar.trip", + name="A", + current_streak=5, + last_completion_date="2026-07-05", + pause_streak_when_unavailable=True, + unavailability_entity="calendar.trip", ) coord = _make_coord({"streak_reset_mode": "reset"}, [child], {"calendar.trip": "on"}) _run_check(coord, NOW) - assert child.current_streak == 5 # frozen, not reset - assert child.streak_paused is True # will resume on return + assert child.current_streak == 5 # frozen, not reset + assert child.streak_paused is True # will resume on return def test_optout_away_resets_normally_reset_mode(self): # Flag off + a genuine non-vacation gap -> normal reset. child = Child( - name="A", current_streak=5, last_completion_date="2026-07-22", - pause_streak_when_unavailable=False, unavailability_entity="calendar.trip", + name="A", + current_streak=5, + last_completion_date="2026-07-22", + pause_streak_when_unavailable=False, + unavailability_entity="calendar.trip", ) coord = _make_coord({"streak_reset_mode": "reset"}, [child], {"calendar.trip": "on"}) _run_check(coord, NOW) @@ -164,7 +179,8 @@ def test_global_calendar_freezes_uninvolved_child(self): child = Child(name="A", current_streak=3, last_completion_date="2026-07-05") coord = _make_coord( {"streak_reset_mode": "reset", "vacation_calendar": "calendar.family"}, - [child], {"calendar.family": "on"}, + [child], + {"calendar.family": "on"}, ) _run_check(coord, NOW) assert child.current_streak == 3 @@ -175,7 +191,9 @@ def test_already_paused_streak_resumes_not_resets_on_return(self): # In reset mode a normal gap would reset — but an already-paused streak # must be preserved so the next completion resumes it. child = Child( - name="A", current_streak=7, last_completion_date="2026-07-05", + name="A", + current_streak=7, + last_completion_date="2026-07-05", streak_paused=True, ) coord = _make_coord({"streak_reset_mode": "reset"}, [child]) @@ -185,8 +203,11 @@ def test_already_paused_streak_resumes_not_resets_on_return(self): def test_idempotent_no_double_save_while_away(self): child = Child( - name="A", current_streak=5, last_completion_date="2026-07-05", - pause_streak_when_unavailable=True, unavailability_entity="calendar.trip", + name="A", + current_streak=5, + last_completion_date="2026-07-05", + pause_streak_when_unavailable=True, + unavailability_entity="calendar.trip", streak_paused=True, # already frozen from a previous night ) coord = _make_coord({"streak_reset_mode": "reset"}, [child], {"calendar.trip": "on"}) @@ -197,8 +218,10 @@ def test_idempotent_no_double_save_while_away(self): class TestChoreHidingWhileAway: def test_chore_hidden_for_away_child_only(self): from custom_components.taskmate.models import Chore + away = Child( - name="Away", pause_streak_when_unavailable=True, + name="Away", + pause_streak_when_unavailable=True, unavailability_entity="calendar.away", ) home = Child(name="Home") diff --git a/tests/test_chore_difficulty.py b/tests/test_chore_difficulty.py index 1a66f33..4f069ee 100644 --- a/tests/test_chore_difficulty.py +++ b/tests/test_chore_difficulty.py @@ -5,6 +5,7 @@ exact award value. The multiplier per tier is configurable via settings (difficulty_multiplier_); defaults are easy 0.5, medium 1.0, hard 2.0. """ + from __future__ import annotations from unittest.mock import AsyncMock, MagicMock @@ -39,10 +40,12 @@ def test_unknown_tier_falls_back_to_medium_baseline(self): assert coord.difficulty_multiplier("legendary") == 1.0 def test_settings_override_defaults(self): - coord = _make_coord({ - "difficulty_multiplier_easy": "0.75", - "difficulty_multiplier_hard": "3.0", - }) + coord = _make_coord( + { + "difficulty_multiplier_easy": "0.75", + "difficulty_multiplier_hard": "3.0", + } + ) assert coord.difficulty_multiplier("easy") == 0.75 assert coord.difficulty_multiplier("hard") == 3.0 # Untouched tier keeps its default. diff --git a/tests/test_chore_expiry.py b/tests/test_chore_expiry.py index 68c9a91..9bb7736 100644 --- a/tests/test_chore_expiry.py +++ b/tests/test_chore_expiry.py @@ -1,4 +1,5 @@ """Tests for dated chore expiry (expires_on).""" + from __future__ import annotations import asyncio @@ -30,15 +31,17 @@ def _coord(chores): def _run_on(coord, date_obj): ndt = dt.datetime(date_obj.year, date_obj.month, date_obj.day, 0, 5) - with patch("homeassistant.util.dt.now", return_value=ndt), \ - patch("homeassistant.util.dt.as_local", side_effect=lambda d: d): + with ( + patch("homeassistant.util.dt.now", return_value=ndt), + patch("homeassistant.util.dt.as_local", side_effect=lambda d: d), + ): run(coord._async_expire_dated_chores()) def test_expires_after_date(): c = Chore(name="Summer job", expires_on="2026-06-16", enabled=True, id="c1") coord = _coord([c]) - _run_on(coord, dt.date(2026, 6, 17)) # day after expiry + _run_on(coord, dt.date(2026, 6, 17)) # day after expiry assert c.enabled is False coord.storage.update_chore.assert_called_once() @@ -46,7 +49,7 @@ def test_expires_after_date(): def test_still_enabled_on_expiry_day(): c = Chore(name="Lasts today", expires_on="2026-06-17", enabled=True, id="c1") coord = _coord([c]) - _run_on(coord, dt.date(2026, 6, 17)) # inclusive — still valid + _run_on(coord, dt.date(2026, 6, 17)) # inclusive — still valid assert c.enabled is True coord.storage.update_chore.assert_not_called() diff --git a/tests/test_chore_icon.py b/tests/test_chore_icon.py index 5d623db..382e3c9 100644 --- a/tests/test_chore_icon.py +++ b/tests/test_chore_icon.py @@ -12,6 +12,7 @@ The panel and the cards are JavaScript, so what Python can guard is the source itself — these assertions are what stop either half silently regressing. """ + from __future__ import annotations import pathlib @@ -25,7 +26,7 @@ def _method_source(source: str, start_marker: str, end_marker: str) -> str: """Slice a method body out by its definition, not one of its call sites.""" start = source.index(start_marker) - return source[start:source.index(end_marker, start)] + return source[start : source.index(end_marker, start)] def _save_chore_source() -> str: diff --git a/tests/test_chore_image_cleanup.py b/tests/test_chore_image_cleanup.py index 52964f8..d9b629e 100644 --- a/tests/test_chore_image_cleanup.py +++ b/tests/test_chore_image_cleanup.py @@ -1,13 +1,13 @@ """Chore images are deleted with the chore, and on replace (#750).""" + from __future__ import annotations import pathlib import re -SRC = ( - pathlib.Path(__file__).resolve().parent.parent - / "custom_components" / "taskmate" / "coord_chores.py" -).read_text(encoding="utf-8") +SRC = (pathlib.Path(__file__).resolve().parent.parent / "custom_components" / "taskmate" / "coord_chores.py").read_text( + encoding="utf-8" +) def _body(name: str) -> str: @@ -15,15 +15,13 @@ def _body(name: str) -> str: # index() is safe here (unlike the JS method helpers, where the first # occurrence of a bare name is often a call site). start = SRC.index(f"async def {name}(") - nxt = re.search(r"\n (async def|def) ", SRC[start + 10:]) - return SRC[start: start + 10 + nxt.start()] if nxt else SRC[start:] + nxt = re.search(r"\n (async def|def) ", SRC[start + 10 :]) + return SRC[start : start + 10 + nxt.start()] if nxt else SRC[start:] def test_removing_a_chore_deletes_its_image(): body = _body("async_remove_chore") - assert "_async_release_image" in body, ( - "taskmate_images is never orphan-swept, so an undeleted file leaks forever" - ) + assert "_async_release_image" in body, "taskmate_images is never orphan-swept, so an undeleted file leaks forever" def test_replacing_an_image_deletes_the_previous_file(): @@ -52,11 +50,8 @@ def test_the_release_helper_is_the_only_thing_that_unlinks(): def test_the_images_dir_is_not_in_the_photo_sweeper(): coordinator = ( - pathlib.Path(__file__).resolve().parent.parent - / "custom_components" / "taskmate" / "coordinator.py" + pathlib.Path(__file__).resolve().parent.parent / "custom_components" / "taskmate" / "coordinator.py" ).read_text(encoding="utf-8") start = coordinator.index("async def _async_sweep_orphan_photos") - body = coordinator[start:start + 800] - assert "image" not in body, ( - "chore images must not be swept — they are config, not evidence" - ) + body = coordinator[start : start + 800] + assert "image" not in body, "chore images must not be swept — they are config, not evidence" diff --git a/tests/test_chore_image_field.py b/tests/test_chore_image_field.py index e467932..1e363d7 100644 --- a/tests/test_chore_image_field.py +++ b/tests/test_chore_image_field.py @@ -1,4 +1,5 @@ """Chore.image_url flows through model, websocket and sensor (#750).""" + from __future__ import annotations import pathlib @@ -8,14 +9,12 @@ from custom_components.taskmate.models import Chore -WS = ( - pathlib.Path(__file__).resolve().parent.parent - / "custom_components" / "taskmate" / "websocket.py" -).read_text(encoding="utf-8") -SENSOR = ( - pathlib.Path(__file__).resolve().parent.parent - / "custom_components" / "taskmate" / "sensor.py" -).read_text(encoding="utf-8") +WS = (pathlib.Path(__file__).resolve().parent.parent / "custom_components" / "taskmate" / "websocket.py").read_text( + encoding="utf-8" +) +SENSOR = (pathlib.Path(__file__).resolve().parent.parent / "custom_components" / "taskmate" / "sensor.py").read_text( + encoding="utf-8" +) GOOD = "/api/taskmate/image/" + "a" * 32 + ".jpg" @@ -49,13 +48,12 @@ def test_image_url_is_an_editable_field(): # schema but is missing here is silently dropped — exactly what #755 fixed # for `icon` on the browser side. from custom_components.taskmate.websocket import _CHORE_EDITABLE_FIELDS + assert "image_url" in _CHORE_EDITABLE_FIELDS def test_websocket_validates_the_url(): - assert "is_taskmate_image_url" in WS, ( - "an unvalidated image_url would let a chore point at any URL" - ) + assert "is_taskmate_image_url" in WS, "an unvalidated image_url would let a chore point at any URL" def test_state_snapshot_signs_the_image_url(): @@ -77,18 +75,22 @@ def test_image_url_is_only_emitted_when_set(): assert 'record["image_url"] = ' in SENSOR -@pytest.mark.parametrize("bad", [ - "https://evil.example/x.jpg", - "javascript:alert(1)", - "/api/taskmate/photo/" + "a" * 32 + ".jpg", - "/api/taskmate/image/../../etc/passwd", -]) +@pytest.mark.parametrize( + "bad", + [ + "https://evil.example/x.jpg", + "javascript:alert(1)", + "/api/taskmate/photo/" + "a" * 32 + ".jpg", + "/api/taskmate/image/../../etc/passwd", + ], +) def test_bad_urls_are_rejected_by_the_validator(bad): # Test the real validator, not a vol.Any() wrapper around the predicate: # voluptuous treats a bare callable as a coercer, so a predicate returning # False is a *value*, not a failure — vol.Any("", is_taskmate_image_url) # would happily return False and reject nothing. from custom_components.taskmate.websocket import _image_url_or_blank + with pytest.raises(vol.Invalid): _image_url_or_blank(bad) @@ -96,4 +98,5 @@ def test_bad_urls_are_rejected_by_the_validator(bad): @pytest.mark.parametrize("ok", ["", None, "/api/taskmate/image/" + "a" * 32 + ".png"]) def test_validator_accepts_blank_and_our_urls(ok): from custom_components.taskmate.websocket import _image_url_or_blank + assert _image_url_or_blank(ok) == (ok or "") diff --git a/tests/test_chore_image_rendering.py b/tests/test_chore_image_rendering.py index ea938df..0342a72 100644 --- a/tests/test_chore_image_rendering.py +++ b/tests/test_chore_image_rendering.py @@ -5,15 +5,13 @@ and #755 was itself a fix for exactly that. Sites 1-2 and 4-5 below are each a classic/designed pair. """ + from __future__ import annotations import pathlib import re -WWW = ( - pathlib.Path(__file__).resolve().parent.parent - / "custom_components" / "taskmate" / "www" -) +WWW = pathlib.Path(__file__).resolve().parent.parent / "custom_components" / "taskmate" / "www" DESIGN = (WWW / "taskmate-design.js").read_text(encoding="utf-8") CHILD = (WWW / "taskmate-child-card.js").read_text(encoding="utf-8") REORDER = (WWW / "taskmate-reorder-card.js").read_text(encoding="utf-8") @@ -38,7 +36,8 @@ def test_the_resolver_implements_image_over_icon_over_none(): # has to tolerate that idiom between the `=` and the `function`. block = re.search( rf"{RESOLVER}\s*=\s*(?:window\.\w+\s*\|\|\s*)?function[^{{]*\{{(.*?)\n \}};", - DESIGN, re.S, + DESIGN, + re.S, ) assert block, "could not find the resolver body" body = block.group(1) @@ -56,8 +55,8 @@ def _fn(src: str, name: str) -> str: m = re.search(rf"\n {re.escape(name)}\(", src) assert m, f"no definition of {name}" start = m.start() + 1 - nxt = re.search(r"\n [_a-zA-Z]+\(", src[start + 10:]) - return src[start: start + 10 + nxt.start()] if nxt else src[start:] + nxt = re.search(r"\n [_a-zA-Z]+\(", src[start + 10 :]) + return src[start : start + 10 + nxt.start()] if nxt else src[start:] def test_child_card_classic_badge_uses_the_resolver(): @@ -78,9 +77,7 @@ def test_reorder_card_designed_item_uses_the_resolver(): def test_reorder_card_has_two_resolving_paths(): # The classic path is a separate render function from the designed one. - assert REORDER.count(RESOLVER) >= 2, ( - "both reorder paths must resolve, or the image works on one style only" - ) + assert REORDER.count(RESOLVER) >= 2, "both reorder paths must resolve, or the image works on one style only" def test_routine_card_uses_the_resolver(): diff --git a/tests/test_chore_image_sharing.py b/tests/test_chore_image_sharing.py index bdece8e..dc1980b 100644 --- a/tests/test_chore_image_sharing.py +++ b/tests/test_chore_image_sharing.py @@ -4,6 +4,7 @@ the same file on disk as its source. Deleting or re-picturing either copy must not unlink a file the other one is still showing. """ + from __future__ import annotations import asyncio diff --git a/tests/test_chore_roulette.py b/tests/test_chore_roulette.py index d4ff17a..8160d14 100644 --- a/tests/test_chore_roulette.py +++ b/tests/test_chore_roulette.py @@ -4,6 +4,7 @@ it. The pick is recorded per child per day so it survives a reload, can't be re-rolled past the parent's allowance, and expires overnight. """ + from __future__ import annotations from datetime import timedelta @@ -21,8 +22,7 @@ def _today(): return dt_util.as_local(dt_util.now()).date().isoformat() -def _coord(*, enabled=True, multiplier=2.0, spins=1, chores=None, children=None, - completions=None, available=True): +def _coord(*, enabled=True, multiplier=2.0, spins=1, chores=None, children=None, completions=None, available=True): settings = { "roulette_enabled": enabled, "roulette_multiplier": multiplier, @@ -40,6 +40,7 @@ def _coord(*, enabled=True, multiplier=2.0, spins=1, chores=None, children=None, # set_setting must actually persist so the spin result can be read back. def _set(key, value): settings[key] = value + coord.storage.set_setting = MagicMock(side_effect=_set) coord.storage.get_setting = MagicMock(side_effect=lambda k, d="": settings.get(k, d)) return coord @@ -159,9 +160,12 @@ def test_yesterdays_selection_is_ignored(self): """The pick is for today only — it must not linger into tomorrow.""" yesterday = (dt_util.as_local(dt_util.now()).date() - timedelta(days=1)).isoformat() coord = _coord() - coord.storage.set_setting("roulette_state", { - "kid1": {"date": yesterday, "chore_id": "a", "multiplier": 2.0, "spins": 1}, - }) + coord.storage.set_setting( + "roulette_state", + { + "kid1": {"date": yesterday, "chore_id": "a", "multiplier": 2.0, "spins": 1}, + }, + ) assert coord.roulette_selection("kid1") is None assert coord.roulette_spins_left("kid1") == 1 @@ -169,10 +173,13 @@ def test_yesterdays_selection_is_ignored(self): async def test_prune_clears_stale_days(self): yesterday = (dt_util.as_local(dt_util.now()).date() - timedelta(days=1)).isoformat() coord = _coord() - coord.storage.set_setting("roulette_state", { - "kid1": {"date": yesterday, "chore_id": "a"}, - "kid2": {"date": _today(), "chore_id": "b"}, - }) + coord.storage.set_setting( + "roulette_state", + { + "kid1": {"date": yesterday, "chore_id": "a"}, + "kid2": {"date": _today(), "chore_id": "b"}, + }, + ) assert await coord.async_prune_roulette_state() == 1 remaining = coord.storage.get_setting("roulette_state", {}) assert set(remaining) == {"kid2"} @@ -206,8 +213,7 @@ async def test_other_chores_are_unaffected(self): async def test_other_children_are_unaffected(self): """One child's spin must not inflate a sibling's award.""" chore = Chore(name="A", id="a") - coord = _coord(chores=[chore], - children=[Child(name="Kid", id="kid1"), Child(name="Sib", id="kid2")]) + coord = _coord(chores=[chore], children=[Child(name="Kid", id="kid1"), Child(name="Sib", id="kid2")]) await coord.async_spin_roulette("kid1") assert coord._apply_roulette_multiplier(chore, "kid2", 10) == 10 @@ -226,10 +232,15 @@ async def test_fractional_multiplier_rounds(self): async def test_corrupt_stored_multiplier_falls_back(self): chore = Chore(name="A", id="a") coord = _coord(chores=[chore]) - with patch.object(coord.storage, "get_setting", side_effect=lambda k, d="": ( - {"kid1": {"date": _today(), "chore_id": "a", "multiplier": "loads", "spins": 1}} - if k == "roulette_state" else {"roulette_enabled": True}.get(k, d) - )): + with patch.object( + coord.storage, + "get_setting", + side_effect=lambda k, d="": ( + {"kid1": {"date": _today(), "chore_id": "a", "multiplier": "loads", "spins": 1}} + if k == "roulette_state" + else {"roulette_enabled": True}.get(k, d) + ), + ): assert coord._apply_roulette_multiplier(chore, "kid1", 10) == 20 @@ -246,7 +257,10 @@ class TestRouletteRendersOnEveryDesign: SOURCE = ( _pathlib.Path(__file__).resolve().parent.parent - / "custom_components" / "taskmate" / "www" / "taskmate-child-card.js" + / "custom_components" + / "taskmate" + / "www" + / "taskmate-child-card.js" ).read_text(encoding="utf-8") def _designed_region(self) -> str: diff --git a/tests/test_chore_swap.py b/tests/test_chore_swap.py index d662728..5f63cc2 100644 --- a/tests/test_chore_swap.py +++ b/tests/test_chore_swap.py @@ -1,4 +1,5 @@ """Tests for sibling chore swaps.""" + from __future__ import annotations import asyncio @@ -29,8 +30,12 @@ def _coord(chore, children): storage.get_chore = MagicMock(return_value=chore) storage.get_swap_requests = MagicMock(return_value=reqs) storage.add_swap_request = MagicMock(side_effect=lambda r: reqs.append(r)) - storage.update_swap_request = MagicMock(side_effect=lambda rid, **ch: [r.update(ch) for r in reqs if r["id"] == rid]) - storage.remove_swap_request = MagicMock(side_effect=lambda rid: reqs.__setitem__(slice(None), [r for r in reqs if r["id"] != rid])) + storage.update_swap_request = MagicMock( + side_effect=lambda rid, **ch: [r.update(ch) for r in reqs if r["id"] == rid] + ) + storage.remove_swap_request = MagicMock( + side_effect=lambda rid: reqs.__setitem__(slice(None), [r for r in reqs if r["id"] != rid]) + ) storage.update_chore = MagicMock() storage.async_save = AsyncMock() coord.storage = storage diff --git a/tests/test_clone_chore.py b/tests/test_clone_chore.py index 36a8afe..1ea738a 100644 --- a/tests/test_clone_chore.py +++ b/tests/test_clone_chore.py @@ -1,4 +1,5 @@ """Tests for cloning / duplicating a chore.""" + from __future__ import annotations import asyncio diff --git a/tests/test_config_backup.py b/tests/test_config_backup.py index 3de739a..debd686 100644 --- a/tests/test_config_backup.py +++ b/tests/test_config_backup.py @@ -1,4 +1,5 @@ """Tests for config export / import (backup & restore).""" + from __future__ import annotations import asyncio @@ -48,20 +49,24 @@ async def test_import_strips_foreign_photo_url(hass): """SEC-5: a crafted backup can't smuggle a non-TaskMate photo_url.""" storage = TaskMateStorage(hass, "sec5") await storage.async_load() - storage.import_data({ - "completions": [ - {"id": "c1", "chore_id": "x", "child_id": "k", - "photo_url": "javascript:alert(1)"}, - {"id": "c2", "chore_id": "x", "child_id": "k", - "photo_url": "https://evil.example/p.png"}, - {"id": "c3", "chore_id": "x", "child_id": "k", - "photo_url": "/api/taskmate/photo/" + "a" * 32 + ".jpg"}, # one of ours - {"id": "c4", "chore_id": "x", "child_id": "k", "photo_url": ""}, - ], - }) + storage.import_data( + { + "completions": [ + {"id": "c1", "chore_id": "x", "child_id": "k", "photo_url": "javascript:alert(1)"}, + {"id": "c2", "chore_id": "x", "child_id": "k", "photo_url": "https://evil.example/p.png"}, + { + "id": "c3", + "chore_id": "x", + "child_id": "k", + "photo_url": "/api/taskmate/photo/" + "a" * 32 + ".jpg", + }, # one of ours + {"id": "c4", "chore_id": "x", "child_id": "k", "photo_url": ""}, + ], + } + ) by_id = {c["id"]: c for c in storage._data["completions"]} - assert by_id["c1"]["photo_url"] == "" # dangerous scheme stripped - assert by_id["c2"]["photo_url"] == "" # foreign host stripped + assert by_id["c1"]["photo_url"] == "" # dangerous scheme stripped + assert by_id["c2"]["photo_url"] == "" # foreign host stripped assert by_id["c3"]["photo_url"] == "/api/taskmate/photo/" + "a" * 32 + ".jpg" # kept assert by_id["c4"]["photo_url"] == "" @@ -121,4 +126,5 @@ async def _go(): storage.import_data(snap) assert [c.id for c in storage.get_children()] == ["c1"] assert [c.id for c in storage.get_chores()] == ["ch1"] + run(_go()) diff --git a/tests/test_coord_badges.py b/tests/test_coord_badges.py index 72e8b94..d31d093 100644 --- a/tests/test_coord_badges.py +++ b/tests/test_coord_badges.py @@ -1,4 +1,5 @@ """Tests for coord_badges.""" + from __future__ import annotations from datetime import datetime, timezone @@ -144,10 +145,13 @@ def test_chore_trigger_matches_chore_badge(self): assert badge_relevant_to_trigger(b, "chore_completed") is True def test_chore_trigger_matches_compound_badge(self): - b = Badge(name="x", criteria=[ - BadgeCriterion("total_chores", ">=", 10), - BadgeCriterion("total_points", ">=", 100), - ]) + b = Badge( + name="x", + criteria=[ + BadgeCriterion("total_chores", ">=", 10), + BadgeCriterion("total_points", ">=", 100), + ], + ) assert badge_relevant_to_trigger(b, "chore_completed") is True def test_no_criteria_only_matches_manual(self): @@ -515,8 +519,10 @@ async def test_3_plus_awards_in_one_pass_combined_per_child(self): child = Child(name="Mia", total_points_earned=100) child.id = "c1" coord.storage.get_child.return_value = child + def _get_badge(bid): return {"b1": b1, "b2": b2, "b3": b3}.get(bid) + coord.storage.get_badge.side_effect = _get_badge coord.storage.get_badges.return_value = [b1, b2, b3] coord.storage.get_reward_claims.return_value = [] @@ -557,8 +563,14 @@ def test_unknown_defaults_gte(self): class TestCombinatorEval: def _setup(self, coord, child_kwargs, badges): - kwargs = {"name": "Mia", "total_points_earned": 0, "total_chores_completed": 0, - "current_streak": 0, "best_streak": 0, "awarded_perfect_weeks": []} + kwargs = { + "name": "Mia", + "total_points_earned": 0, + "total_chores_completed": 0, + "current_streak": 0, + "best_streak": 0, + "awarded_perfect_weeks": [], + } kwargs.update(child_kwargs) child = Child(**kwargs) child.id = "c1" @@ -569,27 +581,34 @@ def _setup(self, coord, child_kwargs, badges): coord.storage.get_awarded_badges_for_child.return_value = [] async def test_and_requires_all(self, coord): - b = Badge(name="AND", combinator="AND", criteria=[ - BadgeCriterion("total_points", ">=", 100), - BadgeCriterion("total_chores", ">=", 50), - ]) + b = Badge( + name="AND", + combinator="AND", + criteria=[ + BadgeCriterion("total_points", ">=", 100), + BadgeCriterion("total_chores", ">=", 50), + ], + ) b.id = "b1" self._setup(coord, {"total_points_earned": 150, "total_chores_completed": 10}, [b]) assert await coord.evaluate_for_child("c1", "manual") == [] async def test_or_awards_on_any(self, coord): - b = Badge(name="OR", combinator="OR", criteria=[ - BadgeCriterion("total_points", ">=", 100), - BadgeCriterion("total_chores", ">=", 50), - ]) + b = Badge( + name="OR", + combinator="OR", + criteria=[ + BadgeCriterion("total_points", ">=", 100), + BadgeCriterion("total_chores", ">=", 50), + ], + ) b.id = "b1" self._setup(coord, {"total_points_earned": 150, "total_chores_completed": 10}, [b]) awards = await coord.evaluate_for_child("c1", "manual") assert len(awards) == 1 async def test_operator_eq_in_eval(self, coord): - b = Badge(name="exactly 7", combinator="AND", - criteria=[BadgeCriterion("current_streak", "==", 7)]) + b = Badge(name="exactly 7", combinator="AND", criteria=[BadgeCriterion("current_streak", "==", 7)]) b.id = "b1" self._setup(coord, {"current_streak": 7}, [b]) assert len(await coord.evaluate_for_child("c1", "manual")) == 1 diff --git a/tests/test_coordinator_logic.py b/tests/test_coordinator_logic.py index a7b483f..2ecfca5 100755 --- a/tests/test_coordinator_logic.py +++ b/tests/test_coordinator_logic.py @@ -4,6 +4,7 @@ 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 @@ -23,6 +24,7 @@ # Helpers # --------------------------------------------------------------------------- + def _date(year: int, month: int, day: int) -> dt.datetime: return dt.datetime(year, month, day, 12, 0, 0, tzinfo=UTC) @@ -82,9 +84,7 @@ def _make_coord( storage._data = {"completions": [c.to_dict() for c in _completions]} # Wire up replace_completions to update _data like the real implementation storage.replace_completions = MagicMock( - side_effect=lambda comps: storage._data.__setitem__( - "completions", [c.to_dict() for c in comps] - ) + side_effect=lambda comps: storage._data.__setitem__("completions", [c.to_dict() for c in comps]) ) coord.storage = storage @@ -105,6 +105,7 @@ def run(coro): # parse_milestone_setting # --------------------------------------------------------------------------- + class TestParseMilestoneSetting: def test_empty_string_returns_empty_dict(self): result = TaskMateCoordinator.parse_milestone_setting("") @@ -155,11 +156,13 @@ def test_duplicate_days_raises_value_error(self): # _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)) @@ -242,9 +245,11 @@ def test_total_chores_completed_incremented(self): # _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)) @@ -284,9 +289,11 @@ def test_multiplier_one_no_bonus(self): # _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)) @@ -356,9 +363,11 @@ def test_milestones_disabled_no_bonus(self): # _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()) @@ -419,6 +428,7 @@ def test_zero_streak_not_modified(self): # _async_check_perfect_week # --------------------------------------------------------------------------- + class TestCheckPerfectWeek: def _make_completion(self, child_id: str, date_str: str) -> ChoreCompletion: return ChoreCompletion( @@ -431,6 +441,7 @@ def _make_completion(self, child_id: str, date_str: str) -> ChoreCompletion: 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()) @@ -439,8 +450,13 @@ def test_perfect_week_awards_bonus(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", + "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] @@ -462,8 +478,13 @@ 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", + "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] @@ -482,8 +503,7 @@ def test_incomplete_week_no_bonus(self): # 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"] + 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"}, @@ -498,8 +518,13 @@ 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", + "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( @@ -528,10 +553,9 @@ def test_not_monday_skips_check(self): # async_prune_history # --------------------------------------------------------------------------- + class TestPruneHistory: - def _make_completion( - self, *, approved: bool, days_old: int, now: dt.datetime - ) -> ChoreCompletion: + 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", @@ -549,6 +573,7 @@ def test_old_approved_completions_pruned(self): 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)) @@ -564,6 +589,7 @@ def test_unapproved_completions_always_kept(self): 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)) @@ -577,6 +603,7 @@ def test_no_pruning_needed_storage_not_written(self): 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)) @@ -587,6 +614,7 @@ def test_no_pruning_needed_storage_not_written(self): # is_chore_available_for_child # --------------------------------------------------------------------------- + class TestChoreAvailability: def _make_recurring_chore( self, @@ -606,6 +634,7 @@ def _make_recurring_chore( 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) @@ -626,9 +655,7 @@ def test_mode_b_never_completed_available_immediately(self): 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"} - ) + 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 @@ -636,9 +663,7 @@ def test_mode_b_completed_within_window_not_available(self): 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"} - ) + 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 @@ -671,9 +696,7 @@ def test_mode_b_with_recurrence_day_correct_day_available(self): 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"} - ) + 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 @@ -684,13 +707,11 @@ def test_visibility_entity_matches_state(self): name="Visibility chore", schedule_mode="specific_days", visibility_entity="binary_sensor.dishwasher_running", - visibility_state="on" + visibility_state="on", ) coord.storage.get_last_completed = MagicMock(return_value={}) # Mock entity state matching visibility_state - coord.hass.states.get = MagicMock( - return_value=MagicMock(state='on') - ) + coord.hass.states.get = MagicMock(return_value=MagicMock(state="on")) now = _date(2024, 3, 20) assert self._run(coord, chore, "kid1", now) is True @@ -700,13 +721,11 @@ def test_visibility_entity_does_not_match_state(self): name="Visibility chore", schedule_mode="specific_days", visibility_entity="binary_sensor.dishwasher_running", - visibility_state="on" + visibility_state="on", ) coord.storage.get_last_completed = MagicMock(return_value={}) # Mock entity state NOT matching visibility_state - coord.hass.states.get = MagicMock( - return_value=MagicMock(state='off') - ) + coord.hass.states.get = MagicMock(return_value=MagicMock(state="off")) now = _date(2024, 3, 20) assert self._run(coord, chore, "kid1", now) is False @@ -716,13 +735,11 @@ def test_visibility_entity_numeric_state(self): name="Visibility chore", schedule_mode="specific_days", visibility_entity="sensor.temperature", - visibility_state="123" + visibility_state="123", ) coord.storage.get_last_completed = MagicMock(return_value={}) # Mock entity state as numeric string - coord.hass.states.get = MagicMock( - return_value=MagicMock(state='123') - ) + coord.hass.states.get = MagicMock(return_value=MagicMock(state="123")) now = _date(2024, 3, 20) assert self._run(coord, chore, "kid1", now) is True @@ -732,7 +749,7 @@ def test_visibility_entity_missing_defaults_visible(self): name="Visibility chore", schedule_mode="specific_days", visibility_entity="binary_sensor.nonexistent", - visibility_state="on" + visibility_state="on", ) coord.storage.get_last_completed = MagicMock(return_value={}) # Mock entity doesn't exist (get returns None) @@ -744,14 +761,11 @@ def test_visibility_entity_missing_defaults_visible(self): def test_visibility_entity_numeric_gte(self): coord = _make_coord() chore = Chore( - name="Power chore", - schedule_mode="specific_days", - visibility_entity="sensor.power", - visibility_state=">=10" + name="Power chore", schedule_mode="specific_days", visibility_entity="sensor.power", visibility_state=">=10" ) coord.storage.get_last_completed = MagicMock(return_value={}) coord.hass.states.get = MagicMock( - return_value=MagicMock(state='15') # 15 >= 10 + return_value=MagicMock(state="15") # 15 >= 10 ) now = _date(2024, 3, 20) assert self._run(coord, chore, "kid1", now) is True @@ -759,14 +773,11 @@ def test_visibility_entity_numeric_gte(self): def test_visibility_entity_numeric_gte_false(self): coord = _make_coord() chore = Chore( - name="Power chore", - schedule_mode="specific_days", - visibility_entity="sensor.power", - visibility_state=">=10" + name="Power chore", schedule_mode="specific_days", visibility_entity="sensor.power", visibility_state=">=10" ) coord.storage.get_last_completed = MagicMock(return_value={}) coord.hass.states.get = MagicMock( - return_value=MagicMock(state='0') # 0 >= 10 is False + return_value=MagicMock(state="0") # 0 >= 10 is False ) now = _date(2024, 3, 20) assert self._run(coord, chore, "kid1", now) is False @@ -777,11 +788,11 @@ def test_visibility_entity_numeric_lt(self): name="Temperature chore", schedule_mode="specific_days", visibility_entity="sensor.temperature", - visibility_state="<20" + visibility_state="<20", ) coord.storage.get_last_completed = MagicMock(return_value={}) coord.hass.states.get = MagicMock( - return_value=MagicMock(state='15') # 15 < 20 + return_value=MagicMock(state="15") # 15 < 20 ) now = _date(2024, 3, 20) assert self._run(coord, chore, "kid1", now) is True @@ -791,11 +802,13 @@ def test_visibility_entity_numeric_lt(self): # One-Shot Chore Tests # --------------------------------------------------------------------------- + class TestOneShotChores: """Tests for one-shot (non-recurring) chore functionality.""" 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) @@ -950,6 +963,7 @@ def test_midnight_expiry_skips_already_disabled(self): # Career Score # --------------------------------------------------------------------------- + class TestCareerScore: """Tests for career_score tracking across coordinator operations.""" @@ -960,6 +974,7 @@ def test_add_points_increments_career_score(self): coord.storage.get_child = MagicMock(return_value=child) import custom_components.taskmate.coordinator as _mod + with patch.object(_mod.dt_util, "now", return_value=_date(2024, 3, 20)): run(coord.async_add_points(child.id, 10, reason="Bonus: Tidied room")) @@ -975,6 +990,7 @@ def test_penalty_decrements_career_score(self): coord.storage.get_child = MagicMock(return_value=child) import custom_components.taskmate.coordinator as _mod + with patch.object(_mod.dt_util, "now", return_value=_date(2024, 3, 20)): run(coord.async_remove_points(child.id, 15, reason="Penalty: Not tidying")) @@ -990,6 +1006,7 @@ def test_non_penalty_remove_does_not_affect_career_score(self): coord.storage.get_child = MagicMock(return_value=child) import custom_components.taskmate.coordinator as _mod + with patch.object(_mod.dt_util, "now", return_value=_date(2024, 3, 20)): run(coord.async_remove_points(child.id, 20, reason="Admin correction")) @@ -1005,6 +1022,7 @@ def test_pool_allocation_does_not_affect_career_score(self): coord.storage.get_child = MagicMock(return_value=child) import custom_components.taskmate.coordinator as _mod + with patch.object(_mod.dt_util, "now", return_value=_date(2024, 3, 20)): run(coord.async_remove_points(child.id, 30, reason="Allocated to pool: Bike")) @@ -1015,9 +1033,12 @@ def test_award_points_updates_career_score(self): child = _make_child(points=50, current_streak=1, last_completion_date="2024-03-19") child.career_score = 50 child.total_penalties_received = 0 - coord = _make_coord(children=[child], settings={"weekend_multiplier": "1.0", "streak_milestones_enabled": "false"}) + coord = _make_coord( + children=[child], settings={"weekend_multiplier": "1.0", "streak_milestones_enabled": "false"} + ) import custom_components.taskmate.coordinator as _mod + with patch.object(_mod.dt_util, "now", return_value=_date(2024, 3, 20)): total = run(coord._award_points(child, 10)) @@ -1035,6 +1056,7 @@ def test_award_points_with_milestone_bonus(self): ) import custom_components.taskmate.coordinator as _mod + with patch.object(_mod.dt_util, "now", return_value=_date(2024, 3, 20)): run(coord._award_points(child, 5)) @@ -1048,6 +1070,7 @@ def test_award_points_with_milestone_bonus(self): # _reverse_completion_awards — milestone bonus reversal (ERR-1) # --------------------------------------------------------------------------- + class TestReverseCompletionMilestones: def test_reject_reverses_milestone_bonus(self): settings = { @@ -1105,6 +1128,7 @@ def test_reject_keeps_milestone_when_streak_stays_above(self): # FEAT-7: negative-balance policy # --------------------------------------------------------------------------- + class TestNegativeBalancePolicy: def test_remove_points_floors_at_zero_by_default(self): child = _make_child(points=5) @@ -1123,6 +1147,7 @@ def test_remove_points_allows_negative_when_enabled(self): # SEC-6: coordinator-layer guard against negative point arguments # --------------------------------------------------------------------------- + class TestPointArgSignGuard: def test_add_points_rejects_negative(self): child = _make_child(points=10) diff --git a/tests/test_coordinator_rewards.py b/tests/test_coordinator_rewards.py index 89463a5..f206933 100755 --- a/tests/test_coordinator_rewards.py +++ b/tests/test_coordinator_rewards.py @@ -3,6 +3,7 @@ Covers async_claim_reward, async_approve_reward, and async_reject_reward, including the get_reward() method that was previously missing. """ + from __future__ import annotations import asyncio @@ -37,9 +38,7 @@ def _make_coord(*, children=None, rewards=None, claims=None): storage.get_child = MagicMock(side_effect=lambda cid: _children.get(cid)) storage.get_reward = MagicMock(side_effect=lambda rid: _rewards.get(rid)) storage.get_reward_claims = MagicMock(return_value=_claims) - storage.get_pending_reward_claims = MagicMock( - return_value=[c for c in _claims if not c.approved] - ) + storage.get_pending_reward_claims = MagicMock(return_value=[c for c in _claims if not c.approved]) storage.update_child = MagicMock() storage.update_reward_claim = MagicMock() storage.add_reward_claim = MagicMock() @@ -55,9 +54,8 @@ def _make_coord(*, children=None, rewards=None, claims=None): storage._data = {"reward_claims": [c.to_dict() for c in _claims]} def _remove_reward_claim(claim_id): - storage._data["reward_claims"] = [ - c for c in storage._data["reward_claims"] if c.get("id") != claim_id - ] + storage._data["reward_claims"] = [c for c in storage._data["reward_claims"] if c.get("id") != claim_id] + storage.remove_reward_claim = MagicMock(side_effect=_remove_reward_claim) coord.storage = storage @@ -82,6 +80,7 @@ def _reward(cost=50): # get_reward # --------------------------------------------------------------------------- + class TestGetReward: def test_returns_reward_when_found(self): reward = _reward() @@ -98,6 +97,7 @@ def test_returns_none_when_not_found(self): # async_claim_reward # --------------------------------------------------------------------------- + class TestClaimReward: def test_claim_created_when_enough_points(self): child = _child(points=100) @@ -140,13 +140,17 @@ def test_points_not_deducted_on_claim(self): # async_approve_reward # --------------------------------------------------------------------------- + class TestApproveReward: def test_approval_deducts_points(self): child = _child(points=100) reward = _reward(cost=50) - claim = RewardClaim(reward_id="reward1", child_id="kid1", - claimed_at=__import__("datetime").datetime.now( - __import__("datetime").timezone.utc), id="claim1") + claim = RewardClaim( + reward_id="reward1", + child_id="kid1", + claimed_at=__import__("datetime").datetime.now(__import__("datetime").timezone.utc), + id="claim1", + ) coord = _make_coord(children=[child], rewards=[reward], claims=[claim]) run(coord.async_approve_reward("claim1")) assert child.points == 50 @@ -154,9 +158,12 @@ def test_approval_deducts_points(self): def test_approval_raises_when_not_enough_points(self): child = _child(points=20) reward = _reward(cost=50) - claim = RewardClaim(reward_id="reward1", child_id="kid1", - claimed_at=__import__("datetime").datetime.now( - __import__("datetime").timezone.utc), id="claim1") + claim = RewardClaim( + reward_id="reward1", + child_id="kid1", + claimed_at=__import__("datetime").datetime.now(__import__("datetime").timezone.utc), + id="claim1", + ) coord = _make_coord(children=[child], rewards=[reward], claims=[claim]) with pytest.raises(ValueError, match="Not enough points"): run(coord.async_approve_reward("claim1")) @@ -164,9 +171,12 @@ def test_approval_raises_when_not_enough_points(self): def test_approval_marks_claim_approved(self): child = _child(points=100) reward = _reward(cost=50) - claim = RewardClaim(reward_id="reward1", child_id="kid1", - claimed_at=__import__("datetime").datetime.now( - __import__("datetime").timezone.utc), id="claim1") + claim = RewardClaim( + reward_id="reward1", + child_id="kid1", + claimed_at=__import__("datetime").datetime.now(__import__("datetime").timezone.utc), + id="claim1", + ) coord = _make_coord(children=[child], rewards=[reward], claims=[claim]) run(coord.async_approve_reward("claim1")) coord.storage.update_reward_claim.assert_called_once() @@ -178,22 +188,26 @@ def test_approval_marks_claim_approved(self): # async_reject_reward # --------------------------------------------------------------------------- + class TestRejectReward: def test_rejection_removes_claim(self): import datetime as dt - claim = RewardClaim(reward_id="reward1", child_id="kid1", - claimed_at=dt.datetime.now(dt.timezone.utc), id="claim1") + + claim = RewardClaim( + reward_id="reward1", child_id="kid1", claimed_at=dt.datetime.now(dt.timezone.utc), id="claim1" + ) coord = _make_coord(claims=[claim]) run(coord.async_reject_reward("claim1")) - remaining = [c for c in coord.storage._data["reward_claims"] - if c.get("id") == "claim1"] + remaining = [c for c in coord.storage._data["reward_claims"] if c.get("id") == "claim1"] assert remaining == [] def test_rejection_does_not_deduct_points(self): import datetime as dt + child = _child(points=100) - claim = RewardClaim(reward_id="reward1", child_id="kid1", - claimed_at=dt.datetime.now(dt.timezone.utc), id="claim1") + claim = RewardClaim( + reward_id="reward1", child_id="kid1", claimed_at=dt.datetime.now(dt.timezone.utc), id="claim1" + ) coord = _make_coord(children=[child], claims=[claim]) run(coord.async_reject_reward("claim1")) assert child.points == 100 # points were never deducted @@ -269,14 +283,17 @@ def test_approval_in_pool_mode_clears_allocation_without_double_deduction(self): """In beta2, allocations already deducted points at allocation time, so approval should NOT reduce child.points again — it only clears the allocation.""" import datetime as dt + # Simulate the state AFTER allocation: child.points already dropped to 50, # the allocation holds the 50 earmarked points. child = _child(points=50) reward = _reward(cost=50) existing = PoolAllocation(child_id="kid1", reward_id="reward1", allocated_points=50) claim = RewardClaim( - reward_id="reward1", child_id="kid1", - claimed_at=dt.datetime.now(dt.timezone.utc), id="claim1", + reward_id="reward1", + child_id="kid1", + claimed_at=dt.datetime.now(dt.timezone.utc), + id="claim1", ) coord = _make_coord(children=[child], rewards=[reward], claims=[claim]) coord.storage.get_pool_allocation = MagicMock(return_value=existing) @@ -295,17 +312,18 @@ class TestPoolClaimDoesNotBlockOtherAllocations: def test_allocation_to_other_pool_reward_while_first_awaits_approval(self): import datetime as dt + # Child started with 100; 50 already allocated to reward1 (pool-filled). # Visible points dropped to 50, and reward1 is claimed but unapproved. child = _child(points=50) reward1 = Reward(name="Movie", cost=50, id="reward1") reward2 = Reward(name="Toy", cost=50, id="reward2") - filled_alloc = PoolAllocation( - child_id="kid1", reward_id="reward1", allocated_points=50 - ) + filled_alloc = PoolAllocation(child_id="kid1", reward_id="reward1", allocated_points=50) claim = RewardClaim( - reward_id="reward1", child_id="kid1", - claimed_at=dt.datetime.now(dt.timezone.utc), id="claim1", + reward_id="reward1", + child_id="kid1", + claimed_at=dt.datetime.now(dt.timezone.utc), + id="claim1", ) coord = _make_coord(children=[child], rewards=[reward1, reward2], claims=[claim]) @@ -313,6 +331,7 @@ def _get_alloc(child_id, reward_id): if reward_id == "reward1": return filled_alloc return None + coord.storage.get_pool_allocation = MagicMock(side_effect=_get_alloc) # Allocating to a different pool reward should succeed — the pending @@ -325,13 +344,16 @@ def _get_alloc(child_id, reward_id): def test_wallet_claim_still_blocks_pool_allocation(self): """Sanity check: a non-pool-mode pending claim should still reserve points.""" import datetime as dt + child = _child(points=50) reward1 = Reward(name="Movie", cost=40, id="reward1") reward2 = Reward(name="Toy", cost=30, id="reward2") # No allocation → claim is wallet-mode and its cost IS committed. claim = RewardClaim( - reward_id="reward1", child_id="kid1", - claimed_at=dt.datetime.now(dt.timezone.utc), id="claim1", + reward_id="reward1", + child_id="kid1", + claimed_at=dt.datetime.now(dt.timezone.utc), + id="claim1", ) coord = _make_coord(children=[child], rewards=[reward1, reward2], claims=[claim]) # spendable = 50 − 40 = 10, so requesting 30 is capped to 10. @@ -341,35 +363,44 @@ def test_wallet_claim_still_blocks_pool_allocation(self): def test_is_pool_mode_claim_detects_filled_allocation(self): import datetime as dt + reward = _reward(cost=50) coord = _make_coord(rewards=[reward]) filled = PoolAllocation(child_id="kid1", reward_id="reward1", allocated_points=50) coord.storage.get_pool_allocation = MagicMock(return_value=filled) claim = RewardClaim( - reward_id="reward1", child_id="kid1", - claimed_at=dt.datetime.now(dt.timezone.utc), id="claim1", + reward_id="reward1", + child_id="kid1", + claimed_at=dt.datetime.now(dt.timezone.utc), + id="claim1", ) assert coord.is_pool_mode_claim(claim) is True def test_is_pool_mode_claim_false_for_partial_allocation(self): import datetime as dt + reward = _reward(cost=50) coord = _make_coord(rewards=[reward]) partial = PoolAllocation(child_id="kid1", reward_id="reward1", allocated_points=20) coord.storage.get_pool_allocation = MagicMock(return_value=partial) claim = RewardClaim( - reward_id="reward1", child_id="kid1", - claimed_at=dt.datetime.now(dt.timezone.utc), id="claim1", + reward_id="reward1", + child_id="kid1", + claimed_at=dt.datetime.now(dt.timezone.utc), + id="claim1", ) assert coord.is_pool_mode_claim(claim) is False def test_is_pool_mode_claim_false_without_allocation(self): import datetime as dt + reward = _reward(cost=50) coord = _make_coord(rewards=[reward]) claim = RewardClaim( - reward_id="reward1", child_id="kid1", - claimed_at=dt.datetime.now(dt.timezone.utc), id="claim1", + reward_id="reward1", + child_id="kid1", + claimed_at=dt.datetime.now(dt.timezone.utc), + id="claim1", ) assert coord.is_pool_mode_claim(claim) is False @@ -456,18 +487,21 @@ def test_jackpot_cost_reduction_refunds_newest_first(self): # Total was 80, cost dropped to 60 → overshoot 20 refunded from kidB (newer id). assert child_b.points == 20 # full refund of 20 - assert child_a.points == 0 # untouched + assert child_a.points == 0 # untouched def test_approve_refunds_overshoot_on_redeem(self): # Pre-existing over-allocation: child put 11 into a pool that costs 10. # On approval, the 1-point overshoot must be refunded to the wallet. import datetime as dt + child = _child(points=89) # 100 earned − 11 allocated = 89 reward = _reward(cost=10) over_alloc = PoolAllocation(child_id="kid1", reward_id="reward1", allocated_points=11) claim = RewardClaim( - reward_id="reward1", child_id="kid1", - claimed_at=dt.datetime.now(dt.timezone.utc), id="claim1", + reward_id="reward1", + child_id="kid1", + claimed_at=dt.datetime.now(dt.timezone.utc), + id="claim1", ) coord = _make_coord(children=[child], rewards=[reward], claims=[claim]) coord.storage.get_pool_allocation = MagicMock(return_value=over_alloc) @@ -487,11 +521,14 @@ class TestRewardStockAndExpiration: def test_approve_decrements_quantity(self): import datetime as dt + child = _child(points=100) reward = Reward(name="Unique toy", cost=50, quantity=2, id="reward1") claim = RewardClaim( - reward_id="reward1", child_id="kid1", - claimed_at=dt.datetime.now(dt.timezone.utc), id="claim1", + reward_id="reward1", + child_id="kid1", + claimed_at=dt.datetime.now(dt.timezone.utc), + id="claim1", ) coord = _make_coord(children=[child], rewards=[reward], claims=[claim]) run(coord.async_approve_reward("claim1")) @@ -500,11 +537,14 @@ def test_approve_decrements_quantity(self): def test_unlimited_quantity_not_decremented(self): import datetime as dt + child = _child(points=100) reward = Reward(name="Ice cream", cost=50, quantity=None, id="reward1") claim = RewardClaim( - reward_id="reward1", child_id="kid1", - claimed_at=dt.datetime.now(dt.timezone.utc), id="claim1", + reward_id="reward1", + child_id="kid1", + claimed_at=dt.datetime.now(dt.timezone.utc), + id="claim1", ) coord = _make_coord(children=[child], rewards=[reward], claims=[claim]) run(coord.async_approve_reward("claim1")) @@ -515,18 +555,26 @@ def test_approve_sold_out_refunds_other_pool_allocations(self): pre-allocated points on the same reward. On approval the reward hits 0 and child B's allocation must be refunded to their wallet.""" import datetime as dt + child_a = Child(name="A", points=100, id="kidA") child_b = Child(name="B", points=50, id="kidB") reward = Reward(name="Unique toy", cost=50, quantity=1, id="reward1") alloc_b = PoolAllocation( - child_id="kidB", reward_id="reward1", allocated_points=20, id="alloc_b", + child_id="kidB", + reward_id="reward1", + allocated_points=20, + id="alloc_b", ) claim = RewardClaim( - reward_id="reward1", child_id="kidA", - claimed_at=dt.datetime.now(dt.timezone.utc), id="claim1", + reward_id="reward1", + child_id="kidA", + claimed_at=dt.datetime.now(dt.timezone.utc), + id="claim1", ) coord = _make_coord( - children=[child_a, child_b], rewards=[reward], claims=[claim], + children=[child_a, child_b], + rewards=[reward], + claims=[claim], ) # Child A is redeeming from their wallet; allocations belong to B. coord.storage.get_pool_allocation = MagicMock(return_value=None) @@ -544,28 +592,33 @@ def test_jackpot_sold_out_refunds_remaining_contributors(self): the pool-mode redeem path, so when quantity hits 0 there's nothing left to refund. Points stay spent on the reward.""" import datetime as dt + child_a = Child(name="A", points=0, id="kidA") child_b = Child(name="B", points=0, id="kidB") reward = Reward(name="Shared prize", cost=80, quantity=1, is_jackpot=True, id="rewardJ") alloc_a = PoolAllocation(child_id="kidA", reward_id="rewardJ", allocated_points=50, id="a1") alloc_b = PoolAllocation(child_id="kidB", reward_id="rewardJ", allocated_points=30, id="a2") claim = RewardClaim( - reward_id="rewardJ", child_id="kidA", - claimed_at=dt.datetime.now(dt.timezone.utc), id="claim1", + reward_id="rewardJ", + child_id="kidA", + claimed_at=dt.datetime.now(dt.timezone.utc), + id="claim1", ) coord = _make_coord( - children=[child_a, child_b], rewards=[reward], claims=[claim], + children=[child_a, child_b], + rewards=[reward], + claims=[claim], ) coord.storage.get_total_allocated_for_reward = MagicMock(return_value=80) # Stateful mock: allocations shrink as storage.remove_pool_allocation # is called, mirroring the real storage behaviour. allocs = {("kidA", "rewardJ"): alloc_a, ("kidB", "rewardJ"): alloc_b} - coord.storage.get_pool_allocations = MagicMock( - side_effect=lambda: list(allocs.values()) - ) + coord.storage.get_pool_allocations = MagicMock(side_effect=lambda: list(allocs.values())) + def _remove(child_id, reward_id): allocs.pop((child_id, reward_id), None) + coord.storage.remove_pool_allocation = MagicMock(side_effect=_remove) run(coord.async_approve_reward("claim1")) @@ -649,27 +702,21 @@ class TestJackpotImpliesPoolMode: def test_add_jackpot_forces_pool_enabled(self): coord = _make_coord() - reward = run(coord.async_add_reward( - name="Family Trip", cost=100, is_jackpot=True, pool_enabled=False - )) + reward = run(coord.async_add_reward(name="Family Trip", cost=100, is_jackpot=True, pool_enabled=False)) assert reward.pool_enabled is True assert coord.storage.add_reward.call_args.args[0].pool_enabled is True def test_update_jackpot_forces_pool_enabled(self): - existing = Reward(name="Trip", cost=100, is_jackpot=True, - pool_enabled=False, id="rwJ") + existing = Reward(name="Trip", cost=100, is_jackpot=True, pool_enabled=False, id="rwJ") coord = _make_coord(rewards=[existing]) - edited = Reward(name="Trip", cost=100, is_jackpot=True, - pool_enabled=False, id="rwJ") + edited = Reward(name="Trip", cost=100, is_jackpot=True, pool_enabled=False, id="rwJ") run(coord.async_update_reward(edited)) assert edited.pool_enabled is True assert coord.storage.update_reward.call_args.args[0].pool_enabled is True def test_non_jackpot_pool_flag_left_untouched(self): coord = _make_coord() - reward = run(coord.async_add_reward( - name="Ice cream", cost=10, is_jackpot=False, pool_enabled=False - )) + reward = run(coord.async_add_reward(name="Ice cream", cost=10, is_jackpot=False, pool_enabled=False)) assert reward.pool_enabled is False @@ -677,12 +724,13 @@ def test_non_jackpot_pool_flag_left_untouched(self): # async_remove_reward — refund outstanding pool allocations (#564) # --------------------------------------------------------------------------- + class TestRemoveRewardRefundsPool: def test_delete_refunds_pool_allocations_to_wallets(self): # Points were deducted at allocation time; deleting the reward must # return each contributor's earmarked points to their wallet (#564). - child_a = Child(name="A", points=70, id="A") # 30 earmarked earlier - child_b = Child(name="B", points=46, id="B") # 25 earmarked earlier + child_a = Child(name="A", points=70, id="A") # 30 earmarked earlier + child_b = Child(name="B", points=46, id="B") # 25 earmarked earlier reward = Reward(name="Family Trip", cost=800, is_jackpot=True, id="rewardJ") coord = _make_coord(children=[child_a, child_b], rewards=[reward]) alloc_a = PoolAllocation(child_id="A", reward_id="rewardJ", allocated_points=30, id="pa1") @@ -691,8 +739,8 @@ def test_delete_refunds_pool_allocations_to_wallets(self): run(coord.async_remove_reward("rewardJ")) - assert child_a.points == 100 # 70 + 30 refunded - assert child_b.points == 71 # 46 + 25 refunded + assert child_a.points == 100 # 70 + 30 refunded + assert child_b.points == 71 # 46 + 25 refunded assert coord.storage.add_points_transaction.call_count == 2 coord.storage.remove_reward.assert_called_once_with("rewardJ") diff --git a/tests/test_data_version_cache.py b/tests/test_data_version_cache.py index f46b2e4..049a6f0 100644 --- a/tests/test_data_version_cache.py +++ b/tests/test_data_version_cache.py @@ -1,4 +1,5 @@ """PERF-2: storage data_version + coordinator _async_update_data caching.""" + from __future__ import annotations from unittest.mock import AsyncMock, MagicMock diff --git a/tests/test_due_chores.py b/tests/test_due_chores.py index 5615667..b4f21d9 100644 --- a/tests/test_due_chores.py +++ b/tests/test_due_chores.py @@ -3,6 +3,7 @@ is_chore_available_for_child is mocked so these isolate the *added* filtering: assigned_to membership, specific_days due_days, and the completed-today cap. """ + from __future__ import annotations import datetime as dt @@ -57,8 +58,8 @@ def test_excludes_completed_up_to_daily_limit(): Chore(name="Twice", daily_limit=2, id="b"), ] comps = [ - ChoreCompletion(chore_id="a", child_id="ch1", completed_at=NOW), # Once done -> excluded - ChoreCompletion(chore_id="b", child_id="ch1", completed_at=NOW), # Twice 1/2 -> still due + ChoreCompletion(chore_id="a", child_id="ch1", completed_at=NOW), # Once done -> excluded + ChoreCompletion(chore_id="b", child_id="ch1", completed_at=NOW), # Twice 1/2 -> still due ] assert _due(_coord(chores, comps)) == ["Twice"] @@ -85,6 +86,7 @@ def test_unavailable_chores_excluded(): # FEAT-1: chore dependency gating in is_chore_available_for_child # --------------------------------------------------------------------------- + def _avail_coord(chore, child, completions): coord = object.__new__(TaskMateCoordinator) coord.storage = MagicMock() @@ -102,8 +104,15 @@ def _avail(chore, child, completions): def _dep_chore(): - return Chore(name="B", id="B", depends_on=["A"], schedule_mode="specific_days", - due_days=[], assignment_mode="everyone", enabled=True) + return Chore( + name="B", + id="B", + depends_on=["A"], + schedule_mode="specific_days", + due_days=[], + assignment_mode="everyone", + enabled=True, + ) def test_dependency_unmet_blocks_chore(): diff --git a/tests/test_events.py b/tests/test_events.py index f5deb32..af116c4 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -1,5 +1,6 @@ """Tests for taskmate_* automation events added for penalties, bonuses, reward approve/reject and chore reject.""" + from __future__ import annotations import asyncio @@ -83,8 +84,9 @@ def test_reward_rejected_event(): def test_reward_approved_event_wallet_mode(): coord = _base_coord() - claim = RewardClaim(reward_id="r1", child_id="c1", claimed_at=dt.datetime(2024, 1, 1, tzinfo=UTC), - approved=False, id="cl1") + claim = RewardClaim( + reward_id="r1", child_id="c1", claimed_at=dt.datetime(2024, 1, 1, tzinfo=UTC), approved=False, id="cl1" + ) coord.storage.get_reward_claims = MagicMock(return_value=[claim]) coord.storage.get_pool_allocation = MagicMock(return_value=None) coord.storage.update_child = MagicMock() @@ -102,9 +104,14 @@ def test_reward_approved_event_wallet_mode(): def test_chore_rejected_event(): coord = _base_coord() - comp = ChoreCompletion(chore_id="ch1", child_id="c1", - completed_at=dt.datetime(2024, 1, 1, tzinfo=UTC), - approved=False, points_awarded=0, id="comp1") + comp = ChoreCompletion( + chore_id="ch1", + child_id="c1", + completed_at=dt.datetime(2024, 1, 1, tzinfo=UTC), + approved=False, + points_awarded=0, + id="comp1", + ) coord.storage.get_completions = MagicMock(return_value=[comp]) coord.storage.undo_last_completed = MagicMock() coord.storage.remove_completion = MagicMock() diff --git a/tests/test_external_state_version.py b/tests/test_external_state_version.py index 4407c12..acd99a2 100644 --- a/tests/test_external_state_version.py +++ b/tests/test_external_state_version.py @@ -6,6 +6,7 @@ the weather gate — would otherwise serve attributes computed against the old entity state until an unrelated TaskMate mutation happened to bump the version. """ + from __future__ import annotations from unittest.mock import MagicMock @@ -35,14 +36,17 @@ def _event(entity_id): class TestTrackedEntities: def test_visibility_and_weather_entities_are_tracked(self): - coord = _coord(chores=[ - Chore(name="Dishes", visibility_entity="binary_sensor.dishwasher"), - Chore(name="Mow", weather_entity="weather.home"), - Chore(name="Plain"), - ]) + coord = _coord( + chores=[ + Chore(name="Dishes", visibility_entity="binary_sensor.dishwasher"), + Chore(name="Mow", weather_entity="weather.home"), + Chore(name="Plain"), + ] + ) coord._refresh_tracked_availability_entities() assert coord._tracked_visibility_entities == { - "binary_sensor.dishwasher", "weather.home", + "binary_sensor.dishwasher", + "weather.home", } def test_child_availability_entities_stay_separate(self): diff --git a/tests/test_fairness_report.py b/tests/test_fairness_report.py index d5e843d..10366f0 100644 --- a/tests/test_fairness_report.py +++ b/tests/test_fairness_report.py @@ -3,6 +3,7 @@ Answers "am I dumping everything on the eldest?". Judged on chore count rather than points, so a pricier chore can't hide an uneven split. """ + from __future__ import annotations from datetime import timedelta @@ -89,8 +90,7 @@ def test_unparseable_timestamp_is_skipped(self): class TestBalance: def test_even_split_is_balanced(self): - coord = _coord(KIDS, [_completion("a"), _completion("a"), - _completion("b"), _completion("b")]) + coord = _coord(KIDS, [_completion("a"), _completion("a"), _completion("b"), _completion("b")]) report = coord.fairness_report() assert report["balanced"] is True assert {r["status"] for r in report["children"]} == {"balanced"} @@ -139,8 +139,7 @@ def test_status_follows_count_not_points(self): assert _status(coord, "a") == "over" def test_active_days_counts_distinct_days(self): - coord = _coord(KIDS, [_completion("a", days_ago=0), _completion("a", days_ago=0), - _completion("a", days_ago=2)]) + coord = _coord(KIDS, [_completion("a", days_ago=0), _completion("a", days_ago=0), _completion("a", days_ago=2)]) by_id = {r["id"]: r for r in coord.fairness_report()["children"]} assert by_id["a"]["active_days"] == 2 diff --git a/tests/test_family_goal.py b/tests/test_family_goal.py index 194ea25..5debbd2 100644 --- a/tests/test_family_goal.py +++ b/tests/test_family_goal.py @@ -1,4 +1,5 @@ """Tests for the family co-op goal (FEAT-4).""" + from __future__ import annotations from unittest.mock import AsyncMock, MagicMock @@ -34,8 +35,12 @@ def test_progress_is_combined_points(): @pytest.mark.asyncio async def test_goal_fires_when_reached(): - settings = {"family_goal_enabled": True, "family_goal_target": 300, - "family_goal_name": "Movie", "family_goal_reward": "popcorn"} + settings = { + "family_goal_enabled": True, + "family_goal_target": 300, + "family_goal_name": "Movie", + "family_goal_reward": "popcorn", + } c = _coord(_kids(150, 200), settings) # 350 >= 300 await c._async_check_family_goal() assert settings.get("family_goal_achieved") is True @@ -57,8 +62,7 @@ async def test_goal_not_fired_below_target(): @pytest.mark.asyncio async def test_goal_only_fires_once(): - settings = {"family_goal_enabled": True, "family_goal_target": 100, - "family_goal_achieved": True} + settings = {"family_goal_enabled": True, "family_goal_target": 100, "family_goal_achieved": True} c = _coord(_kids(500), settings) await c._async_check_family_goal() c.notifications.fire.assert_not_awaited() diff --git a/tests/test_friction_report.py b/tests/test_friction_report.py index 87f1a64..9881ffc 100644 --- a/tests/test_friction_report.py +++ b/tests/test_friction_report.py @@ -4,6 +4,7 @@ signals TaskMate actually retains — rejections delete the completion and resolved mandatory misses are removed, so neither can be counted. """ + from __future__ import annotations from datetime import date, timedelta @@ -58,8 +59,7 @@ def test_daily_chore_expects_one_per_day(self): def test_specific_days_counts_matching_weekdays(self): coord = _coord() - chore = Chore(name="Mon/Fri", id="a", schedule_mode="specific_days", - due_days=["monday", "friday"]) + chore = Chore(name="Mon/Fri", id="a", schedule_mode="specific_days", due_days=["monday", "friday"]) start = date(2026, 1, 5) # a Monday assert coord._expected_occurrences(chore, start, start + timedelta(days=13)) == 4 @@ -179,10 +179,8 @@ def test_outstanding_misses_are_surfaced(self): def test_escalated_misses_are_counted_as_chasing(self): chore = Chore(name="Bins", id="a") misses = [ - MandatoryMiss(chore_id="a", child_id="k", due_date="2026-01-01", - period_id="anytime", escalation_stage=3), - MandatoryMiss(chore_id="a", child_id="k", due_date="2026-01-02", - period_id="anytime", escalation_stage=1), + MandatoryMiss(chore_id="a", child_id="k", due_date="2026-01-01", period_id="anytime", escalation_stage=3), + MandatoryMiss(chore_id="a", child_id="k", due_date="2026-01-02", period_id="anytime", escalation_stage=1), ] coord = _coord([chore], [], None, misses) row = _row(coord.friction_report(), "a") diff --git a/tests/test_frontend_retired_cards.py b/tests/test_frontend_retired_cards.py index 69ada59..2b210de 100644 --- a/tests/test_frontend_retired_cards.py +++ b/tests/test_frontend_retired_cards.py @@ -5,6 +5,7 @@ `async_register_cards` now deletes those by exact URL — and ONLY those, never a live card and never via heuristic diffing (which once wiped every resource). """ + from __future__ import annotations import sys @@ -53,9 +54,11 @@ def _hass(resources): lovelace.mode = "storage" lovelace.resources = resources hass.data = {"lovelace": lovelace} + # _async_get_version offloads manifest read to the executor — run inline. async def _exec(func, *args): return func(*args) + hass.async_add_executor_job = _exec return hass @@ -65,16 +68,18 @@ async def test_retired_cards_deregistered_live_cards_kept(): live = f"{fe.URL_BASE}/{fe.CARDS[0]}" retired = f"{fe.URL_BASE}/{fe.RETIRED_CARDS[0]}" other = "/local/some-other-card.js" # non-taskmate, must be untouched - res = FakeResources([ - {"id": "a", "url": f"{live}?v=1.0.0", "res_type": "module"}, - {"id": "b", "url": f"{retired}?v=1.0.0", "res_type": "module"}, - {"id": "c", "url": other, "res_type": "module"}, - ]) + res = FakeResources( + [ + {"id": "a", "url": f"{live}?v=1.0.0", "res_type": "module"}, + {"id": "b", "url": f"{retired}?v=1.0.0", "res_type": "module"}, + {"id": "c", "url": other, "res_type": "module"}, + ] + ) await fe.async_register_cards(_hass(res)) urls = [it["url"].split("?")[0] for it in res.async_items()] - assert retired not in urls # retired removed - assert live in urls # live card preserved - assert other in urls # foreign resource untouched + assert retired not in urls # retired removed + assert live in urls # live card preserved + assert other in urls # foreign resource untouched @pytest.mark.asyncio diff --git a/tests/test_gift_points.py b/tests/test_gift_points.py index ceb4e7e..ca2dd29 100644 --- a/tests/test_gift_points.py +++ b/tests/test_gift_points.py @@ -1,4 +1,5 @@ """Tests for inter-child points gifting.""" + from __future__ import annotations import asyncio diff --git a/tests/test_guest_profiles.py b/tests/test_guest_profiles.py index 0ab631c..4e5d330 100644 --- a/tests/test_guest_profiles.py +++ b/tests/test_guest_profiles.py @@ -4,6 +4,7 @@ of the family leaderboard. Archived rather than deleted, so the visit's history survives and the same guest can come back next summer. """ + from __future__ import annotations from datetime import timedelta @@ -185,12 +186,20 @@ def test_legacy_child_is_not_a_guest(self): class TestCardFiltering: def test_leaderboard_card_filters_guests(self): import pathlib - card = (pathlib.Path(__file__).resolve().parent.parent / "custom_components" - / "taskmate" / "www" / "taskmate-leaderboard-card.js").read_text(encoding="utf-8") + + card = ( + pathlib.Path(__file__).resolve().parent.parent + / "custom_components" + / "taskmate" + / "www" + / "taskmate-leaderboard-card.js" + ).read_text(encoding="utf-8") assert card.count("filter(c => !c.is_guest)") == 2 def test_sensor_exposes_the_flag(self): import pathlib - sensor = (pathlib.Path(__file__).resolve().parent.parent / "custom_components" - / "taskmate" / "sensor.py").read_text(encoding="utf-8") + + sensor = ( + pathlib.Path(__file__).resolve().parent.parent / "custom_components" / "taskmate" / "sensor.py" + ).read_text(encoding="utf-8") assert '"is_guest": True' in sensor diff --git a/tests/test_health_report.py b/tests/test_health_report.py index ee5bcb4..e65a5ff 100644 --- a/tests/test_health_report.py +++ b/tests/test_health_report.py @@ -4,6 +4,7 @@ work, entities that no longer exist — with a severity, a plain sentence and where to go and fix it. """ + from __future__ import annotations from unittest.mock import MagicMock @@ -13,8 +14,18 @@ from .test_coordinator_logic import _make_coord -def _coord(children=(), chores=(), rewards=(), completions=(), *, - badges=(), scheduled=(), misses=(), allowlist=(), known_entities=()): +def _coord( + children=(), + chores=(), + rewards=(), + completions=(), + *, + badges=(), + scheduled=(), + misses=(), + allowlist=(), + known_entities=(), +): settings = {"unlock_allowlist": list(allowlist), "active_unlocks": []} coord = _make_coord(settings=settings, children=list(children), completions=list(completions)) coord.storage.get_chores = MagicMock(return_value=list(chores)) @@ -70,18 +81,15 @@ def test_clean_setup_reports_nothing(self): class TestBrokenConfig: def test_missing_visibility_entity(self): - chore = Chore(name="Gated", id="c1", assigned_to=[KID.id], - visibility_entity="binary_sensor.gone") + chore = Chore(name="Gated", id="c1", assigned_to=[KID.id], visibility_entity="binary_sensor.gone") assert "chore_missing_entity" in _codes(_coord([KID], [chore]).health_report()) def test_missing_weather_entity(self): - chore = Chore(name="Outdoor", id="c1", assigned_to=[KID.id], - weather_entity="weather.gone") + chore = Chore(name="Outdoor", id="c1", assigned_to=[KID.id], weather_entity="weather.gone") assert "chore_missing_entity" in _codes(_coord([KID], [chore]).health_report()) def test_present_entity_is_not_flagged(self): - chore = Chore(name="Outdoor", id="c1", assigned_to=[KID.id], - weather_entity="weather.home") + chore = Chore(name="Outdoor", id="c1", assigned_to=[KID.id], weather_entity="weather.home") report = _coord([KID], [chore], known_entities={"weather.home"}).health_report() assert "chore_missing_entity" not in _codes(report) @@ -112,8 +120,7 @@ def test_disabled_chore_does_not_count_as_coverage(self): class TestShape: def test_counts_are_reported(self): chore = Chore(name="Fine", id="c1", assigned_to=[KID.id]) - misses = [MandatoryMiss(chore_id="c1", child_id=KID.id, - due_date="2026-01-01", period_id="anytime")] + misses = [MandatoryMiss(chore_id="c1", child_id=KID.id, due_date="2026-01-01", period_id="anytime")] report = _coord([KID], [chore], misses=misses).health_report() assert report["counts"]["children"] == 1 assert report["counts"]["chores"] == 1 diff --git a/tests/test_ics_export.py b/tests/test_ics_export.py index 42a37a4..0e2b146 100644 --- a/tests/test_ics_export.py +++ b/tests/test_ics_export.py @@ -1,4 +1,5 @@ """Tests for ICS calendar export (FEAT-10).""" + from __future__ import annotations from datetime import date, datetime, timezone @@ -33,11 +34,22 @@ def test_make_uid_stable_and_scoped(): def test_build_calendar_allday_and_timed(): events = [ - {"uid": "u1@taskmate", "summary": "Dishes — Alex", "description": "d", - "start": date(2026, 4, 1), "end": date(2026, 4, 2), "all_day": True}, - {"uid": "u2@taskmate", "summary": "Walk, dog", "description": "", - "start": datetime(2026, 4, 1, 17, 0, tzinfo=UTC), - "end": datetime(2026, 4, 1, 18, 0, tzinfo=UTC), "all_day": False}, + { + "uid": "u1@taskmate", + "summary": "Dishes — Alex", + "description": "d", + "start": date(2026, 4, 1), + "end": date(2026, 4, 2), + "all_day": True, + }, + { + "uid": "u2@taskmate", + "summary": "Walk, dog", + "description": "", + "start": datetime(2026, 4, 1, 17, 0, tzinfo=UTC), + "end": datetime(2026, 4, 1, 18, 0, tzinfo=UTC), + "all_day": False, + }, ] out = ics.build_calendar(events, NOW) assert out.startswith("BEGIN:VCALENDAR\r\n") @@ -46,7 +58,7 @@ def test_build_calendar_allday_and_timed(): assert "DTSTART;VALUE=DATE:20260401" in out assert "DTEND;VALUE=DATE:20260402" in out assert "DTSTART:20260401T170000Z" in out - assert "SUMMARY:Walk\\, dog" in out # comma escaped + assert "SUMMARY:Walk\\, dog" in out # comma escaped assert "UID:u1@taskmate" in out assert "DTSTAMP:20260401T080000Z" in out diff --git a/tests/test_image_storage.py b/tests/test_image_storage.py index 5b35e13..3bdb982 100644 --- a/tests/test_image_storage.py +++ b/tests/test_image_storage.py @@ -1,4 +1,5 @@ """Tests for the pure chore-image storage helpers (custom_components.taskmate.images).""" + from __future__ import annotations import asyncio @@ -43,6 +44,7 @@ def test_detect_allowed_ext_rejects_heic(): # photos.py accepts HEIC for evidence, but a browser can't render it and a # chore image is only ever displayed, so storing one yields a broken image. from custom_components.taskmate import photos + assert photos.detect_image_ext(HEIC) == "heic" assert images.detect_allowed_ext(HEIC) is None @@ -60,7 +62,7 @@ def test_is_taskmate_image_url_rejects_everything_else(): for bad in ( "", None, - "/api/taskmate/photo/" + "a" * 32 + ".jpg", # the OTHER store + "/api/taskmate/photo/" + "a" * 32 + ".jpg", # the OTHER store "/api/taskmate/image/../../secret.txt", "/api/taskmate/image/sub/dir.jpg", "/api/taskmate/image/short.jpg", diff --git a/tests/test_image_views.py b/tests/test_image_views.py index 598bc4d..5f0cc02 100644 --- a/tests/test_image_views.py +++ b/tests/test_image_views.py @@ -6,6 +6,7 @@ without aiohttp — the storage helpers the views delegate to — is covered by ``test_image_storage.py``. """ + from __future__ import annotations import pathlib @@ -13,10 +14,9 @@ from custom_components.taskmate import images -SRC = ( - pathlib.Path(__file__).resolve().parent.parent - / "custom_components" / "taskmate" / "http_images.py" -).read_text(encoding="utf-8") +SRC = (pathlib.Path(__file__).resolve().parent.parent / "custom_components" / "taskmate" / "http_images.py").read_text( + encoding="utf-8" +) def test_views_are_bound_to_the_image_prefix(): @@ -72,9 +72,6 @@ def test_registration_is_idempotent(): def test_frontend_registers_the_image_views(): frontend = ( - pathlib.Path(__file__).resolve().parent.parent - / "custom_components" / "taskmate" / "frontend.py" + pathlib.Path(__file__).resolve().parent.parent / "custom_components" / "taskmate" / "frontend.py" ).read_text(encoding="utf-8") - assert "async_register_image_views" in frontend, ( - "views that are never registered mean every image 404s" - ) + assert "async_register_image_views" in frontend, "views that are never registered mean every image 404s" diff --git a/tests/test_integration.py b/tests/test_integration.py index bf829f0..b0d69e4 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -8,6 +8,7 @@ add reward → claim reward → approve reward → points deducted template apply → chores created """ + from __future__ import annotations import asyncio @@ -67,6 +68,7 @@ async def _noop_refresh(): pass import custom_components.taskmate.coordinator as _mod + coord._dt_now = now coord.async_refresh = _noop_refresh @@ -82,9 +84,7 @@ def test_complete_auto_approved_chore_awards_points(self): with patch.object(_mod.dt_util, "now", return_value=now): child = run(coord.async_add_child("Alice")) - chore = run(coord.async_add_chore( - "Make bed", points=5, requires_approval=False - )) + chore = run(coord.async_add_chore("Make bed", points=5, requires_approval=False)) assert child.points == 0 @@ -103,9 +103,7 @@ def test_complete_approval_required_chore_holds_points(self): with patch.object(_mod.dt_util, "now", return_value=now): child = run(coord.async_add_child("Bob")) - chore = run(coord.async_add_chore( - "Tidy room", points=10, requires_approval=True - )) + chore = run(coord.async_add_chore("Tidy room", points=10, requires_approval=True)) completion = run(coord.async_complete_chore(chore.id, child.id)) assert completion.approved is False @@ -117,9 +115,7 @@ def test_complete_approval_required_chore_holds_points(self): updated_child = storage.get_child(child.id) assert updated_child.points == 10 - approved = next( - c for c in storage.get_completions() if c.id == completion.id - ) + approved = next(c for c in storage.get_completions() if c.id == completion.id) assert approved.approved is True assert approved.points_awarded == 10 @@ -129,9 +125,7 @@ def test_daily_limit_blocks_extra_completions(self): with patch.object(_mod.dt_util, "now", return_value=now): child = run(coord.async_add_child("Charlie")) - chore = run(coord.async_add_chore( - "Feed cat", points=3, requires_approval=False, daily_limit=1 - )) + chore = run(coord.async_add_chore("Feed cat", points=3, requires_approval=False, daily_limit=1)) run(coord.async_complete_chore(chore.id, child.id)) # Hitting the daily limit is an expected soft rejection: silent no-op @@ -148,9 +142,7 @@ def test_streak_increments_across_days(self): with patch.object(_mod.dt_util, "now", return_value=day1): child = run(coord.async_add_child("Dana")) - chore = run(coord.async_add_chore( - "Brush teeth", points=2, requires_approval=False - )) + chore = run(coord.async_add_chore("Brush teeth", points=2, requires_approval=False)) run(coord.async_complete_chore(chore.id, child.id)) assert storage.get_child(child.id).current_streak == 1 @@ -170,9 +162,7 @@ def test_claim_and_approve_reward_deducts_points(self): with patch.object(_mod.dt_util, "now", return_value=now): child = run(coord.async_add_child("Eve")) - chore = run(coord.async_add_chore( - "Hoover", points=20, requires_approval=False - )) + chore = run(coord.async_add_chore("Hoover", points=20, requires_approval=False)) run(coord.async_complete_chore(chore.id, child.id)) assert storage.get_child(child.id).points == 20 @@ -258,9 +248,7 @@ def test_save_from_chores_and_reapply(self): chore2 = run(coord.async_add_chore("Task B", points=8)) with patch.object(_mod.dt_util, "now", return_value=now): - template_id = run(coord.async_save_template_from_chores( - [chore1.id, chore2.id], "My Pack", "mdi:broom" - )) + template_id = run(coord.async_save_template_from_chores([chore1.id, chore2.id], "My Pack", "mdi:broom")) assert template_id is not None @@ -282,9 +270,7 @@ def test_delete_custom_template(self): now = _now() with patch.object(_mod.dt_util, "now", return_value=now): - template_id = run(coord.async_create_template( - "Temp Pack", "mdi:star", [{"name": "X", "points": 1}] - )) + template_id = run(coord.async_create_template("Temp Pack", "mdi:star", [{"name": "X", "points": 1}])) assert len(storage.get_custom_templates()) == 1 @@ -310,10 +296,9 @@ def test_one_shot_completes_once_then_disabled(self): with patch.object(_mod.dt_util, "now", return_value=now): child = run(coord.async_add_child("Jack")) - chore = run(coord.async_add_chore( - "One-off task", points=5, requires_approval=False, - schedule_mode="one_shot" - )) + chore = run( + coord.async_add_chore("One-off task", points=5, requires_approval=False, schedule_mode="one_shot") + ) run(coord.async_complete_chore(chore.id, child.id)) updated_chore = storage.get_chore(chore.id) @@ -335,9 +320,7 @@ def test_remove_child_cleans_completions_and_claims(self): with patch.object(_mod.dt_util, "now", return_value=now): child = run(coord.async_add_child("Kate")) - chore = run(coord.async_add_chore( - "Wash hands", points=2, requires_approval=False - )) + chore = run(coord.async_add_chore("Wash hands", points=2, requires_approval=False)) run(coord.async_complete_chore(chore.id, child.id)) assert len(storage.get_completions()) == 1 @@ -359,9 +342,7 @@ def test_two_children_same_chore_independent_points(self): with patch.object(_mod.dt_util, "now", return_value=now): alice = run(coord.async_add_child("Alice")) bob = run(coord.async_add_child("Bob")) - chore = run(coord.async_add_chore( - "Set table", points=3, requires_approval=False - )) + chore = run(coord.async_add_chore("Set table", points=3, requires_approval=False)) run(coord.async_complete_chore(chore.id, alice.id)) run(coord.async_complete_chore(chore.id, bob.id)) diff --git a/tests/test_intents.py b/tests/test_intents.py index 712cc62..eb2ffdb 100644 --- a/tests/test_intents.py +++ b/tests/test_intents.py @@ -1,4 +1,5 @@ """Tests for TaskMate conversation intents (FEAT-12).""" + from __future__ import annotations from unittest.mock import MagicMock diff --git a/tests/test_interest.py b/tests/test_interest.py index 049c43d..0b315e7 100644 --- a/tests/test_interest.py +++ b/tests/test_interest.py @@ -1,4 +1,5 @@ """Tests for periodic savings interest.""" + from __future__ import annotations import asyncio @@ -34,7 +35,9 @@ def _coord(settings, children): def _run_on(coord, date_obj): - with patch("homeassistant.util.dt.now", return_value=dt.datetime(date_obj.year, date_obj.month, date_obj.day, 0, 5)): + with patch( + "homeassistant.util.dt.now", return_value=dt.datetime(date_obj.year, date_obj.month, date_obj.day, 0, 5) + ): run(coord._async_apply_interest()) @@ -45,8 +48,10 @@ def test_disabled_noop(): def test_weekly_interest_on_monday(): - coord = _coord({"interest_enabled": True, "interest_period": "weekly", "interest_percent": "10"}, - [Child(name="A", points=100, id="c1")]) + coord = _coord( + {"interest_enabled": True, "interest_period": "weekly", "interest_percent": "10"}, + [Child(name="A", points=100, id="c1")], + ) _run_on(coord, dt.date(2026, 6, 17)) # Wed -> none coord.async_add_points.assert_not_awaited() _run_on(coord, dt.date(2026, 6, 15)) # Mon -> 10 @@ -55,8 +60,10 @@ def test_weekly_interest_on_monday(): def test_monthly_first_only(): - coord = _coord({"interest_enabled": True, "interest_period": "monthly", "interest_percent": "5"}, - [Child(name="A", points=200, id="c1")]) + coord = _coord( + {"interest_enabled": True, "interest_period": "monthly", "interest_percent": "5"}, + [Child(name="A", points=200, id="c1")], + ) _run_on(coord, dt.date(2026, 7, 2)) coord.async_add_points.assert_not_awaited() _run_on(coord, dt.date(2026, 7, 1)) @@ -64,15 +71,19 @@ def test_monthly_first_only(): def test_zero_balance_skipped(): - coord = _coord({"interest_enabled": True, "interest_period": "monthly", "interest_percent": "5"}, - [Child(name="A", points=0, id="c1")]) + coord = _coord( + {"interest_enabled": True, "interest_period": "monthly", "interest_percent": "5"}, + [Child(name="A", points=0, id="c1")], + ) _run_on(coord, dt.date(2026, 7, 1)) coord.async_add_points.assert_not_awaited() def test_no_double_same_day(): - coord = _coord({"interest_enabled": True, "interest_period": "monthly", "interest_percent": "5"}, - [Child(name="A", points=100, id="c1")]) + coord = _coord( + {"interest_enabled": True, "interest_period": "monthly", "interest_percent": "5"}, + [Child(name="A", points=100, id="c1")], + ) _run_on(coord, dt.date(2026, 7, 1)) _run_on(coord, dt.date(2026, 7, 1)) assert coord.async_add_points.await_count == 1 diff --git a/tests/test_leaderboard_seasons.py b/tests/test_leaderboard_seasons.py index 5b0def8..79f3d15 100644 --- a/tests/test_leaderboard_seasons.py +++ b/tests/test_leaderboard_seasons.py @@ -1,4 +1,5 @@ """Tests for leaderboard seasons (FEAT-2).""" + from __future__ import annotations import datetime as dt @@ -25,8 +26,9 @@ async def test_positive_transactions_accumulate_per_month(hass): # Negative (penalty/spend) does not count toward earned-season points. s.add_points_transaction(PointsTransaction(child_id="k1", points=-3, created_at=when)) # Different month is bucketed separately. - s.add_points_transaction(PointsTransaction( - child_id="k1", points=99, created_at=dt.datetime(2026, 5, 1, 9, 0, tzinfo=UTC))) + s.add_points_transaction( + PointsTransaction(child_id="k1", points=99, created_at=dt.datetime(2026, 5, 1, 9, 0, tzinfo=UTC)) + ) assert s.get_season_points("2026-04") == {"k1": 15, "k2": 7} assert s.get_season_points("2026-05") == {"k1": 99} @@ -48,13 +50,16 @@ def test_standings_ranked_desc_with_rank(): c = _coord(kids, {"2026-04": {"k1": 5, "k2": 20}}) rows = c.get_season_standings("2026-04") assert [(r["name"], r["points"], r["rank"]) for r in rows] == [ - ("Sam", 20, 1), ("Alex", 5, 2), ("Mo", 0, 3), + ("Sam", 20, 1), + ("Alex", 5, 2), + ("Mo", 0, 3), ] @pytest.mark.asyncio async def test_finalize_records_champion_and_notifies(monkeypatch): from tests.conftest import dt_util_mock + kids = [Child(name="Alex", id="k1"), Child(name="Sam", id="k2")] c = _coord(kids, {"2026-04": {"k1": 30, "k2": 12}}) c.storage.add_season_champion = MagicMock() @@ -74,6 +79,7 @@ async def test_finalize_records_champion_and_notifies(monkeypatch): @pytest.mark.asyncio async def test_finalize_skips_when_no_points(monkeypatch): from tests.conftest import dt_util_mock + c = _coord([Child(name="Alex", id="k1")], {"2026-04": {}}) c.storage.add_season_champion = MagicMock() c.storage.async_save = AsyncMock() @@ -88,8 +94,10 @@ async def test_finalize_skips_when_no_points(monkeypatch): @pytest.mark.asyncio async def test_finalize_is_idempotent_for_month(monkeypatch): from tests.conftest import dt_util_mock - c = _coord([Child(name="Alex", id="k1")], {"2026-04": {"k1": 5}}, - champions=[{"month": "2026-04", "child_id": "k1"}]) + + c = _coord( + [Child(name="Alex", id="k1")], {"2026-04": {"k1": 5}}, champions=[{"month": "2026-04", "child_id": "k1"}] + ) c.storage.add_season_champion = MagicMock() c.storage.async_save = AsyncMock() c.hass = MagicMock() diff --git a/tests/test_levels.py b/tests/test_levels.py index cc28461..be701c5 100644 --- a/tests/test_levels.py +++ b/tests/test_levels.py @@ -1,4 +1,5 @@ """Tests for the levels / XP system.""" + from __future__ import annotations import asyncio @@ -45,7 +46,7 @@ def test_level_info(): def test_custom_step(): coord = _coord("50") - assert coord.level_for_xp(120) == 3 # 120 // 50 + 1 + assert coord.level_for_xp(120) == 3 # 120 // 50 + 1 def test_level_up_fires_event_and_notification(): diff --git a/tests/test_locales_chore_images.py b/tests/test_locales_chore_images.py index e05a238..b9bd4ed 100644 --- a/tests/test_locales_chore_images.py +++ b/tests/test_locales_chore_images.py @@ -1,4 +1,5 @@ """Chore-image strings must exist in every locale (#750).""" + from __future__ import annotations import glob @@ -18,8 +19,12 @@ ] BASE = os.path.join( - os.path.dirname(__file__), "..", - "custom_components", "taskmate", "www", "locales", + os.path.dirname(__file__), + "..", + "custom_components", + "taskmate", + "www", + "locales", ) @@ -42,6 +47,5 @@ def test_all_locales_have_identical_key_sets(): reference = sets["en.json"] for name, keys in sets.items(): assert keys == reference, ( - f"{name} key set differs from en.json: " - f"missing={sorted(reference - keys)} extra={sorted(keys - reference)}" + f"{name} key set differs from en.json: missing={sorted(reference - keys)} extra={sorted(keys - reference)}" ) diff --git a/tests/test_locales_nav_url.py b/tests/test_locales_nav_url.py index cfdffb1..7cdb46a 100644 --- a/tests/test_locales_nav_url.py +++ b/tests/test_locales_nav_url.py @@ -11,8 +11,12 @@ def test_nav_url_strings_present_in_all_locales(): base = os.path.join( - os.path.dirname(__file__), "..", - "custom_components", "taskmate", "www", "locales", + os.path.dirname(__file__), + "..", + "custom_components", + "taskmate", + "www", + "locales", ) files = glob.glob(os.path.join(base, "*.json")) assert files, "no locale files found" @@ -20,5 +24,4 @@ def test_nav_url_strings_present_in_all_locales(): with open(path, encoding="utf-8") as fh: data = json.load(fh) for key in KEYS: - assert key in data and data[key].strip(), \ - f"{key} missing/empty in {os.path.basename(path)}" + assert key in data and data[key].strip(), f"{key} missing/empty in {os.path.basename(path)}" diff --git a/tests/test_locales_point_management.py b/tests/test_locales_point_management.py index 31afbad..5c73451 100644 --- a/tests/test_locales_point_management.py +++ b/tests/test_locales_point_management.py @@ -1,4 +1,5 @@ """Point-management strings must exist in every locale (#746).""" + from __future__ import annotations import glob @@ -31,8 +32,12 @@ } BASE = os.path.join( - os.path.dirname(__file__), "..", - "custom_components", "taskmate", "www", "locales", + os.path.dirname(__file__), + "..", + "custom_components", + "taskmate", + "www", + "locales", ) @@ -62,6 +67,5 @@ def test_all_locales_have_identical_key_sets(): reference = sets["en.json"] for name, keys in sets.items(): assert keys == reference, ( - f"{name} key set differs from en.json: " - f"missing={sorted(reference - keys)} extra={sorted(keys - reference)}" + f"{name} key set differs from en.json: missing={sorted(reference - keys)} extra={sorted(keys - reference)}" ) diff --git a/tests/test_mandatory_actions.py b/tests/test_mandatory_actions.py index 189a118..d3cfb1f 100644 --- a/tests/test_mandatory_actions.py +++ b/tests/test_mandatory_actions.py @@ -1,4 +1,5 @@ """Tests for mandatory-miss resolution actions (#532).""" + from __future__ import annotations import asyncio @@ -42,8 +43,9 @@ def _coord(miss): def _miss(period="morning", penalty=5): - return MandatoryMiss(chore_id="c1", child_id="k1", due_date="2026-06-21", - period_id=period, penalty_points=penalty, id="m1") + return MandatoryMiss( + chore_id="c1", child_id="k1", due_date="2026-06-21", period_id=period, penalty_points=penalty, id="m1" + ) def test_apply_penalty_deducts_and_removes(): diff --git a/tests/test_mandatory_chores_sensor.py b/tests/test_mandatory_chores_sensor.py index 6fce73d..2bb73fc 100644 --- a/tests/test_mandatory_chores_sensor.py +++ b/tests/test_mandatory_chores_sensor.py @@ -1,5 +1,6 @@ """The chores sensor list must carry the mandatory flag so the child card can render the badge/styling (#532 regression guard).""" + from __future__ import annotations from unittest.mock import MagicMock @@ -15,8 +16,7 @@ def _coord(): def test_mandatory_chore_emits_flag_and_penalty(): - chore = Chore(name="Homework", points=8, mandatory=True, - mandatory_penalty_points=10, id="c1") + chore = Chore(name="Homework", points=8, mandatory=True, mandatory_penalty_points=10, id="c1") out = _build_chores_list(_coord(), {"chores": [chore]}) rec = out[0] assert rec["mandatory"] is True @@ -31,8 +31,7 @@ def test_non_mandatory_chore_omits_flag(): def test_mandatory_with_zero_penalty_omits_penalty_key(): - chore = Chore(name="Brush teeth", points=3, mandatory=True, - mandatory_penalty_points=0, id="c3") + chore = Chore(name="Brush teeth", points=3, mandatory=True, mandatory_penalty_points=0, id="c3") rec = _build_chores_list(_coord(), {"chores": [chore]})[0] assert rec["mandatory"] is True assert "mandatory_penalty_points" not in rec diff --git a/tests/test_mandatory_detection.py b/tests/test_mandatory_detection.py index 8019f1f..a9b0b7f 100644 --- a/tests/test_mandatory_detection.py +++ b/tests/test_mandatory_detection.py @@ -1,4 +1,5 @@ """Tests for mandatory-miss detection (#532).""" + from __future__ import annotations import asyncio @@ -40,8 +41,14 @@ def _coord(chores, children, completions): def test_miss_created_for_incomplete_mandatory(): - chore = Chore(name="Homework", mandatory=True, mandatory_penalty_points=5, - time_category="afternoon", assigned_to=["k1"], id="c1") + chore = Chore( + name="Homework", + mandatory=True, + mandatory_penalty_points=5, + time_category="afternoon", + assigned_to=["k1"], + id="c1", + ) coord = _coord([chore], [Child(name="Kid", id="k1")], []) n = run(coord.async_detect_mandatory_misses("afternoon", DAY)) assert n == 1 @@ -51,31 +58,26 @@ def test_miss_created_for_incomplete_mandatory(): def test_no_miss_when_completed_today(): - chore = Chore(name="Homework", mandatory=True, time_category="afternoon", - assigned_to=["k1"], id="c1") - comp = ChoreCompletion(chore_id="c1", child_id="k1", - completed_at=dt.datetime(2026, 6, 21, 13, 0)) + chore = Chore(name="Homework", mandatory=True, time_category="afternoon", assigned_to=["k1"], id="c1") + comp = ChoreCompletion(chore_id="c1", child_id="k1", completed_at=dt.datetime(2026, 6, 21, 13, 0)) coord = _coord([chore], [Child(name="Kid", id="k1")], [comp]) assert run(coord.async_detect_mandatory_misses("afternoon", DAY)) == 0 def test_no_miss_for_non_mandatory(): - chore = Chore(name="Extra", mandatory=False, time_category="afternoon", - assigned_to=["k1"], id="c1") + chore = Chore(name="Extra", mandatory=False, time_category="afternoon", assigned_to=["k1"], id="c1") coord = _coord([chore], [Child(name="Kid", id="k1")], []) assert run(coord.async_detect_mandatory_misses("afternoon", DAY)) == 0 def test_wrong_period_skipped(): - chore = Chore(name="Homework", mandatory=True, time_category="morning", - assigned_to=["k1"], id="c1") + chore = Chore(name="Homework", mandatory=True, time_category="morning", assigned_to=["k1"], id="c1") coord = _coord([chore], [Child(name="Kid", id="k1")], []) assert run(coord.async_detect_mandatory_misses("afternoon", DAY)) == 0 def test_per_child_and_idempotent(): - chore = Chore(name="Homework", mandatory=True, time_category="afternoon", - assigned_to=["k1", "k2"], id="c1") + chore = Chore(name="Homework", mandatory=True, time_category="afternoon", assigned_to=["k1", "k2"], id="c1") coord = _coord([chore], [Child(name="A", id="k1"), Child(name="B", id="k2")], []) assert run(coord.async_detect_mandatory_misses("afternoon", DAY)) == 2 existing = list(coord.storage._added) @@ -84,7 +86,8 @@ def test_per_child_and_idempotent(): def test_disabled_for_child_skipped(): - chore = Chore(name="Homework", mandatory=True, time_category="afternoon", - assigned_to=["k1"], disabled_for=["k1"], id="c1") + chore = Chore( + name="Homework", mandatory=True, time_category="afternoon", assigned_to=["k1"], disabled_for=["k1"], id="c1" + ) coord = _coord([chore], [Child(name="Kid", id="k1")], []) assert run(coord.async_detect_mandatory_misses("afternoon", DAY)) == 0 diff --git a/tests/test_mandatory_escalation.py b/tests/test_mandatory_escalation.py index 56221b6..584a064 100644 --- a/tests/test_mandatory_escalation.py +++ b/tests/test_mandatory_escalation.py @@ -1,4 +1,5 @@ """Tests for mandatory reminder escalation (FEAT-6).""" + from __future__ import annotations from datetime import datetime, timezone @@ -41,20 +42,19 @@ def _setup(coord, *, with_parent=True): coord.storage.add_chore(chore) coord.storage.set_notification_master("mandatory_reminder", True) - coord.storage.set_notification_route( - "mandatory_reminder", f"child:{child.id}", NotificationRoute(enabled=True) - ) + coord.storage.set_notification_route("mandatory_reminder", f"child:{child.id}", NotificationRoute(enabled=True)) parent = None if with_parent: parent = ParentRecipient(name="John", notify_service="notify.john") coord.storage.upsert_parent_recipient(parent) coord.storage.set_notification_master("mandatory_parent_alert", True) - coord.storage.set_notification_route( - "mandatory_parent_alert", parent.id, NotificationRoute(enabled=True) - ) + coord.storage.set_notification_route("mandatory_parent_alert", parent.id, NotificationRoute(enabled=True)) miss = MandatoryMiss( - chore_id=chore.id, child_id=child.id, due_date=DAY, period_id="morning", + chore_id=chore.id, + child_id=child.id, + due_date=DAY, + period_id="morning", created_at="2024-03-20T09:00:00+00:00", ) coord.storage.add_mandatory_miss(miss) @@ -68,6 +68,7 @@ def _notify_services(call_list): @pytest.mark.asyncio async def test_full_escalation_ladder(coord, hass): from unittest.mock import AsyncMock + hass.services.async_call = AsyncMock() child, chore, miss, parent = _setup(coord) @@ -96,6 +97,7 @@ async def test_full_escalation_ladder(coord, hass): @pytest.mark.asyncio async def test_nudge_only_before_reminder_threshold(coord, hass): from unittest.mock import AsyncMock + hass.services.async_call = AsyncMock() _setup(coord) @@ -111,12 +113,17 @@ async def test_completed_chore_is_not_escalated(coord, hass): from unittest.mock import AsyncMock from custom_components.taskmate.models import ChoreCompletion + hass.services.async_call = AsyncMock() child, chore, miss, parent = _setup(coord) - coord.storage.add_completion(ChoreCompletion( - chore_id=chore.id, child_id=child.id, - completed_at=_now(9, 30), approved=True, - )) + coord.storage.add_completion( + ChoreCompletion( + chore_id=chore.id, + child_id=child.id, + completed_at=_now(9, 30), + approved=True, + ) + ) n = await coord.async_escalate_mandatory_misses(_now(12, 0)) assert n == 0 @@ -126,27 +133,25 @@ async def test_completed_chore_is_not_escalated(coord, hass): @pytest.mark.asyncio async def test_other_day_miss_is_skipped(coord, hass): from unittest.mock import AsyncMock + hass.services.async_call = AsyncMock() _setup(coord) # Now is the next day — the miss's due_date no longer matches "today". - n = await coord.async_escalate_mandatory_misses( - datetime(2024, 3, 21, 12, 0, tzinfo=timezone.utc) - ) + n = await coord.async_escalate_mandatory_misses(datetime(2024, 3, 21, 12, 0, tzinfo=timezone.utc)) assert n == 0 @pytest.mark.asyncio async def test_reminder_targets_only_the_owing_child(coord, hass): from unittest.mock import AsyncMock + hass.services.async_call = AsyncMock() _setup(coord, with_parent=False) # A second child is also routed for mandatory_reminder, but the miss is # Alex's — only_recipients must keep the nudge from fanning out to them. other = Child(name="Sam", notify_service="notify.sam") coord.storage.add_child(other) - coord.storage.set_notification_route( - "mandatory_reminder", f"child:{other.id}", NotificationRoute(enabled=True) - ) + coord.storage.set_notification_route("mandatory_reminder", f"child:{other.id}", NotificationRoute(enabled=True)) await coord.async_escalate_mandatory_misses(_now(9, 10)) services = _notify_services(hass.services.async_call.call_args_list) diff --git a/tests/test_mandatory_model.py b/tests/test_mandatory_model.py index 6f893cc..5303fca 100644 --- a/tests/test_mandatory_model.py +++ b/tests/test_mandatory_model.py @@ -1,4 +1,5 @@ """Tests for mandatory-chore model fields and the MandatoryMiss model (#532).""" + from __future__ import annotations from custom_components.taskmate.models import Chore, MandatoryMiss @@ -29,9 +30,14 @@ def test_mandatory_legacy_record_loads(): def test_mandatory_miss_round_trip(): m = MandatoryMiss( - chore_id="c1", child_id="k1", due_date="2026-06-21", - period_id="morning", penalty_points=5, postpone_count=1, - created_at="2026-06-21T12:00:00", id="m1", + chore_id="c1", + child_id="k1", + due_date="2026-06-21", + period_id="morning", + penalty_points=5, + postpone_count=1, + created_at="2026-06-21T12:00:00", + id="m1", ) d = m.to_dict() m2 = MandatoryMiss.from_dict(d) diff --git a/tests/test_mandatory_prune.py b/tests/test_mandatory_prune.py index e94f772..18b2f95 100644 --- a/tests/test_mandatory_prune.py +++ b/tests/test_mandatory_prune.py @@ -1,4 +1,5 @@ """Tests for orphan mandatory-miss pruning (#532).""" + from __future__ import annotations import asyncio diff --git a/tests/test_mandatory_schedule.py b/tests/test_mandatory_schedule.py index 6111d7c..3b76ce5 100644 --- a/tests/test_mandatory_schedule.py +++ b/tests/test_mandatory_schedule.py @@ -1,4 +1,5 @@ """Tests for mandatory period-end scheduling (#532).""" + from __future__ import annotations import asyncio @@ -18,11 +19,13 @@ def run(coro): def _coord(): c = object.__new__(TaskMateCoordinator) - c.get_time_periods = MagicMock(return_value=[ - {"id": "morning", "start": "06:00", "end": "12:00"}, - {"id": "afternoon", "start": "12:00", "end": "17:00"}, - {"id": "evening", "start": "17:00", "end": "21:00"}, - ]) + c.get_time_periods = MagicMock( + return_value=[ + {"id": "morning", "start": "06:00", "end": "12:00"}, + {"id": "afternoon", "start": "12:00", "end": "17:00"}, + {"id": "evening", "start": "17:00", "end": "21:00"}, + ] + ) return c diff --git a/tests/test_mandatory_services.py b/tests/test_mandatory_services.py index be82d10..9f92616 100644 --- a/tests/test_mandatory_services.py +++ b/tests/test_mandatory_services.py @@ -1,4 +1,5 @@ """Tests for mandatory state exposure (#532).""" + from __future__ import annotations from unittest.mock import MagicMock @@ -10,10 +11,13 @@ def test_state_includes_enriched_misses(): c = object.__new__(TaskMateCoordinator) s = MagicMock() - s.get_mandatory_misses = MagicMock(return_value=[ - MandatoryMiss(chore_id="c1", child_id="k1", due_date="2026-06-21", - period_id="morning", penalty_points=5, id="m1"), - ]) + s.get_mandatory_misses = MagicMock( + return_value=[ + MandatoryMiss( + chore_id="c1", child_id="k1", due_date="2026-06-21", period_id="morning", penalty_points=5, id="m1" + ), + ] + ) s.get_chores = MagicMock(return_value=[Chore(name="Homework", id="c1")]) s.get_children = MagicMock(return_value=[Child(name="Kid", id="k1")]) c.storage = s @@ -27,9 +31,11 @@ def test_state_includes_enriched_misses(): def test_state_handles_missing_refs(): c = object.__new__(TaskMateCoordinator) s = MagicMock() - s.get_mandatory_misses = MagicMock(return_value=[ - MandatoryMiss(chore_id="gone", child_id="gone", due_date="d", period_id="morning", id="m1"), - ]) + s.get_mandatory_misses = MagicMock( + return_value=[ + MandatoryMiss(chore_id="gone", child_id="gone", due_date="d", period_id="morning", id="m1"), + ] + ) s.get_chores = MagicMock(return_value=[]) s.get_children = MagicMock(return_value=[]) c.storage = s diff --git a/tests/test_mandatory_storage.py b/tests/test_mandatory_storage.py index fad97d9..feb1478 100644 --- a/tests/test_mandatory_storage.py +++ b/tests/test_mandatory_storage.py @@ -1,4 +1,5 @@ """Tests for the mandatory_misses storage collection (#532).""" + from __future__ import annotations from custom_components.taskmate.models import MandatoryMiss @@ -33,8 +34,10 @@ def test_update_miss(): def test_replace_misses(): s = _storage() s.add_mandatory_miss(MandatoryMiss(chore_id="c1", child_id="k1", due_date="d", period_id="morning", id="m1")) - s.replace_mandatory_misses([ - MandatoryMiss(chore_id="c2", child_id="k2", due_date="d", period_id="evening", id="m2"), - ]) + s.replace_mandatory_misses( + [ + MandatoryMiss(chore_id="c2", child_id="k2", due_date="d", period_id="evening", id="m2"), + ] + ) got = s.get_mandatory_misses() assert len(got) == 1 and got[0].id == "m2" diff --git a/tests/test_models.py b/tests/test_models.py index 7212a70..1958fce 100755 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,4 +1,5 @@ """Tests for custom_components.taskmate.models.""" + from __future__ import annotations import datetime as dt @@ -24,6 +25,7 @@ # parse_datetime # --------------------------------------------------------------------------- + class TestParseDatetime: def test_none_returns_none(self): assert parse_datetime(None) is None @@ -60,6 +62,7 @@ def test_aware_iso_string_with_positive_offset(self): # format_datetime # --------------------------------------------------------------------------- + class TestFormatDatetime: def test_none_returns_none(self): assert format_datetime(None) is None @@ -90,6 +93,7 @@ def test_roundtrip(self): # Child # --------------------------------------------------------------------------- + class TestChild: def test_defaults(self): child = Child(name="Alice") @@ -171,6 +175,7 @@ def test_legacy_missing_availability_entity_backcompat(self): # Chore # --------------------------------------------------------------------------- + class TestChore: def test_defaults(self): chore = Chore(name="Clean room") @@ -256,6 +261,7 @@ def test_legacy_missing_skip_fields_backcompat(self): class TestTaskGroup: def test_defaults(self): from custom_components.taskmate.models import TaskGroup + g = TaskGroup(name="Cat litter") assert g.policy == "sticky" assert g.chore_ids == [] @@ -263,6 +269,7 @@ def test_defaults(self): def test_roundtrip(self): from custom_components.taskmate.models import TaskGroup + g = TaskGroup(name="Cat litter", policy="spread", chore_ids=["c1", "c2"], id="g1") restored = TaskGroup.from_dict(g.to_dict()) assert restored.name == g.name @@ -275,6 +282,7 @@ def test_roundtrip(self): # Reward # --------------------------------------------------------------------------- + class TestReward: def test_defaults(self): reward = Reward(name="Movie night") @@ -335,6 +343,7 @@ def test_legacy_missing_quantity_and_expires_at_backcompat(self): # ChoreCompletion # --------------------------------------------------------------------------- + class TestChoreCompletion: def test_roundtrip(self): comp = ChoreCompletion( @@ -387,6 +396,7 @@ def test_pending_completion_defaults(self): # RewardClaim # --------------------------------------------------------------------------- + class TestRewardClaim: def test_roundtrip(self): claim = RewardClaim( @@ -419,6 +429,7 @@ def test_pending_claim_defaults(self): # PointsTransaction # --------------------------------------------------------------------------- + class TestPointsTransaction: def test_roundtrip(self): tx = PointsTransaction( @@ -449,6 +460,7 @@ def test_negative_points_preserved(self): # Chore one-shot fields # --------------------------------------------------------------------------- + class TestChoreOneShotFields: def test_new_fields_defaults(self): chore = Chore(name="Test") @@ -507,6 +519,7 @@ def test_disabled_chore_roundtrip(self): # Bonus model # --------------------------------------------------------------------------- + class TestBonus: def test_defaults(self): bonus = Bonus(name="Tidied bedroom", points=5) @@ -600,6 +613,7 @@ def test_child_notify_service_defaults_none(): def test_parent_recipient_round_trip(): from custom_components.taskmate.models import ParentRecipient + p = ParentRecipient(name="John", notify_service="notify.mobile_app_johns_iphone") assert p.id.startswith("parent:") assert p.enabled is True @@ -611,6 +625,7 @@ def test_parent_recipient_round_trip(): def test_notification_route_round_trip(): from custom_components.taskmate.models import NotificationRoute + r = NotificationRoute(enabled=True, time="19:30") d = r.to_dict() assert d == {"enabled": True, "time": "19:30"} @@ -620,6 +635,7 @@ def test_notification_route_round_trip(): def test_notification_config_round_trip(): from custom_components.taskmate.models import NotificationConfig, NotificationRoute + cfg = NotificationConfig( type_id="bedtime_reminder", master_enabled=True, @@ -632,6 +648,7 @@ def test_notification_config_round_trip(): def test_custom_notification_round_trip(): from custom_components.taskmate.models import CustomNotification + n = CustomNotification( name="Brush teeth", message_template="Time to brush, {child_name}!", @@ -648,6 +665,7 @@ def test_custom_notification_round_trip(): def test_notification_config_nav_url_roundtrip(): from custom_components.taskmate.models import NotificationConfig + cfg = NotificationConfig(type_id="badge_earned", nav_url="/lovelace/parents") d = cfg.to_dict() assert d["nav_url"] == "/lovelace/parents" @@ -656,6 +674,7 @@ def test_notification_config_nav_url_roundtrip(): def test_notification_config_nav_url_omitted_when_empty(): from custom_components.taskmate.models import NotificationConfig + cfg = NotificationConfig(type_id="badge_earned") assert "nav_url" not in cfg.to_dict() assert NotificationConfig.from_dict({"type_id": "x"}).nav_url == "" diff --git a/tests/test_monthly_report.py b/tests/test_monthly_report.py index 8352f08..3c6756e 100644 --- a/tests/test_monthly_report.py +++ b/tests/test_monthly_report.py @@ -1,4 +1,5 @@ """Tests for the monthly report (FEAT-14).""" + from __future__ import annotations import datetime as dt @@ -24,19 +25,25 @@ def _coord(children, completions): def _comp(child_id, when, approved=True, points=10, bonus=""): - return ChoreCompletion(chore_id="x", child_id=child_id, completed_at=when, - approved=approved, points_awarded=points, bonus_subtask_id=bonus) + return ChoreCompletion( + chore_id="x", + child_id=child_id, + completed_at=when, + approved=approved, + points_awarded=points, + bonus_subtask_id=bonus, + ) def test_monthly_report_counts_only_in_range_and_approved(): kid = Child(name="Alex", id="k1", level=4, best_streak=9) comps = [ - _comp("k1", dt.datetime(2026, 4, 5, 9, 0, tzinfo=UTC)), # in range - _comp("k1", dt.datetime(2026, 4, 20, 9, 0, tzinfo=UTC)), # in range - _comp("k1", dt.datetime(2026, 3, 31, 9, 0, tzinfo=UTC)), # before - _comp("k1", dt.datetime(2026, 5, 1, 9, 0, tzinfo=UTC)), # after + _comp("k1", dt.datetime(2026, 4, 5, 9, 0, tzinfo=UTC)), # in range + _comp("k1", dt.datetime(2026, 4, 20, 9, 0, tzinfo=UTC)), # in range + _comp("k1", dt.datetime(2026, 3, 31, 9, 0, tzinfo=UTC)), # before + _comp("k1", dt.datetime(2026, 5, 1, 9, 0, tzinfo=UTC)), # after _comp("k1", dt.datetime(2026, 4, 10, 9, 0, tzinfo=UTC), approved=False), # pending - _comp("k1", dt.datetime(2026, 4, 11, 9, 0, tzinfo=UTC), bonus="b1"), # bonus + _comp("k1", dt.datetime(2026, 4, 11, 9, 0, tzinfo=UTC), bonus="b1"), # bonus ] c = _coord([kid], comps) out = c._build_monthly_report(date(2026, 4, 1), date(2026, 4, 30)) @@ -51,6 +58,7 @@ def test_monthly_report_empty_without_children(): @pytest.mark.asyncio async def test_send_monthly_report_targets_previous_month(monkeypatch): from tests.conftest import dt_util_mock + kid = Child(name="Alex", id="k1") comp = _comp("k1", dt.datetime(2026, 4, 15, 9, 0, tzinfo=UTC)) c = _coord([kid], [comp]) diff --git a/tests/test_notifications_actionable.py b/tests/test_notifications_actionable.py index 775dd02..9fe898e 100644 --- a/tests/test_notifications_actionable.py +++ b/tests/test_notifications_actionable.py @@ -1,4 +1,5 @@ """Tests for actionable approval / reject flow.""" + from __future__ import annotations from unittest.mock import AsyncMock @@ -29,6 +30,7 @@ async def coord(hass): def _evt(action: str): class _E: data = {"action": action} + return _E() diff --git a/tests/test_notifications_dispatch.py b/tests/test_notifications_dispatch.py index a33c606..449968e 100644 --- a/tests/test_notifications_dispatch.py +++ b/tests/test_notifications_dispatch.py @@ -1,4 +1,5 @@ """Tests for NotificationCoordinator dispatch.""" + from __future__ import annotations from unittest.mock import AsyncMock @@ -46,10 +47,7 @@ async def test_fire_routes_only_to_enabled_recipients(coord, hass): await coord.fire("badge_earned", {"child_name": "M", "badge_name": "Star"}) - notify_calls = [ - c for c in hass.services.async_call.call_args_list - if c[0][0] == "notify" - ] + notify_calls = [c for c in hass.services.async_call.call_args_list if c[0][0] == "notify"] assert len(notify_calls) == 1 assert notify_calls[0][0][1] == "john" @@ -66,10 +64,7 @@ async def test_fire_renders_template_safely_with_missing_key(coord, hass): # Missing badge_name key — should not raise; literal "{badge_name}" stays await coord.fire("badge_earned", {"child_name": "M"}) - msg = next( - c for c in hass.services.async_call.call_args_list - if c[0][0] == "notify" - )[0][2]["message"] + msg = next(c for c in hass.services.async_call.call_args_list if c[0][0] == "notify")[0][2]["message"] assert "{badge_name}" in msg assert "M" in msg @@ -112,15 +107,19 @@ async def test_pending_chore_approval_dispatches_actionable(coord, hass): # are seeded from the migration helper. Re-seed for the test: coord.storage.set_notification_master("pending_chore_approval", True) coord.storage.set_notification_route( - "pending_chore_approval", p.id, NotificationRoute(enabled=True), + "pending_chore_approval", + p.id, + NotificationRoute(enabled=True), ) await coord.fire( "pending_chore_approval", { "entry_id": "completion-123", - "child_name": "Maria", "chore_name": "Bin", - "points": 10, "points_name": "Stars", + "child_name": "Maria", + "chore_name": "Bin", + "points": 10, + "points_name": "Stars", }, ) @@ -128,7 +127,7 @@ async def test_pending_chore_approval_dispatches_actionable(coord, hass): assert len(notify_calls) == 1 data = notify_calls[0][0][2]["data"] assert {"action": "TASKMATE_APPROVE_completion-123", "title": "Approve"} in data["actions"] - assert {"action": "TASKMATE_REJECT_completion-123", "title": "Reject"} in data["actions"] + assert {"action": "TASKMATE_REJECT_completion-123", "title": "Reject"} in data["actions"] @pytest.mark.asyncio @@ -141,15 +140,19 @@ async def test_pending_approval_push_carries_clear_tag(coord, hass): coord.storage.upsert_parent_recipient(p) coord.storage.set_notification_master("pending_chore_approval", True) coord.storage.set_notification_route( - "pending_chore_approval", p.id, NotificationRoute(enabled=True), + "pending_chore_approval", + p.id, + NotificationRoute(enabled=True), ) await coord.fire( "pending_chore_approval", { "entry_id": "completion-123", - "child_name": "Maria", "chore_name": "Bin", - "points": 10, "points_name": "Stars", + "child_name": "Maria", + "chore_name": "Bin", + "points": 10, + "points_name": "Stars", }, ) @@ -171,10 +174,14 @@ async def test_clear_approval_targets_only_mobile_app(coord, hass): coord.storage.upsert_parent_recipient(other) coord.storage.set_notification_master("pending_chore_approval", True) coord.storage.set_notification_route( - "pending_chore_approval", mobile.id, NotificationRoute(enabled=True), + "pending_chore_approval", + mobile.id, + NotificationRoute(enabled=True), ) coord.storage.set_notification_route( - "pending_chore_approval", other.id, NotificationRoute(enabled=True), + "pending_chore_approval", + other.id, + NotificationRoute(enabled=True), ) await coord.clear_approval("pending_chore_approval", "completion-123") @@ -196,7 +203,9 @@ async def test_clear_approval_skips_when_master_disabled(coord, hass): coord.storage.upsert_parent_recipient(mobile) coord.storage.set_notification_master("pending_chore_approval", False) coord.storage.set_notification_route( - "pending_chore_approval", mobile.id, NotificationRoute(enabled=True), + "pending_chore_approval", + mobile.id, + NotificationRoute(enabled=True), ) await coord.clear_approval("pending_chore_approval", "completion-123") @@ -212,7 +221,9 @@ async def test_clear_approval_noop_without_entry_id(coord, hass): coord.storage.upsert_parent_recipient(mobile) coord.storage.set_notification_master("pending_chore_approval", True) coord.storage.set_notification_route( - "pending_chore_approval", mobile.id, NotificationRoute(enabled=True), + "pending_chore_approval", + mobile.id, + NotificationRoute(enabled=True), ) await coord.clear_approval("pending_chore_approval", "") @@ -291,10 +302,16 @@ async def test_nav_url_coexists_with_action_buttons(coord, hass): coord.storage.upsert_parent_recipient(p) coord.storage.set_notification_master("pending_chore_approval", True) coord.storage.set_notification_route("pending_chore_approval", p.id, NotificationRoute(enabled=True)) - await coord.fire("pending_chore_approval", { - "entry_id": "c-1", "child_name": "Maria", "chore_name": "Bin", - "points": 10, "points_name": "Stars", - }) + await coord.fire( + "pending_chore_approval", + { + "entry_id": "c-1", + "child_name": "Maria", + "chore_name": "Bin", + "points": 10, + "points_name": "Stars", + }, + ) data = _notify_data(hass)["data"] assert data["clickAction"] == "/taskmate-admin" assert "actions" in data diff --git a/tests/test_notifications_events.py b/tests/test_notifications_events.py index 58096f8..c8b5e5a 100644 --- a/tests/test_notifications_events.py +++ b/tests/test_notifications_events.py @@ -1,4 +1,5 @@ """Tests for taskmate_* event bus emissions.""" + from __future__ import annotations import asyncio @@ -55,6 +56,7 @@ async def _noop_refresh(): pass import custom_components.taskmate.coordinator as _mod + coord._dt_now = now coord.async_refresh = _noop_refresh @@ -70,19 +72,14 @@ def test_fires_on_auto_approved_completion(self): with patch.object(_mod.dt_util, "now", return_value=now): child = run(coord.async_add_child("Alice")) - chore = run(coord.async_add_chore( - "Make bed", points=5, requires_approval=False - )) + chore = run(coord.async_add_chore("Make bed", points=5, requires_approval=False)) coord.hass.bus.async_fire = MagicMock() with patch.object(_mod.dt_util, "now", return_value=now): run(coord.async_complete_chore(chore.id, child.id)) - fires = [ - c for c in coord.hass.bus.async_fire.call_args_list - if c[0][0] == "taskmate_chore_completed" - ] + fires = [c for c in coord.hass.bus.async_fire.call_args_list if c[0][0] == "taskmate_chore_completed"] assert len(fires) >= 1 payload = fires[0][0][1] assert payload["child_id"] == child.id @@ -98,19 +95,14 @@ def test_fires_on_approval_required_completion(self): with patch.object(_mod.dt_util, "now", return_value=now): child = run(coord.async_add_child("Bob")) - chore = run(coord.async_add_chore( - "Tidy room", points=10, requires_approval=True - )) + chore = run(coord.async_add_chore("Tidy room", points=10, requires_approval=True)) coord.hass.bus.async_fire = MagicMock() with patch.object(_mod.dt_util, "now", return_value=now): run(coord.async_complete_chore(chore.id, child.id)) - fires = [ - c for c in coord.hass.bus.async_fire.call_args_list - if c[0][0] == "taskmate_chore_completed" - ] + fires = [c for c in coord.hass.bus.async_fire.call_args_list if c[0][0] == "taskmate_chore_completed"] assert len(fires) >= 1 payload = fires[0][0][1] assert payload["child_id"] == child.id @@ -127,9 +119,7 @@ def test_fires_on_approval(self): with patch.object(_mod.dt_util, "now", return_value=now): child = run(coord.async_add_child("Carol")) - chore = run(coord.async_add_chore( - "Wash dishes", points=8, requires_approval=True - )) + chore = run(coord.async_add_chore("Wash dishes", points=8, requires_approval=True)) completion = run(coord.async_complete_chore(chore.id, child.id)) coord.hass.bus.async_fire = MagicMock() @@ -137,10 +127,7 @@ def test_fires_on_approval(self): with patch.object(_mod.dt_util, "now", return_value=now): run(coord.async_approve_chore(completion.id)) - fires = [ - c for c in coord.hass.bus.async_fire.call_args_list - if c[0][0] == "taskmate_chore_approved" - ] + fires = [c for c in coord.hass.bus.async_fire.call_args_list if c[0][0] == "taskmate_chore_approved"] assert len(fires) >= 1 payload = fires[0][0][1] assert payload["child_id"] == child.id @@ -155,9 +142,7 @@ def test_no_double_fire_of_completed_on_approval(self): with patch.object(_mod.dt_util, "now", return_value=now): child = run(coord.async_add_child("Dave")) - chore = run(coord.async_add_chore( - "Feed dog", points=6, requires_approval=True - )) + chore = run(coord.async_add_chore("Feed dog", points=6, requires_approval=True)) completion = run(coord.async_complete_chore(chore.id, child.id)) coord.hass.bus.async_fire = MagicMock() @@ -165,10 +150,7 @@ def test_no_double_fire_of_completed_on_approval(self): with patch.object(_mod.dt_util, "now", return_value=now): run(coord.async_approve_chore(completion.id)) - completed_fires = [ - c for c in coord.hass.bus.async_fire.call_args_list - if c[0][0] == "taskmate_chore_completed" - ] + completed_fires = [c for c in coord.hass.bus.async_fire.call_args_list if c[0][0] == "taskmate_chore_completed"] assert len(completed_fires) == 0 @@ -181,9 +163,7 @@ def test_fires_on_claim(self): with patch.object(_mod.dt_util, "now", return_value=now): child = run(coord.async_add_child("Eve")) - chore = run(coord.async_add_chore( - "Homework", points=100, requires_approval=False - )) + chore = run(coord.async_add_chore("Homework", points=100, requires_approval=False)) run(coord.async_complete_chore(chore.id, child.id)) reward = run(coord.async_add_reward("Movie night", cost=50)) @@ -192,10 +172,7 @@ def test_fires_on_claim(self): with patch.object(_mod.dt_util, "now", return_value=now): claim = run(coord.async_claim_reward(reward.id, child.id)) - fires = [ - c for c in coord.hass.bus.async_fire.call_args_list - if c[0][0] == "taskmate_reward_claimed" - ] + fires = [c for c in coord.hass.bus.async_fire.call_args_list if c[0][0] == "taskmate_reward_claimed"] assert len(fires) >= 1 payload = fires[0][0][1] assert payload["child_id"] == child.id @@ -214,19 +191,14 @@ def test_fires_on_first_completion(self): with patch.object(_mod.dt_util, "now", return_value=now): child = run(coord.async_add_child("Faye")) - chore = run(coord.async_add_chore( - "Brush teeth", points=3, requires_approval=False - )) + chore = run(coord.async_add_chore("Brush teeth", points=3, requires_approval=False)) coord.hass.bus.async_fire = MagicMock() with patch.object(_mod.dt_util, "now", return_value=now): run(coord.async_complete_chore(chore.id, child.id)) - fires = [ - c for c in coord.hass.bus.async_fire.call_args_list - if c[0][0] == "taskmate_streak_updated" - ] + fires = [c for c in coord.hass.bus.async_fire.call_args_list if c[0][0] == "taskmate_streak_updated"] assert len(fires) >= 1 payload = fires[0][0][1] assert payload["child_id"] == child.id @@ -242,9 +214,7 @@ def test_fires_on_consecutive_day(self): with patch.object(_mod.dt_util, "now", return_value=day1): child = run(coord.async_add_child("Grace")) - chore = run(coord.async_add_chore( - "Read book", points=4, requires_approval=False - )) + chore = run(coord.async_add_chore("Read book", points=4, requires_approval=False)) run(coord.async_complete_chore(chore.id, child.id)) coord.hass.bus.async_fire = MagicMock() @@ -252,10 +222,7 @@ def test_fires_on_consecutive_day(self): with patch.object(_mod.dt_util, "now", return_value=day2): run(coord.async_complete_chore(chore.id, child.id)) - fires = [ - c for c in coord.hass.bus.async_fire.call_args_list - if c[0][0] == "taskmate_streak_updated" - ] + fires = [c for c in coord.hass.bus.async_fire.call_args_list if c[0][0] == "taskmate_streak_updated"] assert len(fires) >= 1 payload = fires[0][0][1] assert payload["current_streak"] == 2 diff --git a/tests/test_notifications_fallback.py b/tests/test_notifications_fallback.py index 61e5a18..50f9d42 100644 --- a/tests/test_notifications_fallback.py +++ b/tests/test_notifications_fallback.py @@ -4,6 +4,7 @@ notify backend we must not send dead buttons — instead the message gets a hint pointing the recipient at the TaskMate panel. """ + from __future__ import annotations from unittest.mock import AsyncMock @@ -32,12 +33,13 @@ async def _fire_pending(coord, hass, notify_service): coord.storage.upsert_parent_recipient(p) coord.storage.set_notification_master("pending_chore_approval", True) coord.storage.set_notification_route( - "pending_chore_approval", p.id, NotificationRoute(enabled=True), + "pending_chore_approval", + p.id, + NotificationRoute(enabled=True), ) await coord.fire( "pending_chore_approval", - {"entry_id": "completion-1", "child_name": "Mia", "chore_name": "Bin", - "points": 10, "points_name": "Stars"}, + {"entry_id": "completion-1", "child_name": "Mia", "chore_name": "Bin", "points": 10, "points_name": "Stars"}, ) return next(c for c in hass.services.async_call.call_args_list if c[0][0] == "notify")[0][2] diff --git a/tests/test_notifications_milestone.py b/tests/test_notifications_milestone.py index 70f61e7..975f773 100644 --- a/tests/test_notifications_milestone.py +++ b/tests/test_notifications_milestone.py @@ -1,4 +1,5 @@ """Tests that reaching a streak milestone fires the streak_milestone notification.""" + from __future__ import annotations import asyncio @@ -28,6 +29,7 @@ def _coord(settings): def _run_award(coord, child, now_dt): import custom_components.taskmate.coordinator as _mod + with patch.object(_mod.dt_util, "now", return_value=now_dt): loop = asyncio.new_event_loop() try: diff --git a/tests/test_notifications_parent_routing.py b/tests/test_notifications_parent_routing.py index 70d4e9d..838d8af 100644 --- a/tests/test_notifications_parent_routing.py +++ b/tests/test_notifications_parent_routing.py @@ -8,6 +8,7 @@ parent-audience types whose routes are still empty, so the parent actually gets the notification. """ + from __future__ import annotations from unittest.mock import AsyncMock @@ -45,17 +46,15 @@ async def test_ensure_subscribes_parent_to_empty_parent_types(coord): @pytest.mark.asyncio async def test_ensure_does_not_override_existing_routes(coord): # A type already configured (even to someone else) is left untouched. - coord.storage.set_notification_route( - "pending_chore_approval", "child:abc", NotificationRoute(enabled=True) - ) + coord.storage.set_notification_route("pending_chore_approval", "child:abc", NotificationRoute(enabled=True)) p = ParentRecipient(name="John", notify_service="notify.mobile_app_john") coord.storage.upsert_parent_recipient(p) coord.ensure_parent_default_routes() routes = coord.storage.get_notification_config("pending_chore_approval").routes - assert p.id not in routes # not forced in - assert "child:abc" in routes # existing route preserved + assert p.id not in routes # not forced in + assert "child:abc" in routes # existing route preserved @pytest.mark.asyncio @@ -75,11 +74,8 @@ async def test_fire_does_not_leak_persistent_notification(coord, hass): await coord.fire("pending_chore_approval", {"child_name": "Malia", "chore_name": "Tidy"}) - persistent = [ - c for c in hass.services.async_call.call_args_list - if c[0][0] == "persistent_notification" - ] - assert persistent == [] # the leak is gone + persistent = [c for c in hass.services.async_call.call_args_list if c[0][0] == "persistent_notification"] + assert persistent == [] # the leak is gone hass.services.async_call.assert_not_called() @@ -93,8 +89,6 @@ async def test_fire_routes_to_subscribed_parent(coord, hass): await coord.fire("pending_chore_approval", {"child_name": "Malia", "chore_name": "Tidy"}) - notify_calls = [ - c for c in hass.services.async_call.call_args_list if c[0][0] == "notify" - ] + notify_calls = [c for c in hass.services.async_call.call_args_list if c[0][0] == "notify"] assert len(notify_calls) == 1 assert notify_calls[0][0][1] == "mobile_app_john" diff --git a/tests/test_notifications_scheduler.py b/tests/test_notifications_scheduler.py index 853ca44..bf2516f 100644 --- a/tests/test_notifications_scheduler.py +++ b/tests/test_notifications_scheduler.py @@ -1,4 +1,5 @@ """Tests for time-gated notification scheduling.""" + from __future__ import annotations from unittest.mock import AsyncMock, patch @@ -24,16 +25,17 @@ async def coord(hass): async def test_async_setup_registers_one_callback_per_enabled_bedtime_route(coord, hass): child = Child(name="Maria") child.notify_service = "notify.maria" - coord.storage.add_child(child) if hasattr(coord.storage, "add_child") else coord.storage._data.setdefault("children", []).append(child.to_dict()) + coord.storage.add_child(child) if hasattr(coord.storage, "add_child") else coord.storage._data.setdefault( + "children", [] + ).append(child.to_dict()) coord.storage.set_notification_master("bedtime_reminder", True) coord.storage.set_notification_route( - "bedtime_reminder", f"child:{child.id}", + "bedtime_reminder", + f"child:{child.id}", NotificationRoute(enabled=True, time="19:30"), ) - with patch( - "custom_components.taskmate.coord_notifications.async_track_time_change" - ) as track: + with patch("custom_components.taskmate.coord_notifications.async_track_time_change") as track: track.return_value = lambda: None await coord.async_setup_schedules() assert track.called @@ -60,6 +62,7 @@ async def test_async_reload_schedules_cancels_old_and_re_registers(coord, hass): @pytest.mark.asyncio async def test_bedtime_skips_when_no_outstanding_chores(coord, hass): from datetime import datetime + child = Child(name="M", notify_service="notify.m") coord.storage._data.setdefault("children", []).append(child.to_dict()) # No chores assigned to this child → no outstanding work @@ -74,6 +77,7 @@ async def test_streak_at_risk_skips_when_completed_today(coord, hass): from datetime import datetime from homeassistant.util import dt as dt_util + today = dt_util.now().date().isoformat() child = Child(name="M", current_streak=5, last_completion_date=today) coord.storage._data.setdefault("children", []).append(child.to_dict()) @@ -85,6 +89,7 @@ async def test_streak_at_risk_skips_when_completed_today(coord, hass): @pytest.mark.asyncio async def test_streak_at_risk_skips_when_streak_below_two(coord, hass): from datetime import datetime + child = Child(name="M", current_streak=1) coord.storage._data.setdefault("children", []).append(child.to_dict()) coord.fire = AsyncMock() @@ -95,6 +100,7 @@ async def test_streak_at_risk_skips_when_streak_below_two(coord, hass): @pytest.mark.asyncio async def test_streak_at_risk_fires_when_streak_active_and_not_extended(coord, hass): from datetime import datetime + child = Child(name="M", current_streak=5, last_completion_date="2024-01-01") coord.storage._data.setdefault("children", []).append(child.to_dict()) coord.fire = AsyncMock() @@ -110,10 +116,14 @@ async def test_custom_skips_when_day_mask_excludes_today(coord, hass, monkeypatc from datetime import datetime from custom_components.taskmate.models import CustomNotification + # day_mask=0 means no day enabled n = CustomNotification( - name="X", message_template="hi", time="20:00", - day_mask=0, recipient_ids=["child:abc"], + name="X", + message_template="hi", + time="20:00", + day_mask=0, + recipient_ids=["child:abc"], ) coord.storage.upsert_custom_notification(n) @@ -130,14 +140,18 @@ async def test_custom_fires_when_today_bit_set(coord, hass): from homeassistant.util import dt as dt_util from custom_components.taskmate.models import CustomNotification + today_bit = 1 << dt_util.now().date().weekday() child = Child(name="M", notify_service="notify.m") coord.storage._data.setdefault("children", []).append(child.to_dict()) n = CustomNotification( - name="X", message_template="hi {child_name}", time="20:00", - day_mask=today_bit, recipient_ids=[f"child:{child.id}"], + name="X", + message_template="hi {child_name}", + time="20:00", + day_mask=today_bit, + recipient_ids=[f"child:{child.id}"], ) coord.storage.upsert_custom_notification(n) diff --git a/tests/test_notifications_storage.py b/tests/test_notifications_storage.py index cf91dea..ea93e75 100644 --- a/tests/test_notifications_storage.py +++ b/tests/test_notifications_storage.py @@ -1,4 +1,5 @@ """Tests for notification storage migration + CRUD.""" + from __future__ import annotations import pytest diff --git a/tests/test_notifications_test_send.py b/tests/test_notifications_test_send.py index 2da70b6..a7fedf6 100644 --- a/tests/test_notifications_test_send.py +++ b/tests/test_notifications_test_send.py @@ -1,4 +1,5 @@ """Tests for the test-notification (send_test) feature.""" + from __future__ import annotations from unittest.mock import AsyncMock @@ -70,6 +71,7 @@ async def test_send_test_carries_nav_url(coord, hass): coord.storage.upsert_parent_recipient(p) coord.storage.set_notification_route("badge_earned", p.id, NotificationRoute(enabled=True)) await coord.send_test("badge_earned") - calls = [c for c in hass.services.async_call.call_args_list - if c[0][0] == "notify" and c[0][1].startswith("mobile_app")] + calls = [ + c for c in hass.services.async_call.call_args_list if c[0][0] == "notify" and c[0][1].startswith("mobile_app") + ] assert calls and calls[0][0][2]["data"]["clickAction"] == "/taskmate-admin" diff --git a/tests/test_notifications_ws.py b/tests/test_notifications_ws.py index 9096fb3..fba4deb 100644 --- a/tests/test_notifications_ws.py +++ b/tests/test_notifications_ws.py @@ -1,4 +1,5 @@ """Tests for notification WebSocket commands.""" + from __future__ import annotations from unittest.mock import MagicMock @@ -16,9 +17,11 @@ async def setup(hass): coord.hass = hass coord.entry_id = "ws_test" from custom_components.taskmate.storage import TaskMateStorage + coord.storage = TaskMateStorage(hass, "ws_test") await coord.storage.async_load() from custom_components.taskmate.coord_notifications import NotificationCoordinator + coord.notifications = NotificationCoordinator(hass, coord.storage) coord.notifications.coordinator = coord hass.data = {DOMAIN: {"ws_test": coord}} @@ -46,8 +49,7 @@ async def test_get_state_returns_full_snapshot(setup, hass): async def test_set_master_enabled(setup, hass): coord = setup connection = MagicMock() - msg = {"id": 2, "type": "taskmate/notifications/set_master_enabled", - "type_id": "bedtime_reminder", "enabled": True} + msg = {"id": 2, "type": "taskmate/notifications/set_master_enabled", "type_id": "bedtime_reminder", "enabled": True} await ws.ws_notif_set_master(hass, connection, msg) args, _ = connection.send_result.call_args @@ -61,7 +63,8 @@ async def test_set_route(setup, hass): coord = setup connection = MagicMock() msg = { - "id": 3, "type": "taskmate/notifications/set_route", + "id": 3, + "type": "taskmate/notifications/set_route", "type_id": "bedtime_reminder", "recipient_id": "child:abc", "enabled": True, @@ -81,7 +84,8 @@ async def test_set_route(setup, hass): async def test_set_child_notify_not_found(setup, hass): connection = MagicMock() msg = { - "id": 4, "type": "taskmate/notifications/set_child_notify", + "id": 4, + "type": "taskmate/notifications/set_child_notify", "child_id": "nonexistent", "notify_service": "notify.test", } @@ -95,12 +99,14 @@ async def test_set_child_notify_not_found(setup, hass): async def test_set_child_notify_success(setup, hass): coord = setup from custom_components.taskmate.models import Child + c = Child(name="Maria") coord.storage.add_child(c) connection = MagicMock() msg = { - "id": 5, "type": "taskmate/notifications/set_child_notify", + "id": 5, + "type": "taskmate/notifications/set_child_notify", "child_id": c.id, "notify_service": "notify.marias_phone", } @@ -118,8 +124,11 @@ async def test_upsert_parent_create(setup, hass): coord = setup connection = MagicMock() msg = { - "id": 6, "type": "taskmate/notifications/upsert_parent", - "name": "John", "notify_service": "notify.johns_phone", "enabled": True, + "id": 6, + "type": "taskmate/notifications/upsert_parent", + "name": "John", + "notify_service": "notify.johns_phone", + "enabled": True, } await ws.ws_notif_upsert_parent(hass, connection, msg) @@ -135,14 +144,18 @@ async def test_upsert_parent_create(setup, hass): async def test_upsert_parent_update(setup, hass): coord = setup from custom_components.taskmate.models import ParentRecipient + p = ParentRecipient(name="John", notify_service="notify.johns_phone") coord.storage.upsert_parent_recipient(p) connection = MagicMock() msg = { - "id": 7, "type": "taskmate/notifications/upsert_parent", - "parent_id": p.id, "name": "John Mac", - "notify_service": "notify.johns_phone", "enabled": True, + "id": 7, + "type": "taskmate/notifications/upsert_parent", + "parent_id": p.id, + "name": "John Mac", + "notify_service": "notify.johns_phone", + "enabled": True, } await ws.ws_notif_upsert_parent(hass, connection, msg) @@ -156,9 +169,12 @@ async def test_upsert_parent_update(setup, hass): async def test_upsert_parent_update_not_found(setup, hass): connection = MagicMock() msg = { - "id": 8, "type": "taskmate/notifications/upsert_parent", + "id": 8, + "type": "taskmate/notifications/upsert_parent", "parent_id": "parent:doesnotexist", - "name": "Ghost", "notify_service": "notify.ghost", "enabled": True, + "name": "Ghost", + "notify_service": "notify.ghost", + "enabled": True, } await ws.ws_notif_upsert_parent(hass, connection, msg) connection.send_error.assert_called_once() @@ -170,6 +186,7 @@ async def test_upsert_parent_update_not_found(setup, hass): async def test_delete_parent(setup, hass): coord = setup from custom_components.taskmate.models import ParentRecipient + p = ParentRecipient(name="Lisa", notify_service="notify.lisas_phone") coord.storage.upsert_parent_recipient(p) @@ -188,7 +205,8 @@ async def test_upsert_custom_create(setup, hass): coord = setup connection = MagicMock() msg = { - "id": 10, "type": "taskmate/notifications/upsert_custom", + "id": 10, + "type": "taskmate/notifications/upsert_custom", "name": "Brush teeth", "message_template": "Brush your teeth, {child_name}!", "time": "20:30", @@ -210,6 +228,7 @@ async def test_upsert_custom_create(setup, hass): async def test_upsert_custom_update(setup, hass): coord = setup from custom_components.taskmate.models import CustomNotification + n = CustomNotification( name="Brush teeth", message_template="Brush!", @@ -219,7 +238,8 @@ async def test_upsert_custom_update(setup, hass): connection = MagicMock() msg = { - "id": 11, "type": "taskmate/notifications/upsert_custom", + "id": 11, + "type": "taskmate/notifications/upsert_custom", "custom_id": n.id, "name": "Brush teeth updated", "message_template": "Brush your teeth, {child_name}!", @@ -240,6 +260,7 @@ async def test_upsert_custom_update(setup, hass): async def test_delete_custom(setup, hass): coord = setup from custom_components.taskmate.models import CustomNotification + n = CustomNotification( name="Brush teeth", message_template="Brush!", @@ -279,7 +300,8 @@ async def test_set_streak_cutoff(setup, hass): coord = setup connection = MagicMock() msg = { - "id": 14, "type": "taskmate/notifications/set_streak_cutoff", + "id": 14, + "type": "taskmate/notifications/set_streak_cutoff", "time": "19:45", } await ws.ws_notif_set_streak_cutoff(hass, connection, msg) @@ -316,8 +338,7 @@ async def test_set_nav_url_global(setup, hass): async def test_set_nav_url_per_type(setup, hass): coord = setup connection = MagicMock() - msg = {"id": 21, "type": "taskmate/notifications/set_nav_url", - "type_id": "badge_earned", "nav_url": "/lovelace/x"} + msg = {"id": 21, "type": "taskmate/notifications/set_nav_url", "type_id": "badge_earned", "nav_url": "/lovelace/x"} await ws.ws_notif_set_nav_url(hass, connection, msg) assert coord.storage.get_notification_config("badge_earned").nav_url == "/lovelace/x" @@ -346,14 +367,12 @@ async def test_set_nav_url_rejects_dangerous_schemes(setup, hass): async def test_set_nav_url_accepts_noaction_and_https(setup, hass): coord = setup connection = MagicMock() - msg = {"id": 24, "type": "taskmate/notifications/set_nav_url", - "nav_url": "noAction"} + msg = {"id": 24, "type": "taskmate/notifications/set_nav_url", "nav_url": "noAction"} await ws.ws_notif_set_nav_url(hass, connection, msg) assert coord.storage.get_setting("notification_nav_url") == "noAction" connection = MagicMock() - msg = {"id": 25, "type": "taskmate/notifications/set_nav_url", - "nav_url": "https://example.com/dash"} + msg = {"id": 25, "type": "taskmate/notifications/set_nav_url", "nav_url": "https://example.com/dash"} await ws.ws_notif_set_nav_url(hass, connection, msg) assert coord.storage.get_setting("notification_nav_url") == "https://example.com/dash" @@ -362,8 +381,7 @@ async def test_set_nav_url_accepts_noaction_and_https(setup, hass): async def test_set_nav_url_rejects_unknown_type_id(setup, hass): coord = setup connection = MagicMock() - msg = {"id": 26, "type": "taskmate/notifications/set_nav_url", - "type_id": "not_a_type", "nav_url": "/lovelace/x"} + msg = {"id": 26, "type": "taskmate/notifications/set_nav_url", "type_id": "not_a_type", "nav_url": "/lovelace/x"} await ws.ws_notif_set_nav_url(hass, connection, msg) connection.send_result.assert_not_called() args, _ = connection.send_error.call_args diff --git a/tests/test_panel_fills_viewport.py b/tests/test_panel_fills_viewport.py index c76b474..0215670 100644 --- a/tests/test_panel_fills_viewport.py +++ b/tests/test_panel_fills_viewport.py @@ -11,15 +11,13 @@ Structural (grep over source) because the collapse depends on the surrounding Home Assistant frontend, which no unit test instantiates. """ + from __future__ import annotations import pathlib import re -PANEL = ( - pathlib.Path(__file__).resolve().parent.parent - / "custom_components" / "taskmate" / "www" / "taskmate-panel.js" -) +PANEL = pathlib.Path(__file__).resolve().parent.parent / "custom_components" / "taskmate" / "www" / "taskmate-panel.js" def test_panel_stylesheet_is_not_cut_short_by_a_stray_backtick(): @@ -65,8 +63,7 @@ def test_panel_host_is_exactly_one_viewport_tall(): "collapses to content height wherever the ancestor chain is auto" ) assert re.search(r"height:\s*100%", rules) is None, ( - "the panel host must not fall back on height:100% — that is the " - "percentage chain this fix removes" + "the panel host must not fall back on height:100% — that is the percentage chain this fix removes" ) assert "min-height: 100dvh" not in rules, ( "a floor-only rule lets a long section grow the panel past the " @@ -85,11 +82,9 @@ def test_panel_host_is_a_flex_column(): def test_shell_flexes_instead_of_claiming_a_percentage_height(): rules = _block(".tm-shell") assert re.search(r"height:\s*100%", rules) is None, ( - ".tm-shell must not use height:100%; it resolves to auto whenever the " - "ancestor chain has no definite height" + ".tm-shell must not use height:100%; it resolves to auto whenever the ancestor chain has no definite height" ) assert "flex: 1 1 auto" in rules, ".tm-shell must flex to fill the panel" assert "min-height: 0" in rules, ( - "a flex item defaults to min-height:auto, which would force the grid " - "back to its content height" + "a flex item defaults to min-height:auto, which would force the grid back to its content height" ) diff --git a/tests/test_panel_navigation.py b/tests/test_panel_navigation.py index 3b751e5..6ab8c71 100644 --- a/tests/test_panel_navigation.py +++ b/tests/test_panel_navigation.py @@ -5,6 +5,7 @@ `_sidebarGroups()`. The nav is built solely from that list, so there was no way to open it. Nothing failed; the tab was simply invisible. """ + from __future__ import annotations import pathlib @@ -36,8 +37,7 @@ def _sidebar_ids() -> set[str]: class TestEveryViewIsReachable: def test_audit_is_in_the_sidebar(self): assert "audit" in _sidebar_ids(), ( - "the audit view has a render case but no sidebar entry, so it " - "cannot be opened" + "the audit view has a render case but no sidebar entry, so it cannot be opened" ) def test_every_rendered_view_has_a_sidebar_entry(self): @@ -45,9 +45,7 @@ def test_every_rendered_view_has_a_sidebar_entry(self): sidebar = _sidebar_ids() assert rendered, "expected to find render cases" unreachable = sorted(rendered - sidebar) - assert unreachable == [], ( - f"views with a render case but no way to navigate to them: {unreachable}" - ) + assert unreachable == [], f"views with a render case but no way to navigate to them: {unreachable}" def test_no_sidebar_entry_points_at_a_missing_view(self): """The mirror image: a nav item that renders nothing.""" diff --git a/tests/test_panel_undo_deny_list.py b/tests/test_panel_undo_deny_list.py index 61f83ea..90fcded 100644 --- a/tests/test_panel_undo_deny_list.py +++ b/tests/test_panel_undo_deny_list.py @@ -10,6 +10,7 @@ new derived reason must be added to its list; a third copy is a third place to forget. """ + from __future__ import annotations import pathlib @@ -17,19 +18,14 @@ from custom_components.taskmate.coord_points import PointsMixin -WWW = ( - pathlib.Path(__file__).resolve().parent.parent - / "custom_components" / "taskmate" / "www" -) +WWW = pathlib.Path(__file__).resolve().parent.parent / "custom_components" / "taskmate" / "www" PANEL = (WWW / "taskmate-panel.js").read_text(encoding="utf-8") ACTIVITY_CARD = (WWW / "taskmate-activity-card.js").read_text(encoding="utf-8") def _js_deny_prefixes(src: str, label: str) -> list[str]: """Pull the string literals out of a JS _UNDO_DENY_PREFIXES getter.""" - block = re.search( - r"_UNDO_DENY_PREFIXES\(\)\s*\{\s*return\s*\[(.*?)\]", src, re.S - ) + block = re.search(r"_UNDO_DENY_PREFIXES\(\)\s*\{\s*return\s*\[(.*?)\]", src, re.S) assert block, f"{label} has no _UNDO_DENY_PREFIXES getter" prefixes = re.findall(r'"([^"]+)"', block.group(1)) assert prefixes, f"{label} _UNDO_DENY_PREFIXES is empty" @@ -39,8 +35,7 @@ def _js_deny_prefixes(src: str, label: str) -> list[str]: def test_panel_no_longer_uses_a_reason_allow_list(): # The bug: an allow-list of just these two prefixes. Its absence is the fix. assert 'startsWith("Penalty: ") || t.reason.startsWith("Bonus: ")' not in PANEL, ( - "the panel is still gating undo on a Penalty:/Bonus: allow-list, which " - "hides undo for manual adjustments" + "the panel is still gating undo on a Penalty:/Bonus: allow-list, which hides undo for manual adjustments" ) @@ -48,9 +43,7 @@ def test_panel_gates_undo_on_reversibility(): block = re.search(r"_renderActivityTab\(\) \{(.*?)\n \}", PANEL, re.S) assert block, "could not find _renderActivityTab" body = block.group(1) - assert "_txnReversible(" in body, ( - "the transactions table must decide undoability via _txnReversible" - ) + assert "_txnReversible(" in body, "the transactions table must decide undoability via _txnReversible" def test_panel_has_a_reversibility_helper(): @@ -65,23 +58,17 @@ def test_all_three_deny_lists_are_identical(): backend = list(PointsMixin._UNDO_DENY_PREFIXES) panel = _js_deny_prefixes(PANEL, "taskmate-panel.js") card = _js_deny_prefixes(ACTIVITY_CARD, "taskmate-activity-card.js") - assert panel == backend, ( - "panel deny-list drifted from coord_points.py: " - f"panel={panel} backend={backend}" - ) - assert card == backend, ( - "activity-card deny-list drifted from coord_points.py: " - f"card={card} backend={backend}" - ) + assert panel == backend, f"panel deny-list drifted from coord_points.py: panel={panel} backend={backend}" + assert card == backend, f"activity-card deny-list drifted from coord_points.py: card={card} backend={backend}" def test_manual_adjustments_are_reversible_under_the_deny_list(): # The whole point of #761: these reasons must NOT be denied. deny = tuple(PointsMixin._UNDO_DENY_PREFIXES) for reason in ( - "Admin panel adjustment", # the #746 quick buttons - "Broke a window", # a free-text reason from the custom dialog - "", # a bare add_points/remove_points call + "Admin panel adjustment", # the #746 quick buttons + "Broke a window", # a free-text reason from the custom dialog + "", # a bare add_points/remove_points call "Penalty: Messy room", "Bonus: Helped out", ): diff --git a/tests/test_parent_complete.py b/tests/test_parent_complete.py index 230d96d..f3a9c66 100644 --- a/tests/test_parent_complete.py +++ b/tests/test_parent_complete.py @@ -1,4 +1,5 @@ """Tests for parent_complete_chore coordinator method.""" + from __future__ import annotations import asyncio @@ -50,6 +51,7 @@ def _make_system(now=None): coord._unsub_availability = None import custom_components.taskmate.coordinator as _mod + coord._dt_now = now coord.async_refresh = AsyncMock() @@ -63,11 +65,16 @@ def test_parent_complete_creates_completion_with_zero_points(self): with patch.object(_mod.dt_util, "now", return_value=now): child = run(coord.async_add_child("Alice")) - chore = run(coord.async_add_chore( - "Vacuum", points=10, requires_approval=False, - schedule_mode="recurring", recurrence="weekly", - assigned_to=[child.id], - )) + chore = run( + coord.async_add_chore( + "Vacuum", + points=10, + requires_approval=False, + schedule_mode="recurring", + recurrence="weekly", + assigned_to=[child.id], + ) + ) with patch.object(_mod.dt_util, "now", return_value=now): run(coord.async_parent_complete_chore(chore.id)) @@ -85,11 +92,16 @@ def test_parent_complete_updates_last_completed_for_all_children(self): with patch.object(_mod.dt_util, "now", return_value=now): alice = run(coord.async_add_child("Alice")) bob = run(coord.async_add_child("Bob")) - chore = run(coord.async_add_chore( - "Dishes", points=5, requires_approval=False, - schedule_mode="recurring", recurrence="weekly", - assigned_to=[alice.id, bob.id], - )) + chore = run( + coord.async_add_chore( + "Dishes", + points=5, + requires_approval=False, + schedule_mode="recurring", + recurrence="weekly", + assigned_to=[alice.id, bob.id], + ) + ) with patch.object(_mod.dt_util, "now", return_value=now): run(coord.async_parent_complete_chore(chore.id)) @@ -105,11 +117,15 @@ def test_parent_complete_rejects_one_shot_chore(self): with patch.object(_mod.dt_util, "now", return_value=now): child = run(coord.async_add_child("Alice")) - chore = run(coord.async_add_chore( - "Special task", points=10, requires_approval=False, - schedule_mode="one_shot", - assigned_to=[child.id], - )) + chore = run( + coord.async_add_chore( + "Special task", + points=10, + requires_approval=False, + schedule_mode="one_shot", + assigned_to=[child.id], + ) + ) with patch.object(_mod.dt_util, "now", return_value=now): with pytest.raises(ValueError, match="one.shot"): @@ -129,11 +145,16 @@ def test_parent_complete_rejects_disabled_chore(self): with patch.object(_mod.dt_util, "now", return_value=now): child = run(coord.async_add_child("Alice")) - chore = run(coord.async_add_chore( - "Mop", points=5, requires_approval=False, - schedule_mode="recurring", recurrence="weekly", - assigned_to=[child.id], - )) + chore = run( + coord.async_add_chore( + "Mop", + points=5, + requires_approval=False, + schedule_mode="recurring", + recurrence="weekly", + assigned_to=[child.id], + ) + ) chore.enabled = False storage.update_chore(chore) @@ -147,11 +168,16 @@ def test_parent_complete_does_not_award_points(self): with patch.object(_mod.dt_util, "now", return_value=now): child = run(coord.async_add_child("Alice")) - chore = run(coord.async_add_chore( - "Sweep", points=15, requires_approval=False, - schedule_mode="recurring", recurrence="weekly", - assigned_to=[child.id], - )) + chore = run( + coord.async_add_chore( + "Sweep", + points=15, + requires_approval=False, + schedule_mode="recurring", + recurrence="weekly", + assigned_to=[child.id], + ) + ) with patch.object(_mod.dt_util, "now", return_value=now): run(coord.async_parent_complete_chore(chore.id)) @@ -166,11 +192,16 @@ def test_parent_complete_suppresses_availability_for_children(self): with patch.object(_mod.dt_util, "now", return_value=now): child = run(coord.async_add_child("Alice")) - chore = run(coord.async_add_chore( - "Laundry", points=5, requires_approval=False, - schedule_mode="recurring", recurrence="weekly", - assigned_to=[child.id], - )) + chore = run( + coord.async_add_chore( + "Laundry", + points=5, + requires_approval=False, + schedule_mode="recurring", + recurrence="weekly", + assigned_to=[child.id], + ) + ) with patch.object(_mod.dt_util, "now", return_value=now): run(coord.async_parent_complete_chore(chore.id)) @@ -186,11 +217,16 @@ def test_parent_complete_everyone_mode_updates_all_children(self): with patch.object(_mod.dt_util, "now", return_value=now): alice = run(coord.async_add_child("Alice")) bob = run(coord.async_add_child("Bob")) - chore = run(coord.async_add_chore( - "Tidy", points=5, requires_approval=False, - schedule_mode="recurring", recurrence="weekly", - assigned_to=[], # empty = everyone - )) + chore = run( + coord.async_add_chore( + "Tidy", + points=5, + requires_approval=False, + schedule_mode="recurring", + recurrence="weekly", + assigned_to=[], # empty = everyone + ) + ) with patch.object(_mod.dt_util, "now", return_value=now): run(coord.async_parent_complete_chore(chore.id)) @@ -210,11 +246,16 @@ def test_chore_returns_after_recurrence_window(self): with patch.object(_mod.dt_util, "now", return_value=now): child = run(coord.async_add_child("Alice")) - chore = run(coord.async_add_chore( - "Weekly clean", points=10, requires_approval=False, - schedule_mode="recurring", recurrence="weekly", - assigned_to=[child.id], - )) + chore = run( + coord.async_add_chore( + "Weekly clean", + points=10, + requires_approval=False, + schedule_mode="recurring", + recurrence="weekly", + assigned_to=[child.id], + ) + ) # Parent completes it with patch.object(_mod.dt_util, "now", return_value=now): @@ -245,12 +286,17 @@ def test_parent_complete_clears_rotation_pointer_today(self): with patch.object(_mod.dt_util, "now", return_value=now): alice = run(coord.async_add_child("Alice")) bob = run(coord.async_add_child("Bob")) - chore = run(coord.async_add_chore( - "Alternating chore", points=5, requires_approval=False, - schedule_mode="recurring", recurrence="weekly", - assigned_to=[alice.id, bob.id], - assignment_mode="alternating", - )) + chore = run( + coord.async_add_chore( + "Alternating chore", + points=5, + requires_approval=False, + schedule_mode="recurring", + recurrence="weekly", + assigned_to=[alice.id, bob.id], + assignment_mode="alternating", + ) + ) chore.assignment_current_child_id = alice.id storage.update_chore(chore) @@ -270,11 +316,16 @@ def test_parent_complete_preserves_everyone_mode_pointer(self): with patch.object(_mod.dt_util, "now", return_value=now): run(coord.async_add_child("Alice")) - chore = run(coord.async_add_chore( - "Brush teeth", points=2, requires_approval=False, - schedule_mode="recurring", recurrence="daily", - assigned_to=[], # everyone - )) + chore = run( + coord.async_add_chore( + "Brush teeth", + points=2, + requires_approval=False, + schedule_mode="recurring", + recurrence="daily", + assigned_to=[], # everyone + ) + ) with patch.object(_mod.dt_util, "now", return_value=now): run(coord.async_parent_complete_chore(chore.id)) @@ -292,12 +343,17 @@ def test_parent_complete_counts_toward_rotation_done_today(self): with patch.object(_mod.dt_util, "now", return_value=now): alice = run(coord.async_add_child("Alice")) bob = run(coord.async_add_child("Bob")) - chore = run(coord.async_add_chore( - "Bins", points=5, requires_approval=False, - schedule_mode="recurring", recurrence="weekly", - assigned_to=[alice.id, bob.id], - assignment_mode="alternating", - )) + chore = run( + coord.async_add_chore( + "Bins", + points=5, + requires_approval=False, + schedule_mode="recurring", + recurrence="weekly", + assigned_to=[alice.id, bob.id], + assignment_mode="alternating", + ) + ) with patch.object(_mod.dt_util, "now", return_value=now): assert coord._is_rotation_done_today(chore) is False diff --git a/tests/test_parent_complete_on_behalf.py b/tests/test_parent_complete_on_behalf.py index 6cc4883..757e186 100644 --- a/tests/test_parent_complete_on_behalf.py +++ b/tests/test_parent_complete_on_behalf.py @@ -1,4 +1,5 @@ """Tests for completing a chore on behalf of a child (as_parent flag).""" + from __future__ import annotations import asyncio @@ -48,6 +49,7 @@ def _make_system(now=None): coord._unsub_availability = None import custom_components.taskmate.coordinator as _mod + coord._dt_now = now coord.async_refresh = AsyncMock() # Isolate the completion logic from the notification subsystem. @@ -62,10 +64,15 @@ def test_as_parent_awards_immediately_when_approval_required(self): now = _now() with patch.object(_mod.dt_util, "now", return_value=now): child = run(coord.async_add_child("Alice")) - chore = run(coord.async_add_chore( - "Dishes", points=10, requires_approval=True, - schedule_mode="specific_days", assigned_to=[child.id], - )) + chore = run( + coord.async_add_chore( + "Dishes", + points=10, + requires_approval=True, + schedule_mode="specific_days", + assigned_to=[child.id], + ) + ) run(coord.async_complete_chore(chore.id, child.id, as_parent=True)) comps = storage.get_completions() @@ -79,10 +86,15 @@ def test_as_parent_false_still_creates_pending_when_approval_required(self): now = _now() with patch.object(_mod.dt_util, "now", return_value=now): child = run(coord.async_add_child("Alice")) - chore = run(coord.async_add_chore( - "Dishes", points=10, requires_approval=True, - schedule_mode="specific_days", assigned_to=[child.id], - )) + chore = run( + coord.async_add_chore( + "Dishes", + points=10, + requires_approval=True, + schedule_mode="specific_days", + assigned_to=[child.id], + ) + ) run(coord.async_complete_chore(chore.id, child.id, as_parent=False)) comps = storage.get_completions() @@ -96,10 +108,15 @@ def test_as_parent_respects_daily_limit(self): now = _now() with patch.object(_mod.dt_util, "now", return_value=now): child = run(coord.async_add_child("Alice")) - chore = run(coord.async_add_chore( - "Dishes", points=10, requires_approval=True, - schedule_mode="specific_days", assigned_to=[child.id], - )) + chore = run( + coord.async_add_chore( + "Dishes", + points=10, + requires_approval=True, + schedule_mode="specific_days", + assigned_to=[child.id], + ) + ) run(coord.async_complete_chore(chore.id, child.id, as_parent=True)) # Daily limit reached is a soft rejection even via as_parent: no-op. result = run(coord.async_complete_chore(chore.id, child.id, as_parent=True)) @@ -111,10 +128,15 @@ def test_as_parent_one_shot_auto_disables_child(self): now = _now() with patch.object(_mod.dt_util, "now", return_value=now): child = run(coord.async_add_child("Alice")) - chore = run(coord.async_add_chore( - "Paint fence", points=20, requires_approval=True, - schedule_mode="one_shot", assigned_to=[child.id], - )) + chore = run( + coord.async_add_chore( + "Paint fence", + points=20, + requires_approval=True, + schedule_mode="one_shot", + assigned_to=[child.id], + ) + ) run(coord.async_complete_chore(chore.id, child.id, as_parent=True)) updated = storage.get_chore(chore.id) diff --git a/tests/test_parent_routing.py b/tests/test_parent_routing.py index 74ba4c9..79865c0 100644 --- a/tests/test_parent_routing.py +++ b/tests/test_parent_routing.py @@ -4,6 +4,7 @@ buzzing everyone every time. Every fallback errs towards over-notifying: an unseen approval is worse than a redundant buzz. """ + from __future__ import annotations from unittest.mock import AsyncMock, MagicMock @@ -19,8 +20,7 @@ def _notifier(parents=(), settings=None, states=None): hass = MagicMock() hass.services.async_call = AsyncMock() known = dict(states or {}) - hass.states.get = MagicMock( - side_effect=lambda e: MagicMock(state=known[e]) if e in known else None) + hass.states.get = MagicMock(side_effect=lambda e: MagicMock(state=known[e]) if e in known else None) storage = MagicMock() storage.get_setting = MagicMock(side_effect=lambda k, d="": conf.get(k, d)) storage.set_setting = MagicMock(side_effect=lambda k, v: conf.__setitem__(k, v)) @@ -37,10 +37,8 @@ def _cfg(*recipient_ids, enabled=True): return cfg -DAD = ParentRecipient(name="Dad", notify_service="notify.dad", id="parent:dad", - presence_entity="device_tracker.dad") -MUM = ParentRecipient(name="Mum", notify_service="notify.mum", id="parent:mum", - presence_entity="device_tracker.mum") +DAD = ParentRecipient(name="Dad", notify_service="notify.dad", id="parent:dad", presence_entity="device_tracker.dad") +MUM = ParentRecipient(name="Mum", notify_service="notify.mum", id="parent:mum", presence_entity="device_tracker.mum") NOENT = ParentRecipient(name="Gran", notify_service="notify.gran", id="parent:gran") diff --git a/tests/test_parent_user_ids_sensor.py b/tests/test_parent_user_ids_sensor.py index 785b291..cdb6dcc 100644 --- a/tests/test_parent_user_ids_sensor.py +++ b/tests/test_parent_user_ids_sensor.py @@ -1,4 +1,5 @@ """Overview sensor publishes parent_user_ids so cards can unlock controls (#661).""" + from __future__ import annotations from unittest.mock import MagicMock diff --git a/tests/test_parent_user_ids_ws.py b/tests/test_parent_user_ids_ws.py index 755dcea..90eb866 100644 --- a/tests/test_parent_user_ids_ws.py +++ b/tests/test_parent_user_ids_ws.py @@ -1,4 +1,5 @@ """WS update_settings accepts and persists parent_user_ids (#661).""" + from __future__ import annotations from unittest.mock import AsyncMock, MagicMock diff --git a/tests/test_photo_gallery.py b/tests/test_photo_gallery.py index 9530d66..de01e5d 100644 --- a/tests/test_photo_gallery.py +++ b/tests/test_photo_gallery.py @@ -1,4 +1,5 @@ """Tests for the photo-gallery sensor slice (FEAT-13).""" + from __future__ import annotations import datetime as dt @@ -19,8 +20,7 @@ def _common(children, completions): def _comp(cid, when, photo="", approved=True): - return ChoreCompletion(chore_id="x", child_id=cid, completed_at=when, - approved=approved, photo_url=photo) + return ChoreCompletion(chore_id="x", child_id=cid, completed_at=when, approved=approved, photo_url=photo) def test_gallery_includes_only_photos_newest_first(): diff --git a/tests/test_photo_proof.py b/tests/test_photo_proof.py index c4e1f05..ae8651d 100644 --- a/tests/test_photo_proof.py +++ b/tests/test_photo_proof.py @@ -1,4 +1,5 @@ """Tests for photo-proof chores.""" + from __future__ import annotations import asyncio @@ -50,8 +51,7 @@ def _coord(chore, child): def test_photo_required_forces_pending_even_if_no_approval(): # require_photo=True, requires_approval=False -> still NOT auto-approved - chore = Chore(name="Room", requires_approval=False, require_photo=True, - assignment_mode="everyone", id="ch1") + chore = Chore(name="Room", requires_approval=False, require_photo=True, assignment_mode="everyone", id="ch1") child = Child(name="Mia", id="c1") coord = _coord(chore, child) photo = "/api/taskmate/photo/" + "a" * 32 + ".jpg" @@ -64,8 +64,7 @@ def test_photo_required_forces_pending_even_if_no_approval(): def test_photo_stored_on_completion(): - chore = Chore(name="Room", requires_approval=True, require_photo=True, - assignment_mode="everyone", id="ch1") + chore = Chore(name="Room", requires_approval=True, require_photo=True, assignment_mode="everyone", id="ch1") coord = _coord(chore, Child(name="Mia", id="c1")) photo = "/api/taskmate/photo/" + "b" * 32 + ".png" run(coord.async_complete_chore("ch1", "c1", photo_url=photo)) @@ -75,8 +74,7 @@ def test_photo_stored_on_completion(): def test_photo_required_blocks_completion_without_photo(): # require_photo=True, child completing with no photo -> hard rejection (ValueError), # NOT a silent pending completion. This is the server-side guard mirroring the card. - chore = Chore(name="Room", requires_approval=False, require_photo=True, - assignment_mode="everyone", id="ch1") + chore = Chore(name="Room", requires_approval=False, require_photo=True, assignment_mode="everyone", id="ch1") coord = _coord(chore, Child(name="Mia", id="c1")) with pytest.raises(ValueError): run(coord.async_complete_chore("ch1", "c1")) @@ -85,8 +83,7 @@ def test_photo_required_blocks_completion_without_photo(): def test_photo_required_blocks_blank_photo(): # A whitespace-only photo_url is treated as no photo. - chore = Chore(name="Room", requires_approval=False, require_photo=True, - assignment_mode="everyone", id="ch1") + chore = Chore(name="Room", requires_approval=False, require_photo=True, assignment_mode="everyone", id="ch1") coord = _coord(chore, Child(name="Mia", id="c1")) with pytest.raises(ValueError): run(coord.async_complete_chore("ch1", "c1", photo_url=" ")) @@ -99,12 +96,9 @@ def test_foreign_photo_url_is_rejected_not_stored(): # approval views. The coordinator drops it to "" at the boundary. With an # approval-required (non-photo) chore the completion still proceeds, but with no # photo attached. - chore = Chore(name="Room", requires_approval=True, require_photo=False, - assignment_mode="everyone", id="ch1") + chore = Chore(name="Room", requires_approval=True, require_photo=False, assignment_mode="everyone", id="ch1") coord = _coord(chore, Child(name="Mia", id="c1")) - for bad in ("javascript:alert(1)", - "https://evil.example/track.png", - "/api/taskmate/photo/../../etc/passwd"): + for bad in ("javascript:alert(1)", "https://evil.example/track.png", "/api/taskmate/photo/../../etc/passwd"): coord._added.clear() run(coord.async_complete_chore("ch1", "c1", photo_url=bad)) assert coord._added[0].photo_url == "" @@ -113,8 +107,7 @@ def test_foreign_photo_url_is_rejected_not_stored(): def test_foreign_photo_url_does_not_satisfy_require_photo(): # A crafted photo_url must not satisfy a require_photo chore — once dropped to # "", the require_photo guard fires exactly as if no photo were sent. - chore = Chore(name="Room", requires_approval=False, require_photo=True, - assignment_mode="everyone", id="ch1") + chore = Chore(name="Room", requires_approval=False, require_photo=True, assignment_mode="everyone", id="ch1") coord = _coord(chore, Child(name="Mia", id="c1")) with pytest.raises(ValueError): run(coord.async_complete_chore("ch1", "c1", photo_url="javascript:alert(1)")) @@ -122,8 +115,7 @@ def test_foreign_photo_url_does_not_satisfy_require_photo(): def test_parent_can_still_autocomplete_photo_chore(): - chore = Chore(name="Room", requires_approval=False, require_photo=True, - assignment_mode="everyone", id="ch1") + chore = Chore(name="Room", requires_approval=False, require_photo=True, assignment_mode="everyone", id="ch1") coord = _coord(chore, Child(name="Mia", id="c1")) run(coord.async_complete_chore("ch1", "c1", as_parent=True)) assert coord._added[0].approved is True @@ -141,7 +133,8 @@ def test_prune_deletes_orphaned_evidence_photos(monkeypatch): deleted = [] monkeypatch.setattr( - coord_points.photos, "async_delete_photo", + coord_points.photos, + "async_delete_photo", AsyncMock(side_effect=lambda hass, url: deleted.append(url)), ) @@ -149,20 +142,27 @@ def test_prune_deletes_orphaned_evidence_photos(monkeypatch): # test doesn't depend on the global mock's current value. now = dt_util.now() old_with_photo = ChoreCompletion( - chore_id="a", child_id="c", completed_at=now - _dt.timedelta(days=200), - approved=True, photo_url="/api/taskmate/photo/" + "a" * 32 + ".jpg") + chore_id="a", + child_id="c", + completed_at=now - _dt.timedelta(days=200), + approved=True, + photo_url="/api/taskmate/photo/" + "a" * 32 + ".jpg", + ) old_no_photo = ChoreCompletion( - chore_id="b", child_id="c", completed_at=now - _dt.timedelta(days=200), - approved=True, photo_url="") + chore_id="b", child_id="c", completed_at=now - _dt.timedelta(days=200), approved=True, photo_url="" + ) recent = ChoreCompletion( - chore_id="d", child_id="c", completed_at=now - _dt.timedelta(days=1), - approved=True, photo_url="/api/taskmate/photo/" + "b" * 32 + ".jpg") + chore_id="d", + child_id="c", + completed_at=now - _dt.timedelta(days=1), + approved=True, + photo_url="/api/taskmate/photo/" + "b" * 32 + ".jpg", + ) coord = object.__new__(TaskMateCoordinator) coord.hass = MagicMock() storage = MagicMock() - storage.get_completions = MagicMock( - return_value=[old_with_photo, old_no_photo, recent]) + storage.get_completions = MagicMock(return_value=[old_with_photo, old_no_photo, recent]) storage.replace_completions = MagicMock() storage.async_save = AsyncMock() coord.storage = storage @@ -175,9 +175,9 @@ def test_prune_deletes_orphaned_evidence_photos(monkeypatch): def test_completion_photo_round_trips(): - c = ChoreCompletion(chore_id="x", child_id="y", - completed_at=__import__("datetime").datetime(2026, 1, 1), - photo_url="http://p") + c = ChoreCompletion( + chore_id="x", child_id="y", completed_at=__import__("datetime").datetime(2026, 1, 1), photo_url="http://p" + ) assert ChoreCompletion.from_dict(c.to_dict()).photo_url == "http://p" diff --git a/tests/test_photo_storage.py b/tests/test_photo_storage.py index 75af581..f0fc957 100644 --- a/tests/test_photo_storage.py +++ b/tests/test_photo_storage.py @@ -1,4 +1,5 @@ """Tests for the pure evidence-photo storage helpers (custom_components.taskmate.photos).""" + from __future__ import annotations import asyncio @@ -31,6 +32,7 @@ async def _exec(func, *args): # ── detect_image_ext ──────────────────────────────────────────────────────── + def test_detect_jpeg(): assert photos.detect_image_ext(b"\xff\xd8\xff\xe0\x00\x10JFIF") == "jpg" @@ -56,6 +58,7 @@ def test_detect_rejects_non_image(): # ── photo_file_for_url (path-traversal safety) ────────────────────────────── + def test_photo_file_for_valid_url(tmp_path): hass = _hass(tmp_path) name = "0123456789abcdef0123456789abcdef.jpg" @@ -80,6 +83,7 @@ def test_photo_file_for_url_rejects_traversal(tmp_path): # ── sign_photo_url (foreign/blank passthrough — no HA needed) ─────────────── + def test_sign_photo_url_passes_through_foreign(tmp_path): hass = _hass(tmp_path) # Foreign/blank URLs return unchanged without touching async_sign_path. @@ -90,6 +94,7 @@ def test_sign_photo_url_passes_through_foreign(tmp_path): # ── async_delete_photo ────────────────────────────────────────────────────── + def test_delete_photo_removes_file(tmp_path): hass = _hass(tmp_path) name = "0123456789abcdef0123456789abcdef.jpg" @@ -115,6 +120,7 @@ def test_delete_photo_ignores_foreign_url(tmp_path): # ── quota + orphan sweep (SEC-2) ───────────────────────────────────────────── + def _write_photo(tmp_path, name, data=b"x", age_hours=0): d = Path(tmp_path, photos.PHOTOS_DIR) d.mkdir(parents=True, exist_ok=True) @@ -138,7 +144,7 @@ def test_total_photos_bytes(tmp_path): def test_sweep_removes_old_unreferenced_only(tmp_path): hass = _hass(tmp_path) - keep = "a" * 32 + ".jpg" # old but referenced -> keep + keep = "a" * 32 + ".jpg" # old but referenced -> keep orphan_old = "b" * 32 + ".jpg" # old + unreferenced -> delete orphan_new = "c" * 32 + ".jpg" # new + unreferenced -> keep (grace window) _write_photo(tmp_path, keep, age_hours=48) diff --git a/tests/test_points_decay.py b/tests/test_points_decay.py index ea95738..8209708 100644 --- a/tests/test_points_decay.py +++ b/tests/test_points_decay.py @@ -1,4 +1,5 @@ """Tests for periodic points decay.""" + from __future__ import annotations import asyncio @@ -51,9 +52,8 @@ def test_disabled_noop(): def test_monthly_decay_on_first(): c = Child(name="A", points=100, id="c1") - coord = _coord({"points_decay_enabled": True, "points_decay_period": "monthly", - "points_decay_percent": "10"}, [c]) - _run_on(coord, dt.date(2026, 7, 1)) # 1st -> decay 10% + coord = _coord({"points_decay_enabled": True, "points_decay_period": "monthly", "points_decay_percent": "10"}, [c]) + _run_on(coord, dt.date(2026, 7, 1)) # 1st -> decay 10% assert c.points == 90 coord.storage.add_points_transaction.assert_called_once() assert any(x[0][0] == "taskmate_points_decay" for x in coord.hass.bus.async_fire.call_args_list) @@ -61,16 +61,14 @@ def test_monthly_decay_on_first(): def test_monthly_skips_non_first(): c = Child(name="A", points=100, id="c1") - coord = _coord({"points_decay_enabled": True, "points_decay_period": "monthly", - "points_decay_percent": "10"}, [c]) + coord = _coord({"points_decay_enabled": True, "points_decay_period": "monthly", "points_decay_percent": "10"}, [c]) _run_on(coord, dt.date(2026, 7, 15)) assert c.points == 100 def test_weekly_on_monday_only(): c = Child(name="A", points=200, id="c1") - coord = _coord({"points_decay_enabled": True, "points_decay_period": "weekly", - "points_decay_percent": "25"}, [c]) + coord = _coord({"points_decay_enabled": True, "points_decay_period": "weekly", "points_decay_percent": "25"}, [c]) _run_on(coord, dt.date(2026, 6, 17)) # Wed assert c.points == 200 _run_on(coord, dt.date(2026, 6, 15)) # Mon -> 25% off @@ -79,18 +77,16 @@ def test_weekly_on_monday_only(): def test_no_double_decay_same_day(): c = Child(name="A", points=100, id="c1") - coord = _coord({"points_decay_enabled": True, "points_decay_period": "monthly", - "points_decay_percent": "10"}, [c]) + coord = _coord({"points_decay_enabled": True, "points_decay_period": "monthly", "points_decay_percent": "10"}, [c]) _run_on(coord, dt.date(2026, 7, 1)) assert c.points == 90 - _run_on(coord, dt.date(2026, 7, 1)) # guarded by points_decay_last + _run_on(coord, dt.date(2026, 7, 1)) # guarded by points_decay_last assert c.points == 90 def test_zero_balance_skipped(): c = Child(name="A", points=0, id="c1") - coord = _coord({"points_decay_enabled": True, "points_decay_period": "monthly", - "points_decay_percent": "10"}, [c]) + coord = _coord({"points_decay_enabled": True, "points_decay_period": "monthly", "points_decay_percent": "10"}, [c]) _run_on(coord, dt.date(2026, 7, 1)) assert c.points == 0 coord.storage.add_points_transaction.assert_not_called() diff --git a/tests/test_printable_chart.py b/tests/test_printable_chart.py index 2778fd3..893c263 100644 --- a/tests/test_printable_chart.py +++ b/tests/test_printable_chart.py @@ -3,6 +3,7 @@ A sheet for the fridge. Pure string building, so the layout is testable without a Home Assistant install. """ + from __future__ import annotations from datetime import date @@ -17,8 +18,7 @@ def _child(name="Ella", cid="a"): def _chore(name="Make bed", **over): - chore = {"name": name, "enabled": True, "schedule_mode": "specific_days", - "due_days": [], "assigned_to": []} + chore = {"name": name, "enabled": True, "schedule_mode": "specific_days", "due_days": [], "assigned_to": []} chore.update(over) return chore @@ -73,13 +73,13 @@ def test_every_task_gets_a_box_to_tick(self): assert 'class="tick"' in html def test_unassigned_chores_belong_to_everyone(self): - html = printable.build_chart([_child("Ella"), _child("Sam", "b")], - [_chore("Teeth", assigned_to=[])], MON) + html = printable.build_chart([_child("Ella"), _child("Sam", "b")], [_chore("Teeth", assigned_to=[])], MON) assert html.count("Teeth") == 14 # 7 days x 2 children def test_assigned_chores_only_reach_their_child(self): - html = printable.build_chart([_child("Ella", "a"), _child("Sam", "b")], - [_chore("Bins", assigned_to=["a"], due_days=["monday"])], MON) + html = printable.build_chart( + [_child("Ella", "a"), _child("Sam", "b")], [_chore("Bins", assigned_to=["a"], due_days=["monday"])], MON + ) assert html.count("Bins") == 1 def test_disabled_chores_are_omitted(self): @@ -88,14 +88,14 @@ def test_disabled_chores_are_omitted(self): def test_one_shot_appears_only_on_its_date(self): html = printable.build_chart( - [_child()], - [_chore("Once", schedule_mode="one_shot", created_date=MON.isoformat())], - MON) + [_child()], [_chore("Once", schedule_mode="one_shot", created_date=MON.isoformat())], MON + ) assert html.count("Once") == 1 def test_children_with_no_chores_are_skipped(self): - html = printable.build_chart([_child("Ella", "a"), _child("Sam", "b")], - [_chore("Bins", assigned_to=["a"])], MON) + html = printable.build_chart( + [_child("Ella", "a"), _child("Sam", "b")], [_chore("Bins", assigned_to=["a"])], MON + ) assert "Sam" not in html def test_empty_chart_says_so(self): @@ -115,8 +115,8 @@ class TestSafety: def test_names_are_escaped(self): """A chore name is user input and lands in an HTML document.""" html = printable.build_chart( - [_child('')], - [_chore('')], MON) + [_child("")], [_chore("")], MON + ) # The payloads must survive only as inert text: no unescaped tag can # appear. "onerror=alert" still occurs as escaped text, which is fine — # what matters is that no " datetime: # --- pure window logic ----------------------------------------------------- + def test_disabled_when_either_bound_blank(): assert _is_within_quiet_hours("", "07:00", _at(3)) is False assert _is_within_quiet_hours("20:00", "", _at(3)) is False @@ -40,7 +42,7 @@ def test_disabled_when_malformed(): def test_daytime_window(): # School hours 09:00-15:00 assert _is_within_quiet_hours("09:00", "15:00", _at(8, 59)) is False - assert _is_within_quiet_hours("09:00", "15:00", _at(9, 0)) is True # start inclusive + assert _is_within_quiet_hours("09:00", "15:00", _at(9, 0)) is True # start inclusive assert _is_within_quiet_hours("09:00", "15:00", _at(12, 0)) is True assert _is_within_quiet_hours("09:00", "15:00", _at(15, 0)) is False # end exclusive assert _is_within_quiet_hours("09:00", "15:00", _at(16, 0)) is False @@ -49,16 +51,17 @@ def test_daytime_window(): def test_overnight_window(): # Bedtime 20:00-07:00 assert _is_within_quiet_hours("20:00", "07:00", _at(19, 59)) is False - assert _is_within_quiet_hours("20:00", "07:00", _at(20, 0)) is True # start inclusive + assert _is_within_quiet_hours("20:00", "07:00", _at(20, 0)) is True # start inclusive assert _is_within_quiet_hours("20:00", "07:00", _at(23, 30)) is True assert _is_within_quiet_hours("20:00", "07:00", _at(2, 0)) is True assert _is_within_quiet_hours("20:00", "07:00", _at(6, 59)) is True - assert _is_within_quiet_hours("20:00", "07:00", _at(7, 0)) is False # end exclusive + assert _is_within_quiet_hours("20:00", "07:00", _at(7, 0)) is False # end exclusive assert _is_within_quiet_hours("20:00", "07:00", _at(12, 0)) is False # --- dispatch integration -------------------------------------------------- + @pytest.fixture async def coord(hass): storage = TaskMateStorage(hass, "test") @@ -79,9 +82,7 @@ async def test_fire_suppresses_child_during_quiet_hours(coord, hass, monkeypatch ) coord.storage.add_child(child) coord.storage.set_notification_master("badge_earned", True) - coord.storage.set_notification_route( - "badge_earned", f"child:{child.id}", NotificationRoute(enabled=True) - ) + coord.storage.set_notification_route("badge_earned", f"child:{child.id}", NotificationRoute(enabled=True)) # Pretend it is 22:00 — inside the window. The conftest dt_util mock is the # single source of "now" the integration sees. @@ -109,9 +110,7 @@ async def test_fire_delivers_child_outside_quiet_hours(coord, hass, monkeypatch) ) coord.storage.add_child(child) coord.storage.set_notification_master("badge_earned", True) - coord.storage.set_notification_route( - "badge_earned", f"child:{child.id}", NotificationRoute(enabled=True) - ) + coord.storage.set_notification_route("badge_earned", f"child:{child.id}", NotificationRoute(enabled=True)) monkeypatch.setattr(dt_util_mock, "_now", _at(12)) # midday, outside window diff --git a/tests/test_reactive_chores.py b/tests/test_reactive_chores.py index 8996c3d..a2e5c64 100644 --- a/tests/test_reactive_chores.py +++ b/tests/test_reactive_chores.py @@ -4,6 +4,7 @@ it within 30 minutes". It disappears when the deadline passes, and beating the deadline pays a speed bonus. """ + from __future__ import annotations from datetime import datetime, timedelta @@ -82,14 +83,22 @@ def _available(self, coord, chore): def test_chore_within_deadline_is_available(self): coord = _coord() - chore = Chore(name="Empty the washer", schedule_mode="one_shot", - created_date=_now().date().isoformat(), deadline_at=_in(20)) + chore = Chore( + name="Empty the washer", + schedule_mode="one_shot", + created_date=_now().date().isoformat(), + deadline_at=_in(20), + ) assert self._available(coord, chore) is True def test_chore_past_deadline_is_unavailable(self): coord = _coord() - chore = Chore(name="Empty the washer", schedule_mode="one_shot", - created_date=_now().date().isoformat(), deadline_at=_in(-5)) + chore = Chore( + name="Empty the washer", + schedule_mode="one_shot", + created_date=_now().date().isoformat(), + deadline_at=_in(-5), + ) assert self._available(coord, chore) is False def test_chore_without_deadline_is_unaffected(self): @@ -123,8 +132,7 @@ def test_zero_bonus_is_a_no_op(self): def test_bonus_stacks_on_top_of_the_time_adjustment(self): """Both incentives can apply to one completion.""" coord = _coord() - chore = Chore(name="C", deadline_at=_in(10), speed_bonus_points=5, - due_time="23:59", early_bonus=3) + chore = Chore(name="C", deadline_at=_in(10), speed_bonus_points=5, due_time="23:59", early_bonus=3) adjusted = coord._apply_time_adjustment(chore, 10, dt_util.now()) assert coord._apply_speed_bonus(chore, adjusted, dt_util.now()) == 18 diff --git a/tests/test_read_aloud.py b/tests/test_read_aloud.py index ba6fff6..9ffbfd5 100644 --- a/tests/test_read_aloud.py +++ b/tests/test_read_aloud.py @@ -4,6 +4,7 @@ parent-editable templates: the frontend locales don't reach the backend, and a family may want phrasing that isn't one of the eight shipped languages. """ + from __future__ import annotations from unittest.mock import AsyncMock, MagicMock @@ -28,8 +29,7 @@ def _coord(due=(), settings=None, tts_entities=()): coord.get_due_chores_for_child = MagicMock(return_value=list(due)) coord.hass.services.async_call = AsyncMock() coord.hass.bus.async_fire = MagicMock() - coord.hass.states.async_all = MagicMock( - return_value=[MagicMock(entity_id=e) for e in tts_entities]) + coord.hass.states.async_all = MagicMock(return_value=[MagicMock(entity_id=e) for e in tts_entities]) return coord @@ -65,14 +65,12 @@ def test_joiner_is_parent_editable(self): def test_blank_template_falls_back_to_the_default(self): coord = _coord([], {"read_aloud_done_template": " "}) - assert coord.build_read_aloud_message("kid1") == DEFAULT_DONE_TEMPLATE.format( - name="Ella", count=0, chores="") + assert coord.build_read_aloud_message("kid1") == DEFAULT_DONE_TEMPLATE.format(name="Ella", count=0, chores="") def test_bad_placeholder_does_not_silence_the_feature(self): """A typo'd template should still speak, using the built-in wording.""" coord = _coord([_chore("a"), _chore("b")], {"read_aloud_template": "{nmae} {oops}"}) - assert coord.build_read_aloud_message("kid1") == DEFAULT_TEMPLATE.format( - name="Ella", count=2, chores="a and b") + assert coord.build_read_aloud_message("kid1") == DEFAULT_TEMPLATE.format(name="Ella", count=2, chores="a and b") def test_unknown_child_is_rejected(self): coord = _coord([]) @@ -86,56 +84,55 @@ class TestSpeaking: async def test_speaks_blocking_so_failures_surface(self): """A parent invoked this deliberately; "it silently did nothing" is the worst possible answer.""" - coord = _coord([_chore("a")], {"read_aloud_media_player": "media_player.kitchen"}, - tts_entities=["tts.piper"]) + coord = _coord([_chore("a")], {"read_aloud_media_player": "media_player.kitchen"}, tts_entities=["tts.piper"]) await coord.async_read_aloud("kid1") assert coord.hass.services.async_call.await_args.kwargs["blocking"] is True @pytest.mark.asyncio async def test_speaks_through_tts(self): - coord = _coord([_chore("a")], {"read_aloud_media_player": "media_player.kitchen"}, - tts_entities=["tts.piper"]) + coord = _coord([_chore("a")], {"read_aloud_media_player": "media_player.kitchen"}, tts_entities=["tts.piper"]) said = await coord.async_read_aloud("kid1") coord.hass.services.async_call.assert_awaited_with( - "tts", "speak", - {"entity_id": "tts.piper", - "media_player_entity_id": "media_player.kitchen", - "message": said}, - blocking=True) + "tts", + "speak", + {"entity_id": "tts.piper", "media_player_entity_id": "media_player.kitchen", "message": said}, + blocking=True, + ) @pytest.mark.asyncio async def test_explicit_arguments_win_over_settings(self): - coord = _coord([_chore("a")], - {"read_aloud_media_player": "media_player.kitchen", - "read_aloud_tts_entity": "tts.configured"}, - tts_entities=["tts.discovered"]) - await coord.async_read_aloud("kid1", media_player="media_player.bedroom", - tts_entity="tts.explicit") + coord = _coord( + [_chore("a")], + {"read_aloud_media_player": "media_player.kitchen", "read_aloud_tts_entity": "tts.configured"}, + tts_entities=["tts.discovered"], + ) + await coord.async_read_aloud("kid1", media_player="media_player.bedroom", tts_entity="tts.explicit") payload = coord.hass.services.async_call.await_args[0][2] assert payload["entity_id"] == "tts.explicit" assert payload["media_player_entity_id"] == "media_player.bedroom" @pytest.mark.asyncio async def test_configured_tts_beats_discovery(self): - coord = _coord([_chore("a")], - {"read_aloud_media_player": "media_player.kitchen", - "read_aloud_tts_entity": "tts.configured"}, - tts_entities=["tts.other"]) + coord = _coord( + [_chore("a")], + {"read_aloud_media_player": "media_player.kitchen", "read_aloud_tts_entity": "tts.configured"}, + tts_entities=["tts.other"], + ) await coord.async_read_aloud("kid1") assert coord.hass.services.async_call.await_args[0][2]["entity_id"] == "tts.configured" @pytest.mark.asyncio async def test_single_tts_entity_is_picked_automatically(self): """Most households have exactly one — don't make them configure it.""" - coord = _coord([_chore("a")], {"read_aloud_media_player": "media_player.kitchen"}, - tts_entities=["tts.only_one"]) + coord = _coord( + [_chore("a")], {"read_aloud_media_player": "media_player.kitchen"}, tts_entities=["tts.only_one"] + ) await coord.async_read_aloud("kid1") assert coord.hass.services.async_call.await_args[0][2]["entity_id"] == "tts.only_one" @pytest.mark.asyncio async def test_message_override_is_spoken_verbatim(self): - coord = _coord([_chore("a")], {"read_aloud_media_player": "media_player.kitchen"}, - tts_entities=["tts.piper"]) + coord = _coord([_chore("a")], {"read_aloud_media_player": "media_player.kitchen"}, tts_entities=["tts.piper"]) said = await coord.async_read_aloud("kid1", message="Dinner is ready") assert said == "Dinner is ready" @@ -153,8 +150,7 @@ async def test_no_tts_entity_is_a_clear_error(self): @pytest.mark.asyncio async def test_speaking_fires_an_event(self): - coord = _coord([_chore("a")], {"read_aloud_media_player": "media_player.kitchen"}, - tts_entities=["tts.piper"]) + coord = _coord([_chore("a")], {"read_aloud_media_player": "media_player.kitchen"}, tts_entities=["tts.piper"]) await coord.async_read_aloud("kid1") event, payload = coord.hass.bus.async_fire.call_args[0] assert event == "taskmate_read_aloud" @@ -163,8 +159,7 @@ async def test_speaking_fires_an_event(self): class TestPreview: def test_preview_does_not_speak(self): - coord = _coord([_chore("a")], {"read_aloud_media_player": "media_player.kitchen"}, - tts_entities=["tts.piper"]) + coord = _coord([_chore("a")], {"read_aloud_media_player": "media_player.kitchen"}, tts_entities=["tts.piper"]) preview = coord.read_aloud_preview("kid1") assert preview["message"] == DEFAULT_ONE_TEMPLATE.format(name="Ella", count=1, chores="a") assert preview["tts_entity"] == "tts.piper" diff --git a/tests/test_reward_restock.py b/tests/test_reward_restock.py index 91bf982..4f434ee 100644 --- a/tests/test_reward_restock.py +++ b/tests/test_reward_restock.py @@ -1,4 +1,5 @@ """Tests for reward auto-restock.""" + from __future__ import annotations import asyncio @@ -37,16 +38,30 @@ def _run_on(coord, date_obj): def test_daily_restocks_every_day(): - r = Reward(name="Snack", quantity=0, restock_enabled=True, restock_amount=3, - restock_period="daily", restock_last="", id="r1") + r = Reward( + name="Snack", + quantity=0, + restock_enabled=True, + restock_amount=3, + restock_period="daily", + restock_last="", + id="r1", + ) coord = _coord([r]) _run_on(coord, dt.date(2026, 6, 17)) # any day assert r.quantity == 3 and r.restock_last == "2026-06-17" def test_weekly_only_on_monday(): - r = Reward(name="Movie", quantity=0, restock_enabled=True, restock_amount=1, - restock_period="weekly", restock_last="", id="r1") + r = Reward( + name="Movie", + quantity=0, + restock_enabled=True, + restock_amount=1, + restock_period="weekly", + restock_last="", + id="r1", + ) coord = _coord([r]) _run_on(coord, dt.date(2026, 6, 17)) # Wednesday -> no restock assert r.quantity == 0 @@ -55,18 +70,32 @@ def test_weekly_only_on_monday(): def test_monthly_only_on_first(): - r = Reward(name="Outing", quantity=0, restock_enabled=True, restock_amount=2, - restock_period="monthly", restock_last="", id="r1") + r = Reward( + name="Outing", + quantity=0, + restock_enabled=True, + restock_amount=2, + restock_period="monthly", + restock_last="", + id="r1", + ) coord = _coord([r]) _run_on(coord, dt.date(2026, 6, 17)) # not the 1st assert r.quantity == 0 - _run_on(coord, dt.date(2026, 7, 1)) # 1st -> restock + _run_on(coord, dt.date(2026, 7, 1)) # 1st -> restock assert r.quantity == 2 def test_no_double_restock_same_day(): - r = Reward(name="Snack", quantity=5, restock_enabled=True, restock_amount=3, - restock_period="daily", restock_last="2026-06-17", id="r1") + r = Reward( + name="Snack", + quantity=5, + restock_enabled=True, + restock_amount=3, + restock_period="daily", + restock_last="2026-06-17", + id="r1", + ) coord = _coord([r]) _run_on(coord, dt.date(2026, 6, 17)) # already restocked today assert r.quantity == 5 # untouched @@ -74,10 +103,8 @@ def test_no_double_restock_same_day(): def test_disabled_or_zero_amount_skipped(): - r1 = Reward(name="Off", quantity=0, restock_enabled=False, restock_amount=3, - restock_period="daily", id="r1") - r2 = Reward(name="ZeroAmt", quantity=0, restock_enabled=True, restock_amount=0, - restock_period="daily", id="r2") + r1 = Reward(name="Off", quantity=0, restock_enabled=False, restock_amount=3, restock_period="daily", id="r1") + r2 = Reward(name="ZeroAmt", quantity=0, restock_enabled=True, restock_amount=0, restock_period="daily", id="r2") coord = _coord([r1, r2]) _run_on(coord, dt.date(2026, 6, 17)) assert r1.quantity == 0 and r2.quantity == 0 @@ -85,8 +112,7 @@ def test_disabled_or_zero_amount_skipped(): def test_restock_round_trips_serialization(): - r = Reward(name="X", restock_enabled=True, restock_amount=4, restock_period="monthly", - restock_last="2026-06-01") + r = Reward(name="X", restock_enabled=True, restock_amount=4, restock_period="monthly", restock_last="2026-06-01") r2 = Reward.from_dict(r.to_dict()) assert r2.restock_enabled and r2.restock_amount == 4 assert r2.restock_period == "monthly" and r2.restock_last == "2026-06-01" diff --git a/tests/test_rotation_quota.py b/tests/test_rotation_quota.py index 7b02960..eae7713 100644 --- a/tests/test_rotation_quota.py +++ b/tests/test_rotation_quota.py @@ -7,6 +7,7 @@ - once the rotation is done for the day no further pool member can complete; - a parent (as_parent) may still complete on behalf of the off-rotation child. """ + from __future__ import annotations import asyncio @@ -54,6 +55,7 @@ def _make_system(): coord.async_refresh = AsyncMock() import custom_components.taskmate.coordinator as _mod + return coord, storage, _mod @@ -61,10 +63,15 @@ def _alternating_chore(coord, _mod, now): with patch.object(_mod.dt_util, "now", return_value=now): alice = run(coord.async_add_child("Alice")) bob = run(coord.async_add_child("Bob")) - chore = run(coord.async_add_chore( - "Dishes", points=10, requires_approval=False, - assignment_mode="alternating", assigned_to=[alice.id, bob.id], - )) + chore = run( + coord.async_add_chore( + "Dishes", + points=10, + requires_approval=False, + assignment_mode="alternating", + assigned_to=[alice.id, bob.id], + ) + ) active = coord._compute_active_children(chore)[0] inactive = next(c.id for c in (alice, bob) if c.id != active) return chore, active, inactive diff --git a/tests/test_routine_card.py b/tests/test_routine_card.py index ca40349..13a6bb8 100644 --- a/tests/test_routine_card.py +++ b/tests/test_routine_card.py @@ -5,6 +5,7 @@ exists on disk, and that the new one is wired into the resource list — a card listed but missing 404s on every dashboard load. """ + from __future__ import annotations import pathlib @@ -114,9 +115,7 @@ def test_pulls_in_the_shared_token_styles(self): def test_stamps_the_design_attribute_on_its_host(self): """var(--tmd-*) resolves only below an element carrying the attribute.""" - assert re.search(r"__taskmate_design\s*\.\s*apply\(", self.SOURCE) or ( - "apply(this" in self.SOURCE - ) + assert re.search(r"__taskmate_design\s*\.\s*apply\(", self.SOURCE) or ("apply(this" in self.SOURCE) def test_consumes_design_tokens_in_its_styles(self): """Hard-coded hexes ignore the active palette.""" @@ -131,15 +130,15 @@ def test_editor_offers_the_design_picker(self): def test_accessible_style_reaches_the_action_buttons(self): """Done/Back/Skip are the whole interface — if they keep hard-coded greens and greys the accessible palette has not actually applied.""" - styles = self.SOURCE[self.SOURCE.index("static get styles()"):] - btn = styles[styles.index(".rt-done"):styles.index(".rt-row")] + styles = self.SOURCE[self.SOURCE.index("static get styles()") :] + btn = styles[styles.index(".rt-done") : styles.index(".rt-row")] assert "var(--tmd-" in btn, "the primary Done button ignores the design tokens" def test_layout_classes_do_not_collide_with_the_shared_kit(self): """The kit's own .btn/.bar rules are design-qualified, so they outrank an unprefixed .btn-done here and repaint the primary action.""" kit = (WWW / "taskmate-design.js").read_text(encoding="utf-8") - template = self.SOURCE[self.SOURCE.index("render()"):self.SOURCE.index("static get styles()")] + template = self.SOURCE[self.SOURCE.index("render()") : self.SOURCE.index("static get styles()")] used = set(re.findall(r'class="([^"$]+)"', template)) classes = {c for group in used for c in group.split()} for generic in ("btn", "bar"): diff --git a/tests/test_save_debounce.py b/tests/test_save_debounce.py index 952caab..1ef4ab3 100644 --- a/tests/test_save_debounce.py +++ b/tests/test_save_debounce.py @@ -1,4 +1,5 @@ """PERF-3: async_save debounces; async_save_now writes through.""" + from __future__ import annotations from unittest.mock import AsyncMock, MagicMock @@ -60,6 +61,7 @@ async def test_repeated_saves_each_bump_version_and_debounce(): @pytest.mark.asyncio async def test_shutdown_flushes_pending_save(): from custom_components.taskmate.coordinator import TaskMateCoordinator + coord = object.__new__(TaskMateCoordinator) coord.storage = MagicMock() coord.storage.async_save_now = AsyncMock() diff --git a/tests/test_scheduled_changes.py b/tests/test_scheduled_changes.py index 336de0c..b0b5b8f 100644 --- a/tests/test_scheduled_changes.py +++ b/tests/test_scheduled_changes.py @@ -4,6 +4,7 @@ date, applied at midnight, and caught up at startup if Home Assistant was off on the day it came due. """ + from __future__ import annotations from datetime import timedelta @@ -32,7 +33,8 @@ def _coord(chores=None, changes=None): coord.storage.update_chore = MagicMock(side_effect=lambda c: store.__setitem__(c.id, c)) coord.storage.get_scheduled_changes = MagicMock(side_effect=lambda: list(queued)) coord.storage.get_scheduled_change = MagicMock( - side_effect=lambda cid: next((c for c in queued if c.id == cid), None)) + side_effect=lambda cid: next((c for c in queued if c.id == cid), None) + ) coord.storage.add_scheduled_change = MagicMock(side_effect=queued.append) def _update(change): @@ -41,9 +43,11 @@ def _update(change): queued[i] = change return queued.append(change) + coord.storage.update_scheduled_change = MagicMock(side_effect=_update) coord.storage.remove_scheduled_change = MagicMock( - side_effect=lambda cid: queued.__setitem__(slice(None), [c for c in queued if c.id != cid])) + side_effect=lambda cid: queued.__setitem__(slice(None), [c for c in queued if c.id != cid]) + ) coord.storage.async_save = AsyncMock() coord.async_refresh = AsyncMock() @@ -189,8 +193,7 @@ async def test_overdue_change_still_applies(self): @pytest.mark.asyncio async def test_applied_change_is_not_reapplied(self): chore = Chore(name="Mow", points=99) - change = ScheduledChange( - chore_id=chore.id, apply_on=_day(-1), changes={"points": 20}, applied=True) + change = ScheduledChange(chore_id=chore.id, apply_on=_day(-1), changes={"points": 20}, applied=True) coord = _coord([chore], [change]) assert await coord.async_apply_due_scheduled_changes() == 0 assert chore.points == 99 @@ -198,8 +201,9 @@ async def test_applied_change_is_not_reapplied(self): @pytest.mark.asyncio async def test_multiple_fields_in_one_change(self): chore = Chore(name="Mow", points=10, enabled=True, assigned_to=["a"]) - change = ScheduledChange(chore_id=chore.id, apply_on=_day(0), - changes={"points": 20, "enabled": False, "assigned_to": ["b"]}) + change = ScheduledChange( + chore_id=chore.id, apply_on=_day(0), changes={"points": 20, "enabled": False, "assigned_to": ["b"]} + ) coord = _coord([chore], [change]) await coord.async_apply_due_scheduled_changes() assert (chore.points, chore.enabled, chore.assigned_to) == (20, False, ["b"]) @@ -225,8 +229,7 @@ async def test_unparseable_date_is_skipped_not_fatal(self): async def test_unknown_field_is_skipped_at_apply_time(self): """Defence in depth: storage could have been hand-edited since queueing.""" chore = Chore(name="Mow", points=10) - change = ScheduledChange(chore_id=chore.id, apply_on=_day(0), - changes={"points": 20, "skip_date": "2026-01-01"}) + change = ScheduledChange(chore_id=chore.id, apply_on=_day(0), changes={"points": 20, "skip_date": "2026-01-01"}) coord = _coord([chore], [change]) await coord.async_apply_due_scheduled_changes() assert chore.points == 20 @@ -279,8 +282,7 @@ def test_filtered_by_chore(self): class TestModel: def test_round_trip(self): - change = ScheduledChange(chore_id="c1", apply_on="2026-09-01", - changes={"points": 20}, note="new school year") + change = ScheduledChange(chore_id="c1", apply_on="2026-09-01", changes={"points": 20}, note="new school year") restored = ScheduledChange.from_dict(change.to_dict()) assert restored.chore_id == "c1" assert restored.apply_on == "2026-09-01" diff --git a/tests/test_sensor_attributes.py b/tests/test_sensor_attributes.py index 915a618..2fbf650 100644 --- a/tests/test_sensor_attributes.py +++ b/tests/test_sensor_attributes.py @@ -7,6 +7,7 @@ asserts that each of the five global sensors stays below the 16384-byte limit for extra_state_attributes. """ + from __future__ import annotations import datetime as dt @@ -221,10 +222,13 @@ def _stress_coordinator(): coord.is_chore_available_for_child = MagicMock(return_value=True) # Medium-difficulty chores award their base points (×1.0 baseline). coord.effective_chore_points = MagicMock(side_effect=lambda c: c.points) - coord.level_info = MagicMock(side_effect=lambda c: { - "level": (c.total_points_earned or 0) // 100 + 1, - "progress": (c.total_points_earned or 0) % 100, "target": 100, - }) + coord.level_info = MagicMock( + side_effect=lambda c: { + "level": (c.total_points_earned or 0) // 100 + 1, + "progress": (c.total_points_earned or 0) % 100, + "target": 100, + } + ) coord.storage = MagicMock() coord.storage.get_last_completed = MagicMock(return_value={"current": "2026-04-20T08:00:00Z"}) return coord @@ -238,8 +242,7 @@ def _bytes(obj) -> int: def _assert_slice_under_limit(name: str, attrs: dict) -> None: size = _bytes(attrs) assert size < MAX_ATTR_BYTES, ( - f"{name} attribute payload is {size} bytes — exceeds the " - f"{MAX_ATTR_BYTES}-byte recorder limit" + f"{name} attribute payload is {size} bytes — exceeds the {MAX_ATTR_BYTES}-byte recorder limit" ) @@ -490,8 +493,12 @@ def test_includes_pending_completion_from_previous_day(self, hass): chore.id = "ch1" yesterday = dt.datetime(2026, 6, 6, 18, 0, 0, tzinfo=UTC) comp = ChoreCompletion( - chore_id="ch1", child_id="c1", completed_at=yesterday, - approved=False, points_awarded=0, id="comp-yesterday", + chore_id="ch1", + child_id="c1", + completed_at=yesterday, + approved=False, + points_awarded=0, + id="comp-yesterday", ) coord = self._coord([child], [chore], [comp]) sensor = PendingApprovalsSensor(coord, _MockEntry()) @@ -508,9 +515,12 @@ def test_bonus_subtask_completion_named_and_priced_correctly(self, hass): chore = Chore(name="Make bed", points=10, bonus_subtasks=[sub]) chore.id = "ch1" comp = ChoreCompletion( - chore_id="ch1", child_id="c1", + chore_id="ch1", + child_id="c1", completed_at=dt.datetime(2026, 6, 7, 8, 0, 0, tzinfo=UTC), - approved=False, points_awarded=0, bonus_subtask_id="sub1", + approved=False, + points_awarded=0, + bonus_subtask_id="sub1", id="comp-bonus", ) coord = self._coord([child], [chore], [comp]) diff --git a/tests/test_service_admin_gate.py b/tests/test_service_admin_gate.py index 5108e0c..ddc4b49 100644 --- a/tests/test_service_admin_gate.py +++ b/tests/test_service_admin_gate.py @@ -5,6 +5,7 @@ `_async_require_admin` helper that both the `_admin` wrapper and the as_parent branch use. """ + from __future__ import annotations from unittest.mock import AsyncMock, MagicMock @@ -58,6 +59,7 @@ async def test_rejects_unknown_user(): # SEC-3: mutating service calls are recorded in the admin audit log # --------------------------------------------------------------------------- + def _audit_call(user_id, service, data): call = MagicMock() call.context.user_id = user_id diff --git a/tests/test_service_desc_fingerprint.py b/tests/test_service_desc_fingerprint.py index 9790e62..d25e36f 100644 --- a/tests/test_service_desc_fingerprint.py +++ b/tests/test_service_desc_fingerprint.py @@ -1,4 +1,5 @@ """PERF-4: service-description rebuild is gated by storage.data_version.""" + from __future__ import annotations from unittest.mock import MagicMock, patch @@ -14,8 +15,7 @@ def _hass_with_coord(version): coord = object.__new__(TaskMateCoordinator) storage = MagicMock() storage.data_version = version - for getter in ("get_children", "get_chores", "get_rewards", - "get_penalties", "get_bonuses", "get_task_groups"): + for getter in ("get_children", "get_chores", "get_rewards", "get_penalties", "get_bonuses", "get_task_groups"): setattr(storage, getter, MagicMock(return_value=[])) coord.storage = storage hass = MagicMock() @@ -25,20 +25,24 @@ def _hass_with_coord(version): def test_skips_rebuild_when_version_unchanged(): hass, coord = _hass_with_coord(version=5) - with patch("custom_components.taskmate.async_set_service_schema"), \ - patch("custom_components.taskmate._load_base_descriptions", return_value={}): - _async_update_service_descriptions(hass) # first pass -> builds + with ( + patch("custom_components.taskmate.async_set_service_schema"), + patch("custom_components.taskmate._load_base_descriptions", return_value={}), + ): + _async_update_service_descriptions(hass) # first pass -> builds assert coord.storage.get_children.call_count == 1 - _async_update_service_descriptions(hass) # same version -> short-circuit + _async_update_service_descriptions(hass) # same version -> short-circuit assert coord.storage.get_children.call_count == 1 def test_rebuilds_after_version_bump(): hass, coord = _hass_with_coord(version=5) - with patch("custom_components.taskmate.async_set_service_schema"), \ - patch("custom_components.taskmate._load_base_descriptions", return_value={}): + with ( + patch("custom_components.taskmate.async_set_service_schema"), + patch("custom_components.taskmate._load_base_descriptions", return_value={}), + ): _async_update_service_descriptions(hass) assert coord.storage.get_children.call_count == 1 coord.storage.data_version = 6 - _async_update_service_descriptions(hass) # version changed -> rebuild + _async_update_service_descriptions(hass) # version changed -> rebuild assert coord.storage.get_children.call_count == 2 diff --git a/tests/test_service_linked_child_gate.py b/tests/test_service_linked_child_gate.py index b495d92..340f1c8 100644 --- a/tests/test_service_linked_child_gate.py +++ b/tests/test_service_linked_child_gate.py @@ -7,6 +7,7 @@ linked user (admins and context-less calls always pass). Children with no link keep the default open/kiosk behaviour. """ + from __future__ import annotations from unittest.mock import AsyncMock, MagicMock @@ -96,15 +97,11 @@ async def test_unlinked_child_blocks_known_other_child(): """SEC-4: a user linked to a different child can't act via an unlinked child.""" coord = _coordinator("", others=[MagicMock(linked_user_id="uid-sibling")]) with pytest.raises(tm.Unauthorized): - await tm._async_require_linked_child( - _hass(MagicMock(is_admin=False)), _call("uid-sibling"), coord, "child-1" - ) + await tm._async_require_linked_child(_hass(MagicMock(is_admin=False)), _call("uid-sibling"), coord, "child-1") @pytest.mark.asyncio async def test_unlinked_child_admin_still_allowed_with_other_links(): """An admin is allowed through an unlinked child even when other links exist.""" coord = _coordinator("", others=[MagicMock(linked_user_id="uid-sibling")]) - await tm._async_require_linked_child( - _hass(MagicMock(is_admin=True)), _call("uid-parent"), coord, "child-1" - ) + await tm._async_require_linked_child(_hass(MagicMock(is_admin=True)), _call("uid-parent"), coord, "child-1") diff --git a/tests/test_service_parent_gate.py b/tests/test_service_parent_gate.py index 9ed6e61..46a91f4 100644 --- a/tests/test_service_parent_gate.py +++ b/tests/test_service_parent_gate.py @@ -5,6 +5,7 @@ context-less calls, *and* non-admin users listed in ``parent_user_ids``. Structural config stays on ``_async_require_admin``. """ + from __future__ import annotations from unittest.mock import AsyncMock, MagicMock @@ -98,8 +99,12 @@ async def _handlers(user, parent_ids, monkeypatch): coordinator.storage.get_parent_user_ids = MagicMock(return_value=list(parent_ids)) coordinator.async_record_audit = AsyncMock() for method in ( - "async_apply_bonus", "async_apply_penalty", "async_remove_points", - "async_add_bonus", "async_update_bonus", "async_remove_bonus", + "async_apply_bonus", + "async_apply_penalty", + "async_remove_points", + "async_add_bonus", + "async_update_bonus", + "async_remove_bonus", ): setattr(coordinator, method, AsyncMock()) monkeypatch.setattr(tm, "_get_coordinator", lambda hass: coordinator) diff --git a/tests/test_service_validation_errors.py b/tests/test_service_validation_errors.py index 00004f5..3d66f3a 100644 --- a/tests/test_service_validation_errors.py +++ b/tests/test_service_validation_errors.py @@ -6,6 +6,7 @@ result with the message instead of an unhandled error + traceback. A handler that already raises ``ServiceValidationError`` must pass through untouched. """ + from __future__ import annotations from types import SimpleNamespace diff --git a/tests/test_settings_entities.py b/tests/test_settings_entities.py index 235c96d..3668116 100644 --- a/tests/test_settings_entities.py +++ b/tests/test_settings_entities.py @@ -1,4 +1,5 @@ """Tests for the number/select config-setting entities (FEAT-9).""" + from __future__ import annotations import asyncio @@ -34,12 +35,16 @@ def _entry(): def test_number_reads_default_and_value(): coord, _ = _coord({"weekend_multiplier": "2.0"}) - num = TaskMateSettingNumber(coord, _entry(), "weekend_multiplier", "weekend_multiplier", 1.0, 5.0, 0.5, 1.0, "mdi:x") + num = TaskMateSettingNumber( + coord, _entry(), "weekend_multiplier", "weekend_multiplier", 1.0, 5.0, 0.5, 1.0, "mdi:x" + ) assert num._attr_unique_id == "e1_setting_weekend_multiplier" assert num.native_value == 2.0 # missing setting -> default coord2, _ = _coord({}) - num2 = TaskMateSettingNumber(coord2, _entry(), "weekend_multiplier", "weekend_multiplier", 1.0, 5.0, 0.5, 1.0, "mdi:x") + num2 = TaskMateSettingNumber( + coord2, _entry(), "weekend_multiplier", "weekend_multiplier", 1.0, 5.0, 0.5, 1.0, "mdi:x" + ) assert num2.native_value == 1.0 @@ -54,23 +59,31 @@ def test_number_set_persists_int_when_integer(): def test_number_set_keeps_float_step(): coord, store = _coord({}) - num = TaskMateSettingNumber(coord, _entry(), "weekend_multiplier", "weekend_multiplier", 1.0, 5.0, 0.5, 1.0, "mdi:x") + num = TaskMateSettingNumber( + coord, _entry(), "weekend_multiplier", "weekend_multiplier", 1.0, 5.0, 0.5, 1.0, "mdi:x" + ) run(num.async_set_native_value(1.5)) assert store["weekend_multiplier"] == 1.5 def test_select_current_option_falls_back_for_invalid(): coord, _ = _coord({"streak_reset_mode": "bogus"}) - sel = TaskMateSettingSelect(coord, _entry(), "streak_reset_mode", "streak_reset_mode", ["reset", "pause"], "reset", "mdi:x") + sel = TaskMateSettingSelect( + coord, _entry(), "streak_reset_mode", "streak_reset_mode", ["reset", "pause"], "reset", "mdi:x" + ) assert sel.current_option == "reset" coord2, _ = _coord({"streak_reset_mode": "pause"}) - sel2 = TaskMateSettingSelect(coord2, _entry(), "streak_reset_mode", "streak_reset_mode", ["reset", "pause"], "reset", "mdi:x") + sel2 = TaskMateSettingSelect( + coord2, _entry(), "streak_reset_mode", "streak_reset_mode", ["reset", "pause"], "reset", "mdi:x" + ) assert sel2.current_option == "pause" def test_select_set_persists_and_rejects_invalid(): coord, store = _coord({}) - sel = TaskMateSettingSelect(coord, _entry(), "card_design", "card_design", ["classic", "playroom"], "classic", "mdi:x") + sel = TaskMateSettingSelect( + coord, _entry(), "card_design", "card_design", ["classic", "playroom"], "classic", "mdi:x" + ) run(sel.async_select_option("playroom")) assert store["card_design"] == "playroom" run(sel.async_select_option("hacker")) # invalid -> ignored diff --git a/tests/test_settings_schema_routing.py b/tests/test_settings_schema_routing.py index 51f65a1..3442897 100644 --- a/tests/test_settings_schema_routing.py +++ b/tests/test_settings_schema_routing.py @@ -7,15 +7,15 @@ happened while building chore roulette (#677): the panel toggle appeared to save, and nothing changed. """ + from __future__ import annotations import pathlib import re -SRC = ( - pathlib.Path(__file__).resolve().parent.parent - / "custom_components" / "taskmate" / "websocket.py" -).read_text(encoding="utf-8") +SRC = (pathlib.Path(__file__).resolve().parent.parent / "custom_components" / "taskmate" / "websocket.py").read_text( + encoding="utf-8" +) # Keys the handler deals with by hand rather than through the two sets. _EXPLICIT = {"type", "time_periods", "vacation_periods", "parent_user_ids"} @@ -39,8 +39,7 @@ def test_every_accepted_setting_is_persisted(): persisted = _string_set("_TOP_LEVEL_SETTINGS") | _string_set("_SUBKEY_SETTINGS") | _EXPLICIT orphans = sorted(accepted - persisted) assert orphans == [], ( - f"accepted by the schema but never stored: {orphans}. " - "Add them to _SUBKEY_SETTINGS (or handle them explicitly)." + f"accepted by the schema but never stored: {orphans}. Add them to _SUBKEY_SETTINGS (or handle them explicitly)." ) @@ -49,9 +48,7 @@ def test_no_persisted_setting_is_unreachable(): accepted = _schema_keys() subkeys = _string_set("_SUBKEY_SETTINGS") unreachable = sorted(subkeys - accepted) - assert unreachable == [], ( - f"storable but not accepted by the schema: {unreachable}" - ) + assert unreachable == [], f"storable but not accepted by the schema: {unreachable}" def test_roulette_settings_round_trip(): diff --git a/tests/test_spend_cap.py b/tests/test_spend_cap.py index 5b16072..49a1786 100644 --- a/tests/test_spend_cap.py +++ b/tests/test_spend_cap.py @@ -1,4 +1,5 @@ """Tests for the per-period reward spending cap.""" + from __future__ import annotations import datetime as dt @@ -23,8 +24,14 @@ def _coord(settings, rewards, claims): def _approved_claim(child, reward_id, when): - return RewardClaim(reward_id=reward_id, child_id=child, claimed_at=when, - approved=True, approved_at=when, id=f"cl-{reward_id}-{when.day}") + return RewardClaim( + reward_id=reward_id, + child_id=child, + claimed_at=when, + approved=True, + approved_at=when, + id=f"cl-{reward_id}-{when.day}", + ) REWARDS = [Reward(name="Movie", cost=30, id="r1"), Reward(name="Toy", cost=50, id="r2")] @@ -38,40 +45,46 @@ def test_disabled_never_raises(): def test_under_cap_ok(): now = dt.datetime(2026, 6, 17, 12, tzinfo=UTC) # Wednesday claims = [_approved_claim("c1", "r1", now)] # spent 30 this week - coord = _coord({"spend_cap_enabled": True, "spend_cap_period": "weekly", - "spend_cap_amount": "100"}, REWARDS, claims) - with patch("homeassistant.util.dt.now", return_value=now), \ - patch("homeassistant.util.dt.as_local", side_effect=lambda d: d): + coord = _coord( + {"spend_cap_enabled": True, "spend_cap_period": "weekly", "spend_cap_amount": "100"}, REWARDS, claims + ) + with ( + patch("homeassistant.util.dt.now", return_value=now), + patch("homeassistant.util.dt.as_local", side_effect=lambda d: d), + ): coord._enforce_spend_cap("c1", 50) # 30 + 50 = 80 <= 100 -> ok def test_over_cap_raises(): now = dt.datetime(2026, 6, 17, 12, tzinfo=UTC) claims = [_approved_claim("c1", "r2", now)] # spent 50 this week - coord = _coord({"spend_cap_enabled": True, "spend_cap_period": "weekly", - "spend_cap_amount": "60"}, REWARDS, claims) - with patch("homeassistant.util.dt.now", return_value=now), \ - patch("homeassistant.util.dt.as_local", side_effect=lambda d: d): + coord = _coord({"spend_cap_enabled": True, "spend_cap_period": "weekly", "spend_cap_amount": "60"}, REWARDS, claims) + with ( + patch("homeassistant.util.dt.now", return_value=now), + patch("homeassistant.util.dt.as_local", side_effect=lambda d: d), + ): with pytest.raises(ValueError, match="cap reached"): coord._enforce_spend_cap("c1", 30) # 50 + 30 = 80 > 60 def test_prior_period_not_counted(): - now = dt.datetime(2026, 6, 17, 12, tzinfo=UTC) # this week (Mon=15th) - old = dt.datetime(2026, 6, 1, 12, tzinfo=UTC) # earlier, different week - claims = [_approved_claim("c1", "r2", old)] # 50 spent earlier - coord = _coord({"spend_cap_enabled": True, "spend_cap_period": "weekly", - "spend_cap_amount": "60"}, REWARDS, claims) - with patch("homeassistant.util.dt.now", return_value=now), \ - patch("homeassistant.util.dt.as_local", side_effect=lambda d: d): + now = dt.datetime(2026, 6, 17, 12, tzinfo=UTC) # this week (Mon=15th) + old = dt.datetime(2026, 6, 1, 12, tzinfo=UTC) # earlier, different week + claims = [_approved_claim("c1", "r2", old)] # 50 spent earlier + coord = _coord({"spend_cap_enabled": True, "spend_cap_period": "weekly", "spend_cap_amount": "60"}, REWARDS, claims) + with ( + patch("homeassistant.util.dt.now", return_value=now), + patch("homeassistant.util.dt.as_local", side_effect=lambda d: d), + ): coord._enforce_spend_cap("c1", 30) # only this week counts -> 0 + 30 ok def test_other_child_not_counted(): now = dt.datetime(2026, 6, 17, 12, tzinfo=UTC) claims = [_approved_claim("c2", "r2", now)] # sibling spent - coord = _coord({"spend_cap_enabled": True, "spend_cap_period": "weekly", - "spend_cap_amount": "60"}, REWARDS, claims) - with patch("homeassistant.util.dt.now", return_value=now), \ - patch("homeassistant.util.dt.as_local", side_effect=lambda d: d): + coord = _coord({"spend_cap_enabled": True, "spend_cap_period": "weekly", "spend_cap_amount": "60"}, REWARDS, claims) + with ( + patch("homeassistant.util.dt.now", return_value=now), + patch("homeassistant.util.dt.as_local", side_effect=lambda d: d), + ): coord._enforce_spend_cap("c1", 30) # c1 spent 0 -> ok diff --git a/tests/test_storage.py b/tests/test_storage.py index 1167a83..1e5600c 100755 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -3,6 +3,7 @@ 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 @@ -19,6 +20,7 @@ # Helpers # --------------------------------------------------------------------------- + def run(coro): loop = asyncio.new_event_loop() try: @@ -49,6 +51,7 @@ def _make_storage(initial_data: dict | None = None) -> TaskMateStorage: # async_load — defaults and migration # --------------------------------------------------------------------------- + class TestAsyncLoad: def test_fresh_load_creates_default_structure(self): storage = _make_storage(initial_data=None) @@ -81,13 +84,24 @@ def test_existing_data_without_last_completed_gets_migrated(self): 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": []}], + "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": [], @@ -107,6 +121,7 @@ def test_existing_data_preserved_on_load(self): # _migrate_assigned_to_child_ids # --------------------------------------------------------------------------- + class TestMigrateAssignedTo: def _build_storage_with_data(self, children_raw, chores_raw): storage = _make_storage() @@ -148,12 +163,19 @@ def test_empty_chores_and_children_skipped(self): # Children CRUD # --------------------------------------------------------------------------- + class TestChildrenCrud: def _storage(self): s = _make_storage() - s._data = {"children": [], "chores": [], "rewards": [], - "completions": [], "reward_claims": [], "points_transactions": [], - "last_completed": {}} + s._data = { + "children": [], + "chores": [], + "rewards": [], + "completions": [], + "reward_claims": [], + "points_transactions": [], + "last_completed": {}, + } return s def test_add_then_get(self): @@ -204,6 +226,7 @@ def test_remove_nonexistent_child_harmless(self): # last_completed store # --------------------------------------------------------------------------- + class TestLastCompleted: def _storage(self): s = _make_storage() @@ -254,6 +277,7 @@ def test_undo_nonexistent_is_harmless(self): # Points transactions — 200-item cap # --------------------------------------------------------------------------- + class TestPointsTransactionCap: def _storage(self): s = _make_storage() @@ -262,6 +286,7 @@ def _storage(self): def test_transactions_capped_at_200(self): from custom_components.taskmate.models import PointsTransaction + storage = self._storage() for i in range(210): tx = PointsTransaction( @@ -275,6 +300,7 @@ def test_transactions_capped_at_200(self): def test_most_recent_transactions_kept(self): from custom_components.taskmate.models import PointsTransaction + storage = self._storage() for i in range(205): tx = PointsTransaction( @@ -286,7 +312,7 @@ def test_most_recent_transactions_kept(self): 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[0] == 5 # oldest kept assert kept_points[-1] == 204 # most recent @@ -294,6 +320,7 @@ def test_most_recent_transactions_kept(self): # get_pending_completions # --------------------------------------------------------------------------- + class TestGetPendingCompletions: def _storage(self): s = _make_storage() @@ -303,12 +330,15 @@ def _storage(self): def test_returns_only_unapproved(self): storage = self._storage() approved = ChoreCompletion( - chore_id="c1", child_id="k1", + chore_id="c1", + child_id="k1", completed_at=dt.datetime(2024, 3, 19, 12, 0, 0, tzinfo=UTC), - approved=True, points_awarded=10, + approved=True, + points_awarded=10, ) pending = ChoreCompletion( - chore_id="c1", child_id="k1", + chore_id="c1", + child_id="k1", completed_at=dt.datetime(2024, 3, 20, 12, 0, 0, tzinfo=UTC), approved=False, ) @@ -321,7 +351,8 @@ def test_returns_only_unapproved(self): def test_empty_when_all_approved(self): storage = self._storage() comp = ChoreCompletion( - chore_id="c1", child_id="k1", + chore_id="c1", + child_id="k1", completed_at=dt.datetime(2024, 3, 20, 12, 0, 0, tzinfo=UTC), approved=True, ) @@ -333,6 +364,7 @@ def test_empty_when_all_approved(self): # Settings helpers # --------------------------------------------------------------------------- + class TestSettings: def _storage(self): s = _make_storage() @@ -374,12 +406,22 @@ class TestPoolSemanticsV2Migration: def test_beta1_install_has_points_adjusted_on_upgrade(self): existing = { "children": [ - {"id": "kid1", "name": "Alice", "points": 31, - "total_points_earned": 100, "total_chores_completed": 5, - "current_streak": 0, "best_streak": 0, "avatar": "mdi:account-circle", - "pending_rewards": [], "chore_order": [], - "last_completion_date": None, "streak_paused": False, - "streak_milestones_achieved": [], "awarded_perfect_weeks": []}, + { + "id": "kid1", + "name": "Alice", + "points": 31, + "total_points_earned": 100, + "total_chores_completed": 5, + "current_streak": 0, + "best_streak": 0, + "avatar": "mdi:account-circle", + "pending_rewards": [], + "chore_order": [], + "last_completion_date": None, + "streak_paused": False, + "streak_milestones_achieved": [], + "awarded_perfect_weeks": [], + }, ], "chores": [], "rewards": [], @@ -390,7 +432,8 @@ def test_beta1_install_has_points_adjusted_on_upgrade(self): {"id": "a1", "child_id": "kid1", "reward_id": "r1", "allocated_points": 20}, {"id": "a2", "child_id": "kid1", "reward_id": "r2", "allocated_points": 5}, ], - "points_name": "Stars", "points_icon": "mdi:star", + "points_name": "Stars", + "points_icon": "mdi:star", } storage = _make_storage(initial_data=existing) run(storage.async_load()) @@ -402,19 +445,33 @@ def test_beta1_install_has_points_adjusted_on_upgrade(self): def test_already_migrated_is_idempotent(self): existing = { "children": [ - {"id": "kid1", "name": "Alice", "points": 10, - "total_points_earned": 100, "total_chores_completed": 5, - "current_streak": 0, "best_streak": 0, "avatar": "mdi:account-circle", - "pending_rewards": [], "chore_order": [], - "last_completion_date": None, "streak_paused": False, - "streak_milestones_achieved": [], "awarded_perfect_weeks": []}, + { + "id": "kid1", + "name": "Alice", + "points": 10, + "total_points_earned": 100, + "total_chores_completed": 5, + "current_streak": 0, + "best_streak": 0, + "avatar": "mdi:account-circle", + "pending_rewards": [], + "chore_order": [], + "last_completion_date": None, + "streak_paused": False, + "streak_milestones_achieved": [], + "awarded_perfect_weeks": [], + }, ], - "chores": [], "rewards": [], "completions": [], - "reward_claims": [], "points_transactions": [], + "chores": [], + "rewards": [], + "completions": [], + "reward_claims": [], + "points_transactions": [], "pool_allocations": [ {"id": "a1", "child_id": "kid1", "reward_id": "r1", "allocated_points": 20}, ], - "points_name": "Stars", "points_icon": "mdi:star", + "points_name": "Stars", + "points_icon": "mdi:star", "_pool_semantics_version": 2, } storage = _make_storage(initial_data=existing) @@ -425,19 +482,33 @@ def test_already_migrated_is_idempotent(self): def test_zero_points_does_not_go_negative(self): existing = { "children": [ - {"id": "kid1", "name": "Alice", "points": 5, - "total_points_earned": 100, "total_chores_completed": 5, - "current_streak": 0, "best_streak": 0, "avatar": "mdi:account-circle", - "pending_rewards": [], "chore_order": [], - "last_completion_date": None, "streak_paused": False, - "streak_milestones_achieved": [], "awarded_perfect_weeks": []}, + { + "id": "kid1", + "name": "Alice", + "points": 5, + "total_points_earned": 100, + "total_chores_completed": 5, + "current_streak": 0, + "best_streak": 0, + "avatar": "mdi:account-circle", + "pending_rewards": [], + "chore_order": [], + "last_completion_date": None, + "streak_paused": False, + "streak_milestones_achieved": [], + "awarded_perfect_weeks": [], + }, ], - "chores": [], "rewards": [], "completions": [], - "reward_claims": [], "points_transactions": [], + "chores": [], + "rewards": [], + "completions": [], + "reward_claims": [], + "points_transactions": [], "pool_allocations": [ {"id": "a1", "child_id": "kid1", "reward_id": "r1", "allocated_points": 99}, ], - "points_name": "Stars", "points_icon": "mdi:star", + "points_name": "Stars", + "points_icon": "mdi:star", } storage = _make_storage(initial_data=existing) run(storage.async_load()) @@ -449,21 +520,36 @@ def test_zero_points_does_not_go_negative(self): # Career score migration # --------------------------------------------------------------------------- + class TestCareerScoreMigration: def test_migration_sets_career_score_from_total_earned(self): existing = { "children": [ - {"id": "kid1", "name": "Alice", "points": 80, - "total_points_earned": 200, "total_chores_completed": 15, - "current_streak": 0, "best_streak": 0, "avatar": "mdi:account-circle", - "pending_rewards": [], "chore_order": [], - "last_completion_date": None, "streak_paused": False, - "streak_milestones_achieved": [], "awarded_perfect_weeks": []}, + { + "id": "kid1", + "name": "Alice", + "points": 80, + "total_points_earned": 200, + "total_chores_completed": 15, + "current_streak": 0, + "best_streak": 0, + "avatar": "mdi:account-circle", + "pending_rewards": [], + "chore_order": [], + "last_completion_date": None, + "streak_paused": False, + "streak_milestones_achieved": [], + "awarded_perfect_weeks": [], + }, ], - "chores": [], "rewards": [], "completions": [], - "reward_claims": [], "points_transactions": [], + "chores": [], + "rewards": [], + "completions": [], + "reward_claims": [], + "points_transactions": [], "pool_allocations": [], - "points_name": "Stars", "points_icon": "mdi:star", + "points_name": "Stars", + "points_icon": "mdi:star", } storage = _make_storage(initial_data=existing) run(storage.async_load()) @@ -475,18 +561,33 @@ def test_migration_sets_career_score_from_total_earned(self): def test_migration_runs_only_once(self): existing = { "children": [ - {"id": "kid1", "name": "Alice", "points": 80, - "total_points_earned": 200, "total_chores_completed": 15, - "current_streak": 0, "best_streak": 0, "avatar": "mdi:account-circle", - "pending_rewards": [], "chore_order": [], - "last_completion_date": None, "streak_paused": False, - "streak_milestones_achieved": [], "awarded_perfect_weeks": [], - "career_score": 150, "total_penalties_received": 50}, + { + "id": "kid1", + "name": "Alice", + "points": 80, + "total_points_earned": 200, + "total_chores_completed": 15, + "current_streak": 0, + "best_streak": 0, + "avatar": "mdi:account-circle", + "pending_rewards": [], + "chore_order": [], + "last_completion_date": None, + "streak_paused": False, + "streak_milestones_achieved": [], + "awarded_perfect_weeks": [], + "career_score": 150, + "total_penalties_received": 50, + }, ], - "chores": [], "rewards": [], "completions": [], - "reward_claims": [], "points_transactions": [], + "chores": [], + "rewards": [], + "completions": [], + "reward_claims": [], + "points_transactions": [], "pool_allocations": [], - "points_name": "Stars", "points_icon": "mdi:star", + "points_name": "Stars", + "points_icon": "mdi:star", "_career_score_initialized": True, } storage = _make_storage(initial_data=existing) @@ -506,6 +607,7 @@ def test_fresh_load_initialises_career_score_history(self): # Career score history management # --------------------------------------------------------------------------- + class TestCareerScoreHistory: def _loaded_storage(self): storage = _make_storage(initial_data=None) @@ -572,6 +674,7 @@ def test_get_nonexistent_child(self): # parent_user_ids — non-admin parent role (#661) # --------------------------------------------------------------------------- + class TestParentUserIds: def test_default_empty(self): storage = _make_storage() diff --git a/tests/test_streak_all_chores.py b/tests/test_streak_all_chores.py index e460d0f..3d0fa24 100644 --- a/tests/test_streak_all_chores.py +++ b/tests/test_streak_all_chores.py @@ -8,6 +8,7 @@ Both default off → today's "any one chore" behaviour is preserved. """ + from __future__ import annotations import asyncio @@ -36,8 +37,7 @@ def _child(**kw): def _comp(chore_id, when, child_id="kid"): - return ChoreCompletion(chore_id=chore_id, child_id=child_id, completed_at=when, - approved=True, points_awarded=5) + return ChoreCompletion(chore_id=chore_id, child_id=child_id, completed_at=when, approved=True, points_awarded=5) def _coord(*, settings=None, chores=None, completions=None, children=None): @@ -68,6 +68,7 @@ def _coord(*, settings=None, chores=None, completions=None, children=None): def _award(coord, child, *, chore_id, now): import custom_components.taskmate.coord_points as _mod + with patch.object(_mod.dt_util, "now", return_value=now): return run(coord._award_points(child, 10, chore_id=chore_id)) @@ -78,9 +79,9 @@ def _award(coord, child, *, chore_id, now): # ── Streak: all-chores mode ─────────────────────────────────────────────── + def test_all_mode_streak_waits_until_all_chores_done(): - chores = [Chore(name="A", id="c1", assigned_to=["kid"]), - Chore(name="B", id="c2", assigned_to=["kid"])] + chores = [Chore(name="A", id="c1", assigned_to=["kid"]), Chore(name="B", id="c2", assigned_to=["kid"])] coord = _coord(settings=ALL, chores=chores, completions=[]) child = _child() # Completing only the first of two due chores must NOT advance the streak. @@ -90,8 +91,7 @@ def test_all_mode_streak_waits_until_all_chores_done(): def test_all_mode_streak_advances_when_last_chore_done(): - chores = [Chore(name="A", id="c1", assigned_to=["kid"]), - Chore(name="B", id="c2", assigned_to=["kid"])] + chores = [Chore(name="A", id="c1", assigned_to=["kid"]), Chore(name="B", id="c2", assigned_to=["kid"])] # c1 already completed & stored; now completing c2 finishes the day. coord = _coord(settings=ALL, chores=chores, completions=[_comp("c1", NOW)]) child = _child() @@ -110,8 +110,7 @@ def test_all_mode_nothing_due_still_advances(): def test_any_mode_first_completion_advances(): # Setting OFF → existing behaviour: first completion of the day advances. - chores = [Chore(name="A", id="c1", assigned_to=["kid"]), - Chore(name="B", id="c2", assigned_to=["kid"])] + chores = [Chore(name="A", id="c1", assigned_to=["kid"]), Chore(name="B", id="c2", assigned_to=["kid"])] coord = _coord(settings={}, chores=chores, completions=[]) child = _child() _award(coord, child, chore_id="c1", now=NOW) @@ -122,12 +121,13 @@ def test_any_mode_first_completion_advances(): # A Monday so _async_check_perfect_week runs (it only runs on Mondays); the # "last week" it evaluates is the 7 days ending the day before. -MONDAY = dt.datetime(2024, 3, 18, 9, 0, tzinfo=UTC) # today = Monday +MONDAY = dt.datetime(2024, 3, 18, 9, 0, tzinfo=UTC) # today = Monday LAST_WEEK = [dt.date(2024, 3, 11) + dt.timedelta(days=i) for i in range(7)] # Mon..Sun def _run_perfect_week(coord, now=MONDAY): import custom_components.taskmate.coord_points as _mod + with patch.object(_mod.dt_util, "now", return_value=now): run(coord._async_check_perfect_week()) @@ -149,7 +149,9 @@ def test_perfect_week_all_mode_not_awarded_when_a_day_missed_a_chore(): child = _child() coord = _coord( settings={"perfect_week_enabled": "true", "perfect_week_requires_all_chores": "true"}, - chores=chores, completions=comps, children=[child], + chores=chores, + completions=comps, + children=[child], ) _run_perfect_week(coord) assert child.awarded_perfect_weeks == [] # one chore missed on Wed → no bonus @@ -164,9 +166,14 @@ def test_perfect_week_all_mode_awarded_when_every_chore_done_every_day(): comps.append(_comp("c2", when)) child = _child() coord = _coord( - settings={"perfect_week_enabled": "true", "perfect_week_bonus": "50", - "perfect_week_requires_all_chores": "true"}, - chores=chores, completions=comps, children=[child], + settings={ + "perfect_week_enabled": "true", + "perfect_week_bonus": "50", + "perfect_week_requires_all_chores": "true", + }, + chores=chores, + completions=comps, + children=[child], ) _run_perfect_week(coord) assert child.awarded_perfect_weeks == ["2024-03-11"] @@ -183,7 +190,9 @@ def test_perfect_week_any_mode_awarded_with_partial_days(): child = _child() coord = _coord( settings={"perfect_week_enabled": "true"}, - chores=chores, completions=comps, children=[child], + chores=chores, + completions=comps, + children=[child], ) _run_perfect_week(coord) assert child.awarded_perfect_weeks == ["2024-03-11"] diff --git a/tests/test_surprise_bonus.py b/tests/test_surprise_bonus.py index af88509..8d7ed25 100644 --- a/tests/test_surprise_bonus.py +++ b/tests/test_surprise_bonus.py @@ -1,4 +1,5 @@ """Tests for the daily surprise / random bonus roll.""" + from __future__ import annotations import asyncio @@ -38,13 +39,17 @@ def test_disabled_does_nothing(): def test_enabled_awards_when_roll_hits(): coord = _coord( - {"surprise_bonus_enabled": True, "surprise_bonus_chance": "100", - "surprise_bonus_min": "5", "surprise_bonus_max": "5"}, + { + "surprise_bonus_enabled": True, + "surprise_bonus_chance": "100", + "surprise_bonus_min": "5", + "surprise_bonus_max": "5", + }, [Child(name="Mia", id="c1")], ) import custom_components.taskmate.coordinator as mod - with patch.object(mod.random, "random", return_value=0.0), \ - patch.object(mod.random, "randint", return_value=5): + + with patch.object(mod.random, "random", return_value=0.0), patch.object(mod.random, "randint", return_value=5): run(coord._async_run_surprise_bonus()) coord.async_add_points.assert_awaited_once() args = coord.async_add_points.await_args @@ -60,6 +65,7 @@ def test_roll_miss_skips_child(): [Child(name="Mia", id="c1")], ) import custom_components.taskmate.coordinator as mod + # random()*100 = 50 >= chance 10 -> miss with patch.object(mod.random, "random", return_value=0.5): run(coord._async_run_surprise_bonus()) @@ -68,29 +74,42 @@ def test_roll_miss_skips_child(): def test_enabled_accepts_string_true(): coord = _coord( - {"surprise_bonus_enabled": "true", "surprise_bonus_chance": "100", - "surprise_bonus_min": "8", "surprise_bonus_max": "8"}, + { + "surprise_bonus_enabled": "true", + "surprise_bonus_chance": "100", + "surprise_bonus_min": "8", + "surprise_bonus_max": "8", + }, [Child(name="Mia", id="c1")], ) import custom_components.taskmate.coordinator as mod - with patch.object(mod.random, "random", return_value=0.0), \ - patch.object(mod.random, "randint", return_value=8): + + with patch.object(mod.random, "random", return_value=0.0), patch.object(mod.random, "randint", return_value=8): run(coord._async_run_surprise_bonus()) coord.async_add_points.assert_awaited_once() def test_reversed_min_max_swapped(): coord = _coord( - {"surprise_bonus_enabled": True, "surprise_bonus_chance": "100", - "surprise_bonus_min": "20", "surprise_bonus_max": "5"}, + { + "surprise_bonus_enabled": True, + "surprise_bonus_chance": "100", + "surprise_bonus_min": "20", + "surprise_bonus_max": "5", + }, [Child(name="Mia", id="c1")], ) import custom_components.taskmate.coordinator as mod + captured = {} + def _randint(a, b): captured["lo"], captured["hi"] = a, b return a - with patch.object(mod.random, "random", return_value=0.0), \ - patch.object(mod.random, "randint", side_effect=_randint): + + with ( + patch.object(mod.random, "random", return_value=0.0), + patch.object(mod.random, "randint", side_effect=_randint), + ): run(coord._async_run_surprise_bonus()) assert captured["lo"] == 5 and captured["hi"] == 20 # swapped diff --git a/tests/test_template_packs.py b/tests/test_template_packs.py index ce506e8..941e49c 100644 --- a/tests/test_template_packs.py +++ b/tests/test_template_packs.py @@ -4,6 +4,7 @@ made. A pack is arbitrary user-supplied JSON, so import validates everything and drops every field it doesn't recognise. """ + from __future__ import annotations from unittest.mock import AsyncMock, MagicMock @@ -28,17 +29,22 @@ def _coord(custom=()): def _tpl(name="Morning", chores=None, tid="t1"): return { - "id": tid, "name": name, "icon": "mdi:sun", "builtin": False, + "id": tid, + "name": name, + "icon": "mdi:sun", + "builtin": False, "chores": chores or [{"name": "Make bed", "points": 2, "time_category": "morning"}], } def _pack(templates=None, **over): pack = { - "format": PACK_FORMAT, "version": 1, - "templates": templates if templates is not None else [ - {"name": "Shared routine", "icon": "mdi:sun", - "chores": [{"name": "Make bed", "points": 2}]}, + "format": PACK_FORMAT, + "version": 1, + "templates": templates + if templates is not None + else [ + {"name": "Shared routine", "icon": "mdi:sun", "chores": [{"name": "Make bed", "points": 2}]}, ], } pack.update(over) @@ -109,9 +115,16 @@ def test_rejects_a_malformed_chore(self): def test_drops_unknown_chore_fields(self): """A shared pack must not be able to set fields the panel wouldn't.""" - pack = _pack(templates=[{"name": "T", "chores": [ - {"name": "c", "points": 3, "assignment_current_child_id": "kid1", "enabled": False}, - ]}]) + pack = _pack( + templates=[ + { + "name": "T", + "chores": [ + {"name": "c", "points": 3, "assignment_current_child_id": "kid1", "enabled": False}, + ], + } + ] + ) chore = _coord()._validate_pack(pack)[0]["chores"][0] assert "assignment_current_child_id" not in chore assert chore["points"] == 3 @@ -166,10 +179,21 @@ async def test_a_bad_pack_writes_nothing(self): class TestRoundTrip: @pytest.mark.asyncio async def test_export_then_import_preserves_the_template(self): - source = _coord([_tpl(chores=[ - {"name": "Make bed", "points": 2, "time_category": "morning", - "due_days": ["monday"], "requires_approval": False}, - ])]) + source = _coord( + [ + _tpl( + chores=[ + { + "name": "Make bed", + "points": 2, + "time_category": "morning", + "due_days": ["monday"], + "requires_approval": False, + }, + ] + ) + ] + ) pack = source.export_templates() target = _coord() diff --git a/tests/test_templates.py b/tests/test_templates.py index 5f3c374..1922aab 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -1,4 +1,5 @@ """Tests for chore template CRUD and apply logic.""" + from __future__ import annotations import asyncio @@ -64,7 +65,13 @@ def test_remove_nonexistent_template_raises(self, storage): storage.remove_custom_template("nope") def test_get_custom_template_by_id(self, storage): - tpl = {"id": "my_tpl", "name": "My Pack", "icon": "mdi:star", "builtin": False, "chores": [{"name": "X", "points": 1}]} + tpl = { + "id": "my_tpl", + "name": "My Pack", + "icon": "mdi:star", + "builtin": False, + "chores": [{"name": "X", "points": 1}], + } storage.add_custom_template(tpl) result = storage.get_custom_template("my_tpl") assert result is not None diff --git a/tests/test_time_incentives.py b/tests/test_time_incentives.py index e941bdc..0bfcdf7 100644 --- a/tests/test_time_incentives.py +++ b/tests/test_time_incentives.py @@ -1,4 +1,5 @@ """Tests for chore early-bonus / late-penalty by completion time.""" + from __future__ import annotations import datetime as dt diff --git a/tests/test_time_periods.py b/tests/test_time_periods.py index 4d60033..363a18d 100644 --- a/tests/test_time_periods.py +++ b/tests/test_time_periods.py @@ -4,6 +4,7 @@ legacy flat keys → defaults) and the websocket payload validation, including the block-on-delete rule for periods still used by chores. """ + from __future__ import annotations from datetime import time @@ -18,6 +19,7 @@ # Resolver: coordinator.get_time_periods() # --------------------------------------------------------------------------- + def test_resolver_defaults_when_no_settings(): coord = _coord([Child(name="A")]) periods = coord.get_time_periods() @@ -39,10 +41,13 @@ def test_resolver_legacy_flat_keys_preserved(): def test_resolver_time_periods_setting_wins_and_sorts(): coord = _coord([Child(name="A")]) - coord.storage.set_setting("time_periods", [ - {"id": "night", "label": "", "start": "21:00", "end": "23:59", "icon": "mdi:weather-night"}, - {"id": "school_run", "label": "School run", "start": "08:00", "end": "09:00", "icon": "mdi:school"}, - ]) + coord.storage.set_setting( + "time_periods", + [ + {"id": "night", "label": "", "start": "21:00", "end": "23:59", "icon": "mdi:weather-night"}, + {"id": "school_run", "label": "School run", "start": "08:00", "end": "09:00", "icon": "mdi:school"}, + ], + ) periods = coord.get_time_periods() assert [p["id"] for p in periods] == ["school_run", "night"] assert periods[0]["label"] == "School run" @@ -50,12 +55,15 @@ def test_resolver_time_periods_setting_wins_and_sorts(): def test_resolver_skips_garbage_entries(): coord = _coord([Child(name="A")]) - coord.storage.set_setting("time_periods", [ - "not-a-dict", - {"id": "anytime", "start": "01:00", "end": "02:00"}, # reserved id - {"id": "ok", "label": "OK", "start": "10:00", "end": "11:00", "icon": ""}, - {"id": "bad_time", "label": "Bad", "start": "xx:yy", "end": "11:00"}, - ]) + coord.storage.set_setting( + "time_periods", + [ + "not-a-dict", + {"id": "anytime", "start": "01:00", "end": "02:00"}, # reserved id + {"id": "ok", "label": "OK", "start": "10:00", "end": "11:00", "icon": ""}, + {"id": "bad_time", "label": "Bad", "start": "xx:yy", "end": "11:00"}, + ], + ) periods = coord.get_time_periods() assert [p["id"] for p in periods] == ["ok"] assert periods[0]["icon"] == "mdi:clock-outline" # empty icon falls back @@ -63,9 +71,12 @@ def test_resolver_skips_garbage_entries(): def test_boundaries_built_from_periods(): coord = _coord([Child(name="A")]) - coord.storage.set_setting("time_periods", [ - {"id": "school_run", "label": "School run", "start": "08:00", "end": "09:15", "icon": "mdi:school"}, - ]) + coord.storage.set_setting( + "time_periods", + [ + {"id": "school_run", "label": "School run", "start": "08:00", "end": "09:15", "icon": "mdi:school"}, + ], + ) boundaries = coord._get_time_boundaries() assert boundaries["anytime"] is None assert boundaries["school_run"] == (time(8, 0), time(9, 15)) @@ -73,10 +84,14 @@ def test_boundaries_built_from_periods(): def test_time_category_window_for_custom_period(): from datetime import date + coord = _coord([Child(name="A")]) - coord.storage.set_setting("time_periods", [ - {"id": "school_run", "label": "School run", "start": "08:00", "end": "09:15", "icon": "mdi:school"}, - ]) + coord.storage.set_setting( + "time_periods", + [ + {"id": "school_run", "label": "School run", "start": "08:00", "end": "09:15", "icon": "mdi:school"}, + ], + ) window = coord._time_category_window("school_run", date(2026, 6, 11)) assert window is not None start, end = window @@ -89,6 +104,7 @@ def test_time_category_window_for_custom_period(): # Validation: websocket._validate_time_periods() # --------------------------------------------------------------------------- + def _valid_payload(): return [ {"id": "morning", "label": "", "start": "06:00", "end": "12:00", "icon": "mdi:weather-sunny"}, @@ -133,22 +149,19 @@ def test_validate_allows_gaps(): def test_validate_rejects_start_after_end(): coord = _coord([Child(name="A")]) - periods, err = _validate_time_periods( - [{"label": "Backwards", "start": "10:00", "end": "09:00"}], coord) + periods, err = _validate_time_periods([{"label": "Backwards", "start": "10:00", "end": "09:00"}], coord) assert periods is None and "start before" in err def test_validate_rejects_bad_time_format(): coord = _coord([Child(name="A")]) - periods, err = _validate_time_periods( - [{"label": "Bad", "start": "25:00", "end": "26:00"}], coord) + periods, err = _validate_time_periods([{"label": "Bad", "start": "25:00", "end": "26:00"}], coord) assert periods is None and "HH:MM" in err def test_validate_rejects_blank_custom_label(): coord = _coord([Child(name="A")]) - periods, err = _validate_time_periods( - [{"label": " ", "start": "10:00", "end": "11:00"}], coord) + periods, err = _validate_time_periods([{"label": " ", "start": "10:00", "end": "11:00"}], coord) assert periods is None and "name" in err @@ -182,9 +195,11 @@ def test_validate_slug_collision_gets_suffix(): def test_validate_blocks_deleting_period_in_use(): coord = _coord([Child(name="A")]) - coord.storage.get_chores = MagicMock(return_value=[ - Chore(name="Brush teeth", time_category="night"), - ]) + coord.storage.get_chores = MagicMock( + return_value=[ + Chore(name="Brush teeth", time_category="night"), + ] + ) # Payload drops the built-in "night" period payload = [{"id": "morning", "label": "", "start": "06:00", "end": "12:00"}] periods, err = _validate_time_periods(payload, coord) @@ -194,9 +209,11 @@ def test_validate_blocks_deleting_period_in_use(): def test_validate_allows_deleting_unused_period(): coord = _coord([Child(name="A")]) - coord.storage.get_chores = MagicMock(return_value=[ - Chore(name="Brush teeth", time_category="anytime"), - ]) + coord.storage.get_chores = MagicMock( + return_value=[ + Chore(name="Brush teeth", time_category="anytime"), + ] + ) payload = [{"id": "morning", "label": "", "start": "06:00", "end": "12:00"}] periods, err = _validate_time_periods(payload, coord) assert err is None diff --git a/tests/test_timed_task.py b/tests/test_timed_task.py index 0a2e6cb..4c1e346 100644 --- a/tests/test_timed_task.py +++ b/tests/test_timed_task.py @@ -4,6 +4,7 @@ stubbed storage layer (no real Home Assistant), covering the validation guards and the running/paused state transitions. """ + from __future__ import annotations import asyncio @@ -52,6 +53,7 @@ def _patch_now(): # ── start validation ───────────────────────────────────────────────────────── + def test_start_unknown_chore_raises(): coord = _coord(chore=None) with pytest.raises(ValueError): @@ -89,9 +91,13 @@ def test_start_fresh_creates_running_session(): def test_resume_paused_appends_segment(): - paused = TimedSession(chore_id="cho1", child_id="ch1", state="paused", - segments=[{"start": NOW.isoformat(), "end": NOW.isoformat()}], - total_seconds_today=60) + paused = TimedSession( + chore_id="cho1", + child_id="ch1", + state="paused", + segments=[{"start": NOW.isoformat(), "end": NOW.isoformat()}], + total_seconds_today=60, + ) coord = _coord(chore=_timed_chore(), child=Child(name="A", id="ch1"), active=paused) with _patch_now(): run(coord.async_start_timed_task("cho1", "ch1")) @@ -100,8 +106,7 @@ def test_resume_paused_appends_segment(): def test_resume_blocked_by_daily_cap(): - paused = TimedSession(chore_id="cho1", child_id="ch1", state="paused", - segments=[], total_seconds_today=3600) + paused = TimedSession(chore_id="cho1", child_id="ch1", state="paused", segments=[], total_seconds_today=3600) coord = _coord(chore=_timed_chore(max_daily=30), child=Child(name="A", id="ch1"), active=paused) with pytest.raises(ValueError): run(coord.async_start_timed_task("cho1", "ch1")) @@ -109,6 +114,7 @@ def test_resume_blocked_by_daily_cap(): # ── pause ──────────────────────────────────────────────────────────────────── + def test_pause_without_running_raises(): coord = _coord(active=None) with pytest.raises(ValueError): @@ -117,8 +123,7 @@ def test_pause_without_running_raises(): def test_pause_running_sets_paused_and_closes_segment(): start = (NOW - dt.timedelta(minutes=5)).isoformat() - running = TimedSession(chore_id="cho1", child_id="ch1", state="running", - segments=[{"start": start, "end": None}]) + running = TimedSession(chore_id="cho1", child_id="ch1", state="running", segments=[{"start": start, "end": None}]) coord = _coord(active=running) with _patch_now(): run(coord.async_pause_timed_task("cho1", "ch1")) @@ -129,6 +134,7 @@ def test_pause_running_sets_paused_and_closes_segment(): # ── stop ───────────────────────────────────────────────────────────────────── + def test_stop_without_active_raises(): coord = _coord(active=None) with pytest.raises(ValueError): diff --git a/tests/test_timed_unlocks.py b/tests/test_timed_unlocks.py index 5ba1b5c..989bb9b 100644 --- a/tests/test_timed_unlocks.py +++ b/tests/test_timed_unlocks.py @@ -5,6 +5,7 @@ entity on and back off, and that entity must be on the parent's allowlist — checked at save time AND again when it actually fires. """ + from __future__ import annotations from datetime import timedelta @@ -28,6 +29,7 @@ def _coord(allowlist=None, unlocks=None): def _set(key, value): settings[key] = value + coord.storage.set_setting = MagicMock(side_effect=_set) coord.storage.get_setting = MagicMock(side_effect=lambda k, d="": settings.get(k, d)) coord.storage.async_save = AsyncMock() @@ -102,10 +104,12 @@ class TestStartingAnUnlock: async def test_unlock_turns_the_entity_on(self): coord = _coord(["switch.tv"]) record = await coord.async_start_unlock( - _reward(unlock_entity="switch.tv", unlock_minutes=30), Child(name="Kid", id="k1")) + _reward(unlock_entity="switch.tv", unlock_minutes=30), Child(name="Kid", id="k1") + ) assert record["entity_id"] == "switch.tv" coord.hass.services.async_call.assert_awaited_with( - "homeassistant", "turn_on", {"entity_id": "switch.tv"}, blocking=False) + "homeassistant", "turn_on", {"entity_id": "switch.tv"}, blocking=False + ) @pytest.mark.asyncio async def test_reward_without_unlock_does_nothing(self): @@ -118,7 +122,8 @@ async def test_entity_removed_from_allowlist_is_refused_at_fire_time(self): """The allowlist can change after a reward was created — re-check.""" coord = _coord(["switch.something_else"]) result = await coord.async_start_unlock( - _reward(unlock_entity="switch.tv", unlock_minutes=30), Child(name="Kid", id="k1")) + _reward(unlock_entity="switch.tv", unlock_minutes=30), Child(name="Kid", id="k1") + ) assert result is None coord.hass.services.async_call.assert_not_awaited() @@ -126,7 +131,8 @@ async def test_entity_removed_from_allowlist_is_refused_at_fire_time(self): async def test_unlock_is_persisted_so_a_restart_can_revert_it(self): coord = _coord(["switch.tv"]) await coord.async_start_unlock( - _reward(unlock_entity="switch.tv", unlock_minutes=30), Child(name="Kid", id="k1")) + _reward(unlock_entity="switch.tv", unlock_minutes=30), Child(name="Kid", id="k1") + ) assert len(coord.active_unlocks()) == 1 coord.storage.async_save.assert_awaited() @@ -134,7 +140,8 @@ async def test_unlock_is_persisted_so_a_restart_can_revert_it(self): async def test_zero_minutes_does_not_schedule_a_revert(self): coord = _coord(["switch.tv"]) record = await coord.async_start_unlock( - _reward(unlock_entity="switch.tv", unlock_minutes=0), Child(name="Kid", id="k1")) + _reward(unlock_entity="switch.tv", unlock_minutes=0), Child(name="Kid", id="k1") + ) assert record["revert_at"] == "" assert coord.active_unlocks() == [] @@ -142,7 +149,8 @@ async def test_zero_minutes_does_not_schedule_a_revert(self): async def test_starting_fires_an_event(self): coord = _coord(["switch.tv"]) await coord.async_start_unlock( - _reward(unlock_entity="switch.tv", unlock_minutes=30), Child(name="Kid", id="k1")) + _reward(unlock_entity="switch.tv", unlock_minutes=30), Child(name="Kid", id="k1") + ) event, payload = coord.hass.bus.async_fire.call_args[0] assert event == "taskmate_unlock_started" assert payload["child_name"] == "Kid" @@ -153,17 +161,20 @@ class TestReverting: async def test_revert_turns_the_entity_off_and_clears_the_record(self): coord = _coord(["switch.tv"]) record = await coord.async_start_unlock( - _reward(unlock_entity="switch.tv", unlock_minutes=30), Child(name="Kid", id="k1")) + _reward(unlock_entity="switch.tv", unlock_minutes=30), Child(name="Kid", id="k1") + ) await coord.async_revert_unlock(record) coord.hass.services.async_call.assert_awaited_with( - "homeassistant", "turn_off", {"entity_id": "switch.tv"}, blocking=False) + "homeassistant", "turn_off", {"entity_id": "switch.tv"}, blocking=False + ) assert coord.active_unlocks() == [] @pytest.mark.asyncio async def test_revert_fires_an_event(self): coord = _coord(["switch.tv"]) record = await coord.async_start_unlock( - _reward(unlock_entity="switch.tv", unlock_minutes=30), Child(name="Kid", id="k1")) + _reward(unlock_entity="switch.tv", unlock_minutes=30), Child(name="Kid", id="k1") + ) coord.hass.bus.async_fire.reset_mock() await coord.async_revert_unlock(record) assert coord.hass.bus.async_fire.call_args[0][0] == "taskmate_unlock_ended" @@ -178,7 +189,8 @@ async def test_expired_unlock_is_reverted_on_startup(self): coord = _coord(["switch.tv"], [{"entity_id": "switch.tv", "revert_at": past}]) assert await coord.async_resume_unlocks() == 1 coord.hass.services.async_call.assert_awaited_with( - "homeassistant", "turn_off", {"entity_id": "switch.tv"}, blocking=False) + "homeassistant", "turn_off", {"entity_id": "switch.tv"}, blocking=False + ) assert coord.active_unlocks() == [] @pytest.mark.asyncio @@ -195,7 +207,8 @@ async def test_unparseable_revert_time_is_turned_off_not_left_on(self): coord = _coord(["switch.tv"], [{"entity_id": "switch.tv", "revert_at": "whenever"}]) assert await coord.async_resume_unlocks() == 1 coord.hass.services.async_call.assert_awaited_with( - "homeassistant", "turn_off", {"entity_id": "switch.tv"}, blocking=False) + "homeassistant", "turn_off", {"entity_id": "switch.tv"}, blocking=False + ) @pytest.mark.asyncio async def test_resume_reverts_even_if_no_longer_allowlisted(self): @@ -204,7 +217,8 @@ async def test_resume_reverts_even_if_no_longer_allowlisted(self): coord = _coord([], [{"entity_id": "switch.tv", "revert_at": past}]) assert await coord.async_resume_unlocks() == 1 coord.hass.services.async_call.assert_awaited_with( - "homeassistant", "turn_off", {"entity_id": "switch.tv"}, blocking=False) + "homeassistant", "turn_off", {"entity_id": "switch.tv"}, blocking=False + ) @pytest.mark.asyncio async def test_nothing_active_is_a_no_op(self): diff --git a/tests/test_todo.py b/tests/test_todo.py index 02dec06..e5b6cf1 100644 --- a/tests/test_todo.py +++ b/tests/test_todo.py @@ -1,4 +1,5 @@ """Tests for the per-child todo platform (FEAT-8).""" + from __future__ import annotations import asyncio diff --git a/tests/test_undo_chore_approval.py b/tests/test_undo_chore_approval.py index a9ada8f..da6439e 100644 --- a/tests/test_undo_chore_approval.py +++ b/tests/test_undo_chore_approval.py @@ -4,6 +4,7 @@ and flips it back to pending (so it returns to the approval queue), and does NOT notify the child. """ + from __future__ import annotations import asyncio @@ -53,23 +54,29 @@ def _fired(coord, name): def test_undo_approval_reverts_to_pending_and_reverses_awards(): - child = Child(name="Mia", id="c1", points=50, total_points_earned=100, - total_chores_completed=3, current_streak=4, - last_completion_date="2024-01-01") + child = Child( + name="Mia", + id="c1", + points=50, + total_points_earned=100, + total_chores_completed=3, + current_streak=4, + last_completion_date="2024-01-01", + ) chore = Chore(name="Bin", id="ch1") - comp = ChoreCompletion(chore_id="ch1", child_id="c1", completed_at=DAY, - approved=True, approved_at=DAY, points_awarded=5, - id="comp1") + comp = ChoreCompletion( + chore_id="ch1", child_id="c1", completed_at=DAY, approved=True, approved_at=DAY, points_awarded=5, id="comp1" + ) coord = _coord(child, chore, [comp]) run(coord.async_undo_chore_approval("comp1")) # Awards reversed - assert child.points == 45 # 50 - 5 - assert child.total_points_earned == 95 # 100 - 5 - assert child.total_chores_completed == 2 # 3 - 1 - assert child.current_streak == 3 # sole completion that day - assert child.last_completion_date is None # no remaining completions + assert child.points == 45 # 50 - 5 + assert child.total_points_earned == 95 # 100 - 5 + assert child.total_chores_completed == 2 # 3 - 1 + assert child.current_streak == 3 # sole completion that day + assert child.last_completion_date is None # no remaining completions # Reverted to pending, NOT removed assert comp.approved is False @@ -80,12 +87,11 @@ def test_undo_approval_reverts_to_pending_and_reverses_awards(): def test_undo_approval_fires_undone_not_rejected(): - child = Child(name="Mia", id="c1", points=10, total_points_earned=10, - total_chores_completed=1, current_streak=1) + child = Child(name="Mia", id="c1", points=10, total_points_earned=10, total_chores_completed=1, current_streak=1) chore = Chore(name="Bin", id="ch1") - comp = ChoreCompletion(chore_id="ch1", child_id="c1", completed_at=DAY, - approved=True, approved_at=DAY, points_awarded=10, - id="comp1") + comp = ChoreCompletion( + chore_id="ch1", child_id="c1", completed_at=DAY, approved=True, approved_at=DAY, points_awarded=10, id="comp1" + ) coord = _coord(child, chore, [comp]) run(coord.async_undo_chore_approval("comp1")) @@ -95,13 +101,11 @@ def test_undo_approval_fires_undone_not_rejected(): def test_undo_approval_one_shot_reenables(): - child = Child(name="Mia", id="c1", points=10, total_points_earned=10, - total_chores_completed=1) - chore = Chore(name="Bin", id="ch1", schedule_mode="one_shot", - enabled=False, disabled_for=["c1"]) - comp = ChoreCompletion(chore_id="ch1", child_id="c1", completed_at=DAY, - approved=True, approved_at=DAY, points_awarded=10, - id="comp1") + child = Child(name="Mia", id="c1", points=10, total_points_earned=10, total_chores_completed=1) + chore = Chore(name="Bin", id="ch1", schedule_mode="one_shot", enabled=False, disabled_for=["c1"]) + comp = ChoreCompletion( + chore_id="ch1", child_id="c1", completed_at=DAY, approved=True, approved_at=DAY, points_awarded=10, id="comp1" + ) coord = _coord(child, chore, [comp]) run(coord.async_undo_chore_approval("comp1")) @@ -113,8 +117,9 @@ def test_undo_approval_one_shot_reenables(): def test_undo_unapproved_completion_raises(): child = Child(name="Mia", id="c1") chore = Chore(name="Bin", id="ch1") - comp = ChoreCompletion(chore_id="ch1", child_id="c1", completed_at=DAY, - approved=False, points_awarded=0, id="comp1") + comp = ChoreCompletion( + chore_id="ch1", child_id="c1", completed_at=DAY, approved=False, points_awarded=0, id="comp1" + ) coord = _coord(child, chore, [comp]) with pytest.raises(ValueError, match="not approved"): run(coord.async_undo_chore_approval("comp1")) diff --git a/tests/test_undo_transaction.py b/tests/test_undo_transaction.py index 606df08..7431898 100644 --- a/tests/test_undo_transaction.py +++ b/tests/test_undo_transaction.py @@ -1,4 +1,5 @@ """Tests for undo/retract of applied penalties and bonuses.""" + from __future__ import annotations import asyncio @@ -37,9 +38,14 @@ def _coord(child, txns): def _txn(points, reason, tid="t1", child_id="c1", link_id=""): - return PointsTransaction(child_id=child_id, points=points, reason=reason, - created_at=dt.datetime(2024, 1, 1, tzinfo=UTC), id=tid, - link_id=link_id) + return PointsTransaction( + child_id=child_id, + points=points, + reason=reason, + created_at=dt.datetime(2024, 1, 1, tzinfo=UTC), + id=tid, + link_id=link_id, + ) def _coord_multi(children, txns): @@ -69,30 +75,28 @@ async def test_storage_remove_transaction(hass): # ── coordinator ────────────────────────────────────────────────────────────── def test_undo_penalty_restores_points_and_counter(): - child = Child(name="Mia", points=40, total_points_earned=100, - total_penalties_received=10) + child = Child(name="Mia", points=40, total_points_earned=100, total_penalties_received=10) coord = _coord(child, [_txn(-10, "Penalty: Messy room")]) run(coord.async_undo_transaction("t1")) - assert child.points == 50 # 40 + 10 back - assert child.total_penalties_received == 0 # 10 - 10 - assert child.career_score == 100 # earned - penalties + assert child.points == 50 # 40 + 10 back + assert child.total_penalties_received == 0 # 10 - 10 + assert child.career_score == 100 # earned - penalties coord.storage.remove_points_transaction.assert_called_once_with("t1") def test_undo_bonus_removes_points_and_earned(): - child = Child(name="Mia", points=50, total_points_earned=120, - total_penalties_received=0) + child = Child(name="Mia", points=50, total_points_earned=120, total_penalties_received=0) coord = _coord(child, [_txn(20, "Bonus: Helped out")]) run(coord.async_undo_transaction("t1")) - assert child.points == 30 # 50 - 20 - assert child.total_points_earned == 100 # 120 - 20 + assert child.points == 30 # 50 - 20 + assert child.total_points_earned == 100 # 120 - 20 def test_undo_bonus_points_floor_at_zero(): child = Child(name="Mia", points=5, total_points_earned=5) coord = _coord(child, [_txn(20, "Bonus: Big")]) run(coord.async_undo_transaction("t1")) - assert child.points == 0 # clamped, not negative + assert child.points == 0 # clamped, not negative def test_undo_missing_transaction_raises(): @@ -108,21 +112,20 @@ def test_undo_manual_add_removes_points_and_earned(): child = Child(name="Mia", points=50, total_points_earned=120) coord = _coord(child, [_txn(20, "Pocket money top-up")]) run(coord.async_undo_transaction("t1")) - assert child.points == 30 # 50 - 20 - assert child.total_points_earned == 100 # 120 - 20 + assert child.points == 30 # 50 - 20 + assert child.total_points_earned == 100 # 120 - 20 coord.storage.remove_points_transaction.assert_called_once_with("t1") def test_undo_manual_remove_restores_points_only(): # A plain (non-penalty) remove only reduced spendable points; undo must not # touch totals. - child = Child(name="Mia", points=30, total_points_earned=100, - total_penalties_received=0) + child = Child(name="Mia", points=30, total_points_earned=100, total_penalties_received=0) coord = _coord(child, [_txn(-10, "Confiscated tablet")]) run(coord.async_undo_transaction("t1")) - assert child.points == 40 # 30 + 10 back - assert child.total_points_earned == 100 # unchanged - assert child.total_penalties_received == 0 # unchanged + assert child.points == 40 # 30 + 10 back + assert child.total_points_earned == 100 # unchanged + assert child.total_penalties_received == 0 # unchanged def test_undo_manual_add_with_empty_reason(): @@ -142,11 +145,11 @@ def test_undo_gift_reverses_both_legs(): _txn(20, "Gift from Mia", tid="g_recv", child_id="c2", link_id="L1"), ] coord = _coord_multi({"c1": sender, "c2": recipient}, legs) - run(coord.async_undo_transaction("g_send")) # undo from either leg - assert sender.points == 50 # 30 + 20 back to sender - assert recipient.points == 50 # 70 - 20 removed from recipient + run(coord.async_undo_transaction("g_send")) # undo from either leg + assert sender.points == 50 # 30 + 20 back to sender + assert recipient.points == 50 # 70 - 20 removed from recipient removed = {c.args[0] for c in coord.storage.remove_points_transaction.call_args_list} - assert removed == {"g_send", "g_recv"} # both legs gone + assert removed == {"g_send", "g_recv"} # both legs gone def test_undo_legacy_gift_without_link_id_refused(): @@ -161,16 +164,19 @@ def test_undo_legacy_gift_without_link_id_refused(): # ── derived transactions stay refused (deny-list) ──────────────────────────── -@pytest.mark.parametrize("reason", [ - "Weekend bonus (×2)", - "Streak milestone bonus (7 day streak!)", - "Perfect week bonus! (01 Jan – 07 Jan)", - "Allocated to pool: Bike", - "Pool refund (reward expired)", - "Points decay (-10%)", - "Savings interest (+5%)", - "Badge: Tidy Titan", -]) +@pytest.mark.parametrize( + "reason", + [ + "Weekend bonus (×2)", + "Streak milestone bonus (7 day streak!)", + "Perfect week bonus! (01 Jan – 07 Jan)", + "Allocated to pool: Bike", + "Pool refund (reward expired)", + "Points decay (-10%)", + "Savings interest (+5%)", + "Badge: Tidy Titan", + ], +) def test_undo_refuses_derived(reason): child = Child(name="Mia", points=50, id="c1") coord = _coord(child, [_txn(-10, reason)]) diff --git a/tests/test_vacation_mode.py b/tests/test_vacation_mode.py index 9d4e271..3354103 100644 --- a/tests/test_vacation_mode.py +++ b/tests/test_vacation_mode.py @@ -4,6 +4,7 @@ (unavailable) and streaks are frozen — a missed day inside a vacation never breaks a streak. Periods are stored as the ``vacation_periods`` setting. """ + from __future__ import annotations import asyncio @@ -54,9 +55,9 @@ def test_no_periods(self): def test_inside_range_inclusive_bounds(self): coord = _make_coord({"vacation_periods": VAC}) - assert coord.is_vacation_day(date(2026, 7, 10)) is True # start - assert coord.is_vacation_day(date(2026, 7, 20)) is True # end - assert coord.is_vacation_day(date(2026, 7, 15)) is True # middle + assert coord.is_vacation_day(date(2026, 7, 10)) is True # start + assert coord.is_vacation_day(date(2026, 7, 20)) is True # end + assert coord.is_vacation_day(date(2026, 7, 15)) is True # middle def test_outside_range(self): coord = _make_coord({"vacation_periods": VAC}) @@ -69,19 +70,27 @@ def test_active_vacation_returns_period(self): assert p and p["name"] == "Summer" def test_malformed_entries_ignored(self): - coord = _make_coord({"vacation_periods": [ - {"name": "bad", "start": "not-a-date", "end": "2026-07-20"}, - "garbage", - VAC[0], - ]}) + coord = _make_coord( + { + "vacation_periods": [ + {"name": "bad", "start": "not-a-date", "end": "2026-07-20"}, + "garbage", + VAC[0], + ] + } + ) periods = coord.get_vacation_periods() assert len(periods) == 1 assert periods[0]["name"] == "Summer" def test_reversed_dates_are_swapped(self): - coord = _make_coord({"vacation_periods": [ - {"id": "x", "name": "Oops", "start": "2026-07-20", "end": "2026-07-10"}, - ]}) + coord = _make_coord( + { + "vacation_periods": [ + {"id": "x", "name": "Oops", "start": "2026-07-20", "end": "2026-07-10"}, + ] + } + ) p = coord.get_vacation_periods()[0] assert p["start"] == "2026-07-10" assert p["end"] == "2026-07-20" @@ -115,6 +124,7 @@ def test_partial_vacation_still_breaks_on_normal_missed_day(self): class TestStreakCheckFreezes: def _run_check(self, coord, now_dt): import custom_components.taskmate.coord_points as _mod + with patch.object(_mod.dt_util, "now", return_value=now_dt): run(coord._async_check_streaks()) @@ -141,17 +151,21 @@ def test_empty_list_ok(self): assert err is None and periods == [] def test_valid_entry(self): - periods, err = _validate_vacation_periods([ - {"name": "Trip", "start": "2026-08-01", "end": "2026-08-05"}, - ]) + periods, err = _validate_vacation_periods( + [ + {"name": "Trip", "start": "2026-08-01", "end": "2026-08-05"}, + ] + ) assert err is None assert periods[0]["name"] == "Trip" assert periods[0]["id"] # generated def test_reversed_swapped(self): - periods, err = _validate_vacation_periods([ - {"name": "x", "start": "2026-08-05", "end": "2026-08-01"}, - ]) + periods, err = _validate_vacation_periods( + [ + {"name": "x", "start": "2026-08-05", "end": "2026-08-01"}, + ] + ) assert err is None assert periods[0]["start"] == "2026-08-01" assert periods[0]["end"] == "2026-08-05" @@ -165,9 +179,11 @@ def test_not_a_list_rejected(self): assert periods is None and err def test_sorted_by_start(self): - periods, err = _validate_vacation_periods([ - {"name": "B", "start": "2026-09-01", "end": "2026-09-02"}, - {"name": "A", "start": "2026-08-01", "end": "2026-08-02"}, - ]) + periods, err = _validate_vacation_periods( + [ + {"name": "B", "start": "2026-09-01", "end": "2026-09-02"}, + {"name": "A", "start": "2026-08-01", "end": "2026-08-02"}, + ] + ) assert err is None assert [p["name"] for p in periods] == ["A", "B"] diff --git a/tests/test_weather_chores.py b/tests/test_weather_chores.py index f7a3f53..97bdc8d 100644 --- a/tests/test_weather_chores.py +++ b/tests/test_weather_chores.py @@ -4,6 +4,7 @@ stops counting as a mandatory miss and can't break a streak. Every path is fail-open: a broken weather integration must never hide the family's chores. """ + from __future__ import annotations from unittest.mock import MagicMock @@ -45,9 +46,16 @@ class TestOptionalFloat: def test_unset_values_read_as_none(self, value): assert optional_float(value) is None - @pytest.mark.parametrize(("value", "expected"), [ - (0, 0.0), ("0", 0.0), (-5, -5.0), ("12.5", 12.5), (3, 3.0), - ]) + @pytest.mark.parametrize( + ("value", "expected"), + [ + (0, 0.0), + ("0", 0.0), + (-5, -5.0), + ("12.5", 12.5), + (3, 3.0), + ], + ) def test_numeric_values_survive(self, value, expected): assert optional_float(value) == expected @@ -108,7 +116,9 @@ def test_wind_at_maximum_is_allowed(self): def test_condition_takes_precedence_over_limits(self): coord = _coord_with_weather(_weather_state("pouring", temperature=-5, wind_speed=99)) chore = _chore( - weather_block_conditions=["pouring"], weather_temp_min=0, weather_wind_max=20, + weather_block_conditions=["pouring"], + weather_temp_min=0, + weather_wind_max=20, ) assert coord.weather_block_reason(chore) == coord.WEATHER_REASON_CONDITION @@ -189,10 +199,15 @@ def test_legacy_chore_without_weather_fields(self): def test_string_limits_are_coerced(self): """The panel sends numbers, but hand-edited storage may hold strings.""" - restored = Chore.from_dict({ - "name": "Chore", "weather_entity": "weather.home", - "weather_temp_min": "5.5", "weather_temp_max": "", "weather_wind_max": None, - }) + restored = Chore.from_dict( + { + "name": "Chore", + "weather_entity": "weather.home", + "weather_temp_min": "5.5", + "weather_temp_max": "", + "weather_wind_max": None, + } + ) assert restored.weather_temp_min == 5.5 assert restored.weather_temp_max is None assert restored.weather_wind_max is None diff --git a/tests/test_websocket_admin.py b/tests/test_websocket_admin.py index f8da68c..ca832f7 100644 --- a/tests/test_websocket_admin.py +++ b/tests/test_websocket_admin.py @@ -3,6 +3,7 @@ Every panel command — including the notification handlers — must reject non-admin users with ERR_UNAUTHORIZED before touching any data. """ + from __future__ import annotations from unittest.mock import MagicMock @@ -20,9 +21,11 @@ async def setup(hass): coord.hass = hass coord.entry_id = "ws_admin_test" from custom_components.taskmate.storage import TaskMateStorage + coord.storage = TaskMateStorage(hass, "ws_admin_test") await coord.storage.async_load() from custom_components.taskmate.coord_notifications import NotificationCoordinator + coord.notifications = NotificationCoordinator(hass, coord.storage) coord.notifications.coordinator = coord hass.data = {DOMAIN: {"ws_admin_test": coord}} @@ -86,8 +89,10 @@ async def test_notification_handler_allows_admin(setup, hass): coord = setup connection = _admin_connection() msg = { - "id": 3, "type": "test", - "type_id": "bedtime_reminder", "enabled": True, + "id": 3, + "type": "test", + "type_id": "bedtime_reminder", + "enabled": True, } await ws.ws_notif_set_master(hass, connection, msg) diff --git a/tests/test_weekly_digest.py b/tests/test_weekly_digest.py index 580bdd8..5eff4d9 100644 --- a/tests/test_weekly_digest.py +++ b/tests/test_weekly_digest.py @@ -1,4 +1,5 @@ """Tests for the weekly digest.""" + from __future__ import annotations import asyncio @@ -32,8 +33,9 @@ def _coord(children, completions): def _comp(child, when, approved=True, pts=10, bonus=""): - return ChoreCompletion(chore_id="x", child_id=child, completed_at=when, - approved=approved, points_awarded=pts, bonus_subtask_id=bonus) + return ChoreCompletion( + chore_id="x", child_id=child, completed_at=when, approved=approved, points_awarded=pts, bonus_subtask_id=bonus + ) def test_digest_counts_this_week_only(): @@ -42,11 +44,17 @@ def test_digest_counts_this_week_only(): last_week = dt.datetime(2026, 6, 8, 9, tzinfo=UTC) coord = _coord( [Child(name="Mia", id="a"), Child(name="Bo", id="b")], - [_comp("a", this_week, pts=10), _comp("a", this_week, pts=5), - _comp("a", last_week, pts=99), _comp("b", this_week, pts=7)], + [ + _comp("a", this_week, pts=10), + _comp("a", this_week, pts=5), + _comp("a", last_week, pts=99), + _comp("b", this_week, pts=7), + ], ) - with patch("homeassistant.util.dt.now", return_value=dt.datetime(2026, 6, 21, 18, tzinfo=UTC)), \ - patch("homeassistant.util.dt.as_local", side_effect=lambda d: d): + with ( + patch("homeassistant.util.dt.now", return_value=dt.datetime(2026, 6, 21, 18, tzinfo=UTC)), + patch("homeassistant.util.dt.as_local", side_effect=lambda d: d), + ): s = coord._build_weekly_digest() assert "Mia: 2 chores, 15 Stars" in s assert "Bo: 1 chores, 7 Stars" in s @@ -55,11 +63,14 @@ def test_digest_counts_this_week_only(): def test_digest_excludes_pending_and_bonus(): now = dt.datetime(2026, 6, 17, 9, tzinfo=UTC) - coord = _coord([Child(name="Mia", id="a")], - [_comp("a", now, approved=False, pts=10), _comp("a", now, bonus="sub", pts=5), - _comp("a", now, pts=8)]) - with patch("homeassistant.util.dt.now", return_value=dt.datetime(2026, 6, 21, 18, tzinfo=UTC)), \ - patch("homeassistant.util.dt.as_local", side_effect=lambda d: d): + coord = _coord( + [Child(name="Mia", id="a")], + [_comp("a", now, approved=False, pts=10), _comp("a", now, bonus="sub", pts=5), _comp("a", now, pts=8)], + ) + with ( + patch("homeassistant.util.dt.now", return_value=dt.datetime(2026, 6, 21, 18, tzinfo=UTC)), + patch("homeassistant.util.dt.as_local", side_effect=lambda d: d), + ): s = coord._build_weekly_digest() assert "Mia: 1 chores, 8 Stars" in s From ddaf25224293ad1c935b8ad7281be1d6db8cc373 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Mon, 10 Aug 2026 22:16:33 +0000 Subject: [PATCH 3/3] chore(ci): enforce ruff format, and ignore the reformat commit in blame - lint.yml gains a 'ruff format --check' step alongside 'ruff check' - pre-commit gains the ruff-format hook - .git-blame-ignore-revs lists the sweep commit; GitHub applies it to blame automatically, locally set blame.ignoreRevsFile to get the same --- .git-blame-ignore-revs | 8 ++++++++ .github/CONTRIBUTING.md | 3 ++- .github/workflows/lint.yml | 3 +++ .pre-commit-config.yaml | 1 + 4 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..16603cf --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,8 @@ +# Commits that only reformat code. Ignore them in blame: +# +# git config blame.ignoreRevsFile .git-blame-ignore-revs +# +# GitHub's blame view applies this file automatically. + +# style: apply ruff format across the codebase +6af294d26500d4ecd328477e4143ac523c31ac05 diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index f17e589..14a3015 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -65,8 +65,9 @@ bind-mounted: Run these locally before pushing: ```bash -# Lint (matches the "Ruff" CI check) +# Lint and formatting (both match the "Ruff" CI check) ruff check custom_components/taskmate tests scripts +ruff format --check custom_components/taskmate tests scripts # drop --check to fix # Cards and panel (matches the "ESLint" CI check) npm ci && npm run lint diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 09782c3..3853545 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -39,6 +39,9 @@ jobs: - name: Run ruff run: ruff check custom_components/taskmate tests scripts + - name: Check formatting + run: ruff format --check custom_components/taskmate tests scripts + eslint: name: ESLint (cards + panel) runs-on: ubuntu-latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3612efe..70e18b9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,6 +40,7 @@ repos: hooks: - id: ruff args: [--fix] + - id: ruff-format - repo: local hooks: