Migrate Habitica integration to habiticalib (#131032)

* Migrate data to habiticalib

* Add habiticalib to init and coordinator

* Migrate Habitica config flow to habiticalib

* migrate init to habiticalib

* migrate buttons to habiticalib

* migrate switch to habiticalib

* update habiticalib

* cast_skill action

* migrate update_score

* migrate transformation items action

* migrate quest actions

* fix fixture errors

* Migrate coordinator data and content

* bump habiticalib

* Remove habitipy and use wrapper in habiticalub

* changes

* some fixes

* minor refactoring

* class_needed annotation

* Update diagnostics

* do integration setup in coordinator setup

* small changes

* raise HomeAssistantError for TooManyRequestsError

* fix docstring

* update tests

* changes to tests/snapshots

* fix update_todo_item
This commit is contained in:
Manu 2024-12-29 15:00:31 +01:00 committed by GitHub
parent 4717eb3142
commit 0db07a033b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 3664 additions and 2100 deletions

View File

@ -1,27 +1,15 @@
"""The habitica integration.""" """The habitica integration."""
from http import HTTPStatus from habiticalib import Habitica
from aiohttp import ClientResponseError
from habitipy.aio import HabitipyAsync
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
APPLICATION_NAME,
CONF_API_KEY,
CONF_NAME,
CONF_URL,
CONF_VERIFY_SSL,
Platform,
__version__,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .const import CONF_API_USER, DEVELOPER_ID, DOMAIN from .const import CONF_API_USER, DOMAIN, X_CLIENT
from .coordinator import HabiticaDataUpdateCoordinator from .coordinator import HabiticaDataUpdateCoordinator
from .services import async_setup_services from .services import async_setup_services
from .types import HabiticaConfigEntry from .types import HabiticaConfigEntry
@ -51,47 +39,17 @@ async def async_setup_entry(
) -> bool: ) -> bool:
"""Set up habitica from a config entry.""" """Set up habitica from a config entry."""
class HAHabitipyAsync(HabitipyAsync): session = async_get_clientsession(
"""Closure API class to hold session."""
def __call__(self, **kwargs):
return super().__call__(websession, **kwargs)
def _make_headers(self) -> dict[str, str]:
headers = super()._make_headers()
headers.update(
{"x-client": f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}"}
)
return headers
websession = async_get_clientsession(
hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True) hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True)
) )
api = await hass.async_add_executor_job( api = Habitica(
HAHabitipyAsync, session,
{ api_user=config_entry.data[CONF_API_USER],
"url": config_entry.data[CONF_URL], api_key=config_entry.data[CONF_API_KEY],
"login": config_entry.data[CONF_API_USER], url=config_entry.data[CONF_URL],
"password": config_entry.data[CONF_API_KEY], x_client=X_CLIENT,
},
) )
try:
user = await api.user.get(userFields="profile")
except ClientResponseError as e:
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
raise ConfigEntryNotReady(e) from e
if not config_entry.data.get(CONF_NAME):
name = user["profile"]["name"]
hass.config_entries.async_update_entry(
config_entry,
data={**config_entry.data, CONF_NAME: name},
)
coordinator = HabiticaDataUpdateCoordinator(hass, api) coordinator = HabiticaDataUpdateCoordinator(hass, api)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()

View File

@ -5,7 +5,8 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
from typing import Any
from habiticalib import UserData
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
BinarySensorEntity, BinarySensorEntity,
@ -23,8 +24,8 @@ from .types import HabiticaConfigEntry
class HabiticaBinarySensorEntityDescription(BinarySensorEntityDescription): class HabiticaBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Habitica Binary Sensor Description.""" """Habitica Binary Sensor Description."""
value_fn: Callable[[dict[str, Any]], bool | None] value_fn: Callable[[UserData], bool | None]
entity_picture: Callable[[dict[str, Any]], str | None] entity_picture: Callable[[UserData], str | None]
class HabiticaBinarySensor(StrEnum): class HabiticaBinarySensor(StrEnum):
@ -33,10 +34,10 @@ class HabiticaBinarySensor(StrEnum):
PENDING_QUEST = "pending_quest" PENDING_QUEST = "pending_quest"
def get_scroll_image_for_pending_quest_invitation(user: dict[str, Any]) -> str | None: def get_scroll_image_for_pending_quest_invitation(user: UserData) -> str | None:
"""Entity picture for pending quest invitation.""" """Entity picture for pending quest invitation."""
if user["party"]["quest"].get("key") and user["party"]["quest"]["RSVPNeeded"]: if user.party.quest.key and user.party.quest.RSVPNeeded:
return f"inventory_quest_scroll_{user["party"]["quest"]["key"]}.png" return f"inventory_quest_scroll_{user.party.quest.key}.png"
return None return None
@ -44,7 +45,7 @@ BINARY_SENSOR_DESCRIPTIONS: tuple[HabiticaBinarySensorEntityDescription, ...] =
HabiticaBinarySensorEntityDescription( HabiticaBinarySensorEntityDescription(
key=HabiticaBinarySensor.PENDING_QUEST, key=HabiticaBinarySensor.PENDING_QUEST,
translation_key=HabiticaBinarySensor.PENDING_QUEST, translation_key=HabiticaBinarySensor.PENDING_QUEST,
value_fn=lambda user: user["party"]["quest"]["RSVPNeeded"], value_fn=lambda user: user.party.quest.RSVPNeeded,
entity_picture=get_scroll_image_for_pending_quest_invitation, entity_picture=get_scroll_image_for_pending_quest_invitation,
), ),
) )

View File

@ -5,10 +5,17 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
from http import HTTPStatus
from typing import Any from typing import Any
from aiohttp import ClientResponseError from aiohttp import ClientError
from habiticalib import (
HabiticaClass,
HabiticaException,
NotAuthorizedError,
Skill,
TaskType,
TooManyRequestsError,
)
from homeassistant.components.button import ( from homeassistant.components.button import (
DOMAIN as BUTTON_DOMAIN, DOMAIN as BUTTON_DOMAIN,
@ -20,7 +27,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ASSETS_URL, DOMAIN, HEALER, MAGE, ROGUE, WARRIOR from .const import ASSETS_URL, DOMAIN
from .coordinator import HabiticaData, HabiticaDataUpdateCoordinator from .coordinator import HabiticaData, HabiticaDataUpdateCoordinator
from .entity import HabiticaBase from .entity import HabiticaBase
from .types import HabiticaConfigEntry from .types import HabiticaConfigEntry
@ -34,7 +41,7 @@ class HabiticaButtonEntityDescription(ButtonEntityDescription):
press_fn: Callable[[HabiticaDataUpdateCoordinator], Any] press_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
available_fn: Callable[[HabiticaData], bool] available_fn: Callable[[HabiticaData], bool]
class_needed: str | None = None class_needed: HabiticaClass | None = None
entity_picture: str | None = None entity_picture: str | None = None
@ -63,35 +70,33 @@ BUTTON_DESCRIPTIONS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription( HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.RUN_CRON, key=HabitipyButtonEntity.RUN_CRON,
translation_key=HabitipyButtonEntity.RUN_CRON, translation_key=HabitipyButtonEntity.RUN_CRON,
press_fn=lambda coordinator: coordinator.api.cron.post(), press_fn=lambda coordinator: coordinator.habitica.run_cron(),
available_fn=lambda data: data.user["needsCron"], available_fn=lambda data: data.user.needsCron is True,
), ),
HabiticaButtonEntityDescription( HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.BUY_HEALTH_POTION, key=HabitipyButtonEntity.BUY_HEALTH_POTION,
translation_key=HabitipyButtonEntity.BUY_HEALTH_POTION, translation_key=HabitipyButtonEntity.BUY_HEALTH_POTION,
press_fn=( press_fn=lambda coordinator: coordinator.habitica.buy_health_potion(),
lambda coordinator: coordinator.api["user"]["buy-health-potion"].post()
),
available_fn=( available_fn=(
lambda data: data.user["stats"]["gp"] >= 25 lambda data: (data.user.stats.gp or 0) >= 25
and data.user["stats"]["hp"] < 50 and (data.user.stats.hp or 0) < 50
), ),
entity_picture="shop_potion.png", entity_picture="shop_potion.png",
), ),
HabiticaButtonEntityDescription( HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS, key=HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS,
translation_key=HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS, translation_key=HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS,
press_fn=lambda coordinator: coordinator.api["user"]["allocate-now"].post(), press_fn=lambda coordinator: coordinator.habitica.allocate_stat_points(),
available_fn=( available_fn=(
lambda data: data.user["preferences"].get("automaticAllocation") is True lambda data: data.user.preferences.automaticAllocation is True
and data.user["stats"]["points"] > 0 and (data.user.stats.points or 0) > 0
), ),
), ),
HabiticaButtonEntityDescription( HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.REVIVE, key=HabitipyButtonEntity.REVIVE,
translation_key=HabitipyButtonEntity.REVIVE, translation_key=HabitipyButtonEntity.REVIVE,
press_fn=lambda coordinator: coordinator.api["user"]["revive"].post(), press_fn=lambda coordinator: coordinator.habitica.revive(),
available_fn=lambda data: data.user["stats"]["hp"] == 0, available_fn=lambda data: data.user.stats.hp == 0,
), ),
) )
@ -100,166 +105,170 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription( HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.MPHEAL, key=HabitipyButtonEntity.MPHEAL,
translation_key=HabitipyButtonEntity.MPHEAL, translation_key=HabitipyButtonEntity.MPHEAL,
press_fn=lambda coordinator: coordinator.api.user.class_.cast["mpheal"].post(), press_fn=(
available_fn=( lambda coordinator: coordinator.habitica.cast_skill(Skill.ETHEREAL_SURGE)
lambda data: data.user["stats"]["lvl"] >= 12
and data.user["stats"]["mp"] >= 30
), ),
class_needed=MAGE, available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 12
and (data.user.stats.mp or 0) >= 30
),
class_needed=HabiticaClass.MAGE,
entity_picture="shop_mpheal.png", entity_picture="shop_mpheal.png",
), ),
HabiticaButtonEntityDescription( HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.EARTH, key=HabitipyButtonEntity.EARTH,
translation_key=HabitipyButtonEntity.EARTH, translation_key=HabitipyButtonEntity.EARTH,
press_fn=lambda coordinator: coordinator.api.user.class_.cast["earth"].post(), press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.EARTHQUAKE),
available_fn=( available_fn=(
lambda data: data.user["stats"]["lvl"] >= 13 lambda data: (data.user.stats.lvl or 0) >= 13
and data.user["stats"]["mp"] >= 35 and (data.user.stats.mp or 0) >= 35
), ),
class_needed=MAGE, class_needed=HabiticaClass.MAGE,
entity_picture="shop_earth.png", entity_picture="shop_earth.png",
), ),
HabiticaButtonEntityDescription( HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.FROST, key=HabitipyButtonEntity.FROST,
translation_key=HabitipyButtonEntity.FROST, translation_key=HabitipyButtonEntity.FROST,
press_fn=lambda coordinator: coordinator.api.user.class_.cast["frost"].post(), press_fn=(
lambda coordinator: coordinator.habitica.cast_skill(Skill.CHILLING_FROST)
),
# chilling frost can only be cast once per day (streaks buff is false) # chilling frost can only be cast once per day (streaks buff is false)
available_fn=( available_fn=(
lambda data: data.user["stats"]["lvl"] >= 14 lambda data: (data.user.stats.lvl or 0) >= 14
and data.user["stats"]["mp"] >= 40 and (data.user.stats.mp or 0) >= 40
and not data.user["stats"]["buffs"]["streaks"] and not data.user.stats.buffs.streaks
), ),
class_needed=MAGE, class_needed=HabiticaClass.MAGE,
entity_picture="shop_frost.png", entity_picture="shop_frost.png",
), ),
HabiticaButtonEntityDescription( HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.DEFENSIVE_STANCE, key=HabitipyButtonEntity.DEFENSIVE_STANCE,
translation_key=HabitipyButtonEntity.DEFENSIVE_STANCE, translation_key=HabitipyButtonEntity.DEFENSIVE_STANCE,
press_fn=( press_fn=(
lambda coordinator: coordinator.api.user.class_.cast[ lambda coordinator: coordinator.habitica.cast_skill(Skill.DEFENSIVE_STANCE)
"defensiveStance"
].post()
), ),
available_fn=( available_fn=(
lambda data: data.user["stats"]["lvl"] >= 12 lambda data: (data.user.stats.lvl or 0) >= 12
and data.user["stats"]["mp"] >= 25 and (data.user.stats.mp or 0) >= 25
), ),
class_needed=WARRIOR, class_needed=HabiticaClass.WARRIOR,
entity_picture="shop_defensiveStance.png", entity_picture="shop_defensiveStance.png",
), ),
HabiticaButtonEntityDescription( HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.VALOROUS_PRESENCE, key=HabitipyButtonEntity.VALOROUS_PRESENCE,
translation_key=HabitipyButtonEntity.VALOROUS_PRESENCE, translation_key=HabitipyButtonEntity.VALOROUS_PRESENCE,
press_fn=( press_fn=(
lambda coordinator: coordinator.api.user.class_.cast[ lambda coordinator: coordinator.habitica.cast_skill(Skill.VALOROUS_PRESENCE)
"valorousPresence"
].post()
), ),
available_fn=( available_fn=(
lambda data: data.user["stats"]["lvl"] >= 13 lambda data: (data.user.stats.lvl or 0) >= 13
and data.user["stats"]["mp"] >= 20 and (data.user.stats.mp or 0) >= 20
), ),
class_needed=WARRIOR, class_needed=HabiticaClass.WARRIOR,
entity_picture="shop_valorousPresence.png", entity_picture="shop_valorousPresence.png",
), ),
HabiticaButtonEntityDescription( HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.INTIMIDATE, key=HabitipyButtonEntity.INTIMIDATE,
translation_key=HabitipyButtonEntity.INTIMIDATE, translation_key=HabitipyButtonEntity.INTIMIDATE,
press_fn=( press_fn=(
lambda coordinator: coordinator.api.user.class_.cast["intimidate"].post() lambda coordinator: coordinator.habitica.cast_skill(Skill.INTIMIDATING_GAZE)
), ),
available_fn=( available_fn=(
lambda data: data.user["stats"]["lvl"] >= 14 lambda data: (data.user.stats.lvl or 0) >= 14
and data.user["stats"]["mp"] >= 15 and (data.user.stats.mp or 0) >= 15
), ),
class_needed=WARRIOR, class_needed=HabiticaClass.WARRIOR,
entity_picture="shop_intimidate.png", entity_picture="shop_intimidate.png",
), ),
HabiticaButtonEntityDescription( HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.TOOLS_OF_TRADE, key=HabitipyButtonEntity.TOOLS_OF_TRADE,
translation_key=HabitipyButtonEntity.TOOLS_OF_TRADE, translation_key=HabitipyButtonEntity.TOOLS_OF_TRADE,
press_fn=( press_fn=(
lambda coordinator: coordinator.api.user.class_.cast["toolsOfTrade"].post() lambda coordinator: coordinator.habitica.cast_skill(
Skill.TOOLS_OF_THE_TRADE
)
), ),
available_fn=( available_fn=(
lambda data: data.user["stats"]["lvl"] >= 13 lambda data: (data.user.stats.lvl or 0) >= 13
and data.user["stats"]["mp"] >= 25 and (data.user.stats.mp or 0) >= 25
), ),
class_needed=ROGUE, class_needed=HabiticaClass.ROGUE,
entity_picture="shop_toolsOfTrade.png", entity_picture="shop_toolsOfTrade.png",
), ),
HabiticaButtonEntityDescription( HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.STEALTH, key=HabitipyButtonEntity.STEALTH,
translation_key=HabitipyButtonEntity.STEALTH, translation_key=HabitipyButtonEntity.STEALTH,
press_fn=( press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.STEALTH),
lambda coordinator: coordinator.api.user.class_.cast["stealth"].post()
),
# Stealth buffs stack and it can only be cast if the amount of # Stealth buffs stack and it can only be cast if the amount of
# unfinished dailies is smaller than the amount of buffs # buffs is smaller than the amount of unfinished dailies
available_fn=( available_fn=(
lambda data: data.user["stats"]["lvl"] >= 14 lambda data: (data.user.stats.lvl or 0) >= 14
and data.user["stats"]["mp"] >= 45 and (data.user.stats.mp or 0) >= 45
and data.user["stats"]["buffs"]["stealth"] and (data.user.stats.buffs.stealth or 0)
< len( < len(
[ [
r r
for r in data.tasks for r in data.tasks
if r.get("type") == "daily" if r.Type is TaskType.DAILY
and r.get("isDue") is True and r.isDue is True
and r.get("completed") is False and r.completed is False
] ]
) )
), ),
class_needed=ROGUE, class_needed=HabiticaClass.ROGUE,
entity_picture="shop_stealth.png", entity_picture="shop_stealth.png",
), ),
HabiticaButtonEntityDescription( HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.HEAL, key=HabitipyButtonEntity.HEAL,
translation_key=HabitipyButtonEntity.HEAL, translation_key=HabitipyButtonEntity.HEAL,
press_fn=lambda coordinator: coordinator.api.user.class_.cast["heal"].post(), press_fn=(
available_fn=( lambda coordinator: coordinator.habitica.cast_skill(Skill.HEALING_LIGHT)
lambda data: data.user["stats"]["lvl"] >= 11
and data.user["stats"]["mp"] >= 15
and data.user["stats"]["hp"] < 50
), ),
class_needed=HEALER, available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 11
and (data.user.stats.mp or 0) >= 15
and (data.user.stats.hp or 0) < 50
),
class_needed=HabiticaClass.HEALER,
entity_picture="shop_heal.png", entity_picture="shop_heal.png",
), ),
HabiticaButtonEntityDescription( HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.BRIGHTNESS, key=HabitipyButtonEntity.BRIGHTNESS,
translation_key=HabitipyButtonEntity.BRIGHTNESS, translation_key=HabitipyButtonEntity.BRIGHTNESS,
press_fn=( press_fn=(
lambda coordinator: coordinator.api.user.class_.cast["brightness"].post() lambda coordinator: coordinator.habitica.cast_skill(
Skill.SEARING_BRIGHTNESS
)
), ),
available_fn=( available_fn=(
lambda data: data.user["stats"]["lvl"] >= 12 lambda data: (data.user.stats.lvl or 0) >= 12
and data.user["stats"]["mp"] >= 15 and (data.user.stats.mp or 0) >= 15
), ),
class_needed=HEALER, class_needed=HabiticaClass.HEALER,
entity_picture="shop_brightness.png", entity_picture="shop_brightness.png",
), ),
HabiticaButtonEntityDescription( HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.PROTECT_AURA, key=HabitipyButtonEntity.PROTECT_AURA,
translation_key=HabitipyButtonEntity.PROTECT_AURA, translation_key=HabitipyButtonEntity.PROTECT_AURA,
press_fn=( press_fn=(
lambda coordinator: coordinator.api.user.class_.cast["protectAura"].post() lambda coordinator: coordinator.habitica.cast_skill(Skill.PROTECTIVE_AURA)
), ),
available_fn=( available_fn=(
lambda data: data.user["stats"]["lvl"] >= 13 lambda data: (data.user.stats.lvl or 0) >= 13
and data.user["stats"]["mp"] >= 30 and (data.user.stats.mp or 0) >= 30
), ),
class_needed=HEALER, class_needed=HabiticaClass.HEALER,
entity_picture="shop_protectAura.png", entity_picture="shop_protectAura.png",
), ),
HabiticaButtonEntityDescription( HabiticaButtonEntityDescription(
key=HabitipyButtonEntity.HEAL_ALL, key=HabitipyButtonEntity.HEAL_ALL,
translation_key=HabitipyButtonEntity.HEAL_ALL, translation_key=HabitipyButtonEntity.HEAL_ALL,
press_fn=lambda coordinator: coordinator.api.user.class_.cast["healAll"].post(), press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.BLESSING),
available_fn=( available_fn=(
lambda data: data.user["stats"]["lvl"] >= 14 lambda data: (data.user.stats.lvl or 0) >= 14
and data.user["stats"]["mp"] >= 25 and (data.user.stats.mp or 0) >= 25
), ),
class_needed=HEALER, class_needed=HabiticaClass.HEALER,
entity_picture="shop_healAll.png", entity_picture="shop_healAll.png",
), ),
) )
@ -285,10 +294,10 @@ async def async_setup_entry(
for description in CLASS_SKILLS: for description in CLASS_SKILLS:
if ( if (
coordinator.data.user["stats"]["lvl"] >= 10 (coordinator.data.user.stats.lvl or 0) >= 10
and coordinator.data.user["flags"]["classSelected"] and coordinator.data.user.flags.classSelected
and not coordinator.data.user["preferences"]["disableClasses"] and not coordinator.data.user.preferences.disableClasses
and description.class_needed == coordinator.data.user["stats"]["class"] and description.class_needed is coordinator.data.user.stats.Class
): ):
if description.key not in skills_added: if description.key not in skills_added:
buttons.append(HabiticaButton(coordinator, description)) buttons.append(HabiticaButton(coordinator, description))
@ -322,17 +331,17 @@ class HabiticaButton(HabiticaBase, ButtonEntity):
"""Handle the button press.""" """Handle the button press."""
try: try:
await self.entity_description.press_fn(self.coordinator) await self.entity_description.press_fn(self.coordinator)
except ClientResponseError as e: except TooManyRequestsError as e:
if e.status == HTTPStatus.TOO_MANY_REQUESTS: raise HomeAssistantError(
raise ServiceValidationError( translation_domain=DOMAIN,
translation_domain=DOMAIN, translation_key="setup_rate_limit_exception",
translation_key="setup_rate_limit_exception", ) from e
) from e except NotAuthorizedError as e:
if e.status == HTTPStatus.UNAUTHORIZED: raise ServiceValidationError(
raise ServiceValidationError( translation_domain=DOMAIN,
translation_domain=DOMAIN, translation_key="service_call_unallowed",
translation_key="service_call_unallowed", ) from e
) from e except (HabiticaException, ClientError) as e:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="service_call_exception", translation_key="service_call_exception",

View File

@ -5,8 +5,11 @@ from __future__ import annotations
from abc import abstractmethod from abc import abstractmethod
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from enum import StrEnum from enum import StrEnum
from typing import TYPE_CHECKING
from uuid import UUID
from dateutil.rrule import rrule from dateutil.rrule import rrule
from habiticalib import TaskType
from homeassistant.components.calendar import ( from homeassistant.components.calendar import (
CalendarEntity, CalendarEntity,
@ -20,7 +23,6 @@ from homeassistant.util import dt as dt_util
from . import HabiticaConfigEntry from . import HabiticaConfigEntry
from .coordinator import HabiticaDataUpdateCoordinator from .coordinator import HabiticaDataUpdateCoordinator
from .entity import HabiticaBase from .entity import HabiticaBase
from .types import HabiticaTaskType
from .util import build_rrule, get_recurrence_rule from .util import build_rrule, get_recurrence_rule
@ -83,9 +85,7 @@ class HabiticaCalendarEntity(HabiticaBase, CalendarEntity):
@property @property
def start_of_today(self) -> datetime: def start_of_today(self) -> datetime:
"""Habitica daystart.""" """Habitica daystart."""
return dt_util.start_of_local_day( return dt_util.start_of_local_day(self.coordinator.data.user.lastCron)
datetime.fromisoformat(self.coordinator.data.user["lastCron"])
)
def get_recurrence_dates( def get_recurrence_dates(
self, recurrences: rrule, start_date: datetime, end_date: datetime | None = None self, recurrences: rrule, start_date: datetime, end_date: datetime | None = None
@ -115,13 +115,13 @@ class HabiticaTodosCalendarEntity(HabiticaCalendarEntity):
events = [] events = []
for task in self.coordinator.data.tasks: for task in self.coordinator.data.tasks:
if not ( if not (
task["type"] == HabiticaTaskType.TODO task.Type is TaskType.TODO
and not task["completed"] and not task.completed
and task.get("date") # only if has due date and task.date is not None # only if has due date
): ):
continue continue
start = dt_util.start_of_local_day(datetime.fromisoformat(task["date"])) start = dt_util.start_of_local_day(task.date)
end = start + timedelta(days=1) end = start + timedelta(days=1)
# return current and upcoming events or events within the requested range # return current and upcoming events or events within the requested range
@ -132,21 +132,23 @@ class HabiticaTodosCalendarEntity(HabiticaCalendarEntity):
if end_date and start > end_date: if end_date and start > end_date:
# Event starts after date range # Event starts after date range
continue continue
if TYPE_CHECKING:
assert task.text
assert task.id
events.append( events.append(
CalendarEvent( CalendarEvent(
start=start.date(), start=start.date(),
end=end.date(), end=end.date(),
summary=task["text"], summary=task.text,
description=task["notes"], description=task.notes,
uid=task["id"], uid=str(task.id),
) )
) )
return sorted( return sorted(
events, events,
key=lambda event: ( key=lambda event: (
event.start, event.start,
self.coordinator.data.user["tasksOrder"]["todos"].index(event.uid), self.coordinator.data.user.tasksOrder.todos.index(UUID(event.uid)),
), ),
) )
@ -189,7 +191,7 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
events = [] events = []
for task in self.coordinator.data.tasks: for task in self.coordinator.data.tasks:
# only dailies that that are not 'grey dailies' # only dailies that that are not 'grey dailies'
if not (task["type"] == HabiticaTaskType.DAILY and task["everyX"]): if not (task.Type is TaskType.DAILY and task.everyX):
continue continue
recurrences = build_rrule(task) recurrences = build_rrule(task)
@ -199,19 +201,21 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
for recurrence in recurrence_dates: for recurrence in recurrence_dates:
is_future_event = recurrence > self.start_of_today is_future_event = recurrence > self.start_of_today
is_current_event = ( is_current_event = (
recurrence <= self.start_of_today and not task["completed"] recurrence <= self.start_of_today and not task.completed
) )
if not is_future_event and not is_current_event: if not is_future_event and not is_current_event:
continue continue
if TYPE_CHECKING:
assert task.text
assert task.id
events.append( events.append(
CalendarEvent( CalendarEvent(
start=recurrence.date(), start=recurrence.date(),
end=self.end_date(recurrence, end_date), end=self.end_date(recurrence, end_date),
summary=task["text"], summary=task.text,
description=task["notes"], description=task.notes,
uid=task["id"], uid=str(task.id),
rrule=get_recurrence_rule(recurrences), rrule=get_recurrence_rule(recurrences),
) )
) )
@ -219,7 +223,7 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
events, events,
key=lambda event: ( key=lambda event: (
event.start, event.start,
self.coordinator.data.user["tasksOrder"]["dailys"].index(event.uid), self.coordinator.data.user.tasksOrder.dailys.index(UUID(event.uid)),
), ),
) )
@ -254,14 +258,14 @@ class HabiticaTodoRemindersCalendarEntity(HabiticaCalendarEntity):
events = [] events = []
for task in self.coordinator.data.tasks: for task in self.coordinator.data.tasks:
if task["type"] != HabiticaTaskType.TODO or task["completed"]: if task.Type is not TaskType.TODO or task.completed:
continue continue
for reminder in task.get("reminders", []): for reminder in task.reminders:
# reminders are returned by the API in local time but with wrong # reminders are returned by the API in local time but with wrong
# timezone (UTC) and arbitrary added seconds/microseconds. When # timezone (UTC) and arbitrary added seconds/microseconds. When
# creating reminders in Habitica only hours and minutes can be defined. # creating reminders in Habitica only hours and minutes can be defined.
start = datetime.fromisoformat(reminder["time"]).replace( start = reminder.time.replace(
tzinfo=dt_util.DEFAULT_TIME_ZONE, second=0, microsecond=0 tzinfo=dt_util.DEFAULT_TIME_ZONE, second=0, microsecond=0
) )
end = start + timedelta(hours=1) end = start + timedelta(hours=1)
@ -273,14 +277,16 @@ class HabiticaTodoRemindersCalendarEntity(HabiticaCalendarEntity):
if end_date and start > end_date: if end_date and start > end_date:
# Event starts after date range # Event starts after date range
continue continue
if TYPE_CHECKING:
assert task.text
assert task.id
events.append( events.append(
CalendarEvent( CalendarEvent(
start=start, start=start,
end=end, end=end,
summary=task["text"], summary=task.text,
description=task["notes"], description=task.notes,
uid=f"{task["id"]}_{reminder["id"]}", uid=f"{task.id}_{reminder.id}",
) )
) )
@ -298,7 +304,7 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
translation_key=HabiticaCalendar.DAILY_REMINDERS, translation_key=HabiticaCalendar.DAILY_REMINDERS,
) )
def start(self, reminder_time: str, reminder_date: date) -> datetime: def start(self, reminder_time: datetime, reminder_date: date) -> datetime:
"""Generate reminder times for dailies. """Generate reminder times for dailies.
Reminders for dailies have a datetime but the date part is arbitrary, Reminders for dailies have a datetime but the date part is arbitrary,
@ -307,12 +313,10 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
""" """
return datetime.combine( return datetime.combine(
reminder_date, reminder_date,
datetime.fromisoformat(reminder_time) reminder_time.replace(
.replace(
second=0, second=0,
microsecond=0, microsecond=0,
) ).time(),
.time(),
tzinfo=dt_util.DEFAULT_TIME_ZONE, tzinfo=dt_util.DEFAULT_TIME_ZONE,
) )
@ -327,7 +331,7 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
start_date = max(start_date, self.start_of_today) start_date = max(start_date, self.start_of_today)
for task in self.coordinator.data.tasks: for task in self.coordinator.data.tasks:
if not (task["type"] == HabiticaTaskType.DAILY and task["everyX"]): if not (task.Type is TaskType.DAILY and task.everyX):
continue continue
recurrences = build_rrule(task) recurrences = build_rrule(task)
@ -339,27 +343,30 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
for recurrence in recurrence_dates: for recurrence in recurrence_dates:
is_future_event = recurrence > self.start_of_today is_future_event = recurrence > self.start_of_today
is_current_event = ( is_current_event = (
recurrence <= self.start_of_today and not task["completed"] recurrence <= self.start_of_today and not task.completed
) )
if not is_future_event and not is_current_event: if not is_future_event and not is_current_event:
continue continue
for reminder in task.get("reminders", []): for reminder in task.reminders:
start = self.start(reminder["time"], recurrence) start = self.start(reminder.time, recurrence)
end = start + timedelta(hours=1) end = start + timedelta(hours=1)
if end < start_date: if end < start_date:
# Event ends before date range # Event ends before date range
continue continue
if TYPE_CHECKING:
assert task.id
assert task.text
events.append( events.append(
CalendarEvent( CalendarEvent(
start=start, start=start,
end=end, end=end,
summary=task["text"], summary=task.text,
description=task["notes"], description=task.notes,
uid=f"{task["id"]}_{reminder["id"]}", uid=f"{task.id}_{reminder.id}",
) )
) )

View File

@ -2,17 +2,17 @@
from __future__ import annotations from __future__ import annotations
from http import HTTPStatus
import logging import logging
from typing import Any from typing import TYPE_CHECKING, Any
from aiohttp import ClientResponseError from aiohttp import ClientError
from habitipy.aio import HabitipyAsync from habiticalib import Habitica, HabiticaException, NotAuthorizedError
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import ( from homeassistant.const import (
CONF_API_KEY, CONF_API_KEY,
CONF_NAME,
CONF_PASSWORD, CONF_PASSWORD,
CONF_URL, CONF_URL,
CONF_USERNAME, CONF_USERNAME,
@ -33,6 +33,7 @@ from .const import (
HABITICANS_URL, HABITICANS_URL,
SIGN_UP_URL, SIGN_UP_URL,
SITE_DATA_URL, SITE_DATA_URL,
X_CLIENT,
) )
STEP_ADVANCED_DATA_SCHEMA = vol.Schema( STEP_ADVANCED_DATA_SCHEMA = vol.Schema(
@ -93,39 +94,33 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
""" """
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
session = async_get_clientsession(self.hass)
api = Habitica(session=session, x_client=X_CLIENT)
try: try:
session = async_get_clientsession(self.hass) login = await api.login(
api = await self.hass.async_add_executor_job(
HabitipyAsync,
{
"login": "",
"password": "",
"url": DEFAULT_URL,
},
)
login_response = await api.user.auth.local.login.post(
session=session,
username=user_input[CONF_USERNAME], username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD], password=user_input[CONF_PASSWORD],
) )
user = await api.get_user(user_fields="profile")
except ClientResponseError as ex: except NotAuthorizedError:
if ex.status == HTTPStatus.UNAUTHORIZED: errors["base"] = "invalid_auth"
errors["base"] = "invalid_auth" except (HabiticaException, ClientError):
else: errors["base"] = "cannot_connect"
errors["base"] = "cannot_connect"
except Exception: except Exception:
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
else: else:
await self.async_set_unique_id(login_response["id"]) await self.async_set_unique_id(str(login.data.id))
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
if TYPE_CHECKING:
assert user.data.profile.name
return self.async_create_entry( return self.async_create_entry(
title=login_response["username"], title=user.data.profile.name,
data={ data={
CONF_API_USER: login_response["id"], CONF_API_USER: str(login.data.id),
CONF_API_KEY: login_response["apiToken"], CONF_API_KEY: login.data.apiToken,
CONF_USERNAME: login_response["username"], CONF_NAME: user.data.profile.name, # needed for api_call action
CONF_URL: DEFAULT_URL, CONF_URL: DEFAULT_URL,
CONF_VERIFY_SSL: True, CONF_VERIFY_SSL: True,
}, },
@ -150,36 +145,37 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
""" """
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
session = async_get_clientsession(
self.hass, verify_ssl=user_input[CONF_VERIFY_SSL]
)
try: try:
session = async_get_clientsession( api = Habitica(
self.hass, verify_ssl=user_input.get(CONF_VERIFY_SSL, True)
)
api = await self.hass.async_add_executor_job(
HabitipyAsync,
{
"login": user_input[CONF_API_USER],
"password": user_input[CONF_API_KEY],
"url": user_input.get(CONF_URL, DEFAULT_URL),
},
)
api_response = await api.user.get(
session=session, session=session,
userFields="auth", x_client=X_CLIENT,
api_user=user_input[CONF_API_USER],
api_key=user_input[CONF_API_KEY],
url=user_input.get(CONF_URL, DEFAULT_URL),
) )
except ClientResponseError as ex: user = await api.get_user(user_fields="profile")
if ex.status == HTTPStatus.UNAUTHORIZED: except NotAuthorizedError:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
else: except (HabiticaException, ClientError):
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except Exception: except Exception:
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
else: else:
await self.async_set_unique_id(user_input[CONF_API_USER]) await self.async_set_unique_id(user_input[CONF_API_USER])
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
user_input[CONF_USERNAME] = api_response["auth"]["local"]["username"] if TYPE_CHECKING:
assert user.data.profile.name
return self.async_create_entry( return self.async_create_entry(
title=user_input[CONF_USERNAME], data=user_input title=user.data.profile.name,
data={
**user_input,
CONF_URL: user_input.get(CONF_URL, DEFAULT_URL),
CONF_NAME: user.data.profile.name, # needed for api_call action
},
) )
return self.async_show_form( return self.async_show_form(

View File

@ -1,6 +1,6 @@
"""Constants for the habitica integration.""" """Constants for the habitica integration."""
from homeassistant.const import CONF_PATH from homeassistant.const import APPLICATION_NAME, CONF_PATH, __version__
CONF_API_USER = "api_user" CONF_API_USER = "api_user"
@ -44,9 +44,5 @@ SERVICE_SCORE_REWARD = "score_reward"
SERVICE_TRANSFORMATION = "transformation" SERVICE_TRANSFORMATION = "transformation"
WARRIOR = "warrior"
ROGUE = "rogue"
HEALER = "healer"
MAGE = "wizard"
DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf" DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf"
X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}"

View File

@ -5,16 +5,25 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from http import HTTPStatus
import logging import logging
from typing import Any from typing import Any
from aiohttp import ClientResponseError from aiohttp import ClientError
from habitipy.aio import HabitipyAsync from habiticalib import (
ContentData,
Habitica,
HabiticaException,
NotAuthorizedError,
TaskData,
TaskFilter,
TooManyRequestsError,
UserData,
)
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -25,10 +34,10 @@ _LOGGER = logging.getLogger(__name__)
@dataclass @dataclass
class HabiticaData: class HabiticaData:
"""Coordinator data class.""" """Habitica data."""
user: dict[str, Any] user: UserData
tasks: list[dict] tasks: list[TaskData]
class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
@ -36,7 +45,7 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
config_entry: ConfigEntry config_entry: ConfigEntry
def __init__(self, hass: HomeAssistant, habitipy: HabitipyAsync) -> None: def __init__(self, hass: HomeAssistant, habitica: Habitica) -> None:
"""Initialize the Habitica data coordinator.""" """Initialize the Habitica data coordinator."""
super().__init__( super().__init__(
hass, hass,
@ -50,25 +59,54 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
immediate=False, immediate=False,
), ),
) )
self.api = habitipy self.habitica = habitica
self.content: dict[str, Any] = {} self.content: ContentData
async def _async_setup(self) -> None:
"""Set up Habitica integration."""
try:
user = await self.habitica.get_user()
self.content = (
await self.habitica.get_content(user.data.preferences.language)
).data
except NotAuthorizedError as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"account": self.config_entry.title},
) from e
except TooManyRequestsError as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
except (HabiticaException, ClientError) as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="service_call_exception",
) from e
if not self.config_entry.data.get(CONF_NAME):
self.hass.config_entries.async_update_entry(
self.config_entry,
data={**self.config_entry.data, CONF_NAME: user.data.profile.name},
)
async def _async_update_data(self) -> HabiticaData: async def _async_update_data(self) -> HabiticaData:
try: try:
user_response = await self.api.user.get() user = (await self.habitica.get_user()).data
tasks_response = await self.api.tasks.user.get() tasks = (await self.habitica.get_tasks()).data
tasks_response.extend(await self.api.tasks.user.get(type="completedTodos")) completed_todos = (
if not self.content: await self.habitica.get_tasks(TaskFilter.COMPLETED_TODOS)
self.content = await self.api.content.get( ).data
language=user_response["preferences"]["language"] except TooManyRequestsError:
) _LOGGER.debug("Rate limit exceeded, will try again later")
except ClientResponseError as error: return self.data
if error.status == HTTPStatus.TOO_MANY_REQUESTS: except (HabiticaException, ClientError) as e:
_LOGGER.debug("Rate limit exceeded, will try again later") raise UpdateFailed(f"Unable to connect to Habitica: {e}") from e
return self.data else:
raise UpdateFailed(f"Unable to connect to Habitica: {error}") from error return HabiticaData(user=user, tasks=tasks + completed_todos)
return HabiticaData(user=user_response, tasks=tasks_response)
async def execute( async def execute(
self, func: Callable[[HabiticaDataUpdateCoordinator], Any] self, func: Callable[[HabiticaDataUpdateCoordinator], Any]
@ -77,12 +115,12 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
try: try:
await func(self) await func(self)
except ClientResponseError as e: except TooManyRequestsError as e:
if e.status == HTTPStatus.TOO_MANY_REQUESTS: raise HomeAssistantError(
raise ServiceValidationError( translation_domain=DOMAIN,
translation_domain=DOMAIN, translation_key="setup_rate_limit_exception",
translation_key="setup_rate_limit_exception", ) from e
) from e except (HabiticaException, ClientError) as e:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="service_call_exception", translation_key="service_call_exception",

View File

@ -16,12 +16,12 @@ async def async_get_config_entry_diagnostics(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return diagnostics for a config entry.""" """Return diagnostics for a config entry."""
habitica_data = await config_entry.runtime_data.api.user.anonymized.get() habitica_data = await config_entry.runtime_data.habitica.get_user_anonymized()
return { return {
"config_entry_data": { "config_entry_data": {
CONF_URL: config_entry.data[CONF_URL], CONF_URL: config_entry.data[CONF_URL],
CONF_API_USER: config_entry.data[CONF_API_USER], CONF_API_USER: config_entry.data[CONF_API_USER],
}, },
"habitica_data": habitica_data, "habitica_data": habitica_data.to_dict()["data"],
} }

