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: 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_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_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_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: 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