View File

@ -5,6 +5,6 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/habitica", "documentation": "https://www.home-assistant.io/integrations/habitica",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["habitipy", "plumbum"], "loggers": ["habiticalib"],
"requirements": ["habitipy==0.3.3"] "requirements": ["habiticalib==0.3.1"]
} }

View File

@ -3,11 +3,20 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
from dataclasses import dataclass from dataclasses import asdict, dataclass
from enum import StrEnum from enum import StrEnum
import logging import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from habiticalib import (
ContentData,
HabiticaClass,
TaskData,
TaskType,
UserData,
deserialize_task,
)
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN, DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass, SensorDeviceClass,
@ -36,10 +45,10 @@ _LOGGER = logging.getLogger(__name__)
class HabitipySensorEntityDescription(SensorEntityDescription): class HabitipySensorEntityDescription(SensorEntityDescription):
"""Habitipy Sensor Description.""" """Habitipy Sensor Description."""
value_fn: Callable[[dict[str, Any], dict[str, Any]], StateType] value_fn: Callable[[UserData, ContentData], StateType]
attributes_fn: ( attributes_fn: Callable[[UserData, ContentData], dict[str, Any] | None] | None = (
Callable[[dict[str, Any], dict[str, Any]], dict[str, Any] | None] | None None
) = None )
entity_picture: str | None = None entity_picture: str | None = None
@ -47,7 +56,7 @@ class HabitipySensorEntityDescription(SensorEntityDescription):
class HabitipyTaskSensorEntityDescription(SensorEntityDescription): class HabitipyTaskSensorEntityDescription(SensorEntityDescription):
"""Habitipy Task Sensor Description.""" """Habitipy Task Sensor Description."""
value_fn: Callable[[list[dict[str, Any]]], list[dict[str, Any]]] value_fn: Callable[[list[TaskData]], list[TaskData]]
class HabitipySensorEntity(StrEnum): class HabitipySensorEntity(StrEnum):
@ -79,75 +88,70 @@ SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = (
HabitipySensorEntityDescription( HabitipySensorEntityDescription(
key=HabitipySensorEntity.DISPLAY_NAME, key=HabitipySensorEntity.DISPLAY_NAME,
translation_key=HabitipySensorEntity.DISPLAY_NAME, translation_key=HabitipySensorEntity.DISPLAY_NAME,
value_fn=lambda user, _: user.get("profile", {}).get("name"), value_fn=lambda user, _: user.profile.name,
), ),
HabitipySensorEntityDescription( HabitipySensorEntityDescription(
key=HabitipySensorEntity.HEALTH, key=HabitipySensorEntity.HEALTH,
translation_key=HabitipySensorEntity.HEALTH, translation_key=HabitipySensorEntity.HEALTH,
suggested_display_precision=0, suggested_display_precision=0,
value_fn=lambda user, _: user.get("stats", {}).get("hp"), value_fn=lambda user, _: user.stats.hp,
), ),
HabitipySensorEntityDescription( HabitipySensorEntityDescription(
key=HabitipySensorEntity.HEALTH_MAX, key=HabitipySensorEntity.HEALTH_MAX,
translation_key=HabitipySensorEntity.HEALTH_MAX, translation_key=HabitipySensorEntity.HEALTH_MAX,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
value_fn=lambda user, _: user.get("stats", {}).get("maxHealth"), value_fn=lambda user, _: 50,
), ),
HabitipySensorEntityDescription( HabitipySensorEntityDescription(
key=HabitipySensorEntity.MANA, key=HabitipySensorEntity.MANA,
translation_key=HabitipySensorEntity.MANA, translation_key=HabitipySensorEntity.MANA,
suggested_display_precision=0, suggested_display_precision=0,
value_fn=lambda user, _: user.get("stats", {}).get("mp"), value_fn=lambda user, _: user.stats.mp,
), ),
HabitipySensorEntityDescription( HabitipySensorEntityDescription(
key=HabitipySensorEntity.MANA_MAX, key=HabitipySensorEntity.MANA_MAX,
translation_key=HabitipySensorEntity.MANA_MAX, translation_key=HabitipySensorEntity.MANA_MAX,
value_fn=lambda user, _: user.get("stats", {}).get("maxMP"), value_fn=lambda user, _: user.stats.maxMP,
), ),
HabitipySensorEntityDescription( HabitipySensorEntityDescription(
key=HabitipySensorEntity.EXPERIENCE, key=HabitipySensorEntity.EXPERIENCE,
translation_key=HabitipySensorEntity.EXPERIENCE, translation_key=HabitipySensorEntity.EXPERIENCE,
value_fn=lambda user, _: user.get("stats", {}).get("exp"), value_fn=lambda user, _: user.stats.exp,
), ),
HabitipySensorEntityDescription( HabitipySensorEntityDescription(
key=HabitipySensorEntity.EXPERIENCE_MAX, key=HabitipySensorEntity.EXPERIENCE_MAX,
translation_key=HabitipySensorEntity.EXPERIENCE_MAX, translation_key=HabitipySensorEntity.EXPERIENCE_MAX,
value_fn=lambda user, _: user.get("stats", {}).get("toNextLevel"), value_fn=lambda user, _: user.stats.toNextLevel,
), ),
HabitipySensorEntityDescription( HabitipySensorEntityDescription(
key=HabitipySensorEntity.LEVEL, key=HabitipySensorEntity.LEVEL,
translation_key=HabitipySensorEntity.LEVEL, translation_key=HabitipySensorEntity.LEVEL,
value_fn=lambda user, _: user.get("stats", {}).get("lvl"), value_fn=lambda user, _: user.stats.lvl,
), ),
HabitipySensorEntityDescription( HabitipySensorEntityDescription(
key=HabitipySensorEntity.GOLD, key=HabitipySensorEntity.GOLD,
translation_key=HabitipySensorEntity.GOLD, translation_key=HabitipySensorEntity.GOLD,
suggested_display_precision=2, suggested_display_precision=2,
value_fn=lambda user, _: user.get("stats", {}).get("gp"), value_fn=lambda user, _: user.stats.gp,
), ),
HabitipySensorEntityDescription( HabitipySensorEntityDescription(
key=HabitipySensorEntity.CLASS, key=HabitipySensorEntity.CLASS,
translation_key=HabitipySensorEntity.CLASS, translation_key=HabitipySensorEntity.CLASS,
value_fn=lambda user, _: user.get("stats", {}).get("class"), value_fn=lambda user, _: user.stats.Class.value if user.stats.Class else None,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=["warrior", "healer", "wizard", "rogue"], options=[item.value for item in HabiticaClass],
), ),
HabitipySensorEntityDescription( HabitipySensorEntityDescription(
key=HabitipySensorEntity.GEMS, key=HabitipySensorEntity.GEMS,
translation_key=HabitipySensorEntity.GEMS, translation_key=HabitipySensorEntity.GEMS,
value_fn=lambda user, _: user.get("balance", 0) * 4, value_fn=lambda user, _: round(user.balance * 4) if user.balance else None,
suggested_display_precision=0, suggested_display_precision=0,
entity_picture="shop_gem.png", entity_picture="shop_gem.png",
), ),
HabitipySensorEntityDescription( HabitipySensorEntityDescription(
key=HabitipySensorEntity.TRINKETS, key=HabitipySensorEntity.TRINKETS,
translation_key=HabitipySensorEntity.TRINKETS, translation_key=HabitipySensorEntity.TRINKETS,
value_fn=( value_fn=lambda user, _: user.purchased.plan.consecutive.trinkets or 0,
lambda user, _: user.get("purchased", {})
.get("plan", {})
.get("consecutive", {})
.get("trinkets", 0)
),
suggested_display_precision=0, suggested_display_precision=0,
native_unit_of_measurement="", native_unit_of_measurement="",
entity_picture="notif_subscriber_reward.png", entity_picture="notif_subscriber_reward.png",
@ -155,16 +159,16 @@ SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = (
HabitipySensorEntityDescription( HabitipySensorEntityDescription(
key=HabitipySensorEntity.STRENGTH, key=HabitipySensorEntity.STRENGTH,
translation_key=HabitipySensorEntity.STRENGTH, translation_key=HabitipySensorEntity.STRENGTH,
value_fn=lambda user, content: get_attributes_total(user, content, "str"), value_fn=lambda user, content: get_attributes_total(user, content, "Str"),
attributes_fn=lambda user, content: get_attribute_points(user, content, "str"), attributes_fn=lambda user, content: get_attribute_points(user, content, "Str"),
suggested_display_precision=0, suggested_display_precision=0,
native_unit_of_measurement="STR", native_unit_of_measurement="STR",
), ),
HabitipySensorEntityDescription( HabitipySensorEntityDescription(
key=HabitipySensorEntity.INTELLIGENCE, key=HabitipySensorEntity.INTELLIGENCE,
translation_key=HabitipySensorEntity.INTELLIGENCE, translation_key=HabitipySensorEntity.INTELLIGENCE,
value_fn=lambda user, content: get_attributes_total(user, content, "int"), value_fn=lambda user, content: get_attributes_total(user, content, "Int"),
attributes_fn=lambda user, content: get_attribute_points(user, content, "int"), attributes_fn=lambda user, content: get_attribute_points(user, content, "Int"),
suggested_display_precision=0, suggested_display_precision=0,
native_unit_of_measurement="INT", native_unit_of_measurement="INT",
), ),
@ -203,7 +207,7 @@ TASKS_MAP = {
"yester_daily": "yesterDaily", "yester_daily": "yesterDaily",
"completed": "completed", "completed": "completed",
"collapse_checklist": "collapseChecklist", "collapse_checklist": "collapseChecklist",
"type": "type", "type": "Type",
"notes": "notes", "notes": "notes",
"tags": "tags", "tags": "tags",
"value": "value", "value": "value",
@ -221,26 +225,28 @@ TASK_SENSOR_DESCRIPTION: tuple[HabitipyTaskSensorEntityDescription, ...] = (
HabitipyTaskSensorEntityDescription( HabitipyTaskSensorEntityDescription(
key=HabitipySensorEntity.HABITS, key=HabitipySensorEntity.HABITS,
translation_key=HabitipySensorEntity.HABITS, translation_key=HabitipySensorEntity.HABITS,
value_fn=lambda tasks: [r for r in tasks if r.get("type") == "habit"], value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.HABIT],
), ),
HabitipyTaskSensorEntityDescription( HabitipyTaskSensorEntityDescription(
key=HabitipySensorEntity.DAILIES, key=HabitipySensorEntity.DAILIES,
translation_key=HabitipySensorEntity.DAILIES, translation_key=HabitipySensorEntity.DAILIES,
value_fn=lambda tasks: [r for r in tasks if r.get("type") == "daily"], value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.DAILY],
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
), ),
HabitipyTaskSensorEntityDescription( HabitipyTaskSensorEntityDescription(
key=HabitipySensorEntity.TODOS, key=HabitipySensorEntity.TODOS,
translation_key=HabitipySensorEntity.TODOS, translation_key=HabitipySensorEntity.TODOS,
value_fn=lambda tasks: [ value_fn=(
r for r in tasks if r.get("type") == "todo" and not r.get("completed") lambda tasks: [
], r for r in tasks if r.Type is TaskType.TODO and not r.completed
]
),
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
), ),
HabitipyTaskSensorEntityDescription( HabitipyTaskSensorEntityDescription(
key=HabitipySensorEntity.REWARDS, key=HabitipySensorEntity.REWARDS,
translation_key=HabitipySensorEntity.REWARDS, translation_key=HabitipySensorEntity.REWARDS,
value_fn=lambda tasks: [r for r in tasks if r.get("type") == "reward"], value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.REWARD],
), ),
) )
@ -309,15 +315,14 @@ class HabitipyTaskSensor(HabiticaBase, SensorEntity):
attrs = {} attrs = {}
# Map tasks to TASKS_MAP # Map tasks to TASKS_MAP
for received_task in self.entity_description.value_fn( for task_data in self.entity_description.value_fn(self.coordinator.data.tasks):
self.coordinator.data.tasks received_task = deserialize_task(asdict(task_data))
):
task_id = received_task[TASKS_MAP_ID] task_id = received_task[TASKS_MAP_ID]
task = {} task = {}
for map_key, map_value in TASKS_MAP.items(): for map_key, map_value in TASKS_MAP.items():
if value := received_task.get(map_value): if value := received_task.get(map_value):
task[map_key] = value task[map_key] = value
attrs[task_id] = task attrs[str(task_id)] = task
return attrs return attrs
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:

View File

@ -2,11 +2,19 @@
from __future__ import annotations from __future__ import annotations
from http import HTTPStatus from dataclasses import asdict
import logging import logging
from typing import Any from typing import TYPE_CHECKING
from aiohttp import ClientResponseError from aiohttp import ClientError
from habiticalib import (
Direction,
HabiticaException,
NotAuthorizedError,
NotFoundError,
Skill,
TooManyRequestsError,
)
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
@ -88,6 +96,25 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema(
} }
) )
SKILL_MAP = {
"pickpocket": Skill.PICKPOCKET,
"backstab": Skill.BACKSTAB,
"smash": Skill.BRUTAL_SMASH,
"fireball": Skill.BURST_OF_FLAMES,
}
COST_MAP = {
"pickpocket": "10 MP",
"backstab": "15 MP",
"smash": "10 MP",
"fireball": "10 MP",
}
ITEMID_MAP = {
"snowball": Skill.SNOWBALL,
"spooky_sparkles": Skill.SPOOKY_SPARKLES,
"seafoam": Skill.SEAFOAM,
"shiny_seed": Skill.SHINY_SEED,
}
def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry:
"""Return config entry or raise if not found or not loaded.""" """Return config entry or raise if not found or not loaded."""
@ -123,12 +150,12 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
name = call.data[ATTR_NAME] name = call.data[ATTR_NAME]
path = call.data[ATTR_PATH] path = call.data[ATTR_PATH]
entries = hass.config_entries.async_entries(DOMAIN) entries: list[HabiticaConfigEntry] = hass.config_entries.async_entries(DOMAIN)
api = None api = None
for entry in entries: for entry in entries:
if entry.data[CONF_NAME] == name: if entry.data[CONF_NAME] == name:
api = entry.runtime_data.api api = await entry.runtime_data.habitica.habitipy()
break break
if api is None: if api is None:
_LOGGER.error("API_CALL: User '%s' not configured", name) _LOGGER.error("API_CALL: User '%s' not configured", name)
@ -151,18 +178,15 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
"""Skill action.""" """Skill action."""
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
coordinator = entry.runtime_data coordinator = entry.runtime_data
skill = {
"pickpocket": {"spellId": "pickPocket", "cost": "10 MP"}, skill = SKILL_MAP[call.data[ATTR_SKILL]]
"backstab": {"spellId": "backStab", "cost": "15 MP"}, cost = COST_MAP[call.data[ATTR_SKILL]]
"smash": {"spellId": "smash", "cost": "10 MP"},
"fireball": {"spellId": "fireball", "cost": "10 MP"},
}
try: try:
task_id = next( task_id = next(
task["id"] task.id
for task in coordinator.data.tasks for task in coordinator.data.tasks
if call.data[ATTR_TASK] in (task["id"], task.get("alias")) if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text)
or call.data[ATTR_TASK] == task["text"]
) )
except StopIteration as e: except StopIteration as e:
raise ServiceValidationError( raise ServiceValidationError(
@ -172,75 +196,76 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
) from e ) from e
try: try:
response: dict[str, Any] = await coordinator.api.user.class_.cast[ response = await coordinator.habitica.cast_skill(skill, task_id)
skill[call.data[ATTR_SKILL]]["spellId"] except TooManyRequestsError as e:
].post(targetId=task_id) raise HomeAssistantError(
except ClientResponseError as e: translation_domain=DOMAIN,
if e.status == HTTPStatus.TOO_MANY_REQUESTS: translation_key="setup_rate_limit_exception",
raise ServiceValidationError( ) from e
translation_domain=DOMAIN, except NotAuthorizedError as e:
translation_key="setup_rate_limit_exception", raise ServiceValidationError(
) from e translation_domain=DOMAIN,
if e.status == HTTPStatus.UNAUTHORIZED: translation_key="not_enough_mana",
raise ServiceValidationError( translation_placeholders={
translation_domain=DOMAIN, "cost": cost,
translation_key="not_enough_mana", "mana": f"{int(coordinator.data.user.stats.mp or 0)} MP",
translation_placeholders={ },
"cost": skill[call.data[ATTR_SKILL]]["cost"], ) from e
"mana": f"{int(coordinator.data.user.get("stats", {}).get("mp", 0))} MP", except NotFoundError as e:
}, # could also be task not found, but the task is looked up
) from e # before the request, so most likely wrong skill selected
if e.status == HTTPStatus.NOT_FOUND: # or the skill hasn't been unlocked yet.
# could also be task not found, but the task is looked up raise ServiceValidationError(
# before the request, so most likely wrong skill selected translation_domain=DOMAIN,
# or the skill hasn't been unlocked yet. translation_key="skill_not_found",
raise ServiceValidationError( translation_placeholders={"skill": call.data[ATTR_SKILL]},
translation_domain=DOMAIN, ) from e
translation_key="skill_not_found", except (HabiticaException, ClientError) as e:
translation_placeholders={"skill": call.data[ATTR_SKILL]},
) from e
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="service_call_exception", translation_key="service_call_exception",
) from e ) from e
else: else:
await coordinator.async_request_refresh() await coordinator.async_request_refresh()
return response return asdict(response.data)
async def manage_quests(call: ServiceCall) -> ServiceResponse: async def manage_quests(call: ServiceCall) -> ServiceResponse:
"""Accept, reject, start, leave or cancel quests.""" """Accept, reject, start, leave or cancel quests."""
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
coordinator = entry.runtime_data coordinator = entry.runtime_data
COMMAND_MAP = { FUNC_MAP = {
SERVICE_ABORT_QUEST: "abort", SERVICE_ABORT_QUEST: coordinator.habitica.abort_quest,
SERVICE_ACCEPT_QUEST: "accept", SERVICE_ACCEPT_QUEST: coordinator.habitica.accept_quest,
SERVICE_CANCEL_QUEST: "cancel", SERVICE_CANCEL_QUEST: coordinator.habitica.cancel_quest,
SERVICE_LEAVE_QUEST: "leave", SERVICE_LEAVE_QUEST: coordinator.habitica.leave_quest,
SERVICE_REJECT_QUEST: "reject", SERVICE_REJECT_QUEST: coordinator.habitica.reject_quest,
SERVICE_START_QUEST: "force-start", SERVICE_START_QUEST: coordinator.habitica.start_quest,
} }
func = FUNC_MAP[call.service]
try: try:
return await coordinator.api.groups.party.quests[ response = await func()
COMMAND_MAP[call.service] except TooManyRequestsError as e:
].post() raise HomeAssistantError(
except ClientResponseError as e: translation_domain=DOMAIN,
if e.status == HTTPStatus.TOO_MANY_REQUESTS: translation_key="setup_rate_limit_exception",
raise ServiceValidationError( ) from e
translation_domain=DOMAIN, except NotAuthorizedError as e:
translation_key="setup_rate_limit_exception", raise ServiceValidationError(
) from e translation_domain=DOMAIN, translation_key="quest_action_unallowed"
if e.status == HTTPStatus.UNAUTHORIZED: ) from e
raise ServiceValidationError( except NotFoundError as e:
translation_domain=DOMAIN, translation_key="quest_action_unallowed" raise ServiceValidationError(
) from e translation_domain=DOMAIN, translation_key="quest_not_found"
if e.status == HTTPStatus.NOT_FOUND: ) from e
raise ServiceValidationError( except (HabiticaException, ClientError) as e:
translation_domain=DOMAIN, translation_key="quest_not_found"
) from e
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="service_call_exception" translation_domain=DOMAIN, translation_key="service_call_exception"
) from e ) from e
else:
return asdict(response.data)
for service in ( for service in (
SERVICE_ABORT_QUEST, SERVICE_ABORT_QUEST,
@ -262,12 +287,15 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
"""Score a task action.""" """Score a task action."""
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
coordinator = entry.runtime_data coordinator = entry.runtime_data
direction = (
Direction.DOWN if call.data.get(ATTR_DIRECTION) == "down" else Direction.UP
)
try: try:
task_id, task_value = next( task_id, task_value = next(
(task["id"], task.get("value")) (task.id, task.value)
for task in coordinator.data.tasks for task in coordinator.data.tasks
if call.data[ATTR_TASK] in (task["id"], task.get("alias")) if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text)
or call.data[ATTR_TASK] == task["text"]
) )
except StopIteration as e: except StopIteration as e:
raise ServiceValidationError( raise ServiceValidationError(
@ -276,81 +304,76 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"},
) from e ) from e
if TYPE_CHECKING:
assert task_id
try: try:
response: dict[str, Any] = ( response = await coordinator.habitica.update_score(task_id, direction)
await coordinator.api.tasks[task_id] except TooManyRequestsError as e:
.score[call.data.get(ATTR_DIRECTION, "up")] raise HomeAssistantError(
.post() translation_domain=DOMAIN,
) translation_key="setup_rate_limit_exception",
except ClientResponseError as e: ) from e
if e.status == HTTPStatus.TOO_MANY_REQUESTS: except NotAuthorizedError as e:
raise ServiceValidationError( if task_value is not None:
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
if e.status == HTTPStatus.UNAUTHORIZED and task_value is not None:
raise ServiceValidationError( raise ServiceValidationError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="not_enough_gold", translation_key="not_enough_gold",
translation_placeholders={ translation_placeholders={
"gold": f"{coordinator.data.user["stats"]["gp"]:.2f} GP", "gold": f"{(coordinator.data.user.stats.gp or 0):.2f} GP",
"cost": f"{task_value} GP", "cost": f"{task_value:.2f} GP",
}, },
) from e ) from e
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="service_call_exception", translation_key="service_call_exception",
) from e ) from e
except (HabiticaException, ClientError) as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
) from e
else: else:
await coordinator.async_request_refresh() await coordinator.async_request_refresh()
return response return asdict(response.data)
async def transformation(call: ServiceCall) -> ServiceResponse: async def transformation(call: ServiceCall) -> ServiceResponse:
"""User a transformation item on a player character.""" """User a transformation item on a player character."""
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
coordinator = entry.runtime_data coordinator = entry.runtime_data
ITEMID_MAP = {
"snowball": {"itemId": "snowball"}, item = ITEMID_MAP[call.data[ATTR_ITEM]]
"spooky_sparkles": {"itemId": "spookySparkles"},
"seafoam": {"itemId": "seafoam"},
"shiny_seed": {"itemId": "shinySeed"},
}
# check if target is self # check if target is self
if call.data[ATTR_TARGET] in ( if call.data[ATTR_TARGET] in (
coordinator.data.user["id"], str(coordinator.data.user.id),
coordinator.data.user["profile"]["name"], coordinator.data.user.profile.name,
coordinator.data.user["auth"]["local"]["username"], coordinator.data.user.auth.local.username,
): ):
target_id = coordinator.data.user["id"] target_id = coordinator.data.user.id
else: else:
# check if target is a party member # check if target is a party member
try: try:
party = await coordinator.api.groups.party.members.get() party = await coordinator.habitica.get_group_members(public_fields=True)
except ClientResponseError as e: except NotFoundError as e:
if e.status == HTTPStatus.TOO_MANY_REQUESTS: raise ServiceValidationError(
raise ServiceValidationError( translation_domain=DOMAIN,
translation_domain=DOMAIN, translation_key="party_not_found",
translation_key="setup_rate_limit_exception", ) from e
) from e except (ClientError, HabiticaException) as e:
if e.status == HTTPStatus.NOT_FOUND:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="party_not_found",
) from e
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="service_call_exception", translation_key="service_call_exception",
) from e ) from e
try: try:
target_id = next( target_id = next(
member["id"] member.id
for member in party for member in party.data
if call.data[ATTR_TARGET].lower() if member.id
and call.data[ATTR_TARGET].lower()
in ( in (
member["id"], str(member.id),
member["auth"]["local"]["username"].lower(), str(member.auth.local.username).lower(),
member["profile"]["name"].lower(), str(member.profile.name).lower(),
) )
) )
except StopIteration as e: except StopIteration as e:
@ -360,27 +383,25 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
translation_placeholders={"target": f"'{call.data[ATTR_TARGET]}'"}, translation_placeholders={"target": f"'{call.data[ATTR_TARGET]}'"},
) from e ) from e
try: try:
response: dict[str, Any] = await coordinator.api.user.class_.cast[ response = await coordinator.habitica.cast_skill(item, target_id)
ITEMID_MAP[call.data[ATTR_ITEM]]["itemId"] except TooManyRequestsError as e:
].post(targetId=target_id) raise HomeAssistantError(
except ClientResponseError as e: translation_domain=DOMAIN,
if e.status == HTTPStatus.TOO_MANY_REQUESTS: translation_key="setup_rate_limit_exception",
raise ServiceValidationError( ) from e
translation_domain=DOMAIN, except NotAuthorizedError as e:
translation_key="setup_rate_limit_exception", raise ServiceValidationError(
) from e translation_domain=DOMAIN,
if e.status == HTTPStatus.UNAUTHORIZED: translation_key="item_not_found",
raise ServiceValidationError( translation_placeholders={"item": call.data[ATTR_ITEM]},
translation_domain=DOMAIN, ) from e
translation_key="item_not_found", except (HabiticaException, ClientError) as e:
translation_placeholders={"item": call.data[ATTR_ITEM]},
) from e
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="service_call_exception", translation_key="service_call_exception",
) from e ) from e
else: else:
return response return asdict(response.data)
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,

View File

@ -49,7 +49,8 @@
"data_description": { "data_description": {
"url": "URL of the Habitica installation to connect to. Defaults to `{default_url}`", "url": "URL of the Habitica installation to connect to. Defaults to `{default_url}`",
"api_user": "User ID of your Habitica account", "api_user": "User ID of your Habitica account",
"api_key": "API Token of the Habitica account" "api_key": "API Token of the Habitica account",
"verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to a Habitica instance using a self-signed certificate"
}, },
"description": "You can retrieve your `User ID` and `API Token` from [**Settings -> Site Data**]({site_data}) on Habitica or the instance you want to connect to" "description": "You can retrieve your `User ID` and `API Token` from [**Settings -> Site Data**]({site_data}) on Habitica or the instance you want to connect to"
} }
@ -365,6 +366,9 @@
}, },
"item_not_found": { "item_not_found": {
"message": "Unable to use {item}, you don't own this item." "message": "Unable to use {item}, you don't own this item."
},
"invalid_auth": {
"message": "Authentication failed for {account}."
} }
}, },
"issues": { "issues": {

View File

@ -28,7 +28,7 @@ class HabiticaSwitchEntityDescription(SwitchEntityDescription):
turn_on_fn: Callable[[HabiticaDataUpdateCoordinator], Any] turn_on_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
turn_off_fn: Callable[[HabiticaDataUpdateCoordinator], Any] turn_off_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
is_on_fn: Callable[[HabiticaData], bool] is_on_fn: Callable[[HabiticaData], bool | None]
class HabiticaSwitchEntity(StrEnum): class HabiticaSwitchEntity(StrEnum):
@ -42,9 +42,9 @@ SWTICH_DESCRIPTIONS: tuple[HabiticaSwitchEntityDescription, ...] = (
key=HabiticaSwitchEntity.SLEEP, key=HabiticaSwitchEntity.SLEEP,
translation_key=HabiticaSwitchEntity.SLEEP, translation_key=HabiticaSwitchEntity.SLEEP,
device_class=SwitchDeviceClass.SWITCH, device_class=SwitchDeviceClass.SWITCH,
turn_on_fn=lambda coordinator: coordinator.api["user"]["sleep"].post(), turn_on_fn=lambda coordinator: coordinator.habitica.toggle_sleep(),
turn_off_fn=lambda coordinator: coordinator.api["user"]["sleep"].post(), turn_off_fn=lambda coordinator: coordinator.habitica.toggle_sleep(),
is_on_fn=lambda data: data.user["preferences"]["sleep"], is_on_fn=lambda data: data.user.preferences.sleep,
), ),
) )

View File

@ -2,11 +2,12 @@
from __future__ import annotations from __future__ import annotations
import datetime
from enum import StrEnum from enum import StrEnum
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID
from aiohttp import ClientResponseError from aiohttp import ClientError
from habiticalib import Direction, HabiticaException, Task, TaskType
from homeassistant.components import persistent_notification from homeassistant.components import persistent_notification
from homeassistant.components.todo import ( from homeassistant.components.todo import (
@ -24,7 +25,7 @@ from homeassistant.util import dt as dt_util
from .const import ASSETS_URL, DOMAIN from .const import ASSETS_URL, DOMAIN
from .coordinator import HabiticaDataUpdateCoordinator from .coordinator import HabiticaDataUpdateCoordinator
from .entity import HabiticaBase from .entity import HabiticaBase
from .types import HabiticaConfigEntry, HabiticaTaskType from .types import HabiticaConfigEntry
from .util import next_due_date from .util import next_due_date
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 1
@ -70,8 +71,8 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
"""Delete Habitica tasks.""" """Delete Habitica tasks."""
if len(uids) > 1 and self.entity_description.key is HabiticaTodoList.TODOS: if len(uids) > 1 and self.entity_description.key is HabiticaTodoList.TODOS:
try: try:
await self.coordinator.api.tasks.clearCompletedTodos.post() await self.coordinator.habitica.delete_completed_todos()
except ClientResponseError as e: except (HabiticaException, ClientError) as e:
raise ServiceValidationError( raise ServiceValidationError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="delete_completed_todos_failed", translation_key="delete_completed_todos_failed",
@ -79,8 +80,8 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
else: else:
for task_id in uids: for task_id in uids:
try: try:
await self.coordinator.api.tasks[task_id].delete() await self.coordinator.habitica.delete_task(UUID(task_id))
except ClientResponseError as e: except (HabiticaException, ClientError) as e:
raise ServiceValidationError( raise ServiceValidationError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key=f"delete_{self.entity_description.key}_failed", translation_key=f"delete_{self.entity_description.key}_failed",
@ -106,9 +107,8 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
pos = 0 pos = 0
try: try:
await self.coordinator.api.tasks[uid].move.to[str(pos)].post() await self.coordinator.habitica.reorder_task(UUID(uid), pos)
except (HabiticaException, ClientError) as e:
except ClientResponseError as e:
raise ServiceValidationError( raise ServiceValidationError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key=f"move_{self.entity_description.key}_item_failed", translation_key=f"move_{self.entity_description.key}_item_failed",
@ -118,12 +118,14 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
# move tasks in the coordinator until we have fresh data # move tasks in the coordinator until we have fresh data
tasks = self.coordinator.data.tasks tasks = self.coordinator.data.tasks
new_pos = ( new_pos = (
tasks.index(next(task for task in tasks if task["id"] == previous_uid)) tasks.index(
next(task for task in tasks if task.id == UUID(previous_uid))
)
+ 1 + 1
if previous_uid if previous_uid
else 0 else 0
) )
old_pos = tasks.index(next(task for task in tasks if task["id"] == uid)) old_pos = tasks.index(next(task for task in tasks if task.id == UUID(uid)))
tasks.insert(new_pos, tasks.pop(old_pos)) tasks.insert(new_pos, tasks.pop(old_pos))
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
@ -138,14 +140,17 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
if TYPE_CHECKING: if TYPE_CHECKING:
assert item.uid assert item.uid
assert current_item assert current_item
assert item.summary
task = Task(
text=item.summary,
notes=item.description or "",
)
if ( if (
self.entity_description.key is HabiticaTodoList.TODOS self.entity_description.key is HabiticaTodoList.TODOS
and item.due is not None
): # Only todos support a due date. ): # Only todos support a due date.
date = item.due.isoformat() task["date"] = item.due
else:
date = None
if ( if (
item.summary != current_item.summary item.summary != current_item.summary
@ -153,13 +158,9 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
or item.due != current_item.due or item.due != current_item.due
): ):
try: try:
await self.coordinator.api.tasks[item.uid].put( await self.coordinator.habitica.update_task(UUID(item.uid), task)
text=item.summary,
notes=item.description or "",
date=date,
)
refresh_required = True refresh_required = True
except ClientResponseError as e: except (HabiticaException, ClientError) as e:
raise ServiceValidationError( raise ServiceValidationError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key=f"update_{self.entity_description.key}_item_failed", translation_key=f"update_{self.entity_description.key}_item_failed",
@ -172,32 +173,33 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
current_item.status is TodoItemStatus.NEEDS_ACTION current_item.status is TodoItemStatus.NEEDS_ACTION
and item.status == TodoItemStatus.COMPLETED and item.status == TodoItemStatus.COMPLETED
): ):
score_result = ( score_result = await self.coordinator.habitica.update_score(
await self.coordinator.api.tasks[item.uid].score["up"].post() UUID(item.uid), Direction.UP
) )
refresh_required = True refresh_required = True
elif ( elif (
current_item.status is TodoItemStatus.COMPLETED current_item.status is TodoItemStatus.COMPLETED
and item.status == TodoItemStatus.NEEDS_ACTION and item.status == TodoItemStatus.NEEDS_ACTION
): ):
score_result = ( score_result = await self.coordinator.habitica.update_score(
await self.coordinator.api.tasks[item.uid].score["down"].post() UUID(item.uid), Direction.DOWN
) )
refresh_required = True refresh_required = True
else: else:
score_result = None score_result = None
except ClientResponseError as e: except (HabiticaException, ClientError) as e:
raise ServiceValidationError( raise ServiceValidationError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key=f"score_{self.entity_description.key}_item_failed", translation_key=f"score_{self.entity_description.key}_item_failed",
translation_placeholders={"name": item.summary or ""}, translation_placeholders={"name": item.summary or ""},
) from e ) from e
if score_result and (drop := score_result.get("_tmp", {}).get("drop", False)): if score_result and score_result.data.tmp.drop.key:
drop = score_result.data.tmp.drop
msg = ( msg = (
f"![{drop["key"]}]({ASSETS_URL}Pet_{drop["type"]}_{drop["key"]}.png)\n" f"![{drop.key}]({ASSETS_URL}Pet_{drop.Type}_{drop.key}.png)\n"
f"{drop["dialog"]}" f"{drop.dialog}"
) )
persistent_notification.async_create( persistent_notification.async_create(
self.hass, message=msg, title="Habitica" self.hass, message=msg, title="Habitica"
@ -229,38 +231,36 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity):
return [ return [
*( *(
TodoItem( TodoItem(
uid=task["id"], uid=str(task.id),
summary=task["text"], summary=task.text,
description=task["notes"], description=task.notes,
due=( due=dt_util.as_local(task.date).date() if task.date else None,
dt_util.as_local(
datetime.datetime.fromisoformat(task["date"])
).date()
if task.get("date")
else None
),
status=( status=(
TodoItemStatus.NEEDS_ACTION TodoItemStatus.NEEDS_ACTION
if not task["completed"] if not task.completed
else TodoItemStatus.COMPLETED else TodoItemStatus.COMPLETED
), ),
) )
for task in self.coordinator.data.tasks for task in self.coordinator.data.tasks
if task["type"] == HabiticaTaskType.TODO if task.Type is TaskType.TODO
), ),
] ]
async def async_create_todo_item(self, item: TodoItem) -> None: async def async_create_todo_item(self, item: TodoItem) -> None:
"""Create a Habitica todo.""" """Create a Habitica todo."""
if TYPE_CHECKING:
assert item.summary
assert item.description
try: try:
await self.coordinator.api.tasks.user.post( await self.coordinator.habitica.create_task(
text=item.summary, Task(
type=HabiticaTaskType.TODO, text=item.summary,
notes=item.description, type=TaskType.TODO,
date=item.due.isoformat() if item.due else None, notes=item.description,
date=item.due,
)
) )
except ClientResponseError as e: except (HabiticaException, ClientError) as e:
raise ServiceValidationError( raise ServiceValidationError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key=f"create_{self.entity_description.key}_item_failed", translation_key=f"create_{self.entity_description.key}_item_failed",
@ -295,23 +295,23 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity):
that have been completed but forgotten to mark as completed before resetting the dailies. that have been completed but forgotten to mark as completed before resetting the dailies.
Changes of the date input field in Home Assistant will be ignored. Changes of the date input field in Home Assistant will be ignored.
""" """
if TYPE_CHECKING:
last_cron = self.coordinator.data.user["lastCron"] assert self.coordinator.data.user.lastCron
return [ return [
*( *(
TodoItem( TodoItem(
uid=task["id"], uid=str(task.id),
summary=task["text"], summary=task.text,
description=task["notes"], description=task.notes,
due=next_due_date(task, last_cron), due=next_due_date(task, self.coordinator.data.user.lastCron),
status=( status=(
TodoItemStatus.COMPLETED TodoItemStatus.COMPLETED
if task["completed"] if task.completed
else TodoItemStatus.NEEDS_ACTION else TodoItemStatus.NEEDS_ACTION
), ),
) )
for task in self.coordinator.data.tasks for task in self.coordinator.data.tasks
if task["type"] == HabiticaTaskType.DAILY if task.Type is TaskType.DAILY
) )
] ]

View File

@ -2,9 +2,10 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import fields
import datetime import datetime
from math import floor from math import floor
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING
from dateutil.rrule import ( from dateutil.rrule import (
DAILY, DAILY,
@ -20,6 +21,7 @@ from dateutil.rrule import (
YEARLY, YEARLY,
rrule, rrule,
) )
from habiticalib import ContentData, Frequency, TaskData, UserData
from homeassistant.components.automation import automations_with_entity from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity from homeassistant.components.script import scripts_with_entity
@ -27,50 +29,32 @@ from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
def next_due_date(task: dict[str, Any], last_cron: str) -> datetime.date | None: def next_due_date(task: TaskData, today: datetime.datetime) -> datetime.date | None:
"""Calculate due date for dailies and yesterdailies.""" """Calculate due date for dailies and yesterdailies."""
if task["everyX"] == 0 or not task.get("nextDue"): # grey dailies never become due if task.everyX == 0 or not task.nextDue: # grey dailies never become due
return None return None
today = to_date(last_cron)
startdate = to_date(task["startDate"])
if TYPE_CHECKING: if TYPE_CHECKING:
assert today assert task.startDate
assert startdate
if task["isDue"] and not task["completed"]: if task.isDue is True and not task.completed:
return to_date(last_cron) return dt_util.as_local(today).date()
if startdate > today: if task.startDate > today:
if task["frequency"] == "daily" or ( if task.frequency is Frequency.DAILY or (
task["frequency"] in ("monthly", "yearly") and task["daysOfMonth"] task.frequency in (Frequency.MONTHLY, Frequency.YEARLY) and task.daysOfMonth
): ):
return startdate return dt_util.as_local(task.startDate).date()
if ( if (
task["frequency"] in ("weekly", "monthly") task.frequency in (Frequency.WEEKLY, Frequency.MONTHLY)
and (nextdue := to_date(task["nextDue"][0])) and (nextdue := task.nextDue[0])
and startdate > nextdue and task.startDate > nextdue
): ):
return to_date(task["nextDue"][1]) return dt_util.as_local(task.nextDue[1]).date()
return to_date(task["nextDue"][0]) return dt_util.as_local(task.nextDue[0]).date()
def to_date(date: str) -> datetime.date | None:
"""Convert an iso date to a datetime.date object."""
try:
return dt_util.as_local(datetime.datetime.fromisoformat(date)).date()
except ValueError:
# sometimes nextDue dates are JavaScript datetime strings instead of iso:
# "Mon May 06 2024 00:00:00 GMT+0200"
try:
return dt_util.as_local(
datetime.datetime.strptime(date, "%a %b %d %Y %H:%M:%S %Z%z")
).date()
except ValueError:
return None
def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]:
@ -84,30 +68,27 @@ FREQUENCY_MAP = {"daily": DAILY, "weekly": WEEKLY, "monthly": MONTHLY, "yearly":
WEEKDAY_MAP = {"m": MO, "t": TU, "w": WE, "th": TH, "f": FR, "s": SA, "su": SU} WEEKDAY_MAP = {"m": MO, "t": TU, "w": WE, "th": TH, "f": FR, "s": SA, "su": SU}
def build_rrule(task: dict[str, Any]) -> rrule: def build_rrule(task: TaskData) -> rrule:
"""Build rrule string.""" """Build rrule string."""
rrule_frequency = FREQUENCY_MAP.get(task["frequency"], DAILY) if TYPE_CHECKING:
weekdays = [ assert task.frequency
WEEKDAY_MAP[day] for day, is_active in task["repeat"].items() if is_active assert task.everyX
] rrule_frequency = FREQUENCY_MAP.get(task.frequency, DAILY)
weekdays = [day for key, day in WEEKDAY_MAP.items() if getattr(task.repeat, key)]
bymonthday = ( bymonthday = (
task["daysOfMonth"] task.daysOfMonth if rrule_frequency == MONTHLY and task.daysOfMonth else None
if rrule_frequency == MONTHLY and task["daysOfMonth"]
else None
) )
bysetpos = None bysetpos = None
if rrule_frequency == MONTHLY and task["weeksOfMonth"]: if rrule_frequency == MONTHLY and task.weeksOfMonth:
bysetpos = task["weeksOfMonth"] bysetpos = task.weeksOfMonth
weekdays = weekdays if weekdays else [MO] weekdays = weekdays if weekdays else [MO]
return rrule( return rrule(
freq=rrule_frequency, freq=rrule_frequency,
interval=task["everyX"], interval=task.everyX,
dtstart=dt_util.start_of_local_day( dtstart=dt_util.start_of_local_day(task.startDate),
datetime.datetime.fromisoformat(task["startDate"])
),
byweekday=weekdays if rrule_frequency in [WEEKLY, MONTHLY] else None, byweekday=weekdays if rrule_frequency in [WEEKLY, MONTHLY] else None,
bymonthday=bymonthday, bymonthday=bymonthday,
bysetpos=bysetpos, bysetpos=bysetpos,
@ -143,48 +124,37 @@ def get_recurrence_rule(recurrence: rrule) -> str:
def get_attribute_points( def get_attribute_points(
user: dict[str, Any], content: dict[str, Any], attribute: str user: UserData, content: ContentData, attribute: str
) -> dict[str, float]: ) -> dict[str, float]:
"""Get modifiers contributing to strength attribute.""" """Get modifiers contributing to STR/INT/CON/PER attributes."""
gear_set = {
"weapon",
"armor",
"head",
"shield",
"back",
"headAccessory",
"eyewear",
"body",
}
equipment = sum( equipment = sum(
stats[attribute] getattr(stats, attribute)
for gear in gear_set for gear in fields(user.items.gear.equipped)
if (equipped := user["items"]["gear"]["equipped"].get(gear)) if (equipped := getattr(user.items.gear.equipped, gear.name))
and (stats := content["gear"]["flat"].get(equipped)) and (stats := content.gear.flat[equipped])
) )
class_bonus = sum( class_bonus = sum(
stats[attribute] / 2 getattr(stats, attribute) / 2
for gear in gear_set for gear in fields(user.items.gear.equipped)
if (equipped := user["items"]["gear"]["equipped"].get(gear)) if (equipped := getattr(user.items.gear.equipped, gear.name))
and (stats := content["gear"]["flat"].get(equipped)) and (stats := content.gear.flat[equipped])
and stats["klass"] == user["stats"]["class"] and stats.klass == user.stats.Class
) )
if TYPE_CHECKING:
assert user.stats.lvl
return { return {
"level": min(floor(user["stats"]["lvl"] / 2), 50), "level": min(floor(user.stats.lvl / 2), 50),
"equipment": equipment, "equipment": equipment,
"class": class_bonus, "class": class_bonus,
"allocated": user["stats"][attribute], "allocated": getattr(user.stats, attribute),
"buffs": user["stats"]["buffs"][attribute], "buffs": getattr(user.stats.buffs, attribute),
} }
def get_attributes_total( def get_attributes_total(user: UserData, content: ContentData, attribute: str) -> int:
user: dict[str, Any], content: dict[str, Any], attribute: str
) -> int:
"""Get total attribute points.""" """Get total attribute points."""
return floor( return floor(
sum(value for value in get_attribute_points(user, content, attribute).values()) sum(value for value in get_attribute_points(user, content, attribute).values())

View File

@ -1088,7 +1088,7 @@ ha-iotawattpy==0.1.2
ha-philipsjs==3.2.2 ha-philipsjs==3.2.2
# homeassistant.components.habitica # homeassistant.components.habitica
habitipy==0.3.3 habiticalib==0.3.1
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
habluetooth==3.6.0 habluetooth==3.6.0

View File

@ -929,7 +929,7 @@ ha-iotawattpy==0.1.2
ha-philipsjs==3.2.2 ha-philipsjs==3.2.2
# homeassistant.components.habitica # homeassistant.components.habitica
habitipy==0.3.3 habiticalib==0.3.1
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
habluetooth==3.6.0 habluetooth==3.6.0

View File

@ -1,77 +1,41 @@
"""Tests for the habitica component.""" """Tests for the habitica component."""
from unittest.mock import patch from collections.abc import Generator
from unittest.mock import AsyncMock, patch
from habiticalib import (
BadRequestError,
HabiticaContentResponse,
HabiticaErrorResponse,
HabiticaGroupMembersResponse,
HabiticaLoginResponse,
HabiticaQuestResponse,
HabiticaResponse,
HabiticaScoreResponse,
HabiticaSleepResponse,
HabiticaTaskOrderResponse,
HabiticaTaskResponse,
HabiticaTasksResponse,
HabiticaUserAnonymizedrResponse,
HabiticaUserResponse,
NotAuthorizedError,
NotFoundError,
TaskFilter,
TooManyRequestsError,
)
import pytest import pytest
from yarl import URL
from homeassistant.components.habitica.const import CONF_API_USER, DEFAULT_URL, DOMAIN from homeassistant.components.habitica.const import CONF_API_USER, DEFAULT_URL, DOMAIN
from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.const import CONF_API_KEY, CONF_URL
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, load_json_object_fixture from tests.common import MockConfigEntry, load_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
ERROR_RESPONSE = HabiticaErrorResponse(success=False, error="error", message="message")
@pytest.fixture(autouse=True) ERROR_NOT_AUTHORIZED = NotAuthorizedError(error=ERROR_RESPONSE, headers={})
def disable_plumbum(): ERROR_NOT_FOUND = NotFoundError(error=ERROR_RESPONSE, headers={})
"""Disable plumbum in tests as it can cause the test suite to fail. ERROR_BAD_REQUEST = BadRequestError(error=ERROR_RESPONSE, headers={})
ERROR_TOO_MANY_REQUESTS = TooManyRequestsError(error=ERROR_RESPONSE, headers={})
plumbum can leave behind PlumbumTimeoutThreads
"""
with patch("plumbum.local"), patch("plumbum.colors"):
yield
def mock_called_with(
mock_client: AiohttpClientMocker,
method: str,
url: str,
) -> tuple | None:
"""Assert request mock was called with json data."""
return next(
(
call
for call in mock_client.mock_calls
if call[0].upper() == method.upper() and call[1] == URL(url)
),
None,
)
@pytest.fixture
def mock_habitica(aioclient_mock: AiohttpClientMocker) -> AiohttpClientMocker:
"""Mock aiohttp requests."""
aioclient_mock.get(
f"{DEFAULT_URL}/api/v3/user", json=load_json_object_fixture("user.json", DOMAIN)
)
aioclient_mock.get(
f"{DEFAULT_URL}/api/v3/tasks/user",
params={"type": "completedTodos"},
json=load_json_object_fixture("completed_todos.json", DOMAIN),
)
aioclient_mock.get(
f"{DEFAULT_URL}/api/v3/tasks/user",
json=load_json_object_fixture("tasks.json", DOMAIN),
)
aioclient_mock.get(
f"{DEFAULT_URL}/api/v3/content",
params={"language": "en"},
json=load_json_object_fixture("content.json", DOMAIN),
)
aioclient_mock.get(
f"{DEFAULT_URL}/api/v3/user/anonymized",
json={
"data": {
"user": load_json_object_fixture("user.json", DOMAIN)["data"],
"tasks": load_json_object_fixture("tasks.json", DOMAIN)["data"],
}
},
)
return aioclient_mock
@pytest.fixture(name="config_entry") @pytest.fixture(name="config_entry")
@ -82,10 +46,10 @@ def mock_config_entry() -> MockConfigEntry:
title="test-user", title="test-user",
data={ data={
CONF_URL: DEFAULT_URL, CONF_URL: DEFAULT_URL,
CONF_API_USER: "test-api-user", CONF_API_USER: "a380546a-94be-4b8e-8a0b-23e0d5c03303",
CONF_API_KEY: "test-api-key", CONF_API_KEY: "cd0e5985-17de-4b4f-849e-5d506c5e4382",
}, },
unique_id="00000000-0000-0000-0000-000000000000", unique_id="a380546a-94be-4b8e-8a0b-23e0d5c03303",
) )
@ -93,3 +57,109 @@ def mock_config_entry() -> MockConfigEntry:
async def set_tz(hass: HomeAssistant) -> None: async def set_tz(hass: HomeAssistant) -> None:
"""Fixture to set timezone.""" """Fixture to set timezone."""
await hass.config.async_set_time_zone("Europe/Berlin") await hass.config.async_set_time_zone("Europe/Berlin")
def mock_get_tasks(task_type: TaskFilter | None = None) -> HabiticaTasksResponse:
"""Load tasks fixtures."""
if task_type is TaskFilter.COMPLETED_TODOS:
return HabiticaTasksResponse.from_json(
load_fixture("completed_todos.json", DOMAIN)
)
return HabiticaTasksResponse.from_json(load_fixture("tasks.json", DOMAIN))
@pytest.fixture(name="habitica")
async def mock_habiticalib() -> Generator[AsyncMock]:
"""Mock habiticalib."""
with (
patch(
"homeassistant.components.habitica.Habitica", autospec=True
) as mock_client,
patch(
"homeassistant.components.habitica.config_flow.Habitica", new=mock_client
),
):
client = mock_client.return_value
client.login.return_value = HabiticaLoginResponse.from_json(
load_fixture("login.json", DOMAIN)
)
client.get_user.return_value = HabiticaUserResponse.from_json(
load_fixture("user.json", DOMAIN)
)
client.cast_skill.return_value = HabiticaUserResponse.from_json(
load_fixture("user.json", DOMAIN)
)
client.toggle_sleep.return_value = HabiticaSleepResponse(
success=True, data=True
)
client.update_score.return_value = HabiticaUserResponse.from_json(
load_fixture("score_with_drop.json", DOMAIN)
)
client.get_group_members.return_value = HabiticaGroupMembersResponse.from_json(
load_fixture("party_members.json", DOMAIN)
)
for func in (
"leave_quest",
"reject_quest",
"cancel_quest",
"abort_quest",
"start_quest",
"accept_quest",
):
getattr(client, func).return_value = HabiticaQuestResponse.from_json(
load_fixture("party_quest.json", DOMAIN)
)
client.get_content.return_value = HabiticaContentResponse.from_json(
load_fixture("content.json", DOMAIN)
)
client.get_tasks.side_effect = mock_get_tasks
client.update_score.return_value = HabiticaScoreResponse.from_json(
load_fixture("score_with_drop.json", DOMAIN)
)
client.update_task.return_value = HabiticaTaskResponse.from_json(
load_fixture("task.json", DOMAIN)
)
client.create_task.return_value = HabiticaTaskResponse.from_json(
load_fixture("task.json", DOMAIN)
)
client.delete_task.return_value = HabiticaResponse.from_dict(
{"data": {}, "success": True}
)
client.delete_completed_todos.return_value = HabiticaResponse.from_dict(
{"data": {}, "success": True}
)
client.reorder_task.return_value = HabiticaTaskOrderResponse.from_dict(
{"data": [], "success": True}
)
client.get_user_anonymized.return_value = (
HabiticaUserAnonymizedrResponse.from_json(
load_fixture("anonymized.json", DOMAIN)
)
)
client.habitipy.return_value = {
"tasks": {
"user": {
"post": AsyncMock(
return_value={
"text": "Use API from Home Assistant",
"type": "todo",
}
)
}
}
}
yield client
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.habitica.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry

View File

@ -0,0 +1,661 @@
{
"success": true,
"data": {
"user": {
"auth": {
"timestamps": {
"created": "2024-10-10T15:57:01.106Z",
"loggedin": "2024-11-27T19:34:28.887Z",
"updated": "2024-11-27T20:05:19.047Z"
}
},
"achievements": {
"ultimateGearSets": {
"healer": false,
"wizard": false,
"rogue": false,
"warrior": false
},
"streak": 0,
"perfect": 2,
"quests": {},
"completedTask": true,
"partyUp": true,
"snowball": 1,
"spookySparkles": 1,
"seafoam": 1,
"shinySeed": 1,
"createdTask": true
},
"backer": {},
"purchased": {
"ads": false,
"txnCount": 0,
"skin": {},
"hair": {},
"shirt": {},
"background": {
"violet": true,
"blue": true,
"green": true,
"purple": true,
"red": true,
"yellow": true
}
},
"flags": {
"tour": {
"intro": -1,
"classes": -1,
"stats": -1,
"tavern": -1,
"party": -1,
"guilds": -1,
"challenges": -1,
"market": -1,
"pets": -1,
"mounts": -1,
"hall": -1,
"equipment": -1,
"groupPlans": -1
},
"tutorial": {
"common": {
"habits": true,
"dailies": true,
"todos": true,
"rewards": true,
"party": true,
"pets": true,
"gems": true,
"skills": true,
"classes": true,
"tavern": true,
"equipment": true,
"items": true,
"mounts": true,
"inbox": true,
"stats": true
},
"ios": {
"addTask": false,
"editTask": false,
"deleteTask": false,
"filterTask": false,
"groupPets": false,
"inviteParty": false,
"reorderTask": false
}
},
"verifiedUsername": true,
"customizationsNotification": true,
"showTour": true,
"dropsEnabled": false,
"itemsEnabled": true,
"lastNewStuffRead": "",
"rewrite": true,
"classSelected": false,
"rebirthEnabled": false,
"levelDrops": {},
"recaptureEmailsPhase": 0,
"weeklyRecapEmailsPhase": 0,
"lastWeeklyRecap": "2024-10-10T15:57:01.106Z",
"communityGuidelinesAccepted": true,
"cronCount": 6,
"welcomed": true,
"armoireEnabled": true,
"armoireOpened": false,
"armoireEmpty": false,
"cardReceived": false,
"warnedLowHealth": true,
"thirdPartyTools": "2024-11-27T19:32:18.826Z",
"newStuff": false
},
"history": {
"exp": [
{
"date": "2024-10-30T19:37:01.970Z",
"value": 24
},
{
"date": "2024-10-31T23:33:14.972Z",
"value": 48
},
{
"date": "2024-11-05T18:25:04.681Z",
"value": 66
},
{
"date": "2024-11-21T15:09:07.501Z",
"value": 66
},
{
"date": "2024-11-22T00:41:21.137Z",
"value": 66
},
{
"date": "2024-11-27T19:34:28.887Z",
"value": 66
}
],
"todos": [
{
"date": "2024-10-30T19:37:01.970Z",
"value": -5
},
{
"date": "2024-10-31T23:33:14.972Z",
"value": -10.129783523135325
},
{
"date": "2024-11-05T18:25:04.681Z",
"value": -16.396221153338182
},
{
"date": "2024-11-21T15:09:07.501Z",
"value": -22.8326979965846
},
{
"date": "2024-11-22T00:41:21.137Z",
"value": -29.448636229365235
},
{
"date": "2024-11-27T19:34:28.887Z",
"value": -36.25425987861077
}
]
},
"items": {
"gear": {
"equipped": {
"armor": "armor_base_0",
"head": "head_base_0",
"shield": "shield_base_0"
},
"costume": {
"armor": "armor_base_0",
"head": "head_base_0",
"shield": "shield_base_0"
},
"owned": {
"headAccessory_special_blackHeadband": true,
"headAccessory_special_blueHeadband": true,
"headAccessory_special_greenHeadband": true,
"headAccessory_special_pinkHeadband": true,
"headAccessory_special_redHeadband": true,
"headAccessory_special_whiteHeadband": true,
"headAccessory_special_yellowHeadband": true,
"eyewear_special_blackTopFrame": true,
"eyewear_special_blueTopFrame": true,
"eyewear_special_greenTopFrame": true,
"eyewear_special_pinkTopFrame": true,
"eyewear_special_redTopFrame": true,
"eyewear_special_whiteTopFrame": true,
"eyewear_special_yellowTopFrame": true,
"eyewear_special_blackHalfMoon": true,
"eyewear_special_blueHalfMoon": true,
"eyewear_special_greenHalfMoon": true,
"eyewear_special_pinkHalfMoon": true,
"eyewear_special_redHalfMoon": true,
"eyewear_special_whiteHalfMoon": true,
"eyewear_special_yellowHalfMoon": true,
"armor_special_bardRobes": true,
"head_special_bardHat": true
}
},
"special": {
"snowball": 0,
"spookySparkles": 0,
"shinySeed": 0,
"seafoam": 0,
"valentine": 0,
"nye": 0,
"greeting": 0,
"greetingReceived": [],
"thankyou": 0,
"thankyouReceived": [],
"birthday": 0,
"birthdayReceived": [],
"congrats": 0,
"congratsReceived": [],
"getwell": 0,
"getwellReceived": [],
"goodluck": 0,
"goodluckReceived": []
},
"lastDrop": {
"count": 0,
"date": "2024-11-05T18:25:04.619Z"
},
"currentPet": "",
"currentMount": "",
"pets": {},
"eggs": {
"BearCub": 1,
"Cactus": 1,
"Wolf": 1,
"Dragon": 1
},
"hatchingPotions": {
"Skeleton": 1,
"Zombie": 1,
"RoyalPurple": 1
},
"food": {
"Candy_Red": 1,
"Chocolate": 1,
"Meat": 1,
"CottonCandyPink": 1
},
"mounts": {},
"quests": {
"dustbunnies": 1
}
},
"party": {
"quest": {
"progress": {
"up": 0,
"down": 0,
"collectedItems": 0,
"collect": {}
},
"RSVPNeeded": true,
"key": "dustbunnies",
"completed": null
},
"order": "level",
"orderAscending": "ascending",
"_id": "94cd398c-2240-4320-956e-6d345cf2c0de"
},
"preferences": {
"hair": {
"color": "red",
"base": 3,
"bangs": 1,
"beard": 0,
"mustache": 0,
"flower": 1
},
"emailNotifications": {
"unsubscribeFromAll": false,
"newPM": true,
"kickedGroup": true,
"wonChallenge": true,
"giftedGems": true,
"giftedSubscription": true,
"invitedParty": true,
"invitedGuild": true,
"questStarted": true,
"invitedQuest": true,
"importantAnnouncements": true,
"weeklyRecaps": true,
"onboarding": true,
"majorUpdates": true,
"subscriptionReminders": true,
"contentRelease": true
},
"pushNotifications": {
"unsubscribeFromAll": false,
"newPM": true,
"wonChallenge": true,
"giftedGems": true,
"giftedSubscription": true,
"invitedParty": true,
"invitedGuild": true,
"questStarted": true,
"invitedQuest": true,
"majorUpdates": true,
"mentionParty": true,
"mentionJoinedGuild": true,
"mentionUnjoinedGuild": true,
"partyActivity": true,
"contentRelease": true
},
"suppressModals": {
"levelUp": false,
"hatchPet": false,
"raisePet": false,
"streak": false
},
"tasks": {
"activeFilter": {
"habit": "all",
"daily": "all",
"todo": "remaining",
"reward": "all"
},
"groupByChallenge": false,
"confirmScoreNotes": false,
"mirrorGroupTasks": []
},
"language": "de",
"dayStart": 0,
"size": "slim",
"hideHeader": false,
"skin": "915533",
"shirt": "blue",
"timezoneOffset": -60,
"sound": "rosstavoTheme",
"chair": "none",
"allocationMode": "flat",
"autoEquip": true,
"costume": false,
"dateFormat": "MM/dd/yyyy",
"sleep": false,
"stickyHeader": true,
"disableClasses": false,
"newTaskEdit": false,
"dailyDueDefaultView": false,
"advancedCollapsed": false,
"toolbarCollapsed": false,
"reverseChatOrder": false,
"developerMode": false,
"displayInviteToPartyWhenPartyIs1": true,
"webhooks": {},
"improvementCategories": [],
"background": "violet",
"timezoneOffsetAtLastCron": -60
},
"stats": {
"buffs": {
"str": 0,
"int": 0,
"per": 0,
"con": 0,
"stealth": 0,
"streaks": false,
"seafoam": false,
"shinySeed": false,
"snowball": false,
"spookySparkles": false
},
"training": {
"int": 0,
"per": 0,
"str": 0,
"con": 0
},
"hp": 25.40000000000002,
"mp": 32,
"exp": 41,
"gp": 11.100978952781748,
"lvl": 2,
"class": "warrior",
"points": 2,
"str": 0,
"con": 0,
"int": 0,
"per": 0,
"toNextLevel": 50,
"maxHealth": 50,
"maxMP": 32
},
"inbox": {
"newMessages": 0,
"optOut": false,
"blocks": [],
"messages": {}
},
"tasksOrder": {
"habits": ["30923acd-3b4c-486d-9ef3-c8f57cf56049"],
"dailys": ["6e53f1f5-a315-4edd-984d-8d762e4a08ef"],
"todos": ["e6e06dc6-c887-4b86-b175-b99cc2e20fdf"],
"rewards": ["2fbf11a5-ab1e-4fb7-97f0-dfb5c45c96a9"]
},
"_id": "ffce870c-3ff3-4fa4-bad1-87612e52b8e7",
"_v": 131,
"balance": 0,
"_subSignature": "NOT_RUNNING",
"challenges": [],
"guilds": [],
"loginIncentives": 6,
"invitesSent": 0,
"pinnedItemsOrder": [],
"lastCron": "2024-11-27T19:34:28.887Z",
"tags": [
{
"id": "c1a35186-9895-4ac0-9cd7-49e7bb875695",
"name": "tag",
"challenge": "challenge"
},
{
"id": "53d1deb8-ed2b-4f94-bbfc-955e9e92aa98",
"name": "tag",
"challenge": "challenge"
},
{
"id": "29bf6a99-536f-446b-838f-a81d41e1ed4d",
"name": "tag",
"challenge": "challenge"
},
{
"id": "1b1297e7-4fd8-460a-b148-e92d7bcfa9a5",
"name": "tag",
"challenge": "challenge"
},
{
"id": "05e6cf40-48ea-415a-9b8b-e2ecad258ef6",
"name": "tag",
"challenge": "challenge"
},
{
"id": "fe53f179-59d8-4c28-9bf7-b9068ab552a4",
"name": "tag",
"challenge": "challenge"
},
{
"id": "c44e9e8c-4bff-42df-98d5-1a1a7b69eada",
"name": "tag",
"challenge": "challenge"
}
],
"extra": {},
"pushDevices": [],
"pinnedItems": [
{
"path": "gear.flat.weapon_warrior_0",
"type": "marketGear"
},
{
"path": "gear.flat.armor_warrior_1",
"type": "marketGear"
},
{
"path": "gear.flat.shield_warrior_1",
"type": "marketGear"
},
{
"path": "gear.flat.head_warrior_1",
"type": "marketGear"
},
{
"path": "potion",
"type": "potion"
},
{
"path": "armoire",
"type": "armoire"
}
],
"unpinnedItems": [],
"id": "ffce870c-3ff3-4fa4-bad1-87612e52b8e7"
},
"tasks": [
{
"challenge": {},
"group": {
"completedBy": {},
"assignedUsers": []
},
"_id": "30923acd-3b4c-486d-9ef3-c8f57cf56049",
"up": true,
"down": false,
"counterUp": 0,
"counterDown": 0,
"frequency": "daily",
"history": [],
"type": "habit",
"text": "task text",
"notes": "task notes",
"tags": [],
"value": 0,
"priority": 1,
"attribute": "str",
"byHabitica": false,
"reminders": [],
"createdAt": "2024-10-10T15:57:14.287Z",
"updatedAt": "2024-10-10T15:57:14.287Z",
"userId": "ffce870c-3ff3-4fa4-bad1-87612e52b8e7",
"id": "30923acd-3b4c-486d-9ef3-c8f57cf56049"
},
{
"challenge": {},
"group": {
"completedBy": {},
"assignedUsers": []
},
"_id": "e6e06dc6-c887-4b86-b175-b99cc2e20fdf",
"completed": false,
"collapseChecklist": false,
"type": "todo",
"text": "task text",
"notes": "task notes",
"tags": [],
"value": -6.418582324043852,
"priority": 1,
"attribute": "str",
"byHabitica": true,
"checklist": [],
"reminders": [],
"createdAt": "2024-10-10T15:57:14.290Z",
"updatedAt": "2024-11-27T19:34:29.001Z",
"userId": "ffce870c-3ff3-4fa4-bad1-87612e52b8e7",
"id": "e6e06dc6-c887-4b86-b175-b99cc2e20fdf"
},
{
"challenge": {},
"group": {
"completedBy": {},
"assignedUsers": []
},
"_id": "2fbf11a5-ab1e-4fb7-97f0-dfb5c45c96a9",
"type": "reward",
"text": "task text",
"notes": "task notes",
"tags": [],
"value": 10,
"priority": 1,
"attribute": "str",
"byHabitica": false,
"reminders": [],
"createdAt": "2024-10-10T15:57:14.290Z",
"updatedAt": "2024-10-10T15:57:14.290Z",
"userId": "ffce870c-3ff3-4fa4-bad1-87612e52b8e7",
"id": "2fbf11a5-ab1e-4fb7-97f0-dfb5c45c96a9"
},
{
"repeat": {
"m": true,
"t": true,
"w": true,
"th": true,
"f": true,
"s": true,
"su": true
},
"challenge": {},
"group": {
"completedBy": {},
"assignedUsers": []
},
"_id": "6e53f1f5-a315-4edd-984d-8d762e4a08ef",
"frequency": "weekly",
"everyX": 1,
"streak": 0,
"nextDue": [
"2024-11-27T23:00:00.000Z",
"2024-11-28T23:00:00.000Z",
"2024-11-29T23:00:00.000Z",
"2024-12-01T23:00:00.000Z",
"2024-12-02T23:00:00.000Z",
"2024-12-03T23:00:00.000Z"
],
"yesterDaily": true,
"history": [
{
"date": 1730317021817,
"value": 1,
"isDue": true,
"completed": true
},
{
"date": 1730417594890,
"value": 1.9747,
"isDue": true,
"completed": true
},
{
"date": 1730831104730,
"value": 1.024043774264157,
"isDue": true,
"completed": false
},
{
"date": 1732201747573,
"value": 0.049944135963563174,
"isDue": true,
"completed": false
},
{
"date": 1732236081228,
"value": -0.9487768368544092,
"isDue": true,
"completed": false
},
{
"date": 1732736068973,
"value": -1.973387732005249,
"isDue": true,
"completed": false
}
],
"completed": false,
"collapseChecklist": false,
"type": "daily",
"text": "task text",
"notes": "task notes",
"tags": [],
"value": -1.973387732005249,
"priority": 1,
"attribute": "str",
"byHabitica": false,
"startDate": "2024-10-09T22:00:00.000Z",
"daysOfMonth": [],
"weeksOfMonth": [],
"checklist": [],
"reminders": [],
"createdAt": "2024-10-10T15:57:14.304Z",
"updatedAt": "2024-11-27T19:34:29.001Z",
"userId": "ffce870c-3ff3-4fa4-bad1-87612e52b8e7",
"isDue": true,
"id": "6e53f1f5-a315-4edd-984d-8d762e4a08ef"
}
]
},
"notifications": [
{
"id": "35708dfc-74c6-4352-97d3-8aeddbb31d20",
"type": "NEW_CHAT_MESSAGE",
"data": {
"group": {
"id": "94cd398c-2240-4320-956e-6d345cf2c0de",
"name": "tests Party"
}
},
"seen": false
}
],
"userV": 131,
"appVersion": "5.29.2"
}

View File

@ -2,6 +2,88 @@
"success": true, "success": true,
"data": { "data": {
"gear": { "gear": {
"tree": {
"weapon": {
"base": {},
"warrior": {},
"wizard": {},
"rogue": {},
"special": {},
"armoire": {},
"mystery": {},
"healer": {}
},
"armor": {
"base": {},
"warrior": {},
"wizard": {},
"rogue": {},
"special": {},
"armoire": {},
"mystery": {},
"healer": {}
},
"head": {
"base": {},
"warrior": {},
"wizard": {},
"rogue": {},
"special": {},
"armoire": {},
"mystery": {},
"healer": {}
},
"shield": {
"base": {},
"warrior": {},
"wizard": {},
"rogue": {},
"special": {},
"armoire": {},
"mystery": {},
"healer": {}
},
"back": {
"base": {},
"warrior": {},
"wizard": {},
"rogue": {},
"special": {},
"armoire": {},
"mystery": {},
"healer": {}
},
"body": {
"base": {},
"warrior": {},
"wizard": {},
"rogue": {},
"special": {},
"armoire": {},
"mystery": {},
"healer": {}
},
"headAccessory": {
"base": {},
"warrior": {},
"wizard": {},
"rogue": {},
"special": {},
"armoire": {},
"mystery": {},
"healer": {}
},
"eyewear": {
"base": {},
"warrior": {},
"wizard": {},
"rogue": {},
"special": {},
"armoire": {},
"mystery": {},
"healer": {}
}
},
"flat": { "flat": {
"weapon_warrior_5": { "weapon_warrior_5": {
"text": "Ruby Sword", "text": "Ruby Sword",
@ -281,7 +363,110 @@
"per": 0 "per": 0
} }
} }
} },
"achievements": {},
"questSeriesAchievements": {},
"animalColorAchievements": [],
"animalSetAchievements": {},
"stableAchievements": {},
"petSetCompleteAchievs": [],
"quests": {},
"questsByLevel": {},
"userCanOwnQuestCategories": [],
"itemList": {
"weapon": {
"localeKey": "weapon",
"isEquipment": true
},
"armor": {
"localeKey": "armor",
"isEquipment": true
},
"head": {
"localeKey": "headgear",
"isEquipment": true
},
"shield": {
"localeKey": "offhand",
"isEquipment": true
},
"back": {
"localeKey": "back",
"isEquipment": true
},
"body": {
"localeKey": "body",
"isEquipment": true
},
"headAccessory": {
"localeKey": "headAccessory",
"isEquipment": true
},
"eyewear": {
"localeKey": "eyewear",
"isEquipment": true
},
"hatchingPotions": {
"localeKey": "hatchingPotion",
"isEquipment": false
},
"premiumHatchingPotions": {
"localeKey": "hatchingPotion",
"isEquipment": false
},
"eggs": {
"localeKey": "eggSingular",
"isEquipment": false
},
"quests": {
"localeKey": "quest",
"isEquipment": false
},
"food": {
"localeKey": "foodTextThe",
"isEquipment": false
},
"Saddle": {
"localeKey": "foodSaddleText",
"isEquipment": false
},
"bundles": {
"localeKey": "discountBundle",
"isEquipment": false
}
},
"spells": {
"wizard": {},
"warrior": {},
"rogue": {},
"healer": {},
"special": {}
},
"officialPinnedItems": [],
"audioThemes": [],
"classes": [],
"gearTypes": [],
"cardTypes": {},
"special": {},
"dropEggs": {},
"questEggs": {},
"eggs": {},
"dropHatchingPotions": {},
"premiumHatchingPotions": {},
"wackyHatchingPotions": {},
"hatchingPotions": {},
"pets": {},
"premiumPets": {},
"questPets": {},
"specialPets": {},
"wackyPets": {},
"petInfo": {},
"mounts": {},
"premiumMounts": {},
"questMounts": {},
"specialMounts": {},
"mountInfo": {},
"food": {}
}, },
"appVersion": "5.29.2" "appVersion": "5.29.2"
} }

View File

@ -0,0 +1,10 @@
{
"success": true,
"data": {
"id": "a380546a-94be-4b8e-8a0b-23e0d5c03303",
"apiToken": "cd0e5985-17de-4b4f-849e-5d506c5e4382",
"newUser": false,
"username": "test-username"
},
"appVersion": "5.30.0"
}

View File

@ -0,0 +1,31 @@
{
"success": true,
"data": {
"progress": {
"collect": {},
"hp": 100
},
"key": "dustbunnies",
"active": true,
"leader": "a380546a-94be-4b8e-8a0b-23e0d5c03303",
"members": {
"a380546a-94be-4b8e-8a0b-23e0d5c03303": true
},
"extra": {}
},
"notifications": [
{
"id": "3f59313f-6d7c-4fff-a8c4-3b153d828c6f",
"type": "NEW_CHAT_MESSAGE",
"data": {
"group": {
"id": "94cd398c-2240-4320-956e-6d345cf2c0de",
"name": "tests Party"
}
},
"seen": false
}
],
"userV": 287,
"appVersion": "5.29.2"
}

View File

@ -0,0 +1,51 @@
{
"success": true,
"data": {
"_id": "88de7cd9-af2b-49ce-9afd-bf941d87336b",
"date": "2024-09-27T22:17:00.000Z",
"completed": false,
"collapseChecklist": false,
"checklist": [],
"type": "todo",
"text": "Buch zu Ende lesen",
"notes": "Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.",
"tags": [],
"value": 0,
"priority": 1,
"attribute": "str",
"challenge": {},
"group": {
"completedBy": {},
"assignedUsers": []
},
"reminders": [],
"byHabitica": false,
"createdAt": "2024-09-21T22:17:57.816Z",
"updatedAt": "2024-09-21T22:17:57.816Z",
"userId": "5f359083-ef78-4af0-985a-0b2c6d05797c",
"id": "88de7cd9-af2b-49ce-9afd-bf941d87336b"
},
"notifications": [
{
"type": "ITEM_RECEIVED",
"data": {
"icon": "notif_orca_mount",
"title": "Orcas for Summer Splash!",
"text": "To celebrate Summer Splash, we've given you an Orca Mount!",
"destination": "stable"
},
"seen": true,
"id": "b7a85df1-06ed-4ab1-b56d-43418fc6a5e5"
},
{
"type": "UNALLOCATED_STATS_POINTS",
"data": {
"points": 2
},
"seen": true,
"id": "bc3f8a69-231f-4eb1-ba48-a00b6c0e0f37"
}
],
"userV": 589,
"appVersion": "5.28.6"
}

View File

@ -83,6 +83,7 @@
"body": "body_special_aetherAmulet" "body": "body_special_aetherAmulet"
} }
} }
} },
"balance": 10
} }
} }

View File

@ -0,0 +1,86 @@
{
"success": true,
"data": {
"api_user": "test-api-user",
"profile": { "name": "test-user" },
"auth": { "local": { "username": "test-username" } },
"stats": {
"buffs": {
"str": 26,
"int": 26,
"per": 26,
"con": 26,
"stealth": 0,
"streaks": false,
"seafoam": false,
"shinySeed": false,
"snowball": false,
"spookySparkles": false
},
"hp": 0,
"mp": 50.89999999999998,
"exp": 737,
"gp": 137.62587214609795,
"lvl": 38,
"class": "wizard",
"maxHealth": 50,
"maxMP": 166,
"toNextLevel": 880,
"points": 5,
"str": 15,
"con": 15,
"int": 15,
"per": 15
},
"preferences": {
"sleep": false,
"automaticAllocation": true,
"disableClasses": false,
"language": "en"
},
"flags": {
"classSelected": true
},
"tasksOrder": {
"rewards": ["5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"],
"todos": [
"88de7cd9-af2b-49ce-9afd-bf941d87336b",
"2f6fcabc-f670-4ec3-ba65-817e8deea490",
"1aa3137e-ef72-4d1f-91ee-41933602f438",
"86ea2475-d1b5-4020-bdcc-c188c7996afa"
],
"dailys": [
"f21fa608-cfc6-4413-9fc7-0eb1b48ca43a",
"bc1d1855-b2b8-4663-98ff-62e7b763dfc4",
"e97659e0-2c42-4599-a7bb-00282adc410d",
"564b9ac9-c53d-4638-9e7f-1cd96fe19baa",
"f2c85972-1a19-4426-bc6d-ce3337b9d99f",
"2c6d136c-a1c3-4bef-b7c4-fa980784b1e1"
],
"habits": ["1d147de6-5c02-4740-8e2f-71d3015a37f4"]
},
"party": {
"quest": {
"RSVPNeeded": true,
"key": "dustbunnies"
}
},
"needsCron": true,
"lastCron": "2024-09-21T22:01:55.586Z",
"id": "a380546a-94be-4b8e-8a0b-23e0d5c03303",
"items": {
"gear": {
"equipped": {
"weapon": "weapon_warrior_5",
"armor": "armor_warrior_5",
"head": "head_warrior_5",
"shield": "shield_warrior_5",
"back": "back_special_heroicAureole",
"headAccessory": "headAccessory_armoire_gogglesOfBookbinding",
"eyewear": "eyewear_armoire_plagueDoctorMask",
"body": "body_special_aetherAmulet"
}
}
}
}
}

View File

@ -28,7 +28,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabiticaBinarySensor.PENDING_QUEST: 'pending_quest'>, 'translation_key': <HabiticaBinarySensor.PENDING_QUEST: 'pending_quest'>,
'unique_id': '00000000-0000-0000-0000-000000000000_pending_quest', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_pending_quest',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---

View File

@ -28,7 +28,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS: 'allocate_all_stat_points'>, 'translation_key': <HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS: 'allocate_all_stat_points'>,
'unique_id': '00000000-0000-0000-0000-000000000000_allocate_all_stat_points', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_allocate_all_stat_points',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -74,7 +74,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.HEAL_ALL: 'heal_all'>, 'translation_key': <HabitipyButtonEntity.HEAL_ALL: 'heal_all'>,
'unique_id': '00000000-0000-0000-0000-000000000000_heal_all', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_heal_all',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -121,7 +121,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.BUY_HEALTH_POTION: 'buy_health_potion'>, 'translation_key': <HabitipyButtonEntity.BUY_HEALTH_POTION: 'buy_health_potion'>,
'unique_id': '00000000-0000-0000-0000-000000000000_buy_health_potion', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_buy_health_potion',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -168,7 +168,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.HEAL: 'heal'>, 'translation_key': <HabitipyButtonEntity.HEAL: 'heal'>,
'unique_id': '00000000-0000-0000-0000-000000000000_heal', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_heal',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -215,7 +215,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.PROTECT_AURA: 'protect_aura'>, 'translation_key': <HabitipyButtonEntity.PROTECT_AURA: 'protect_aura'>,
'unique_id': '00000000-0000-0000-0000-000000000000_protect_aura', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_protect_aura',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -262,7 +262,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.REVIVE: 'revive'>, 'translation_key': <HabitipyButtonEntity.REVIVE: 'revive'>,
'unique_id': '00000000-0000-0000-0000-000000000000_revive', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_revive',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -308,7 +308,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.BRIGHTNESS: 'brightness'>, 'translation_key': <HabitipyButtonEntity.BRIGHTNESS: 'brightness'>,
'unique_id': '00000000-0000-0000-0000-000000000000_brightness', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_brightness',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -355,7 +355,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.RUN_CRON: 'run_cron'>, 'translation_key': <HabitipyButtonEntity.RUN_CRON: 'run_cron'>,
'unique_id': '00000000-0000-0000-0000-000000000000_run_cron', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_run_cron',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -401,7 +401,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS: 'allocate_all_stat_points'>, 'translation_key': <HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS: 'allocate_all_stat_points'>,
'unique_id': '00000000-0000-0000-0000-000000000000_allocate_all_stat_points', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_allocate_all_stat_points',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -447,7 +447,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.BUY_HEALTH_POTION: 'buy_health_potion'>, 'translation_key': <HabitipyButtonEntity.BUY_HEALTH_POTION: 'buy_health_potion'>,
'unique_id': '00000000-0000-0000-0000-000000000000_buy_health_potion', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_buy_health_potion',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -494,7 +494,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.REVIVE: 'revive'>, 'translation_key': <HabitipyButtonEntity.REVIVE: 'revive'>,
'unique_id': '00000000-0000-0000-0000-000000000000_revive', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_revive',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -540,7 +540,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.RUN_CRON: 'run_cron'>, 'translation_key': <HabitipyButtonEntity.RUN_CRON: 'run_cron'>,
'unique_id': '00000000-0000-0000-0000-000000000000_run_cron', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_run_cron',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -586,7 +586,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.STEALTH: 'stealth'>, 'translation_key': <HabitipyButtonEntity.STEALTH: 'stealth'>,
'unique_id': '00000000-0000-0000-0000-000000000000_stealth', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_stealth',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -633,7 +633,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.TOOLS_OF_TRADE: 'tools_of_trade'>, 'translation_key': <HabitipyButtonEntity.TOOLS_OF_TRADE: 'tools_of_trade'>,
'unique_id': '00000000-0000-0000-0000-000000000000_tools_of_trade', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_tools_of_trade',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -680,7 +680,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS: 'allocate_all_stat_points'>, 'translation_key': <HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS: 'allocate_all_stat_points'>,
'unique_id': '00000000-0000-0000-0000-000000000000_allocate_all_stat_points', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_allocate_all_stat_points',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -726,7 +726,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.BUY_HEALTH_POTION: 'buy_health_potion'>, 'translation_key': <HabitipyButtonEntity.BUY_HEALTH_POTION: 'buy_health_potion'>,
'unique_id': '00000000-0000-0000-0000-000000000000_buy_health_potion', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_buy_health_potion',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -773,7 +773,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.DEFENSIVE_STANCE: 'defensive_stance'>, 'translation_key': <HabitipyButtonEntity.DEFENSIVE_STANCE: 'defensive_stance'>,
'unique_id': '00000000-0000-0000-0000-000000000000_defensive_stance', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_defensive_stance',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -820,7 +820,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.INTIMIDATE: 'intimidate'>, 'translation_key': <HabitipyButtonEntity.INTIMIDATE: 'intimidate'>,
'unique_id': '00000000-0000-0000-0000-000000000000_intimidate', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_intimidate',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -867,7 +867,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.REVIVE: 'revive'>, 'translation_key': <HabitipyButtonEntity.REVIVE: 'revive'>,
'unique_id': '00000000-0000-0000-0000-000000000000_revive', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_revive',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -913,7 +913,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.RUN_CRON: 'run_cron'>, 'translation_key': <HabitipyButtonEntity.RUN_CRON: 'run_cron'>,
'unique_id': '00000000-0000-0000-0000-000000000000_run_cron', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_run_cron',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -959,7 +959,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.VALOROUS_PRESENCE: 'valorous_presence'>, 'translation_key': <HabitipyButtonEntity.VALOROUS_PRESENCE: 'valorous_presence'>,
'unique_id': '00000000-0000-0000-0000-000000000000_valorous_presence', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_valorous_presence',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -1006,7 +1006,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS: 'allocate_all_stat_points'>, 'translation_key': <HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS: 'allocate_all_stat_points'>,
'unique_id': '00000000-0000-0000-0000-000000000000_allocate_all_stat_points', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_allocate_all_stat_points',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -1052,7 +1052,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.BUY_HEALTH_POTION: 'buy_health_potion'>, 'translation_key': <HabitipyButtonEntity.BUY_HEALTH_POTION: 'buy_health_potion'>,
'unique_id': '00000000-0000-0000-0000-000000000000_buy_health_potion', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_buy_health_potion',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -1099,7 +1099,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.FROST: 'frost'>, 'translation_key': <HabitipyButtonEntity.FROST: 'frost'>,
'unique_id': '00000000-0000-0000-0000-000000000000_frost', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_frost',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -1146,7 +1146,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.EARTH: 'earth'>, 'translation_key': <HabitipyButtonEntity.EARTH: 'earth'>,
'unique_id': '00000000-0000-0000-0000-000000000000_earth', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_earth',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -1193,7 +1193,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.MPHEAL: 'mpheal'>, 'translation_key': <HabitipyButtonEntity.MPHEAL: 'mpheal'>,
'unique_id': '00000000-0000-0000-0000-000000000000_mpheal', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_mpheal',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -1240,7 +1240,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.REVIVE: 'revive'>, 'translation_key': <HabitipyButtonEntity.REVIVE: 'revive'>,
'unique_id': '00000000-0000-0000-0000-000000000000_revive', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_revive',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -1286,7 +1286,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipyButtonEntity.RUN_CRON: 'run_cron'>, 'translation_key': <HabitipyButtonEntity.RUN_CRON: 'run_cron'>,
'unique_id': '00000000-0000-0000-0000-000000000000_run_cron', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_run_cron',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---

View File

@ -928,7 +928,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabiticaCalendar.DAILIES: 'dailys'>, 'translation_key': <HabiticaCalendar.DAILIES: 'dailys'>,
'unique_id': '00000000-0000-0000-0000-000000000000_dailys', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_dailys',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -981,7 +981,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabiticaCalendar.DAILY_REMINDERS: 'daily_reminders'>, 'translation_key': <HabiticaCalendar.DAILY_REMINDERS: 'daily_reminders'>,
'unique_id': '00000000-0000-0000-0000-000000000000_daily_reminders', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_daily_reminders',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -1033,7 +1033,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabiticaCalendar.TODO_REMINDERS: 'todo_reminders'>, 'translation_key': <HabiticaCalendar.TODO_REMINDERS: 'todo_reminders'>,
'unique_id': '00000000-0000-0000-0000-000000000000_todo_reminders', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_todo_reminders',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -1085,7 +1085,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabiticaCalendar.TODOS: 'todos'>, 'translation_key': <HabiticaCalendar.TODOS: 'todos'>,
'unique_id': '00000000-0000-0000-0000-000000000000_todos', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_todos',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---

File diff suppressed because it is too large Load Diff

View File

@ -7,9 +7,9 @@
'capabilities': dict({ 'capabilities': dict({
'options': list([ 'options': list([
'warrior', 'warrior',
'healer',
'wizard',
'rogue', 'rogue',
'wizard',
'healer',
]), ]),
}), }),
'config_entry_id': <ANY>, 'config_entry_id': <ANY>,
@ -35,7 +35,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipySensorEntity.CLASS: 'class'>, 'translation_key': <HabitipySensorEntity.CLASS: 'class'>,
'unique_id': '00000000-0000-0000-0000-000000000000_class', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_class',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -46,9 +46,9 @@
'friendly_name': 'test-user Class', 'friendly_name': 'test-user Class',
'options': list([ 'options': list([
'warrior', 'warrior',
'healer',
'wizard',
'rogue', 'rogue',
'wizard',
'healer',
]), ]),
}), }),
'context': <ANY>, 'context': <ANY>,
@ -91,7 +91,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipySensorEntity.CONSTITUTION: 'constitution'>, 'translation_key': <HabitipySensorEntity.CONSTITUTION: 'constitution'>,
'unique_id': '00000000-0000-0000-0000-000000000000_constitution', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_constitution',
'unit_of_measurement': 'CON', 'unit_of_measurement': 'CON',
}) })
# --- # ---
@ -143,7 +143,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipySensorEntity.DAILIES: 'dailys'>, 'translation_key': <HabitipySensorEntity.DAILIES: 'dailys'>,
'unique_id': '00000000-0000-0000-0000-000000000000_dailys', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_dailys',
'unit_of_measurement': 'tasks', 'unit_of_measurement': 'tasks',
}) })
# --- # ---
@ -151,23 +151,39 @@
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'2c6d136c-a1c3-4bef-b7c4-fa980784b1e1': dict({ '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1': dict({
'created_at': '2024-09-22T11:44:43.774Z', 'challenge': dict({
'broken': None,
'id': None,
'shortName': None,
'taskId': None,
'winner': None,
}),
'created_at': '2024-09-22T11:44:43.774000+00:00',
'every_x': 1, 'every_x': 1,
'frequency': 'weekly', 'frequency': 'weekly',
'group': dict({ 'group': dict({
'assignedDate': None,
'assignedUsers': list([ 'assignedUsers': list([
]), ]),
'completedBy': dict({ 'assignedUsersDetail': dict({
}), }),
'assigningUsername': None,
'completedBy': dict({
'date': None,
'userId': None,
}),
'id': None,
'managerNotes': None,
'taskId': None,
}), }),
'is_due': True, 'is_due': True,
'next_due': list([ 'next_due': list([
'2024-09-24T22:00:00.000Z', '2024-09-24T22:00:00+00:00',
'2024-09-27T22:00:00.000Z', '2024-09-27T22:00:00+00:00',
'2024-09-28T22:00:00.000Z', '2024-09-28T22:00:00+00:00',
'2024-10-01T22:00:00.000Z', '2024-10-01T22:00:00+00:00',
'2024-10-04T22:00:00.000Z', '2024-10-04T22:00:00+00:00',
'2024-10-08T22:00:00.000Z', '2024-10-08T22:00:00+00:00',
]), ]),
'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.',
'priority': 2, 'priority': 2,
@ -180,7 +196,7 @@
'th': False, 'th': False,
'w': True, 'w': True,
}), }),
'start_date': '2024-09-21T22:00:00.000Z', 'start_date': '2024-09-21T22:00:00+00:00',
'tags': list([ 'tags': list([
'51076966-2970-4b40-b6ba-d58c6a756dd7', '51076966-2970-4b40-b6ba-d58c6a756dd7',
]), ]),
@ -189,24 +205,40 @@
'yester_daily': True, 'yester_daily': True,
}), }),
'564b9ac9-c53d-4638-9e7f-1cd96fe19baa': dict({ '564b9ac9-c53d-4638-9e7f-1cd96fe19baa': dict({
'challenge': dict({
'broken': None,
'id': None,
'shortName': None,
'taskId': None,
'winner': None,
}),
'completed': True, 'completed': True,
'created_at': '2024-07-07T17:51:53.268Z', 'created_at': '2024-07-07T17:51:53.268000+00:00',
'every_x': 1, 'every_x': 1,
'frequency': 'weekly', 'frequency': 'weekly',
'group': dict({ 'group': dict({
'assignedDate': None,
'assignedUsers': list([ 'assignedUsers': list([
]), ]),
'completedBy': dict({ 'assignedUsersDetail': dict({
}), }),
'assigningUsername': None,
'completedBy': dict({
'date': None,
'userId': None,
}),
'id': None,
'managerNotes': None,
'taskId': None,
}), }),
'is_due': True, 'is_due': True,
'next_due': list([ 'next_due': list([
'Mon Sep 23 2024 00:00:00 GMT+0200', '2024-09-23T00:00:00+02:00',
'Tue Sep 24 2024 00:00:00 GMT+0200', '2024-09-24T00:00:00+02:00',
'Wed Sep 25 2024 00:00:00 GMT+0200', '2024-09-25T00:00:00+02:00',
'Thu Sep 26 2024 00:00:00 GMT+0200', '2024-09-26T00:00:00+02:00',
'Fri Sep 27 2024 00:00:00 GMT+0200', '2024-09-27T00:00:00+02:00',
'Sat Sep 28 2024 00:00:00 GMT+0200', '2024-09-28T00:00:00+02:00',
]), ]),
'notes': 'Klicke um Änderungen zu machen!', 'notes': 'Klicke um Änderungen zu machen!',
'priority': 1, 'priority': 1,
@ -219,7 +251,7 @@
'th': True, 'th': True,
'w': True, 'w': True,
}), }),
'start_date': '2024-07-06T22:00:00.000Z', 'start_date': '2024-07-06T22:00:00+00:00',
'streak': 1, 'streak': 1,
'text': 'Zahnseide benutzen', 'text': 'Zahnseide benutzen',
'type': 'daily', 'type': 'daily',
@ -227,22 +259,38 @@
'yester_daily': True, 'yester_daily': True,
}), }),
'6e53f1f5-a315-4edd-984d-8d762e4a08ef': dict({ '6e53f1f5-a315-4edd-984d-8d762e4a08ef': dict({
'created_at': '2024-10-10T15:57:14.304Z', 'challenge': dict({
'broken': None,
'id': None,
'shortName': None,
'taskId': None,
'winner': None,
}),
'created_at': '2024-10-10T15:57:14.304000+00:00',
'every_x': 1, 'every_x': 1,
'frequency': 'monthly', 'frequency': 'monthly',
'group': dict({ 'group': dict({
'assignedDate': None,
'assignedUsers': list([ 'assignedUsers': list([
]), ]),
'completedBy': dict({ 'assignedUsersDetail': dict({
}), }),
'assigningUsername': None,
'completedBy': dict({
'date': None,
'userId': None,
}),
'id': None,
'managerNotes': None,
'taskId': None,
}), }),
'next_due': list([ 'next_due': list([
'2024-12-14T23:00:00.000Z', '2024-12-14T23:00:00+00:00',
'2025-01-18T23:00:00.000Z', '2025-01-18T23:00:00+00:00',
'2025-02-15T23:00:00.000Z', '2025-02-15T23:00:00+00:00',
'2025-03-15T23:00:00.000Z', '2025-03-15T23:00:00+00:00',
'2025-04-19T23:00:00.000Z', '2025-04-19T23:00:00+00:00',
'2025-05-17T23:00:00.000Z', '2025-05-17T23:00:00+00:00',
]), ]),
'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!',
'priority': 1, 'priority': 1,
@ -255,7 +303,7 @@
'th': False, 'th': False,
'w': False, 'w': False,
}), }),
'start_date': '2024-09-20T23:00:00.000Z', 'start_date': '2024-09-20T23:00:00+00:00',
'streak': 1, 'streak': 1,
'text': 'Arbeite an einem kreativen Projekt', 'text': 'Arbeite an einem kreativen Projekt',
'type': 'daily', 'type': 'daily',
@ -266,23 +314,39 @@
'yester_daily': True, 'yester_daily': True,
}), }),
'f2c85972-1a19-4426-bc6d-ce3337b9d99f': dict({ 'f2c85972-1a19-4426-bc6d-ce3337b9d99f': dict({
'created_at': '2024-07-07T17:51:53.266Z', 'challenge': dict({
'broken': None,
'id': None,
'shortName': None,
'taskId': None,
'winner': None,
}),
'created_at': '2024-07-07T17:51:53.266000+00:00',
'every_x': 1, 'every_x': 1,
'frequency': 'weekly', 'frequency': 'weekly',
'group': dict({ 'group': dict({
'assignedDate': None,
'assignedUsers': list([ 'assignedUsers': list([
]), ]),
'completedBy': dict({ 'assignedUsersDetail': dict({
}), }),
'assigningUsername': None,
'completedBy': dict({
'date': None,
'userId': None,
}),
'id': None,
'managerNotes': None,
'taskId': None,
}), }),
'is_due': True, 'is_due': True,
'next_due': list([ 'next_due': list([
'2024-09-22T22:00:00.000Z', '2024-09-22T22:00:00+00:00',
'2024-09-23T22:00:00.000Z', '2024-09-23T22:00:00+00:00',
'2024-09-24T22:00:00.000Z', '2024-09-24T22:00:00+00:00',
'2024-09-25T22:00:00.000Z', '2024-09-25T22:00:00+00:00',
'2024-09-26T22:00:00.000Z', '2024-09-26T22:00:00+00:00',
'2024-09-27T22:00:00.000Z', '2024-09-27T22:00:00+00:00',
]), ]),
'notes': 'Klicke um Deinen Terminplan festzulegen!', 'notes': 'Klicke um Deinen Terminplan festzulegen!',
'priority': 1, 'priority': 1,
@ -295,7 +359,7 @@
'th': True, 'th': True,
'w': True, 'w': True,
}), }),
'start_date': '2024-07-06T22:00:00.000Z', 'start_date': '2024-07-06T22:00:00+00:00',
'text': '5 Minuten ruhig durchatmen', 'text': '5 Minuten ruhig durchatmen',
'type': 'daily', 'type': 'daily',
'value': -1.919611992979862, 'value': -1.919611992979862,
@ -341,7 +405,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipySensorEntity.DISPLAY_NAME: 'display_name'>, 'translation_key': <HabitipySensorEntity.DISPLAY_NAME: 'display_name'>,
'unique_id': '00000000-0000-0000-0000-000000000000_display_name', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_display_name',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -387,7 +451,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipySensorEntity.EXPERIENCE: 'experience'>, 'translation_key': <HabitipySensorEntity.EXPERIENCE: 'experience'>,
'unique_id': '00000000-0000-0000-0000-000000000000_experience', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_experience',
'unit_of_measurement': 'XP', 'unit_of_measurement': 'XP',
}) })
# --- # ---
@ -437,7 +501,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipySensorEntity.GEMS: 'gems'>, 'translation_key': <HabitipySensorEntity.GEMS: 'gems'>,
'unique_id': '00000000-0000-0000-0000-000000000000_gems', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_gems',
'unit_of_measurement': 'gems', 'unit_of_measurement': 'gems',
}) })
# --- # ---
@ -453,7 +517,7 @@
'last_changed': <ANY>, 'last_changed': <ANY>,
'last_reported': <ANY>, 'last_reported': <ANY>,
'last_updated': <ANY>, 'last_updated': <ANY>,
'state': '0', 'state': '40',
}) })
# --- # ---
# name: test_sensors[sensor.test_user_gold-entry] # name: test_sensors[sensor.test_user_gold-entry]
@ -488,7 +552,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipySensorEntity.GOLD: 'gold'>, 'translation_key': <HabitipySensorEntity.GOLD: 'gold'>,
'unique_id': '00000000-0000-0000-0000-000000000000_gold', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_gold',
'unit_of_measurement': 'GP', 'unit_of_measurement': 'GP',
}) })
# --- # ---
@ -535,7 +599,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipySensorEntity.HABITS: 'habits'>, 'translation_key': <HabitipySensorEntity.HABITS: 'habits'>,
'unique_id': '00000000-0000-0000-0000-000000000000_habits', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_habits',
'unit_of_measurement': 'tasks', 'unit_of_measurement': 'tasks',
}) })
# --- # ---
@ -543,60 +607,160 @@
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'1d147de6-5c02-4740-8e2f-71d3015a37f4': dict({ '1d147de6-5c02-4740-8e2f-71d3015a37f4': dict({
'created_at': '2024-07-07T17:51:53.266Z', 'challenge': dict({
'broken': None,
'id': None,
'shortName': None,
'taskId': None,
'winner': None,
}),
'created_at': '2024-07-07T17:51:53.266000+00:00',
'frequency': 'daily', 'frequency': 'daily',
'group': dict({ 'group': dict({
'assignedDate': None,
'assignedUsers': list([ 'assignedUsers': list([
]), ]),
'completedBy': dict({ 'assignedUsersDetail': dict({
}), }),
'assigningUsername': None,
'completedBy': dict({
'date': None,
'userId': None,
}),
'id': None,
'managerNotes': None,
'taskId': None,
}), }),
'priority': 1, 'priority': 1,
'repeat': dict({
'f': False,
'm': True,
's': False,
'su': False,
't': True,
'th': False,
'w': True,
}),
'text': 'Eine kurze Pause machen', 'text': 'Eine kurze Pause machen',
'type': 'habit', 'type': 'habit',
'up': True, 'up': True,
}), }),
'bc1d1855-b2b8-4663-98ff-62e7b763dfc4': dict({ 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4': dict({
'created_at': '2024-07-07T17:51:53.265Z', 'challenge': dict({
'broken': None,
'id': None,
'shortName': None,
'taskId': None,
'winner': None,
}),
'created_at': '2024-07-07T17:51:53.265000+00:00',
'down': True, 'down': True,
'frequency': 'daily', 'frequency': 'daily',
'group': dict({ 'group': dict({
'assignedDate': None,
'assignedUsers': list([ 'assignedUsers': list([
]), ]),
'completedBy': dict({ 'assignedUsersDetail': dict({
}), }),
'assigningUsername': None,
'completedBy': dict({
'date': None,
'userId': None,
}),
'id': None,
'managerNotes': None,
'taskId': None,
}), }),
'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht',
'priority': 1, 'priority': 1,
'repeat': dict({
'f': False,
'm': True,
's': False,
'su': False,
't': True,
'th': False,
'w': True,
}),
'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest',
'type': 'habit', 'type': 'habit',
}), }),
'e97659e0-2c42-4599-a7bb-00282adc410d': dict({ 'e97659e0-2c42-4599-a7bb-00282adc410d': dict({
'created_at': '2024-07-07T17:51:53.264Z', 'challenge': dict({
'broken': None,
'id': None,
'shortName': None,
'taskId': None,
'winner': None,
}),
'created_at': '2024-07-07T17:51:53.264000+00:00',
'frequency': 'daily', 'frequency': 'daily',
'group': dict({ 'group': dict({
'assignedDate': None,
'assignedUsers': list([ 'assignedUsers': list([
]), ]),
'completedBy': dict({ 'assignedUsersDetail': dict({
}), }),
'assigningUsername': None,
'completedBy': dict({
'date': None,
'userId': None,
}),
'id': None,
'managerNotes': None,
'taskId': None,
}), }),
'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do',
'priority': 1, 'priority': 1,
'repeat': dict({
'f': False,
'm': True,
's': False,
'su': False,
't': True,
'th': False,
'w': True,
}),
'text': 'Füge eine Aufgabe zu Habitica hinzu', 'text': 'Füge eine Aufgabe zu Habitica hinzu',
'type': 'habit', 'type': 'habit',
'up': True, 'up': True,
}), }),
'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a': dict({ 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a': dict({
'created_at': '2024-07-07T17:51:53.268Z', 'challenge': dict({
'broken': None,
'id': None,
'shortName': None,
'taskId': None,
'winner': None,
}),
'created_at': '2024-07-07T17:51:53.268000+00:00',
'down': True, 'down': True,
'frequency': 'daily', 'frequency': 'daily',
'group': dict({ 'group': dict({
'assignedDate': None,
'assignedUsers': list([ 'assignedUsers': list([
]), ]),
'completedBy': dict({ 'assignedUsersDetail': dict({
}), }),
'assigningUsername': None,
'completedBy': dict({
'date': None,
'userId': None,
}),
'id': None,
'managerNotes': None,
'taskId': None,
}), }),
'priority': 1, 'priority': 1,
'repeat': dict({
'f': False,
'm': True,
's': False,
'su': False,
't': True,
'th': False,
'w': True,
}),
'text': 'Gesundes Essen/Junkfood', 'text': 'Gesundes Essen/Junkfood',
'type': 'habit', 'type': 'habit',
'up': True, 'up': True,
@ -644,7 +808,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipySensorEntity.HEALTH: 'health'>, 'translation_key': <HabitipySensorEntity.HEALTH: 'health'>,
'unique_id': '00000000-0000-0000-0000-000000000000_health', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_health',
'unit_of_measurement': 'HP', 'unit_of_measurement': 'HP',
}) })
# --- # ---
@ -659,7 +823,7 @@
'last_changed': <ANY>, 'last_changed': <ANY>,
'last_reported': <ANY>, 'last_reported': <ANY>,
'last_updated': <ANY>, 'last_updated': <ANY>,
'state': '0', 'state': '0.0',
}) })
# --- # ---
# name: test_sensors[sensor.test_user_intelligence-entry] # name: test_sensors[sensor.test_user_intelligence-entry]
@ -694,7 +858,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipySensorEntity.INTELLIGENCE: 'intelligence'>, 'translation_key': <HabitipySensorEntity.INTELLIGENCE: 'intelligence'>,
'unique_id': '00000000-0000-0000-0000-000000000000_intelligence', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_intelligence',
'unit_of_measurement': 'INT', 'unit_of_measurement': 'INT',
}) })
# --- # ---
@ -746,7 +910,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipySensorEntity.LEVEL: 'level'>, 'translation_key': <HabitipySensorEntity.LEVEL: 'level'>,
'unique_id': '00000000-0000-0000-0000-000000000000_level', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_level',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -795,7 +959,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipySensorEntity.MANA: 'mana'>, 'translation_key': <HabitipySensorEntity.MANA: 'mana'>,
'unique_id': '00000000-0000-0000-0000-000000000000_mana', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_mana',
'unit_of_measurement': 'MP', 'unit_of_measurement': 'MP',
}) })
# --- # ---
@ -842,7 +1006,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipySensorEntity.HEALTH_MAX: 'health_max'>, 'translation_key': <HabitipySensorEntity.HEALTH_MAX: 'health_max'>,
'unique_id': '00000000-0000-0000-0000-000000000000_health_max', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_health_max',
'unit_of_measurement': 'HP', 'unit_of_measurement': 'HP',
}) })
# --- # ---
@ -889,7 +1053,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipySensorEntity.MANA_MAX: 'mana_max'>, 'translation_key': <HabitipySensorEntity.MANA_MAX: 'mana_max'>,
'unique_id': '00000000-0000-0000-0000-000000000000_mana_max', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_mana_max',
'unit_of_measurement': 'MP', 'unit_of_measurement': 'MP',
}) })
# --- # ---
@ -939,7 +1103,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipySensorEntity.TRINKETS: 'trinkets'>, 'translation_key': <HabitipySensorEntity.TRINKETS: 'trinkets'>,
'unique_id': '00000000-0000-0000-0000-000000000000_trinkets', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_trinkets',
'unit_of_measurement': '⧖', 'unit_of_measurement': '⧖',
}) })
# --- # ---
@ -987,7 +1151,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipySensorEntity.EXPERIENCE_MAX: 'experience_max'>, 'translation_key': <HabitipySensorEntity.EXPERIENCE_MAX: 'experience_max'>,
'unique_id': '00000000-0000-0000-0000-000000000000_experience_max', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_experience_max',
'unit_of_measurement': 'XP', 'unit_of_measurement': 'XP',
}) })
# --- # ---
@ -1037,7 +1201,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipySensorEntity.PERCEPTION: 'perception'>, 'translation_key': <HabitipySensorEntity.PERCEPTION: 'perception'>,
'unique_id': '00000000-0000-0000-0000-000000000000_perception', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_perception',
'unit_of_measurement': 'PER', 'unit_of_measurement': 'PER',
}) })
# --- # ---
@ -1089,7 +1253,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipySensorEntity.REWARDS: 'rewards'>, 'translation_key': <HabitipySensorEntity.REWARDS: 'rewards'>,
'unique_id': '00000000-0000-0000-0000-000000000000_rewards', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_rewards',
'unit_of_measurement': 'tasks', 'unit_of_measurement': 'tasks',
}) })
# --- # ---
@ -1097,18 +1261,43 @@
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b': dict({ '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b': dict({
'created_at': '2024-07-07T17:51:53.266Z', 'challenge': dict({
'broken': None,
'id': None,
'shortName': None,
'taskId': None,
'winner': None,
}),
'created_at': '2024-07-07T17:51:53.266000+00:00',
'group': dict({ 'group': dict({
'assignedDate': None,
'assignedUsers': list([ 'assignedUsers': list([
]), ]),
'completedBy': dict({ 'assignedUsersDetail': dict({
}), }),
'assigningUsername': None,
'completedBy': dict({
'date': None,
'userId': None,
}),
'id': None,
'managerNotes': None,
'taskId': None,
}), }),
'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!',
'priority': 1, 'priority': 1,
'repeat': dict({
'f': False,
'm': True,
's': False,
'su': False,
't': True,
'th': False,
'w': True,
}),
'text': 'Belohne Dich selbst', 'text': 'Belohne Dich selbst',
'type': 'reward', 'type': 'reward',
'value': 10, 'value': 10.0,
}), }),
'friendly_name': 'test-user Rewards', 'friendly_name': 'test-user Rewards',
'unit_of_measurement': 'tasks', 'unit_of_measurement': 'tasks',
@ -1153,7 +1342,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipySensorEntity.STRENGTH: 'strength'>, 'translation_key': <HabitipySensorEntity.STRENGTH: 'strength'>,
'unique_id': '00000000-0000-0000-0000-000000000000_strength', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_strength',
'unit_of_measurement': 'STR', 'unit_of_measurement': 'STR',
}) })
# --- # ---
@ -1205,7 +1394,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabitipySensorEntity.TODOS: 'todos'>, 'translation_key': <HabitipySensorEntity.TODOS: 'todos'>,
'unique_id': '00000000-0000-0000-0000-000000000000_todos', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_todos',
'unit_of_measurement': 'tasks', 'unit_of_measurement': 'tasks',
}) })
# --- # ---
@ -1213,41 +1402,116 @@
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'1aa3137e-ef72-4d1f-91ee-41933602f438': dict({ '1aa3137e-ef72-4d1f-91ee-41933602f438': dict({
'created_at': '2024-09-21T22:16:38.153Z', 'challenge': dict({
'broken': None,
'id': None,
'shortName': None,
'taskId': None,
'winner': None,
}),
'created_at': '2024-09-21T22:16:38.153000+00:00',
'group': dict({ 'group': dict({
'assignedDate': None,
'assignedUsers': list([ 'assignedUsers': list([
]), ]),
'completedBy': dict({ 'assignedUsersDetail': dict({
}), }),
'assigningUsername': None,
'completedBy': dict({
'date': None,
'userId': None,
}),
'id': None,
'managerNotes': None,
'taskId': None,
}), }),
'notes': 'Rasen mähen und die Pflanzen gießen.', 'notes': 'Rasen mähen und die Pflanzen gießen.',
'priority': 1, 'priority': 1,
'repeat': dict({
'f': False,
'm': True,
's': False,
'su': False,
't': True,
'th': False,
'w': True,
}),
'text': 'Garten pflegen', 'text': 'Garten pflegen',
'type': 'todo', 'type': 'todo',
}), }),
'2f6fcabc-f670-4ec3-ba65-817e8deea490': dict({ '2f6fcabc-f670-4ec3-ba65-817e8deea490': dict({
'created_at': '2024-09-21T22:17:19.513Z', 'challenge': dict({
'broken': None,
'id': None,
'shortName': None,
'taskId': None,
'winner': None,
}),
'created_at': '2024-09-21T22:17:19.513000+00:00',
'group': dict({ 'group': dict({
'assignedDate': None,
'assignedUsers': list([ 'assignedUsers': list([
]), ]),
'completedBy': dict({ 'assignedUsersDetail': dict({
}), }),
'assigningUsername': None,
'completedBy': dict({
'date': None,
'userId': None,
}),
'id': None,
'managerNotes': None,
'taskId': None,
}), }),
'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.',
'priority': 1, 'priority': 1,
'repeat': dict({
'f': False,
'm': True,
's': False,
'su': False,
't': True,
'th': False,
'w': True,
}),
'text': 'Rechnungen bezahlen', 'text': 'Rechnungen bezahlen',
'type': 'todo', 'type': 'todo',
}), }),
'86ea2475-d1b5-4020-bdcc-c188c7996afa': dict({ '86ea2475-d1b5-4020-bdcc-c188c7996afa': dict({
'created_at': '2024-09-21T22:16:16.756Z', 'challenge': dict({
'broken': None,
'id': None,
'shortName': None,
'taskId': None,
'winner': None,
}),
'created_at': '2024-09-21T22:16:16.756000+00:00',
'group': dict({ 'group': dict({
'assignedDate': None,
'assignedUsers': list([ 'assignedUsers': list([
]), ]),
'completedBy': dict({ 'assignedUsersDetail': dict({
}), }),
'assigningUsername': None,
'completedBy': dict({
'date': None,
'userId': None,
}),
'id': None,
'managerNotes': None,
'taskId': None,
}), }),
'notes': 'Den Ausflug für das kommende Wochenende organisieren.', 'notes': 'Den Ausflug für das kommende Wochenende organisieren.',
'priority': 1, 'priority': 1,
'repeat': dict({
'f': False,
'm': True,
's': False,
'su': False,
't': True,
'th': False,
'w': True,
}),
'tags': list([ 'tags': list([
'51076966-2970-4b40-b6ba-d58c6a756dd7', '51076966-2970-4b40-b6ba-d58c6a756dd7',
]), ]),
@ -1255,15 +1519,40 @@
'type': 'todo', 'type': 'todo',
}), }),
'88de7cd9-af2b-49ce-9afd-bf941d87336b': dict({ '88de7cd9-af2b-49ce-9afd-bf941d87336b': dict({
'created_at': '2024-09-21T22:17:57.816Z', 'challenge': dict({
'broken': None,
'id': None,
'shortName': None,
'taskId': None,
'winner': None,
}),
'created_at': '2024-09-21T22:17:57.816000+00:00',
'group': dict({ 'group': dict({
'assignedDate': None,
'assignedUsers': list([ 'assignedUsers': list([
]), ]),
'completedBy': dict({ 'assignedUsersDetail': dict({
}), }),
'assigningUsername': None,
'completedBy': dict({
'date': None,
'userId': None,
}),
'id': None,
'managerNotes': None,
'taskId': None,
}), }),
'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.',
'priority': 1, 'priority': 1,
'repeat': dict({
'f': False,
'm': True,
's': False,
'su': False,
't': True,
'th': False,
'w': True,
}),
'text': 'Buch zu Ende lesen', 'text': 'Buch zu Ende lesen',
'type': 'todo', 'type': 'todo',
}), }),

View File

@ -28,7 +28,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': <HabiticaSwitchEntity.SLEEP: 'sleep'>, 'translation_key': <HabiticaSwitchEntity.SLEEP: 'sleep'>,
'unique_id': '00000000-0000-0000-0000-000000000000_sleep', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_sleep',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---

View File

@ -129,7 +129,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': <TodoListEntityFeature: 92>, 'supported_features': <TodoListEntityFeature: 92>,
'translation_key': <HabiticaTodoList.DAILIES: 'dailys'>, 'translation_key': <HabiticaTodoList.DAILIES: 'dailys'>,
'unique_id': '00000000-0000-0000-0000-000000000000_dailys', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_dailys',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -176,7 +176,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': <TodoListEntityFeature: 95>, 'supported_features': <TodoListEntityFeature: 95>,
'translation_key': <HabiticaTodoList.TODOS: 'todos'>, 'translation_key': <HabiticaTodoList.TODOS: 'todos'>,
'unique_id': '00000000-0000-0000-0000-000000000000_todos', 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_todos',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---

View File

@ -1,19 +1,19 @@
"""Tests for the Habitica binary sensor platform.""" """Tests for the Habitica binary sensor platform."""
from collections.abc import Generator from collections.abc import Generator
from unittest.mock import patch from unittest.mock import AsyncMock, patch
from habiticalib import HabiticaUserResponse
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant.components.habitica.const import ASSETS_URL, DEFAULT_URL, DOMAIN from homeassistant.components.habitica.const import ASSETS_URL, DOMAIN
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.const import STATE_OFF, STATE_ON, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform from tests.common import MockConfigEntry, load_fixture, snapshot_platform
from tests.test_util.aiohttp import AiohttpClientMocker
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -26,7 +26,7 @@ def binary_sensor_only() -> Generator[None]:
yield yield
@pytest.mark.usefixtures("mock_habitica") @pytest.mark.usefixtures("habitica")
async def test_binary_sensors( async def test_binary_sensors(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
@ -54,23 +54,17 @@ async def test_binary_sensors(
async def test_pending_quest_states( async def test_pending_quest_states(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker, habitica: AsyncMock,
fixture: str, fixture: str,
entity_state: str, entity_state: str,
entity_picture: str | None, entity_picture: str | None,
) -> None: ) -> None:
"""Test states of pending quest sensor.""" """Test states of pending quest sensor."""
aioclient_mock.get( habitica.get_user.return_value = HabiticaUserResponse.from_json(
f"{DEFAULT_URL}/api/v3/user", load_fixture(f"{fixture}.json", DOMAIN)
json=load_json_object_fixture(f"{fixture}.json", DOMAIN),
)
aioclient_mock.get(f"{DEFAULT_URL}/api/v3/tasks/user", json={"data": []})
aioclient_mock.get(
f"{DEFAULT_URL}/api/v3/content",
params={"language": "en"},
json=load_json_object_fixture("content.json", DOMAIN),
) )
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id) await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()

View File

@ -2,31 +2,29 @@
from collections.abc import Generator from collections.abc import Generator
from datetime import timedelta from datetime import timedelta
from http import HTTPStatus from unittest.mock import AsyncMock, patch
import re
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
from habiticalib import HabiticaUserResponse, Skill
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.components.habitica.const import DEFAULT_URL, DOMAIN from homeassistant.components.habitica.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
import homeassistant.util.dt as dt_util
from .conftest import mock_called_with from .conftest import ERROR_BAD_REQUEST, ERROR_NOT_AUTHORIZED, ERROR_TOO_MANY_REQUESTS
from tests.common import ( from tests.common import (
MockConfigEntry, MockConfigEntry,
async_fire_time_changed, async_fire_time_changed,
load_json_object_fixture, load_fixture,
snapshot_platform, snapshot_platform,
) )
from tests.test_util.aiohttp import AiohttpClientMocker
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -51,29 +49,15 @@ def button_only() -> Generator[None]:
async def test_buttons( async def test_buttons(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker, habitica: AsyncMock,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
fixture: str, fixture: str,
) -> None: ) -> None:
"""Test button entities.""" """Test button entities."""
aioclient_mock.get(
f"{DEFAULT_URL}/api/v3/user", habitica.get_user.return_value = HabiticaUserResponse.from_json(
json=load_json_object_fixture(f"{fixture}.json", DOMAIN), load_fixture(f"{fixture}.json", DOMAIN)
)
aioclient_mock.get(
f"{DEFAULT_URL}/api/v3/tasks/user",
params={"type": "completedTodos"},
json=load_json_object_fixture("completed_todos.json", DOMAIN),
)
aioclient_mock.get(
f"{DEFAULT_URL}/api/v3/tasks/user",
json=load_json_object_fixture("tasks.json", DOMAIN),
)
aioclient_mock.get(
f"{DEFAULT_URL}/api/v3/content",
params={"language": "en"},
json=load_json_object_fixture("content.json", DOMAIN),
) )
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id) await hass.config_entries.async_setup(config_entry.entry_id)
@ -85,70 +69,87 @@ async def test_buttons(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("entity_id", "api_url", "fixture"), ("entity_id", "call_func", "call_args", "fixture"),
[ [
("button.test_user_allocate_all_stat_points", "user/allocate-now", "user"), (
("button.test_user_buy_a_health_potion", "user/buy-health-potion", "user"), "button.test_user_allocate_all_stat_points",
("button.test_user_revive_from_death", "user/revive", "user"), "allocate_stat_points",
("button.test_user_start_my_day", "cron", "user"), None,
"user",
),
("button.test_user_buy_a_health_potion", "buy_health_potion", None, "user"),
("button.test_user_revive_from_death", "revive", None, "user"),
("button.test_user_start_my_day", "run_cron", None, "user"),
( (
"button.test_user_chilling_frost", "button.test_user_chilling_frost",
"user/class/cast/frost", "cast_skill",
Skill.CHILLING_FROST,
"wizard_fixture", "wizard_fixture",
), ),
( (
"button.test_user_earthquake", "button.test_user_earthquake",
"user/class/cast/earth", "cast_skill",
Skill.EARTHQUAKE,
"wizard_fixture", "wizard_fixture",
), ),
( (
"button.test_user_ethereal_surge", "button.test_user_ethereal_surge",
"user/class/cast/mpheal", "cast_skill",
Skill.ETHEREAL_SURGE,
"wizard_fixture", "wizard_fixture",
), ),
( (
"button.test_user_stealth", "button.test_user_stealth",
"user/class/cast/stealth", "cast_skill",
Skill.STEALTH,
"rogue_fixture", "rogue_fixture",
), ),
( (
"button.test_user_tools_of_the_trade", "button.test_user_tools_of_the_trade",
"user/class/cast/toolsOfTrade", "cast_skill",
Skill.TOOLS_OF_THE_TRADE,
"rogue_fixture", "rogue_fixture",
), ),
( (
"button.test_user_defensive_stance", "button.test_user_defensive_stance",
"user/class/cast/defensiveStance", "cast_skill",
Skill.DEFENSIVE_STANCE,
"warrior_fixture", "warrior_fixture",
), ),
( (
"button.test_user_intimidating_gaze", "button.test_user_intimidating_gaze",
"user/class/cast/intimidate", "cast_skill",
Skill.INTIMIDATING_GAZE,
"warrior_fixture", "warrior_fixture",
), ),
( (
"button.test_user_valorous_presence", "button.test_user_valorous_presence",
"user/class/cast/valorousPresence", "cast_skill",
Skill.VALOROUS_PRESENCE,
"warrior_fixture", "warrior_fixture",
), ),
( (
"button.test_user_healing_light", "button.test_user_healing_light",
"user/class/cast/heal", "cast_skill",
Skill.HEALING_LIGHT,
"healer_fixture", "healer_fixture",
), ),
( (
"button.test_user_protective_aura", "button.test_user_protective_aura",
"user/class/cast/protectAura", "cast_skill",
Skill.PROTECTIVE_AURA,
"healer_fixture", "healer_fixture",
), ),
( (
"button.test_user_searing_brightness", "button.test_user_searing_brightness",
"user/class/cast/brightness", "cast_skill",
Skill.SEARING_BRIGHTNESS,
"healer_fixture", "healer_fixture",
), ),
( (
"button.test_user_blessing", "button.test_user_blessing",
"user/class/cast/healAll", "cast_skill",
Skill.BLESSING,
"healer_fixture", "healer_fixture",
), ),
], ],
@ -156,58 +157,48 @@ async def test_buttons(
async def test_button_press( async def test_button_press(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker, habitica: AsyncMock,
entity_id: str, entity_id: str,
api_url: str, call_func: str,
call_args: Skill | None,
fixture: str, fixture: str,
) -> None: ) -> None:
"""Test button press method.""" """Test button press method."""
aioclient_mock.get(
f"{DEFAULT_URL}/api/v3/user", habitica.get_user.return_value = HabiticaUserResponse.from_json(
json=load_json_object_fixture(f"{fixture}.json", DOMAIN), load_fixture(f"{fixture}.json", DOMAIN)
)
aioclient_mock.get(
f"{DEFAULT_URL}/api/v3/tasks/user",
params={"type": "completedTodos"},
json=load_json_object_fixture("completed_todos.json", DOMAIN),
)
aioclient_mock.get(
f"{DEFAULT_URL}/api/v3/tasks/user",
json=load_json_object_fixture("tasks.json", DOMAIN),
)
aioclient_mock.get(
f"{DEFAULT_URL}/api/v3/content",
params={"language": "en"},
json=load_json_object_fixture("content.json", DOMAIN),
) )
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id) await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
aioclient_mock.post(f"{DEFAULT_URL}/api/v3/{api_url}", json={"data": None}) mocked = getattr(habitica, call_func)
mocked.reset_mock()
await hass.services.async_call( await hass.services.async_call(
BUTTON_DOMAIN, BUTTON_DOMAIN,
SERVICE_PRESS, SERVICE_PRESS,
{ATTR_ENTITY_ID: entity_id}, {ATTR_ENTITY_ID: entity_id},
blocking=True, blocking=True,
) )
if call_args:
assert mock_called_with(aioclient_mock, "post", f"{DEFAULT_URL}/api/v3/{api_url}") mocked.assert_awaited_once_with(call_args)
else:
mocked.assert_awaited_once()
@pytest.mark.parametrize( @pytest.mark.parametrize(
("entity_id", "api_url"), ("entity_id", "call_func"),
[ [
("button.test_user_allocate_all_stat_points", "user/allocate-now"), ("button.test_user_allocate_all_stat_points", "allocate_stat_points"),
("button.test_user_buy_a_health_potion", "user/buy-health-potion"), ("button.test_user_buy_a_health_potion", "buy_health_potion"),
("button.test_user_revive_from_death", "user/revive"), ("button.test_user_revive_from_death", "revive"),
("button.test_user_start_my_day", "cron"), ("button.test_user_start_my_day", "run_cron"),
("button.test_user_chilling_frost", "user/class/cast/frost"), ("button.test_user_chilling_frost", "cast_skill"),
("button.test_user_earthquake", "user/class/cast/earth"), ("button.test_user_earthquake", "cast_skill"),
("button.test_user_ethereal_surge", "user/class/cast/mpheal"), ("button.test_user_ethereal_surge", "cast_skill"),
], ],
ids=[ ids=[
"allocate-points", "allocate-points",
@ -220,20 +211,20 @@ async def test_button_press(
], ],
) )
@pytest.mark.parametrize( @pytest.mark.parametrize(
("status_code", "msg", "exception"), ("raise_exception", "msg", "expected_exception"),
[ [
( (
HTTPStatus.TOO_MANY_REQUESTS, ERROR_TOO_MANY_REQUESTS,
"Rate limit exceeded, try again later", "Rate limit exceeded, try again later",
ServiceValidationError, HomeAssistantError,
), ),
( (
HTTPStatus.BAD_REQUEST, ERROR_BAD_REQUEST,
"Unable to connect to Habitica, try again later", "Unable to connect to Habitica, try again later",
HomeAssistantError, HomeAssistantError,
), ),
( (
HTTPStatus.UNAUTHORIZED, ERROR_NOT_AUTHORIZED,
"Unable to complete action, the required conditions are not met", "Unable to complete action, the required conditions are not met",
ServiceValidationError, ServiceValidationError,
), ),
@ -242,12 +233,12 @@ async def test_button_press(
async def test_button_press_exceptions( async def test_button_press_exceptions(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
entity_id: str, entity_id: str,
api_url: str, call_func: str,
status_code: HTTPStatus, raise_exception: Exception,
msg: str, msg: str,
exception: Exception, expected_exception: Exception,
) -> None: ) -> None:
"""Test button press exceptions.""" """Test button press exceptions."""
@ -257,13 +248,10 @@ async def test_button_press_exceptions(
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
mock_habitica.post( func = getattr(habitica, call_func)
f"{DEFAULT_URL}/api/v3/{api_url}", func.side_effect = raise_exception
status=status_code,
json={"data": None},
)
with pytest.raises(exception, match=msg): with pytest.raises(expected_exception, match=msg):
await hass.services.async_call( await hass.services.async_call(
BUTTON_DOMAIN, BUTTON_DOMAIN,
SERVICE_PRESS, SERVICE_PRESS,
@ -271,8 +259,6 @@ async def test_button_press_exceptions(
blocking=True, blocking=True,
) )
assert mock_called_with(mock_habitica, "post", f"{DEFAULT_URL}/api/v3/{api_url}")
@pytest.mark.parametrize( @pytest.mark.parametrize(
("fixture", "entity_ids"), ("fixture", "entity_ids"),
@ -322,21 +308,15 @@ async def test_button_press_exceptions(
async def test_button_unavailable( async def test_button_unavailable(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker, habitica: AsyncMock,
fixture: str, fixture: str,
entity_ids: list[str], entity_ids: list[str],
) -> None: ) -> None:
"""Test buttons are unavailable if conditions are not met.""" """Test buttons are unavailable if conditions are not met."""
aioclient_mock.get( habitica.get_user.return_value = HabiticaUserResponse.from_json(
f"{DEFAULT_URL}/api/v3/user", load_fixture(f"{fixture}.json", DOMAIN)
json=load_json_object_fixture(f"{fixture}.json", DOMAIN),
) )
aioclient_mock.get(
f"{DEFAULT_URL}/api/v3/tasks/user",
json=load_json_object_fixture("tasks.json", DOMAIN),
)
aioclient_mock.get(re.compile(r".*"), json={"data": []})
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id) await hass.config_entries.async_setup(config_entry.entry_id)
@ -352,9 +332,8 @@ async def test_button_unavailable(
async def test_class_change( async def test_class_change(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker, habitica: AsyncMock,
snapshot: SnapshotAssertion, freezer: FrozenDateTimeFactory,
entity_registry: er.EntityRegistry,
) -> None: ) -> None:
"""Test removing and adding skills after class change.""" """Test removing and adding skills after class change."""
mage_skills = [ mage_skills = [
@ -368,23 +347,9 @@ async def test_class_change(
"button.test_user_searing_brightness", "button.test_user_searing_brightness",
"button.test_user_blessing", "button.test_user_blessing",
] ]
aioclient_mock.get(
f"{DEFAULT_URL}/api/v3/user", habitica.get_user.return_value = HabiticaUserResponse.from_json(
json=load_json_object_fixture("wizard_fixture.json", DOMAIN), load_fixture("wizard_fixture.json", DOMAIN)
)
aioclient_mock.get(
f"{DEFAULT_URL}/api/v3/tasks/user",
params={"type": "completedTodos"},
json=load_json_object_fixture("completed_todos.json", DOMAIN),
)
aioclient_mock.get(
f"{DEFAULT_URL}/api/v3/tasks/user",
json=load_json_object_fixture("tasks.json", DOMAIN),
)
aioclient_mock.get(
f"{DEFAULT_URL}/api/v3/content",
params={"language": "en"},
json=load_json_object_fixture("content.json", DOMAIN),
) )
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id) await hass.config_entries.async_setup(config_entry.entry_id)
@ -395,13 +360,11 @@ async def test_class_change(
for skill in mage_skills: for skill in mage_skills:
assert hass.states.get(skill) assert hass.states.get(skill)
aioclient_mock._mocks.pop(0) habitica.get_user.return_value = HabiticaUserResponse.from_json(
aioclient_mock.get( load_fixture("healer_fixture.json", DOMAIN)
f"{DEFAULT_URL}/api/v3/user",
json=load_json_object_fixture("healer_fixture.json", DOMAIN),
) )
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=60)) async_fire_time_changed(hass)
await hass.async_block_till_done() await hass.async_block_till_done()
for skill in mage_skills: for skill in mage_skills:

View File

@ -3,6 +3,7 @@
from collections.abc import Generator from collections.abc import Generator
from unittest.mock import patch from unittest.mock import patch
from freezegun.api import freeze_time
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
@ -31,8 +32,8 @@ async def set_tz(hass: HomeAssistant) -> None:
await hass.config.async_set_time_zone("Europe/Berlin") await hass.config.async_set_time_zone("Europe/Berlin")
@pytest.mark.usefixtures("mock_habitica") @pytest.mark.usefixtures("habitica")
@pytest.mark.freeze_time("2024-09-20T22:00:00.000Z") @freeze_time("2024-09-20T22:00:00.000Z")
async def test_calendar_platform( async def test_calendar_platform(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
@ -70,8 +71,7 @@ async def test_calendar_platform(
"date range in the past", "date range in the past",
], ],
) )
@pytest.mark.freeze_time("2024-09-20T22:00:00.000Z") @pytest.mark.usefixtures("habitica")
@pytest.mark.usefixtures("mock_habitica")
async def test_api_events( async def test_api_events(
hass: HomeAssistant, hass: HomeAssistant,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,

View File

@ -1,14 +1,14 @@
"""Test the habitica config flow.""" """Test the habitica config flow."""
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock
from aiohttp import ClientResponseError
import pytest import pytest
from homeassistant import config_entries
from homeassistant.components.habitica.const import CONF_API_USER, DEFAULT_URL, DOMAIN from homeassistant.components.habitica.const import CONF_API_USER, DEFAULT_URL, DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import ( from homeassistant.const import (
CONF_API_KEY, CONF_API_KEY,
CONF_NAME,
CONF_PASSWORD, CONF_PASSWORD,
CONF_URL, CONF_URL,
CONF_USERNAME, CONF_USERNAME,
@ -17,23 +17,32 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from .conftest import ERROR_BAD_REQUEST, ERROR_NOT_AUTHORIZED
from tests.common import MockConfigEntry
TEST_API_USER = "a380546a-94be-4b8e-8a0b-23e0d5c03303"
TEST_API_KEY = "cd0e5985-17de-4b4f-849e-5d506c5e4382"
MOCK_DATA_LOGIN_STEP = { MOCK_DATA_LOGIN_STEP = {
CONF_USERNAME: "test-email@example.com", CONF_USERNAME: "test-email@example.com",
CONF_PASSWORD: "test-password", CONF_PASSWORD: "test-password",
} }
MOCK_DATA_ADVANCED_STEP = { MOCK_DATA_ADVANCED_STEP = {
CONF_API_USER: "test-api-user", CONF_API_USER: TEST_API_USER,
CONF_API_KEY: "test-api-key", CONF_API_KEY: TEST_API_KEY,
CONF_URL: DEFAULT_URL, CONF_URL: DEFAULT_URL,
CONF_VERIFY_SSL: True, CONF_VERIFY_SSL: True,
} }
async def test_form_login(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("habitica")
async def test_form_login(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
"""Test we get the login form.""" """Test we get the login form."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": SOURCE_USER}
) )
assert result["type"] is FlowResultType.MENU assert result["type"] is FlowResultType.MENU
@ -47,55 +56,41 @@ async def test_form_login(hass: HomeAssistant) -> None:
assert result["errors"] == {} assert result["errors"] == {}
assert result["step_id"] == "login" assert result["step_id"] == "login"
mock_obj = MagicMock() result = await hass.config_entries.flow.async_configure(
mock_obj.user.auth.local.login.post = AsyncMock() result["flow_id"],
mock_obj.user.auth.local.login.post.return_value = { user_input=MOCK_DATA_LOGIN_STEP,
"id": "test-api-user", )
"apiToken": "test-api-key", await hass.async_block_till_done()
"username": "test-username",
}
with (
patch(
"homeassistant.components.habitica.config_flow.HabitipyAsync",
return_value=mock_obj,
),
patch(
"homeassistant.components.habitica.async_setup", return_value=True
) as mock_setup,
patch(
"homeassistant.components.habitica.async_setup_entry",
return_value=True,
) as mock_setup_entry,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_DATA_LOGIN_STEP,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "test-username" assert result["title"] == "test-user"
assert result["data"] == { assert result["data"] == {
**MOCK_DATA_ADVANCED_STEP, CONF_API_USER: TEST_API_USER,
CONF_USERNAME: "test-username", CONF_API_KEY: TEST_API_KEY,
CONF_URL: DEFAULT_URL,
CONF_NAME: "test-user",
CONF_VERIFY_SSL: True,
} }
assert len(mock_setup.mock_calls) == 1 assert result["result"].unique_id == TEST_API_USER
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize( @pytest.mark.parametrize(
("raise_error", "text_error"), ("raise_error", "text_error"),
[ [
(ClientResponseError(MagicMock(), (), status=400), "cannot_connect"), (ERROR_BAD_REQUEST, "cannot_connect"),
(ClientResponseError(MagicMock(), (), status=401), "invalid_auth"), (ERROR_NOT_AUTHORIZED, "invalid_auth"),
(IndexError(), "unknown"), (IndexError(), "unknown"),
], ],
) )
async def test_form_login_errors(hass: HomeAssistant, raise_error, text_error) -> None: async def test_form_login_errors(
hass: HomeAssistant, habitica: AsyncMock, raise_error, text_error
) -> None:
"""Test we handle invalid credentials error.""" """Test we handle invalid credentials error."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": SOURCE_USER}
) )
assert result["type"] is FlowResultType.MENU assert result["type"] is FlowResultType.MENU
@ -105,26 +100,40 @@ async def test_form_login_errors(hass: HomeAssistant, raise_error, text_error) -
DOMAIN, context={"source": "login"} DOMAIN, context={"source": "login"}
) )
mock_obj = MagicMock() habitica.login.side_effect = raise_error
mock_obj.user.auth.local.login.post = AsyncMock(side_effect=raise_error) result = await hass.config_entries.flow.async_configure(
with patch( result["flow_id"],
"homeassistant.components.habitica.config_flow.HabitipyAsync", user_input=MOCK_DATA_LOGIN_STEP,
return_value=mock_obj, )
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_DATA_LOGIN_STEP,
)
assert result2["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": text_error} assert result["errors"] == {"base": text_error}
# recover from errors
habitica.login.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_DATA_LOGIN_STEP,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "test-user"
assert result["data"] == {
CONF_API_USER: TEST_API_USER,
CONF_API_KEY: TEST_API_KEY,
CONF_URL: DEFAULT_URL,
CONF_NAME: "test-user",
CONF_VERIFY_SSL: True,
}
assert result["result"].unique_id == TEST_API_USER
async def test_form_advanced(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("habitica")
async def test_form_advanced(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
"""Test we get the form.""" """Test we get the form."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": SOURCE_USER}
) )
assert result["type"] is FlowResultType.MENU assert result["type"] is FlowResultType.MENU
@ -144,54 +153,41 @@ async def test_form_advanced(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["errors"] == {} assert result["errors"] == {}
mock_obj = MagicMock() result = await hass.config_entries.flow.async_configure(
mock_obj.user.get = AsyncMock() result["flow_id"],
mock_obj.user.get.return_value = {"auth": {"local": {"username": "test-username"}}} user_input=MOCK_DATA_ADVANCED_STEP,
)
await hass.async_block_till_done()
with ( assert result["type"] is FlowResultType.CREATE_ENTRY
patch( assert result["title"] == "test-user"
"homeassistant.components.habitica.config_flow.HabitipyAsync", assert result["data"] == {
return_value=mock_obj, CONF_API_USER: TEST_API_USER,
), CONF_API_KEY: TEST_API_KEY,
patch( CONF_URL: DEFAULT_URL,
"homeassistant.components.habitica.async_setup", return_value=True CONF_NAME: "test-user",
) as mock_setup, CONF_VERIFY_SSL: True,
patch(
"homeassistant.components.habitica.async_setup_entry",
return_value=True,
) as mock_setup_entry,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_DATA_ADVANCED_STEP,
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "test-username"
assert result2["data"] == {
**MOCK_DATA_ADVANCED_STEP,
CONF_USERNAME: "test-username",
} }
assert len(mock_setup.mock_calls) == 1 assert result["result"].unique_id == TEST_API_USER
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize( @pytest.mark.parametrize(
("raise_error", "text_error"), ("raise_error", "text_error"),
[ [
(ClientResponseError(MagicMock(), (), status=400), "cannot_connect"), (ERROR_BAD_REQUEST, "cannot_connect"),
(ClientResponseError(MagicMock(), (), status=401), "invalid_auth"), (ERROR_NOT_AUTHORIZED, "invalid_auth"),
(IndexError(), "unknown"), (IndexError(), "unknown"),
], ],
) )
async def test_form_advanced_errors( async def test_form_advanced_errors(
hass: HomeAssistant, raise_error, text_error hass: HomeAssistant, habitica: AsyncMock, raise_error, text_error
) -> None: ) -> None:
"""Test we handle invalid credentials error.""" """Test we handle invalid credentials error."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": SOURCE_USER}
) )
assert result["type"] is FlowResultType.MENU assert result["type"] is FlowResultType.MENU
@ -201,17 +197,59 @@ async def test_form_advanced_errors(
DOMAIN, context={"source": "advanced"} DOMAIN, context={"source": "advanced"}
) )
mock_obj = MagicMock() habitica.get_user.side_effect = raise_error
mock_obj.user.get = AsyncMock(side_effect=raise_error)
with patch( result = await hass.config_entries.flow.async_configure(
"homeassistant.components.habitica.config_flow.HabitipyAsync", result["flow_id"],
return_value=mock_obj, user_input=MOCK_DATA_ADVANCED_STEP,
): )
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_DATA_ADVANCED_STEP,
)
assert result2["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": text_error} assert result["errors"] == {"base": text_error}
# recover from errors
habitica.get_user.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_DATA_ADVANCED_STEP,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "test-user"
assert result["data"] == {
CONF_API_USER: TEST_API_USER,
CONF_API_KEY: TEST_API_KEY,
CONF_URL: DEFAULT_URL,
CONF_NAME: "test-user",
CONF_VERIFY_SSL: True,
}
assert result["result"].unique_id == TEST_API_USER
@pytest.mark.usefixtures("habitica")
async def test_form_advanced_already_configured(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test we abort user data set when entry is already configured."""
config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "advanced"}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_DATA_ADVANCED_STEP,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"

View File

@ -10,7 +10,7 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator from tests.typing import ClientSessionGenerator
@pytest.mark.usefixtures("mock_habitica") @pytest.mark.usefixtures("habitica")
async def test_diagnostics( async def test_diagnostics(
hass: HomeAssistant, hass: HomeAssistant,
hass_client: ClientSessionGenerator, hass_client: ClientSessionGenerator,

View File

@ -1,8 +1,8 @@
"""Test the habitica module.""" """Test the habitica module."""
import datetime import datetime
from http import HTTPStatus
import logging import logging
from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
@ -11,7 +11,6 @@ from homeassistant.components.habitica.const import (
ATTR_ARGS, ATTR_ARGS,
ATTR_DATA, ATTR_DATA,
ATTR_PATH, ATTR_PATH,
DEFAULT_URL,
DOMAIN, DOMAIN,
EVENT_API_CALL_SUCCESS, EVENT_API_CALL_SUCCESS,
SERVICE_API_CALL, SERVICE_API_CALL,
@ -20,16 +19,14 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_NAME from homeassistant.const import ATTR_NAME
from homeassistant.core import Event, HomeAssistant from homeassistant.core import Event, HomeAssistant
from tests.common import ( from .conftest import (
MockConfigEntry, ERROR_BAD_REQUEST,
async_capture_events, ERROR_NOT_AUTHORIZED,
async_fire_time_changed, ERROR_NOT_FOUND,
load_json_object_fixture, ERROR_TOO_MANY_REQUESTS,
) )
from tests.test_util.aiohttp import AiohttpClientMocker
TEST_API_CALL_ARGS = {"text": "Use API from Home Assistant", "type": "todo"} from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed
TEST_USER_NAME = "test_user"
@pytest.fixture @pytest.fixture
@ -38,7 +35,7 @@ def capture_api_call_success(hass: HomeAssistant) -> list[Event]:
return async_capture_events(hass, EVENT_API_CALL_SUCCESS) return async_capture_events(hass, EVENT_API_CALL_SUCCESS)
@pytest.mark.usefixtures("mock_habitica") @pytest.mark.usefixtures("habitica")
async def test_entry_setup_unload( async def test_entry_setup_unload(
hass: HomeAssistant, config_entry: MockConfigEntry hass: HomeAssistant, config_entry: MockConfigEntry
) -> None: ) -> None:
@ -55,12 +52,11 @@ async def test_entry_setup_unload(
assert config_entry.state is ConfigEntryState.NOT_LOADED assert config_entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.usefixtures("mock_habitica") @pytest.mark.usefixtures("habitica")
async def test_service_call( async def test_service_call(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
capture_api_call_success: list[Event], capture_api_call_success: list[Event],
mock_habitica: AiohttpClientMocker,
) -> None: ) -> None:
"""Test integration setup, service call and unload.""" """Test integration setup, service call and unload."""
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
@ -71,16 +67,10 @@ async def test_service_call(
assert len(capture_api_call_success) == 0 assert len(capture_api_call_success) == 0
mock_habitica.post(
"https://habitica.com/api/v3/tasks/user",
status=HTTPStatus.CREATED,
json={"data": TEST_API_CALL_ARGS},
)
TEST_SERVICE_DATA = { TEST_SERVICE_DATA = {
ATTR_NAME: "test-user", ATTR_NAME: "test-user",
ATTR_PATH: ["tasks", "user", "post"], ATTR_PATH: ["tasks", "user", "post"],
ATTR_ARGS: TEST_API_CALL_ARGS, ATTR_ARGS: {"text": "Use API from Home Assistant", "type": "todo"},
} }
await hass.services.async_call( await hass.services.async_call(
DOMAIN, SERVICE_API_CALL, TEST_SERVICE_DATA, blocking=True DOMAIN, SERVICE_API_CALL, TEST_SERVICE_DATA, blocking=True
@ -94,21 +84,23 @@ async def test_service_call(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("status"), [HTTPStatus.NOT_FOUND, HTTPStatus.TOO_MANY_REQUESTS] ("exception"),
[
ERROR_BAD_REQUEST,
ERROR_TOO_MANY_REQUESTS,
ERROR_NOT_AUTHORIZED,
],
ids=["BadRequestError", "TooManyRequestsError", "NotAuthorizedError"],
) )
async def test_config_entry_not_ready( async def test_config_entry_not_ready(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker, habitica: AsyncMock,
status: HTTPStatus, exception: Exception,
) -> None: ) -> None:
"""Test config entry not ready.""" """Test config entry not ready."""
aioclient_mock.get( habitica.get_user.side_effect = exception
f"{DEFAULT_URL}/api/v3/user",
status=status,
)
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id) await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -119,19 +111,11 @@ async def test_config_entry_not_ready(
async def test_coordinator_update_failed( async def test_coordinator_update_failed(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker, habitica: AsyncMock,
) -> None: ) -> None:
"""Test coordinator update failed.""" """Test coordinator update failed."""
aioclient_mock.get( habitica.get_tasks.side_effect = ERROR_NOT_FOUND
f"{DEFAULT_URL}/api/v3/user",
json=load_json_object_fixture("user.json", DOMAIN),
)
aioclient_mock.get(
f"{DEFAULT_URL}/api/v3/tasks/user",
status=HTTPStatus.NOT_FOUND,
)
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id) await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -142,7 +126,7 @@ async def test_coordinator_update_failed(
async def test_coordinator_rate_limited( async def test_coordinator_rate_limited(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
freezer: FrozenDateTimeFactory, freezer: FrozenDateTimeFactory,
) -> None: ) -> None:
@ -154,11 +138,7 @@ async def test_coordinator_rate_limited(
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
mock_habitica.clear_requests() habitica.get_user.side_effect = ERROR_TOO_MANY_REQUESTS
mock_habitica.get(
f"{DEFAULT_URL}/api/v3/user",
status=HTTPStatus.TOO_MANY_REQUESTS,
)
with caplog.at_level(logging.DEBUG): with caplog.at_level(logging.DEBUG):
freezer.tick(datetime.timedelta(seconds=60)) freezer.tick(datetime.timedelta(seconds=60))

View File

@ -26,7 +26,7 @@ def sensor_only() -> Generator[None]:
yield yield
@pytest.mark.usefixtures("mock_habitica", "entity_registry_enabled_by_default") @pytest.mark.usefixtures("habitica", "entity_registry_enabled_by_default")
async def test_sensors( async def test_sensors(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
@ -44,7 +44,7 @@ async def test_sensors(
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
@pytest.mark.usefixtures("mock_habitica", "entity_registry_enabled_by_default") @pytest.mark.usefixtures("habitica", "entity_registry_enabled_by_default")
async def test_sensor_deprecation_issue( async def test_sensor_deprecation_issue(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,

View File

@ -1,10 +1,11 @@
"""Test Habitica actions.""" """Test Habitica actions."""
from collections.abc import Generator from collections.abc import Generator
from http import HTTPStatus
from typing import Any from typing import Any
from unittest.mock import patch from unittest.mock import AsyncMock, patch
from uuid import UUID
from habiticalib import Direction, Skill
import pytest import pytest
from homeassistant.components.habitica.const import ( from homeassistant.components.habitica.const import (
@ -14,7 +15,6 @@ from homeassistant.components.habitica.const import (
ATTR_SKILL, ATTR_SKILL,
ATTR_TARGET, ATTR_TARGET,
ATTR_TASK, ATTR_TASK,
DEFAULT_URL,
DOMAIN, DOMAIN,
SERVICE_ABORT_QUEST, SERVICE_ABORT_QUEST,
SERVICE_ACCEPT_QUEST, SERVICE_ACCEPT_QUEST,
@ -31,10 +31,14 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from .conftest import load_json_object_fixture, mock_called_with from .conftest import (
ERROR_BAD_REQUEST,
ERROR_NOT_AUTHORIZED,
ERROR_NOT_FOUND,
ERROR_TOO_MANY_REQUESTS,
)
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
REQUEST_EXCEPTION_MSG = "Unable to connect to Habitica, try again later" REQUEST_EXCEPTION_MSG = "Unable to connect to Habitica, try again later"
RATE_LIMIT_EXCEPTION_MSG = "Rate limit exceeded, try again later" RATE_LIMIT_EXCEPTION_MSG = "Rate limit exceeded, try again later"
@ -54,7 +58,7 @@ def services_only() -> Generator[None]:
async def load_entry( async def load_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
services_only: Generator, services_only: Generator,
) -> None: ) -> None:
"""Load config entry.""" """Load config entry."""
@ -75,55 +79,70 @@ def uuid_mock() -> Generator[None]:
@pytest.mark.parametrize( @pytest.mark.parametrize(
("service_data", "item", "target_id"), (
"service_data",
"call_args",
),
[ [
( (
{ {
ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490",
ATTR_SKILL: "pickpocket", ATTR_SKILL: "pickpocket",
}, },
"pickPocket", {
"2f6fcabc-f670-4ec3-ba65-817e8deea490", "skill": Skill.PICKPOCKET,
"target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"),
},
), ),
( (
{ {
ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490",
ATTR_SKILL: "backstab", ATTR_SKILL: "backstab",
}, },
"backStab", {
"2f6fcabc-f670-4ec3-ba65-817e8deea490", "skill": Skill.BACKSTAB,
"target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"),
},
), ),
( (
{ {
ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490",
ATTR_SKILL: "fireball", ATTR_SKILL: "fireball",
}, },
"fireball", {
"2f6fcabc-f670-4ec3-ba65-817e8deea490", "skill": Skill.BURST_OF_FLAMES,
"target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"),
},
), ),
( (
{ {
ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490",
ATTR_SKILL: "smash", ATTR_SKILL: "smash",
}, },
"smash", {
"2f6fcabc-f670-4ec3-ba65-817e8deea490", "skill": Skill.BRUTAL_SMASH,
"target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"),
},
), ),
( (
{ {
ATTR_TASK: "Rechnungen bezahlen", ATTR_TASK: "Rechnungen bezahlen",
ATTR_SKILL: "smash", ATTR_SKILL: "smash",
}, },
"smash", {
"2f6fcabc-f670-4ec3-ba65-817e8deea490", "skill": Skill.BRUTAL_SMASH,
"target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"),
},
), ),
( (
{ {
ATTR_TASK: "pay_bills", ATTR_TASK: "pay_bills",
ATTR_SKILL: "smash", ATTR_SKILL: "smash",
}, },
"smash", {
"2f6fcabc-f670-4ec3-ba65-817e8deea490", "skill": Skill.BRUTAL_SMASH,
"target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"),
},
), ),
], ],
ids=[ ids=[
@ -138,18 +157,12 @@ def uuid_mock() -> Generator[None]:
async def test_cast_skill( async def test_cast_skill(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
service_data: dict[str, Any], service_data: dict[str, Any],
item: str, call_args: dict[str, Any],
target_id: str,
) -> None: ) -> None:
"""Test Habitica cast skill action.""" """Test Habitica cast skill action."""
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/user/class/cast/{item}?targetId={target_id}",
json={"success": True, "data": {}},
)
await hass.services.async_call( await hass.services.async_call(
DOMAIN, DOMAIN,
SERVICE_CAST_SKILL, SERVICE_CAST_SKILL,
@ -160,18 +173,13 @@ async def test_cast_skill(
return_response=True, return_response=True,
blocking=True, blocking=True,
) )
habitica.cast_skill.assert_awaited_once_with(**call_args)
assert mock_called_with(
mock_habitica,
"post",
f"{DEFAULT_URL}/api/v3/user/class/cast/{item}?targetId={target_id}",
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
( (
"service_data", "service_data",
"http_status", "raise_exception",
"expected_exception", "expected_exception",
"expected_exception_msg", "expected_exception_msg",
), ),
@ -181,7 +189,7 @@ async def test_cast_skill(
ATTR_TASK: "task-not-found", ATTR_TASK: "task-not-found",
ATTR_SKILL: "smash", ATTR_SKILL: "smash",
}, },
HTTPStatus.OK, None,
ServiceValidationError, ServiceValidationError,
"Unable to complete action, could not find the task 'task-not-found'", "Unable to complete action, could not find the task 'task-not-found'",
), ),
@ -190,8 +198,8 @@ async def test_cast_skill(
ATTR_TASK: "Rechnungen bezahlen", ATTR_TASK: "Rechnungen bezahlen",
ATTR_SKILL: "smash", ATTR_SKILL: "smash",
}, },
HTTPStatus.TOO_MANY_REQUESTS, ERROR_TOO_MANY_REQUESTS,
ServiceValidationError, HomeAssistantError,
RATE_LIMIT_EXCEPTION_MSG, RATE_LIMIT_EXCEPTION_MSG,
), ),
( (
@ -199,7 +207,7 @@ async def test_cast_skill(
ATTR_TASK: "Rechnungen bezahlen", ATTR_TASK: "Rechnungen bezahlen",
ATTR_SKILL: "smash", ATTR_SKILL: "smash",
}, },
HTTPStatus.NOT_FOUND, ERROR_NOT_FOUND,
ServiceValidationError, ServiceValidationError,
"Unable to cast skill, your character does not have the skill or spell smash", "Unable to cast skill, your character does not have the skill or spell smash",
), ),
@ -208,7 +216,7 @@ async def test_cast_skill(
ATTR_TASK: "Rechnungen bezahlen", ATTR_TASK: "Rechnungen bezahlen",
ATTR_SKILL: "smash", ATTR_SKILL: "smash",
}, },
HTTPStatus.UNAUTHORIZED, ERROR_NOT_AUTHORIZED,
ServiceValidationError, ServiceValidationError,
"Unable to cast skill, not enough mana. Your character has 50 MP, but the skill costs 10 MP", "Unable to cast skill, not enough mana. Your character has 50 MP, but the skill costs 10 MP",
), ),
@ -217,30 +225,24 @@ async def test_cast_skill(
ATTR_TASK: "Rechnungen bezahlen", ATTR_TASK: "Rechnungen bezahlen",
ATTR_SKILL: "smash", ATTR_SKILL: "smash",
}, },
HTTPStatus.BAD_REQUEST, ERROR_BAD_REQUEST,
HomeAssistantError, HomeAssistantError,
REQUEST_EXCEPTION_MSG, REQUEST_EXCEPTION_MSG,
), ),
], ],
) )
@pytest.mark.usefixtures("mock_habitica")
async def test_cast_skill_exceptions( async def test_cast_skill_exceptions(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
service_data: dict[str, Any], service_data: dict[str, Any],
http_status: HTTPStatus, raise_exception: Exception,
expected_exception: Exception, expected_exception: Exception,
expected_exception_msg: str, expected_exception_msg: str,
) -> None: ) -> None:
"""Test Habitica cast skill action exceptions.""" """Test Habitica cast skill action exceptions."""
mock_habitica.post( habitica.cast_skill.side_effect = raise_exception
f"{DEFAULT_URL}/api/v3/user/class/cast/smash?targetId=2f6fcabc-f670-4ec3-ba65-817e8deea490",
json={"success": True, "data": {}},
status=http_status,
)
with pytest.raises(expected_exception, match=expected_exception_msg): with pytest.raises(expected_exception, match=expected_exception_msg):
await hass.services.async_call( await hass.services.async_call(
DOMAIN, DOMAIN,
@ -254,11 +256,9 @@ async def test_cast_skill_exceptions(
) )
@pytest.mark.usefixtures("mock_habitica")
async def test_get_config_entry( async def test_get_config_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
) -> None: ) -> None:
"""Test Habitica config entry exceptions.""" """Test Habitica config entry exceptions."""
@ -298,31 +298,24 @@ async def test_get_config_entry(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("service", "command"), "service",
[ [
(SERVICE_ABORT_QUEST, "abort"), SERVICE_ABORT_QUEST,
(SERVICE_ACCEPT_QUEST, "accept"), SERVICE_ACCEPT_QUEST,
(SERVICE_CANCEL_QUEST, "cancel"), SERVICE_CANCEL_QUEST,
(SERVICE_LEAVE_QUEST, "leave"), SERVICE_LEAVE_QUEST,
(SERVICE_REJECT_QUEST, "reject"), SERVICE_REJECT_QUEST,
(SERVICE_START_QUEST, "force-start"), SERVICE_START_QUEST,
], ],
ids=[],
) )
async def test_handle_quests( async def test_handle_quests(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
service: str, service: str,
command: str,
) -> None: ) -> None:
"""Test Habitica actions for quest handling.""" """Test Habitica actions for quest handling."""
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/groups/party/quests/{command}",
json={"success": True, "data": {}},
)
await hass.services.async_call( await hass.services.async_call(
DOMAIN, DOMAIN,
service, service,
@ -331,63 +324,65 @@ async def test_handle_quests(
blocking=True, blocking=True,
) )
assert mock_called_with( getattr(habitica, service).assert_awaited_once()
mock_habitica,
"post",
f"{DEFAULT_URL}/api/v3/groups/party/quests/{command}",
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
( (
"http_status", "raise_exception",
"expected_exception", "expected_exception",
"expected_exception_msg", "expected_exception_msg",
), ),
[ [
( (
HTTPStatus.TOO_MANY_REQUESTS, ERROR_TOO_MANY_REQUESTS,
ServiceValidationError, HomeAssistantError,
RATE_LIMIT_EXCEPTION_MSG, RATE_LIMIT_EXCEPTION_MSG,
), ),
( (
HTTPStatus.NOT_FOUND, ERROR_NOT_FOUND,
ServiceValidationError, ServiceValidationError,
"Unable to complete action, quest or group not found", "Unable to complete action, quest or group not found",
), ),
( (
HTTPStatus.UNAUTHORIZED, ERROR_NOT_AUTHORIZED,
ServiceValidationError, ServiceValidationError,
"Action not allowed, only quest leader or group leader can perform this action", "Action not allowed, only quest leader or group leader can perform this action",
), ),
( (
HTTPStatus.BAD_REQUEST, ERROR_BAD_REQUEST,
HomeAssistantError, HomeAssistantError,
REQUEST_EXCEPTION_MSG, REQUEST_EXCEPTION_MSG,
), ),
], ],
) )
@pytest.mark.usefixtures("mock_habitica") @pytest.mark.parametrize(
"service",
[
SERVICE_ACCEPT_QUEST,
SERVICE_ABORT_QUEST,
SERVICE_CANCEL_QUEST,
SERVICE_LEAVE_QUEST,
SERVICE_REJECT_QUEST,
SERVICE_START_QUEST,
],
)
async def test_handle_quests_exceptions( async def test_handle_quests_exceptions(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
http_status: HTTPStatus, raise_exception: Exception,
service: str,
expected_exception: Exception, expected_exception: Exception,
expected_exception_msg: str, expected_exception_msg: str,
) -> None: ) -> None:
"""Test Habitica handle quests action exceptions.""" """Test Habitica handle quests action exceptions."""
mock_habitica.post( getattr(habitica, service).side_effect = raise_exception
f"{DEFAULT_URL}/api/v3/groups/party/quests/accept",
json={"success": True, "data": {}},
status=http_status,
)
with pytest.raises(expected_exception, match=expected_exception_msg): with pytest.raises(expected_exception, match=expected_exception_msg):
await hass.services.async_call( await hass.services.async_call(
DOMAIN, DOMAIN,
SERVICE_ACCEPT_QUEST, service,
service_data={ATTR_CONFIG_ENTRY: config_entry.entry_id}, service_data={ATTR_CONFIG_ENTRY: config_entry.entry_id},
return_response=True, return_response=True,
blocking=True, blocking=True,
@ -395,7 +390,7 @@ async def test_handle_quests_exceptions(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("service", "service_data", "task_id"), ("service", "service_data", "call_args"),
[ [
( (
SERVICE_SCORE_HABIT, SERVICE_SCORE_HABIT,
@ -403,7 +398,10 @@ async def test_handle_quests_exceptions(
ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d",
ATTR_DIRECTION: "up", ATTR_DIRECTION: "up",
}, },
"e97659e0-2c42-4599-a7bb-00282adc410d", {
"task_id": UUID("e97659e0-2c42-4599-a7bb-00282adc410d"),
"direction": Direction.UP,
},
), ),
( (
SERVICE_SCORE_HABIT, SERVICE_SCORE_HABIT,
@ -411,14 +409,20 @@ async def test_handle_quests_exceptions(
ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d",
ATTR_DIRECTION: "down", ATTR_DIRECTION: "down",
}, },
"e97659e0-2c42-4599-a7bb-00282adc410d", {
"task_id": UUID("e97659e0-2c42-4599-a7bb-00282adc410d"),
"direction": Direction.DOWN,
},
), ),
( (
SERVICE_SCORE_REWARD, SERVICE_SCORE_REWARD,
{ {
ATTR_TASK: "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b", ATTR_TASK: "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b",
}, },
"5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b", {
"task_id": UUID("5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"),
"direction": Direction.UP,
},
), ),
( (
SERVICE_SCORE_HABIT, SERVICE_SCORE_HABIT,
@ -426,7 +430,10 @@ async def test_handle_quests_exceptions(
ATTR_TASK: "Füge eine Aufgabe zu Habitica hinzu", ATTR_TASK: "Füge eine Aufgabe zu Habitica hinzu",
ATTR_DIRECTION: "up", ATTR_DIRECTION: "up",
}, },
"e97659e0-2c42-4599-a7bb-00282adc410d", {
"task_id": UUID("e97659e0-2c42-4599-a7bb-00282adc410d"),
"direction": Direction.UP,
},
), ),
( (
SERVICE_SCORE_HABIT, SERVICE_SCORE_HABIT,
@ -434,7 +441,10 @@ async def test_handle_quests_exceptions(
ATTR_TASK: "create_a_task", ATTR_TASK: "create_a_task",
ATTR_DIRECTION: "up", ATTR_DIRECTION: "up",
}, },
"e97659e0-2c42-4599-a7bb-00282adc410d", {
"task_id": UUID("e97659e0-2c42-4599-a7bb-00282adc410d"),
"direction": Direction.UP,
},
), ),
], ],
ids=[ ids=[
@ -448,18 +458,13 @@ async def test_handle_quests_exceptions(
async def test_score_task( async def test_score_task(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
service: str, service: str,
service_data: dict[str, Any], service_data: dict[str, Any],
task_id: str, call_args: dict[str, Any],
) -> None: ) -> None:
"""Test Habitica score task action.""" """Test Habitica score task action."""
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/tasks/{task_id}/score/{service_data.get(ATTR_DIRECTION, "up")}",
json={"success": True, "data": {}},
)
await hass.services.async_call( await hass.services.async_call(
DOMAIN, DOMAIN,
service, service,
@ -471,17 +476,13 @@ async def test_score_task(
blocking=True, blocking=True,
) )
assert mock_called_with( habitica.update_score.assert_awaited_once_with(**call_args)
mock_habitica,
"post",
f"{DEFAULT_URL}/api/v3/tasks/{task_id}/score/{service_data.get(ATTR_DIRECTION, "up")}",
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
( (
"service_data", "service_data",
"http_status", "raise_exception",
"expected_exception", "expected_exception",
"expected_exception_msg", "expected_exception_msg",
), ),
@ -491,7 +492,7 @@ async def test_score_task(
ATTR_TASK: "task does not exist", ATTR_TASK: "task does not exist",
ATTR_DIRECTION: "up", ATTR_DIRECTION: "up",
}, },
HTTPStatus.OK, None,
ServiceValidationError, ServiceValidationError,
"Unable to complete action, could not find the task 'task does not exist'", "Unable to complete action, could not find the task 'task does not exist'",
), ),
@ -500,8 +501,8 @@ async def test_score_task(
ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d",
ATTR_DIRECTION: "up", ATTR_DIRECTION: "up",
}, },
HTTPStatus.TOO_MANY_REQUESTS, ERROR_TOO_MANY_REQUESTS,
ServiceValidationError, HomeAssistantError,
RATE_LIMIT_EXCEPTION_MSG, RATE_LIMIT_EXCEPTION_MSG,
), ),
( (
@ -509,7 +510,7 @@ async def test_score_task(
ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d",
ATTR_DIRECTION: "up", ATTR_DIRECTION: "up",
}, },
HTTPStatus.BAD_REQUEST, ERROR_BAD_REQUEST,
HomeAssistantError, HomeAssistantError,
REQUEST_EXCEPTION_MSG, REQUEST_EXCEPTION_MSG,
), ),
@ -518,35 +519,24 @@ async def test_score_task(
ATTR_TASK: "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b", ATTR_TASK: "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b",
ATTR_DIRECTION: "up", ATTR_DIRECTION: "up",
}, },
HTTPStatus.UNAUTHORIZED, ERROR_NOT_AUTHORIZED,
HomeAssistantError, HomeAssistantError,
"Unable to buy reward, not enough gold. Your character has 137.63 GP, but the reward costs 10 GP", "Unable to buy reward, not enough gold. Your character has 137.63 GP, but the reward costs 10.00 GP",
), ),
], ],
) )
@pytest.mark.usefixtures("mock_habitica")
async def test_score_task_exceptions( async def test_score_task_exceptions(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
service_data: dict[str, Any], service_data: dict[str, Any],
http_status: HTTPStatus, raise_exception: Exception,
expected_exception: Exception, expected_exception: Exception,
expected_exception_msg: str, expected_exception_msg: str,
) -> None: ) -> None:
"""Test Habitica score task action exceptions.""" """Test Habitica score task action exceptions."""
mock_habitica.post( habitica.update_score.side_effect = raise_exception
f"{DEFAULT_URL}/api/v3/tasks/e97659e0-2c42-4599-a7bb-00282adc410d/score/up",
json={"success": True, "data": {}},
status=http_status,
)
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/tasks/5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b/score/up",
json={"success": True, "data": {}},
status=http_status,
)
with pytest.raises(expected_exception, match=expected_exception_msg): with pytest.raises(expected_exception, match=expected_exception_msg):
await hass.services.async_call( await hass.services.async_call(
DOMAIN, DOMAIN,
@ -561,100 +551,119 @@ async def test_score_task_exceptions(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("service_data", "item", "target_id"), ("service_data", "call_args"),
[ [
( (
{ {
ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303", ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303",
ATTR_ITEM: "spooky_sparkles", ATTR_ITEM: "spooky_sparkles",
}, },
"spookySparkles", {
"a380546a-94be-4b8e-8a0b-23e0d5c03303", "skill": Skill.SPOOKY_SPARKLES,
"target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"),
},
), ),
( (
{ {
ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303", ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303",
ATTR_ITEM: "shiny_seed", ATTR_ITEM: "shiny_seed",
}, },
"shinySeed", {
"a380546a-94be-4b8e-8a0b-23e0d5c03303", "skill": Skill.SHINY_SEED,
"target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"),
},
), ),
( (
{ {
ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303", ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303",
ATTR_ITEM: "seafoam", ATTR_ITEM: "seafoam",
}, },
"seafoam", {
"a380546a-94be-4b8e-8a0b-23e0d5c03303", "skill": Skill.SEAFOAM,
"target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"),
},
), ),
( (
{ {
ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303", ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303",
ATTR_ITEM: "snowball", ATTR_ITEM: "snowball",
}, },
"snowball", {
"a380546a-94be-4b8e-8a0b-23e0d5c03303", "skill": Skill.SNOWBALL,
"target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"),
},
), ),
( (
{ {
ATTR_TARGET: "test-user", ATTR_TARGET: "test-user",
ATTR_ITEM: "spooky_sparkles", ATTR_ITEM: "spooky_sparkles",
}, },
"spookySparkles", {
"a380546a-94be-4b8e-8a0b-23e0d5c03303", "skill": Skill.SPOOKY_SPARKLES,
"target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"),
},
), ),
( (
{ {
ATTR_TARGET: "test-username", ATTR_TARGET: "test-username",
ATTR_ITEM: "spooky_sparkles", ATTR_ITEM: "spooky_sparkles",
}, },
"spookySparkles", {
"a380546a-94be-4b8e-8a0b-23e0d5c03303", "skill": Skill.SPOOKY_SPARKLES,
"target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"),
},
), ),
( (
{ {
ATTR_TARGET: "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", ATTR_TARGET: "ffce870c-3ff3-4fa4-bad1-87612e52b8e7",
ATTR_ITEM: "spooky_sparkles", ATTR_ITEM: "spooky_sparkles",
}, },
"spookySparkles", {
"ffce870c-3ff3-4fa4-bad1-87612e52b8e7", "skill": Skill.SPOOKY_SPARKLES,
"target_id": UUID("ffce870c-3ff3-4fa4-bad1-87612e52b8e7"),
},
), ),
( (
{ {
ATTR_TARGET: "test-partymember-username", ATTR_TARGET: "test-partymember-username",
ATTR_ITEM: "spooky_sparkles", ATTR_ITEM: "spooky_sparkles",
}, },
"spookySparkles", {
"ffce870c-3ff3-4fa4-bad1-87612e52b8e7", "skill": Skill.SPOOKY_SPARKLES,
"target_id": UUID("ffce870c-3ff3-4fa4-bad1-87612e52b8e7"),
},
), ),
( (
{ {
ATTR_TARGET: "test-partymember-displayname", ATTR_TARGET: "test-partymember-displayname",
ATTR_ITEM: "spooky_sparkles", ATTR_ITEM: "spooky_sparkles",
}, },
"spookySparkles", {
"ffce870c-3ff3-4fa4-bad1-87612e52b8e7", "skill": Skill.SPOOKY_SPARKLES,
"target_id": UUID("ffce870c-3ff3-4fa4-bad1-87612e52b8e7"),
},
), ),
], ],
ids=[], ids=[
"use spooky sparkles/select self by id",
"use shiny seed",
"use seafoam",
"use snowball",
"select self by displayname",
"select self by username",
"select partymember by id",
"select partymember by username",
"select partymember by displayname",
],
) )
async def test_transformation( async def test_transformation(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
service_data: dict[str, Any], service_data: dict[str, Any],
item: str, call_args: dict[str, Any],
target_id: str,
) -> None: ) -> None:
"""Test Habitica user transformation item action.""" """Test Habitica use transformation item action."""
mock_habitica.get(
f"{DEFAULT_URL}/api/v3/groups/party/members",
json=load_json_object_fixture("party_members.json", DOMAIN),
)
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/user/class/cast/{item}?targetId={target_id}",
json={"success": True, "data": {}},
)
await hass.services.async_call( await hass.services.async_call(
DOMAIN, DOMAIN,
@ -667,18 +676,14 @@ async def test_transformation(
blocking=True, blocking=True,
) )
assert mock_called_with( habitica.cast_skill.assert_awaited_once_with(**call_args)
mock_habitica,
"post",
f"{DEFAULT_URL}/api/v3/user/class/cast/{item}?targetId={target_id}",
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
( (
"service_data", "service_data",
"http_status_members", "raise_exception_members",
"http_status_cast", "raise_exception_cast",
"expected_exception", "expected_exception",
"expected_exception_msg", "expected_exception_msg",
), ),
@ -688,8 +693,8 @@ async def test_transformation(
ATTR_TARGET: "user-not-found", ATTR_TARGET: "user-not-found",
ATTR_ITEM: "spooky_sparkles", ATTR_ITEM: "spooky_sparkles",
}, },
HTTPStatus.OK, None,
HTTPStatus.OK, None,
ServiceValidationError, ServiceValidationError,
"Unable to find target 'user-not-found' in your party", "Unable to find target 'user-not-found' in your party",
), ),
@ -698,18 +703,8 @@ async def test_transformation(
ATTR_TARGET: "test-partymember-username", ATTR_TARGET: "test-partymember-username",
ATTR_ITEM: "spooky_sparkles", ATTR_ITEM: "spooky_sparkles",
}, },
HTTPStatus.TOO_MANY_REQUESTS, ERROR_NOT_FOUND,
HTTPStatus.OK, None,
ServiceValidationError,
RATE_LIMIT_EXCEPTION_MSG,
),
(
{
ATTR_TARGET: "test-partymember-username",
ATTR_ITEM: "spooky_sparkles",
},
HTTPStatus.NOT_FOUND,
HTTPStatus.OK,
ServiceValidationError, ServiceValidationError,
"Unable to find target, you are currently not in a party. You can only target yourself", "Unable to find target, you are currently not in a party. You can only target yourself",
), ),
@ -718,8 +713,8 @@ async def test_transformation(
ATTR_TARGET: "test-partymember-username", ATTR_TARGET: "test-partymember-username",
ATTR_ITEM: "spooky_sparkles", ATTR_ITEM: "spooky_sparkles",
}, },
HTTPStatus.BAD_REQUEST, ERROR_BAD_REQUEST,
HTTPStatus.OK, None,
HomeAssistantError, HomeAssistantError,
"Unable to connect to Habitica, try again later", "Unable to connect to Habitica, try again later",
), ),
@ -728,9 +723,9 @@ async def test_transformation(
ATTR_TARGET: "test-partymember-username", ATTR_TARGET: "test-partymember-username",
ATTR_ITEM: "spooky_sparkles", ATTR_ITEM: "spooky_sparkles",
}, },
HTTPStatus.OK, None,
HTTPStatus.TOO_MANY_REQUESTS, ERROR_TOO_MANY_REQUESTS,
ServiceValidationError, HomeAssistantError,
RATE_LIMIT_EXCEPTION_MSG, RATE_LIMIT_EXCEPTION_MSG,
), ),
( (
@ -738,8 +733,8 @@ async def test_transformation(
ATTR_TARGET: "test-partymember-username", ATTR_TARGET: "test-partymember-username",
ATTR_ITEM: "spooky_sparkles", ATTR_ITEM: "spooky_sparkles",
}, },
HTTPStatus.OK, None,
HTTPStatus.UNAUTHORIZED, ERROR_NOT_AUTHORIZED,
ServiceValidationError, ServiceValidationError,
"Unable to use spooky_sparkles, you don't own this item", "Unable to use spooky_sparkles, you don't own this item",
), ),
@ -748,36 +743,27 @@ async def test_transformation(
ATTR_TARGET: "test-partymember-username", ATTR_TARGET: "test-partymember-username",
ATTR_ITEM: "spooky_sparkles", ATTR_ITEM: "spooky_sparkles",
}, },
HTTPStatus.OK, None,
HTTPStatus.BAD_REQUEST, ERROR_BAD_REQUEST,
HomeAssistantError, HomeAssistantError,
"Unable to connect to Habitica, try again later", "Unable to connect to Habitica, try again later",
), ),
], ],
) )
@pytest.mark.usefixtures("mock_habitica")
async def test_transformation_exceptions( async def test_transformation_exceptions(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
service_data: dict[str, Any], service_data: dict[str, Any],
http_status_members: HTTPStatus, raise_exception_members: Exception,
http_status_cast: HTTPStatus, raise_exception_cast: Exception,
expected_exception: Exception, expected_exception: Exception,
expected_exception_msg: str, expected_exception_msg: str,
) -> None: ) -> None:
"""Test Habitica transformation action exceptions.""" """Test Habitica transformation action exceptions."""
mock_habitica.get(
f"{DEFAULT_URL}/api/v3/groups/party/members",
json=load_json_object_fixture("party_members.json", DOMAIN),
status=http_status_members,
)
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/user/class/cast/spookySparkles?targetId=ffce870c-3ff3-4fa4-bad1-87612e52b8e7",
json={"success": True, "data": {}},
status=http_status_cast,
)
habitica.cast_skill.side_effect = raise_exception_cast
habitica.get_group_members.side_effect = raise_exception_members
with pytest.raises(expected_exception, match=expected_exception_msg): with pytest.raises(expected_exception, match=expected_exception_msg):
await hass.services.async_call( await hass.services.async_call(
DOMAIN, DOMAIN,

View File

@ -1,13 +1,11 @@
"""Tests for the Habitica switch platform.""" """Tests for the Habitica switch platform."""
from collections.abc import Generator from collections.abc import Generator
from http import HTTPStatus from unittest.mock import AsyncMock, patch
from unittest.mock import patch
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant.components.habitica.const import DEFAULT_URL
from homeassistant.components.switch import ( from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN, DOMAIN as SWITCH_DOMAIN,
SERVICE_TOGGLE, SERVICE_TOGGLE,
@ -17,13 +15,12 @@ from homeassistant.components.switch import (
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from .conftest import mock_called_with from .conftest import ERROR_BAD_REQUEST, ERROR_TOO_MANY_REQUESTS
from tests.common import MockConfigEntry, snapshot_platform from tests.common import MockConfigEntry, snapshot_platform
from tests.test_util.aiohttp import AiohttpClientMocker
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -36,7 +33,7 @@ def switch_only() -> Generator[None]:
yield yield
@pytest.mark.usefixtures("mock_habitica") @pytest.mark.usefixtures("habitica")
async def test_switch( async def test_switch(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
@ -66,14 +63,10 @@ async def test_turn_on_off_toggle(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
service_call: str, service_call: str,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
) -> None: ) -> None:
"""Test switch turn on/off, toggle method.""" """Test switch turn on/off, toggle method."""
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/user/sleep",
json={"success": True, "data": False},
)
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id) await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -87,7 +80,7 @@ async def test_turn_on_off_toggle(
blocking=True, blocking=True,
) )
assert mock_called_with(mock_habitica, "post", f"{DEFAULT_URL}/api/v3/user/sleep") habitica.toggle_sleep.assert_awaited_once()
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -99,19 +92,19 @@ async def test_turn_on_off_toggle(
], ],
) )
@pytest.mark.parametrize( @pytest.mark.parametrize(
("status_code", "exception"), ("raise_exception", "expected_exception"),
[ [
(HTTPStatus.TOO_MANY_REQUESTS, ServiceValidationError), (ERROR_TOO_MANY_REQUESTS, HomeAssistantError),
(HTTPStatus.BAD_REQUEST, HomeAssistantError), (ERROR_BAD_REQUEST, HomeAssistantError),
], ],
) )
async def test_turn_on_off_toggle_exceptions( async def test_turn_on_off_toggle_exceptions(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
service_call: str, service_call: str,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
status_code: HTTPStatus, raise_exception: Exception,
exception: Exception, expected_exception: Exception,
) -> None: ) -> None:
"""Test switch turn on/off, toggle method.""" """Test switch turn on/off, toggle method."""
@ -121,18 +114,12 @@ async def test_turn_on_off_toggle_exceptions(
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
mock_habitica.post( habitica.toggle_sleep.side_effect = raise_exception
f"{DEFAULT_URL}/api/v3/user/sleep",
status=status_code,
json={"success": True, "data": False},
)
with pytest.raises(expected_exception=exception): with pytest.raises(expected_exception=expected_exception):
await hass.services.async_call( await hass.services.async_call(
SWITCH_DOMAIN, SWITCH_DOMAIN,
service_call, service_call,
{ATTR_ENTITY_ID: "switch.test_user_rest_in_the_inn"}, {ATTR_ENTITY_ID: "switch.test_user_rest_in_the_inn"},
blocking=True, blocking=True,
) )
assert mock_called_with(mock_habitica, "post", f"{DEFAULT_URL}/api/v3/user/sleep")

View File

@ -1,15 +1,16 @@
"""Tests for Habitica todo platform.""" """Tests for Habitica todo platform."""
from collections.abc import Generator from collections.abc import Generator
from http import HTTPStatus from datetime import date
import json from typing import Any
import re from unittest.mock import AsyncMock, patch
from unittest.mock import patch from uuid import UUID
from habiticalib import Direction, HabiticaTasksResponse, Task, TaskType
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant.components.habitica.const import DEFAULT_URL, DOMAIN from homeassistant.components.habitica.const import DOMAIN
from homeassistant.components.todo import ( from homeassistant.components.todo import (
ATTR_DESCRIPTION, ATTR_DESCRIPTION,
ATTR_DUE_DATE, ATTR_DUE_DATE,
@ -25,15 +26,14 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from .conftest import mock_called_with from .conftest import ERROR_NOT_FOUND
from tests.common import ( from tests.common import (
MockConfigEntry, MockConfigEntry,
async_get_persistent_notifications, async_get_persistent_notifications,
load_json_object_fixture, load_fixture,
snapshot_platform, snapshot_platform,
) )
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import WebSocketGenerator from tests.typing import WebSocketGenerator
@ -47,7 +47,7 @@ def todo_only() -> Generator[None]:
yield yield
@pytest.mark.usefixtures("mock_habitica") @pytest.mark.usefixtures("habitica")
async def test_todos( async def test_todos(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
@ -72,7 +72,7 @@ async def test_todos(
"todo.test_user_dailies", "todo.test_user_dailies",
], ],
) )
@pytest.mark.usefixtures("mock_habitica") @pytest.mark.usefixtures("habitica")
async def test_todo_items( async def test_todo_items(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
@ -111,7 +111,7 @@ async def test_todo_items(
async def test_complete_todo_item( async def test_complete_todo_item(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
entity_id: str, entity_id: str,
uid: str, uid: str,
@ -124,10 +124,6 @@ async def test_complete_todo_item(
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/tasks/{uid}/score/up",
json=load_json_object_fixture("score_with_drop.json", DOMAIN),
)
await hass.services.async_call( await hass.services.async_call(
TODO_DOMAIN, TODO_DOMAIN,
TodoServices.UPDATE_ITEM, TodoServices.UPDATE_ITEM,
@ -136,9 +132,7 @@ async def test_complete_todo_item(
blocking=True, blocking=True,
) )
assert mock_called_with( habitica.update_score.assert_awaited_once_with(UUID(uid), Direction.UP)
mock_habitica, "post", f"{DEFAULT_URL}/api/v3/tasks/{uid}/score/up"
)
# Test notification for item drop # Test notification for item drop
notifications = async_get_persistent_notifications(hass) notifications = async_get_persistent_notifications(hass)
@ -158,7 +152,7 @@ async def test_complete_todo_item(
async def test_uncomplete_todo_item( async def test_uncomplete_todo_item(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
entity_id: str, entity_id: str,
uid: str, uid: str,
) -> None: ) -> None:
@ -170,10 +164,6 @@ async def test_uncomplete_todo_item(
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/tasks/{uid}/score/down",
json={"data": {}, "success": True},
)
await hass.services.async_call( await hass.services.async_call(
TODO_DOMAIN, TODO_DOMAIN,
TodoServices.UPDATE_ITEM, TodoServices.UPDATE_ITEM,
@ -182,9 +172,7 @@ async def test_uncomplete_todo_item(
blocking=True, blocking=True,
) )
assert mock_called_with( habitica.update_score.assert_called_once_with(UUID(uid), Direction.DOWN)
mock_habitica, "post", f"{DEFAULT_URL}/api/v3/tasks/{uid}/score/down"
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -198,7 +186,7 @@ async def test_uncomplete_todo_item(
async def test_complete_todo_item_exception( async def test_complete_todo_item_exception(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
uid: str, uid: str,
status: str, status: str,
) -> None: ) -> None:
@ -210,10 +198,7 @@ async def test_complete_todo_item_exception(
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
mock_habitica.post( habitica.update_score.side_effect = ERROR_NOT_FOUND
re.compile(f"{DEFAULT_URL}/api/v3/tasks/{uid}/score/.+"),
status=HTTPStatus.NOT_FOUND,
)
with pytest.raises( with pytest.raises(
expected_exception=ServiceValidationError, expected_exception=ServiceValidationError,
match=r"Unable to update the score for your Habitica to-do `.+`, please try again", match=r"Unable to update the score for your Habitica to-do `.+`, please try again",
@ -228,30 +213,86 @@ async def test_complete_todo_item_exception(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("entity_id", "uid", "date"), ("entity_id", "service_data", "call_args"),
[ [
( (
"todo.test_user_to_do_s", "todo.test_user_to_do_s",
"88de7cd9-af2b-49ce-9afd-bf941d87336b", {
"2024-07-30", ATTR_ITEM: "88de7cd9-af2b-49ce-9afd-bf941d87336b",
ATTR_RENAME: "test-summary",
ATTR_DESCRIPTION: "test-description",
ATTR_DUE_DATE: date(2024, 7, 30),
},
(
UUID("88de7cd9-af2b-49ce-9afd-bf941d87336b"),
Task(
notes="test-description",
text="test-summary",
date=date(2024, 7, 30),
),
),
),
(
"todo.test_user_to_do_s",
{
ATTR_ITEM: "88de7cd9-af2b-49ce-9afd-bf941d87336b",
ATTR_RENAME: "test-summary",
ATTR_DESCRIPTION: "test-description",
ATTR_DUE_DATE: None,
},
(
UUID("88de7cd9-af2b-49ce-9afd-bf941d87336b"),
Task(
notes="test-description",
text="test-summary",
date=None,
),
),
),
(
"todo.test_user_to_do_s",
{
ATTR_ITEM: "88de7cd9-af2b-49ce-9afd-bf941d87336b",
ATTR_RENAME: "test-summary",
ATTR_DESCRIPTION: None,
ATTR_DUE_DATE: date(2024, 7, 30),
},
(
UUID("88de7cd9-af2b-49ce-9afd-bf941d87336b"),
Task(
notes="",
text="test-summary",
date=date(2024, 7, 30),
),
),
), ),
( (
"todo.test_user_dailies", "todo.test_user_dailies",
"f2c85972-1a19-4426-bc6d-ce3337b9d99f", {
None, ATTR_ITEM: "f2c85972-1a19-4426-bc6d-ce3337b9d99f",
ATTR_RENAME: "test-summary",
ATTR_DESCRIPTION: "test-description",
},
(
UUID("f2c85972-1a19-4426-bc6d-ce3337b9d99f"),
Task(
notes="test-description",
text="test-summary",
),
),
), ),
], ],
ids=["todo", "daily"], ids=["todo", "todo remove date", "todo remove notes", "daily"],
) )
async def test_update_todo_item( async def test_update_todo_item(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
entity_id: str, entity_id: str,
uid: str, service_data: dict[str, Any],
date: str, call_args: tuple[UUID, Task],
) -> None: ) -> None:
"""Test update details of a item on the todo list.""" """Test update details of an item on the todo list."""
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id) await hass.config_entries.async_setup(config_entry.entry_id)
@ -259,38 +300,21 @@ async def test_update_todo_item(
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
mock_habitica.put(
f"{DEFAULT_URL}/api/v3/tasks/{uid}",
json={"data": {}, "success": True},
)
await hass.services.async_call( await hass.services.async_call(
TODO_DOMAIN, TODO_DOMAIN,
TodoServices.UPDATE_ITEM, TodoServices.UPDATE_ITEM,
{ service_data,
ATTR_ITEM: uid,
ATTR_RENAME: "test-summary",
ATTR_DESCRIPTION: "test-description",
ATTR_DUE_DATE: date,
},
target={ATTR_ENTITY_ID: entity_id}, target={ATTR_ENTITY_ID: entity_id},
blocking=True, blocking=True,
) )
mock_call = mock_called_with( habitica.update_task.assert_awaited_once_with(*call_args)
mock_habitica, "PUT", f"{DEFAULT_URL}/api/v3/tasks/{uid}"
)
assert mock_call
assert json.loads(mock_call[2]) == {
"date": date,
"notes": "test-description",
"text": "test-summary",
}
async def test_update_todo_item_exception( async def test_update_todo_item_exception(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
) -> None: ) -> None:
"""Test exception when update item on the todo list.""" """Test exception when update item on the todo list."""
uid = "88de7cd9-af2b-49ce-9afd-bf941d87336b" uid = "88de7cd9-af2b-49ce-9afd-bf941d87336b"
@ -300,10 +324,7 @@ async def test_update_todo_item_exception(
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
mock_habitica.put( habitica.update_task.side_effect = ERROR_NOT_FOUND
f"{DEFAULT_URL}/api/v3/tasks/{uid}",
status=HTTPStatus.NOT_FOUND,
)
with pytest.raises( with pytest.raises(
expected_exception=ServiceValidationError, expected_exception=ServiceValidationError,
match="Unable to update the Habitica to-do `test-summary`, please try again", match="Unable to update the Habitica to-do `test-summary`, please try again",
@ -325,7 +346,7 @@ async def test_update_todo_item_exception(
async def test_add_todo_item( async def test_add_todo_item(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
) -> None: ) -> None:
"""Test add a todo item to the todo list.""" """Test add a todo item to the todo list."""
@ -335,11 +356,6 @@ async def test_add_todo_item(
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/tasks/user",
json={"data": {}, "success": True},
status=HTTPStatus.CREATED,
)
await hass.services.async_call( await hass.services.async_call(
TODO_DOMAIN, TODO_DOMAIN,
TodoServices.ADD_ITEM, TodoServices.ADD_ITEM,
@ -352,24 +368,20 @@ async def test_add_todo_item(
blocking=True, blocking=True,
) )
mock_call = mock_called_with( habitica.create_task.assert_awaited_once_with(
mock_habitica, Task(
"post", date=date(2024, 7, 30),
f"{DEFAULT_URL}/api/v3/tasks/user", notes="test-description",
text="test-summary",
type=TaskType.TODO,
)
) )
assert mock_call
assert json.loads(mock_call[2]) == {
"date": "2024-07-30",
"notes": "test-description",
"text": "test-summary",
"type": "todo",
}
async def test_add_todo_item_exception( async def test_add_todo_item_exception(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
) -> None: ) -> None:
"""Test exception when adding a todo item to the todo list.""" """Test exception when adding a todo item to the todo list."""
@ -379,10 +391,7 @@ async def test_add_todo_item_exception(
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
mock_habitica.post( habitica.create_task.side_effect = ERROR_NOT_FOUND
f"{DEFAULT_URL}/api/v3/tasks/user",
status=HTTPStatus.NOT_FOUND,
)
with pytest.raises( with pytest.raises(
expected_exception=ServiceValidationError, expected_exception=ServiceValidationError,
match="Unable to create new to-do `test-summary` for Habitica, please try again", match="Unable to create new to-do `test-summary` for Habitica, please try again",
@ -403,7 +412,7 @@ async def test_add_todo_item_exception(
async def test_delete_todo_item( async def test_delete_todo_item(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
) -> None: ) -> None:
"""Test deleting a todo item from the todo list.""" """Test deleting a todo item from the todo list."""
@ -414,10 +423,6 @@ async def test_delete_todo_item(
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
mock_habitica.delete(
f"{DEFAULT_URL}/api/v3/tasks/{uid}",
json={"data": {}, "success": True},
)
await hass.services.async_call( await hass.services.async_call(
TODO_DOMAIN, TODO_DOMAIN,
TodoServices.REMOVE_ITEM, TodoServices.REMOVE_ITEM,
@ -426,15 +431,13 @@ async def test_delete_todo_item(
blocking=True, blocking=True,
) )
assert mock_called_with( habitica.delete_task.assert_awaited_once_with(UUID(uid))
mock_habitica, "delete", f"{DEFAULT_URL}/api/v3/tasks/{uid}"
)
async def test_delete_todo_item_exception( async def test_delete_todo_item_exception(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
) -> None: ) -> None:
"""Test exception when deleting a todo item from the todo list.""" """Test exception when deleting a todo item from the todo list."""
@ -445,10 +448,8 @@ async def test_delete_todo_item_exception(
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
mock_habitica.delete( habitica.delete_task.side_effect = ERROR_NOT_FOUND
f"{DEFAULT_URL}/api/v3/tasks/{uid}",
status=HTTPStatus.NOT_FOUND,
)
with pytest.raises( with pytest.raises(
expected_exception=ServiceValidationError, expected_exception=ServiceValidationError,
match="Unable to delete item from Habitica to-do list, please try again", match="Unable to delete item from Habitica to-do list, please try again",
@ -465,7 +466,7 @@ async def test_delete_todo_item_exception(
async def test_delete_completed_todo_items( async def test_delete_completed_todo_items(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
) -> None: ) -> None:
"""Test deleting completed todo items from the todo list.""" """Test deleting completed todo items from the todo list."""
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
@ -474,10 +475,6 @@ async def test_delete_completed_todo_items(
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/tasks/clearCompletedTodos",
json={"data": {}, "success": True},
)
await hass.services.async_call( await hass.services.async_call(
TODO_DOMAIN, TODO_DOMAIN,
TodoServices.REMOVE_COMPLETED_ITEMS, TodoServices.REMOVE_COMPLETED_ITEMS,
@ -486,15 +483,13 @@ async def test_delete_completed_todo_items(
blocking=True, blocking=True,
) )
assert mock_called_with( habitica.delete_completed_todos.assert_awaited_once()
mock_habitica, "post", f"{DEFAULT_URL}/api/v3/tasks/clearCompletedTodos"
)
async def test_delete_completed_todo_items_exception( async def test_delete_completed_todo_items_exception(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
) -> None: ) -> None:
"""Test exception when deleting completed todo items from the todo list.""" """Test exception when deleting completed todo items from the todo list."""
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
@ -503,10 +498,7 @@ async def test_delete_completed_todo_items_exception(
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
mock_habitica.post( habitica.delete_completed_todos.side_effect = ERROR_NOT_FOUND
f"{DEFAULT_URL}/api/v3/tasks/clearCompletedTodos",
status=HTTPStatus.NOT_FOUND,
)
with pytest.raises( with pytest.raises(
expected_exception=ServiceValidationError, expected_exception=ServiceValidationError,
match="Unable to delete completed to-do items from Habitica to-do list, please try again", match="Unable to delete completed to-do items from Habitica to-do list, please try again",
@ -539,7 +531,7 @@ async def test_delete_completed_todo_items_exception(
async def test_move_todo_item( async def test_move_todo_item(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
hass_ws_client: WebSocketGenerator, hass_ws_client: WebSocketGenerator,
entity_id: str, entity_id: str,
uid: str, uid: str,
@ -553,12 +545,6 @@ async def test_move_todo_item(
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
for pos in (0, 1):
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/tasks/{uid}/move/to/{pos}",
json={"data": {}, "success": True},
)
client = await hass_ws_client() client = await hass_ws_client()
# move to second position # move to second position
data = { data = {
@ -572,6 +558,9 @@ async def test_move_todo_item(
resp = await client.receive_json() resp = await client.receive_json()
assert resp.get("success") assert resp.get("success")
habitica.reorder_task.assert_awaited_once_with(UUID(uid), 1)
habitica.reorder_task.reset_mock()
# move to top position # move to top position
data = { data = {
"id": id, "id": id,
@ -583,18 +572,13 @@ async def test_move_todo_item(
resp = await client.receive_json() resp = await client.receive_json()
assert resp.get("success") assert resp.get("success")
for pos in (0, 1): habitica.reorder_task.assert_awaited_once_with(UUID(uid), 0)
assert mock_called_with(
mock_habitica,
"post",
f"{DEFAULT_URL}/api/v3/tasks/{uid}/move/to/{pos}",
)
async def test_move_todo_item_exception( async def test_move_todo_item_exception(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker, habitica: AsyncMock,
hass_ws_client: WebSocketGenerator, hass_ws_client: WebSocketGenerator,
) -> None: ) -> None:
"""Test exception when moving todo item.""" """Test exception when moving todo item."""
@ -606,11 +590,7 @@ async def test_move_todo_item_exception(
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
mock_habitica.post( habitica.reorder_task.side_effect = ERROR_NOT_FOUND
f"{DEFAULT_URL}/api/v3/tasks/{uid}/move/to/0",
status=HTTPStatus.NOT_FOUND,
)
client = await hass_ws_client() client = await hass_ws_client()
data = { data = {
@ -620,8 +600,15 @@ async def test_move_todo_item_exception(
"uid": uid, "uid": uid,
} }
await client.send_json_auto_id(data) await client.send_json_auto_id(data)
resp = await client.receive_json() resp = await client.receive_json()
assert resp.get("success") is False habitica.reorder_task.assert_awaited_once_with(UUID(uid), 0)
assert resp["success"] is False
assert (
resp["error"]["message"]
== "Unable to move the Habitica to-do to position 0, please try again"
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -651,31 +638,18 @@ async def test_move_todo_item_exception(
async def test_next_due_date( async def test_next_due_date(
hass: HomeAssistant, hass: HomeAssistant,
fixture: str, fixture: str,
calculated_due_date: tuple | None, calculated_due_date: str | None,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker, habitica: AsyncMock,
) -> None: ) -> None:
"""Test next_due_date calculation.""" """Test next_due_date calculation."""
dailies_entity = "todo.test_user_dailies" dailies_entity = "todo.test_user_dailies"
aioclient_mock.get( habitica.get_tasks.side_effect = [
f"{DEFAULT_URL}/api/v3/user", json=load_json_object_fixture("user.json", DOMAIN) HabiticaTasksResponse.from_json(load_fixture(fixture, DOMAIN)),
) HabiticaTasksResponse.from_dict({"success": True, "data": []}),
aioclient_mock.get( ]
f"{DEFAULT_URL}/api/v3/tasks/user",
params={"type": "completedTodos"},
json={"data": []},
)
aioclient_mock.get(
f"{DEFAULT_URL}/api/v3/tasks/user",
json=load_json_object_fixture(fixture, DOMAIN),
)
aioclient_mock.get(
f"{DEFAULT_URL}/api/v3/content",
params={"language": "en"},
json=load_json_object_fixture("content.json", DOMAIN),
)
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id) await hass.config_entries.async_setup(config_entry.entry_id)