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."""
from http import HTTPStatus
from aiohttp import ClientResponseError
from habitipy.aio import HabitipyAsync
from habiticalib import Habitica
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
APPLICATION_NAME,
CONF_API_KEY,
CONF_NAME,
CONF_URL,
CONF_VERIFY_SSL,
Platform,
__version__,
)
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
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 .services import async_setup_services
from .types import HabiticaConfigEntry
@ -51,47 +39,17 @@ async def async_setup_entry(
) -> bool:
"""Set up habitica from a config entry."""
class HAHabitipyAsync(HabitipyAsync):
"""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(
session = async_get_clientsession(
hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True)
)
api = await hass.async_add_executor_job(
HAHabitipyAsync,
{
"url": config_entry.data[CONF_URL],
"login": config_entry.data[CONF_API_USER],
"password": config_entry.data[CONF_API_KEY],
},
api = Habitica(
session,
api_user=config_entry.data[CONF_API_USER],
api_key=config_entry.data[CONF_API_KEY],
url=config_entry.data[CONF_URL],
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)
await coordinator.async_config_entry_first_refresh()

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
"""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"
@ -44,9 +44,5 @@ SERVICE_SCORE_REWARD = "score_reward"
SERVICE_TRANSFORMATION = "transformation"
WARRIOR = "warrior"
ROGUE = "rogue"
HEALER = "healer"
MAGE = "wizard"
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 dataclasses import dataclass
from datetime import timedelta
from http import HTTPStatus
import logging
from typing import Any
from aiohttp import ClientResponseError
from habitipy.aio import HabitipyAsync
from aiohttp import ClientError
from habiticalib import (
ContentData,
Habitica,
HabiticaException,
NotAuthorizedError,
TaskData,
TaskFilter,
TooManyRequestsError,
UserData,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
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.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -25,10 +34,10 @@ _LOGGER = logging.getLogger(__name__)
@dataclass
class HabiticaData:
"""Coordinator data class."""
"""Habitica data."""
user: dict[str, Any]
tasks: list[dict]
user: UserData
tasks: list[TaskData]
class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
@ -36,7 +45,7 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
config_entry: ConfigEntry
def __init__(self, hass: HomeAssistant, habitipy: HabitipyAsync) -> None:
def __init__(self, hass: HomeAssistant, habitica: Habitica) -> None:
"""Initialize the Habitica data coordinator."""
super().__init__(
hass,
@ -50,25 +59,54 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
immediate=False,
),
)
self.api = habitipy
self.content: dict[str, Any] = {}
self.habitica = habitica
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:
try:
user_response = await self.api.user.get()
tasks_response = await self.api.tasks.user.get()
tasks_response.extend(await self.api.tasks.user.get(type="completedTodos"))
if not self.content:
self.content = await self.api.content.get(
language=user_response["preferences"]["language"]
)
except ClientResponseError as error:
if error.status == HTTPStatus.TOO_MANY_REQUESTS:
_LOGGER.debug("Rate limit exceeded, will try again later")
return self.data
raise UpdateFailed(f"Unable to connect to Habitica: {error}") from error
return HabiticaData(user=user_response, tasks=tasks_response)
user = (await self.habitica.get_user()).data
tasks = (await self.habitica.get_tasks()).data
completed_todos = (
await self.habitica.get_tasks(TaskFilter.COMPLETED_TODOS)
).data
except TooManyRequestsError:
_LOGGER.debug("Rate limit exceeded, will try again later")
return self.data
except (HabiticaException, ClientError) as e:
raise UpdateFailed(f"Unable to connect to Habitica: {e}") from e
else:
return HabiticaData(user=user, tasks=tasks + completed_todos)
async def execute(
self, func: Callable[[HabiticaDataUpdateCoordinator], Any]
@ -77,12 +115,12 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
try:
await func(self)
except ClientResponseError as e:
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
except TooManyRequestsError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
) from e
except (HabiticaException, ClientError) as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",

View File

@ -16,12 +16,12 @@ async def async_get_config_entry_diagnostics(
) -> dict[str, Any]:
"""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 {
"config_entry_data": {
CONF_URL: config_entry.data[CONF_URL],
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,
"documentation": "https://www.home-assistant.io/integrations/habitica",
"iot_class": "cloud_polling",
"loggers": ["habitipy", "plumbum"],
"requirements": ["habitipy==0.3.3"]
"loggers": ["habiticalib"],
"requirements": ["habiticalib==0.3.1"]
}

View File

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

View File

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

View File

@ -49,7 +49,8 @@
"data_description": {
"url": "URL of the Habitica installation to connect to. Defaults to `{default_url}`",
"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"
}
@ -365,6 +366,9 @@
},
"item_not_found": {
"message": "Unable to use {item}, you don't own this item."
},
"invalid_auth": {
"message": "Authentication failed for {account}."
}
},
"issues": {

View File

@ -28,7 +28,7 @@ class HabiticaSwitchEntityDescription(SwitchEntityDescription):
turn_on_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):
@ -42,9 +42,9 @@ SWTICH_DESCRIPTIONS: tuple[HabiticaSwitchEntityDescription, ...] = (
key=HabiticaSwitchEntity.SLEEP,
translation_key=HabiticaSwitchEntity.SLEEP,
device_class=SwitchDeviceClass.SWITCH,
turn_on_fn=lambda coordinator: coordinator.api["user"]["sleep"].post(),
turn_off_fn=lambda coordinator: coordinator.api["user"]["sleep"].post(),
is_on_fn=lambda data: data.user["preferences"]["sleep"],
turn_on_fn=lambda coordinator: coordinator.habitica.toggle_sleep(),
turn_off_fn=lambda coordinator: coordinator.habitica.toggle_sleep(),
is_on_fn=lambda data: data.user.preferences.sleep,
),
)

View File

@ -2,11 +2,12 @@
from __future__ import annotations
import datetime
from enum import StrEnum
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.todo import (
@ -24,7 +25,7 @@ from homeassistant.util import dt as dt_util
from .const import ASSETS_URL, DOMAIN
from .coordinator import HabiticaDataUpdateCoordinator
from .entity import HabiticaBase
from .types import HabiticaConfigEntry, HabiticaTaskType
from .types import HabiticaConfigEntry
from .util import next_due_date
PARALLEL_UPDATES = 1
@ -70,8 +71,8 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
"""Delete Habitica tasks."""
if len(uids) > 1 and self.entity_description.key is HabiticaTodoList.TODOS:
try:
await self.coordinator.api.tasks.clearCompletedTodos.post()
except ClientResponseError as e:
await self.coordinator.habitica.delete_completed_todos()
except (HabiticaException, ClientError) as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="delete_completed_todos_failed",
@ -79,8 +80,8 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
else:
for task_id in uids:
try:
await self.coordinator.api.tasks[task_id].delete()
except ClientResponseError as e:
await self.coordinator.habitica.delete_task(UUID(task_id))
except (HabiticaException, ClientError) as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=f"delete_{self.entity_description.key}_failed",
@ -106,9 +107,8 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
pos = 0
try:
await self.coordinator.api.tasks[uid].move.to[str(pos)].post()
except ClientResponseError as e:
await self.coordinator.habitica.reorder_task(UUID(uid), pos)
except (HabiticaException, ClientError) as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
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
tasks = self.coordinator.data.tasks
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
if previous_uid
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))
await self.coordinator.async_request_refresh()
@ -138,14 +140,17 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
if TYPE_CHECKING:
assert item.uid
assert current_item
assert item.summary
task = Task(
text=item.summary,
notes=item.description or "",
)
if (
self.entity_description.key is HabiticaTodoList.TODOS
and item.due is not None
): # Only todos support a due date.
date = item.due.isoformat()
else:
date = None
task["date"] = item.due
if (
item.summary != current_item.summary
@ -153,13 +158,9 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
or item.due != current_item.due
):
try:
await self.coordinator.api.tasks[item.uid].put(
text=item.summary,
notes=item.description or "",
date=date,
)
await self.coordinator.habitica.update_task(UUID(item.uid), task)
refresh_required = True
except ClientResponseError as e:
except (HabiticaException, ClientError) as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
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
and item.status == TodoItemStatus.COMPLETED
):
score_result = (
await self.coordinator.api.tasks[item.uid].score["up"].post()
score_result = await self.coordinator.habitica.update_score(
UUID(item.uid), Direction.UP
)
refresh_required = True
elif (
current_item.status is TodoItemStatus.COMPLETED
and item.status == TodoItemStatus.NEEDS_ACTION
):
score_result = (
await self.coordinator.api.tasks[item.uid].score["down"].post()
score_result = await self.coordinator.habitica.update_score(
UUID(item.uid), Direction.DOWN
)
refresh_required = True
else:
score_result = None
except ClientResponseError as e:
except (HabiticaException, ClientError) as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=f"score_{self.entity_description.key}_item_failed",
translation_placeholders={"name": item.summary or ""},
) 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 = (
f"![{drop["key"]}]({ASSETS_URL}Pet_{drop["type"]}_{drop["key"]}.png)\n"
f"{drop["dialog"]}"
f"![{drop.key}]({ASSETS_URL}Pet_{drop.Type}_{drop.key}.png)\n"
f"{drop.dialog}"
)
persistent_notification.async_create(
self.hass, message=msg, title="Habitica"
@ -229,38 +231,36 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity):
return [
*(
TodoItem(
uid=task["id"],
summary=task["text"],
description=task["notes"],
due=(
dt_util.as_local(
datetime.datetime.fromisoformat(task["date"])
).date()
if task.get("date")
else None
),
uid=str(task.id),
summary=task.text,
description=task.notes,
due=dt_util.as_local(task.date).date() if task.date else None,
status=(
TodoItemStatus.NEEDS_ACTION
if not task["completed"]
if not task.completed
else TodoItemStatus.COMPLETED
),
)
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:
"""Create a Habitica todo."""
if TYPE_CHECKING:
assert item.summary
assert item.description
try:
await self.coordinator.api.tasks.user.post(
text=item.summary,
type=HabiticaTaskType.TODO,
notes=item.description,
date=item.due.isoformat() if item.due else None,
await self.coordinator.habitica.create_task(
Task(
text=item.summary,
type=TaskType.TODO,
notes=item.description,
date=item.due,
)
)
except ClientResponseError as e:
except (HabiticaException, ClientError) as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
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.
Changes of the date input field in Home Assistant will be ignored.
"""
last_cron = self.coordinator.data.user["lastCron"]
if TYPE_CHECKING:
assert self.coordinator.data.user.lastCron
return [
*(
TodoItem(
uid=task["id"],
summary=task["text"],
description=task["notes"],
due=next_due_date(task, last_cron),
uid=str(task.id),
summary=task.text,
description=task.notes,
due=next_due_date(task, self.coordinator.data.user.lastCron),
status=(
TodoItemStatus.COMPLETED
if task["completed"]
if task.completed
else TodoItemStatus.NEEDS_ACTION
),
)
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 dataclasses import fields
import datetime
from math import floor
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from dateutil.rrule import (
DAILY,
@ -20,6 +21,7 @@ from dateutil.rrule import (
YEARLY,
rrule,
)
from habiticalib import ContentData, Frequency, TaskData, UserData
from homeassistant.components.automation import automations_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
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."""
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
today = to_date(last_cron)
startdate = to_date(task["startDate"])
if TYPE_CHECKING:
assert today
assert startdate
assert task.startDate
if task["isDue"] and not task["completed"]:
return to_date(last_cron)
if task.isDue is True and not task.completed:
return dt_util.as_local(today).date()
if startdate > today:
if task["frequency"] == "daily" or (
task["frequency"] in ("monthly", "yearly") and task["daysOfMonth"]
if task.startDate > today:
if task.frequency is Frequency.DAILY or (
task.frequency in (Frequency.MONTHLY, Frequency.YEARLY) and task.daysOfMonth
):
return startdate
return dt_util.as_local(task.startDate).date()
if (
task["frequency"] in ("weekly", "monthly")
and (nextdue := to_date(task["nextDue"][0]))
and startdate > nextdue
task.frequency in (Frequency.WEEKLY, Frequency.MONTHLY)
and (nextdue := task.nextDue[0])
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])
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
return dt_util.as_local(task.nextDue[0]).date()
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}
def build_rrule(task: dict[str, Any]) -> rrule:
def build_rrule(task: TaskData) -> rrule:
"""Build rrule string."""
rrule_frequency = FREQUENCY_MAP.get(task["frequency"], DAILY)
weekdays = [
WEEKDAY_MAP[day] for day, is_active in task["repeat"].items() if is_active
]
if TYPE_CHECKING:
assert task.frequency
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 = (
task["daysOfMonth"]
if rrule_frequency == MONTHLY and task["daysOfMonth"]
else None
task.daysOfMonth if rrule_frequency == MONTHLY and task.daysOfMonth else None
)
bysetpos = None
if rrule_frequency == MONTHLY and task["weeksOfMonth"]:
bysetpos = task["weeksOfMonth"]
if rrule_frequency == MONTHLY and task.weeksOfMonth:
bysetpos = task.weeksOfMonth
weekdays = weekdays if weekdays else [MO]
return rrule(
freq=rrule_frequency,
interval=task["everyX"],
dtstart=dt_util.start_of_local_day(
datetime.datetime.fromisoformat(task["startDate"])
),
interval=task.everyX,
dtstart=dt_util.start_of_local_day(task.startDate),
byweekday=weekdays if rrule_frequency in [WEEKLY, MONTHLY] else None,
bymonthday=bymonthday,
bysetpos=bysetpos,
@ -143,48 +124,37 @@ def get_recurrence_rule(recurrence: rrule) -> str:
def get_attribute_points(
user: dict[str, Any], content: dict[str, Any], attribute: str
user: UserData, content: ContentData, attribute: str
) -> dict[str, float]:
"""Get modifiers contributing to strength attribute."""
gear_set = {
"weapon",
"armor",
"head",
"shield",
"back",
"headAccessory",
"eyewear",
"body",
}
"""Get modifiers contributing to STR/INT/CON/PER attributes."""
equipment = sum(
stats[attribute]
for gear in gear_set
if (equipped := user["items"]["gear"]["equipped"].get(gear))
and (stats := content["gear"]["flat"].get(equipped))
getattr(stats, attribute)
for gear in fields(user.items.gear.equipped)
if (equipped := getattr(user.items.gear.equipped, gear.name))
and (stats := content.gear.flat[equipped])
)
class_bonus = sum(
stats[attribute] / 2
for gear in gear_set
if (equipped := user["items"]["gear"]["equipped"].get(gear))
and (stats := content["gear"]["flat"].get(equipped))
and stats["klass"] == user["stats"]["class"]
getattr(stats, attribute) / 2
for gear in fields(user.items.gear.equipped)
if (equipped := getattr(user.items.gear.equipped, gear.name))
and (stats := content.gear.flat[equipped])
and stats.klass == user.stats.Class
)
if TYPE_CHECKING:
assert user.stats.lvl
return {
"level": min(floor(user["stats"]["lvl"] / 2), 50),
"level": min(floor(user.stats.lvl / 2), 50),
"equipment": equipment,
"class": class_bonus,
"allocated": user["stats"][attribute],
"buffs": user["stats"]["buffs"][attribute],
"allocated": getattr(user.stats, attribute),
"buffs": getattr(user.stats.buffs, attribute),
}
def get_attributes_total(
user: dict[str, Any], content: dict[str, Any], attribute: str
) -> int:
def get_attributes_total(user: UserData, content: ContentData, attribute: str) -> int:
"""Get total attribute points."""
return floor(
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
# homeassistant.components.habitica
habitipy==0.3.3
habiticalib==0.3.1
# homeassistant.components.bluetooth
habluetooth==3.6.0

View File

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

View File

@ -1,77 +1,41 @@
"""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
from yarl import URL
from homeassistant.components.habitica.const import CONF_API_USER, DEFAULT_URL, DOMAIN
from homeassistant.const import CONF_API_KEY, CONF_URL
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, load_json_object_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.common import MockConfigEntry, load_fixture
@pytest.fixture(autouse=True)
def disable_plumbum():
"""Disable plumbum in tests as it can cause the test suite to fail.
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
ERROR_RESPONSE = HabiticaErrorResponse(success=False, error="error", message="message")
ERROR_NOT_AUTHORIZED = NotAuthorizedError(error=ERROR_RESPONSE, headers={})
ERROR_NOT_FOUND = NotFoundError(error=ERROR_RESPONSE, headers={})
ERROR_BAD_REQUEST = BadRequestError(error=ERROR_RESPONSE, headers={})
ERROR_TOO_MANY_REQUESTS = TooManyRequestsError(error=ERROR_RESPONSE, headers={})
@pytest.fixture(name="config_entry")
@ -82,10 +46,10 @@ def mock_config_entry() -> MockConfigEntry:
title="test-user",
data={
CONF_URL: DEFAULT_URL,
CONF_API_USER: "test-api-user",
CONF_API_KEY: "test-api-key",
CONF_API_USER: "a380546a-94be-4b8e-8a0b-23e0d5c03303",
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:
"""Fixture to set timezone."""
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,
"data": {
"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": {
"weapon_warrior_5": {
"text": "Ruby Sword",
@ -281,7 +363,110 @@
"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"
}

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"
}
}
}
},
"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,
'supported_features': 0,
'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,
})
# ---

View File

@ -28,7 +28,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -74,7 +74,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -121,7 +121,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -168,7 +168,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -215,7 +215,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -262,7 +262,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -308,7 +308,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -355,7 +355,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -401,7 +401,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -447,7 +447,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -494,7 +494,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -540,7 +540,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -586,7 +586,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -633,7 +633,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -680,7 +680,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -726,7 +726,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -773,7 +773,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -820,7 +820,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -867,7 +867,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -913,7 +913,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -959,7 +959,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -1006,7 +1006,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -1052,7 +1052,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -1099,7 +1099,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -1146,7 +1146,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -1193,7 +1193,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -1240,7 +1240,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -1286,7 +1286,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---

View File

@ -928,7 +928,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -981,7 +981,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -1033,7 +1033,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -1085,7 +1085,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---

File diff suppressed because it is too large Load Diff

View File

@ -7,9 +7,9 @@
'capabilities': dict({
'options': list([
'warrior',
'healer',
'wizard',
'rogue',
'wizard',
'healer',
]),
}),
'config_entry_id': <ANY>,
@ -35,7 +35,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -46,9 +46,9 @@
'friendly_name': 'test-user Class',
'options': list([
'warrior',
'healer',
'wizard',
'rogue',
'wizard',
'healer',
]),
}),
'context': <ANY>,
@ -91,7 +91,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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',
})
# ---
@ -143,7 +143,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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',
})
# ---
@ -151,23 +151,39 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'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,
'frequency': 'weekly',
'group': dict({
'assignedDate': None,
'assignedUsers': list([
]),
'completedBy': dict({
'assignedUsersDetail': dict({
}),
'assigningUsername': None,
'completedBy': dict({
'date': None,
'userId': None,
}),
'id': None,
'managerNotes': None,
'taskId': None,
}),
'is_due': True,
'next_due': list([
'2024-09-24T22:00:00.000Z',
'2024-09-27T22:00:00.000Z',
'2024-09-28T22:00:00.000Z',
'2024-10-01T22:00:00.000Z',
'2024-10-04T22:00:00.000Z',
'2024-10-08T22:00:00.000Z',
'2024-09-24T22:00:00+00:00',
'2024-09-27T22:00:00+00:00',
'2024-09-28T22:00:00+00:00',
'2024-10-01T22:00:00+00:00',
'2024-10-04T22:00:00+00:00',
'2024-10-08T22:00:00+00:00',
]),
'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.',
'priority': 2,
@ -180,7 +196,7 @@
'th': False,
'w': True,
}),
'start_date': '2024-09-21T22:00:00.000Z',
'start_date': '2024-09-21T22:00:00+00:00',
'tags': list([
'51076966-2970-4b40-b6ba-d58c6a756dd7',
]),
@ -189,24 +205,40 @@
'yester_daily': True,
}),
'564b9ac9-c53d-4638-9e7f-1cd96fe19baa': dict({
'challenge': dict({
'broken': None,
'id': None,
'shortName': None,
'taskId': None,
'winner': None,
}),
'completed': True,
'created_at': '2024-07-07T17:51:53.268Z',
'created_at': '2024-07-07T17:51:53.268000+00:00',
'every_x': 1,
'frequency': 'weekly',
'group': dict({
'assignedDate': None,
'assignedUsers': list([
]),
'completedBy': dict({
'assignedUsersDetail': dict({
}),
'assigningUsername': None,
'completedBy': dict({
'date': None,
'userId': None,
}),
'id': None,
'managerNotes': None,
'taskId': None,
}),
'is_due': True,
'next_due': list([
'Mon Sep 23 2024 00:00:00 GMT+0200',
'Tue Sep 24 2024 00:00:00 GMT+0200',
'Wed Sep 25 2024 00:00:00 GMT+0200',
'Thu Sep 26 2024 00:00:00 GMT+0200',
'Fri Sep 27 2024 00:00:00 GMT+0200',
'Sat Sep 28 2024 00:00:00 GMT+0200',
'2024-09-23T00:00:00+02:00',
'2024-09-24T00:00:00+02:00',
'2024-09-25T00:00:00+02:00',
'2024-09-26T00:00:00+02:00',
'2024-09-27T00:00:00+02:00',
'2024-09-28T00:00:00+02:00',
]),
'notes': 'Klicke um Änderungen zu machen!',
'priority': 1,
@ -219,7 +251,7 @@
'th': True,
'w': True,
}),
'start_date': '2024-07-06T22:00:00.000Z',
'start_date': '2024-07-06T22:00:00+00:00',
'streak': 1,
'text': 'Zahnseide benutzen',
'type': 'daily',
@ -227,22 +259,38 @@
'yester_daily': True,
}),
'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,
'frequency': 'monthly',
'group': dict({
'assignedDate': None,
'assignedUsers': list([
]),
'completedBy': dict({
'assignedUsersDetail': dict({
}),
'assigningUsername': None,
'completedBy': dict({
'date': None,
'userId': None,
}),
'id': None,
'managerNotes': None,
'taskId': None,
}),
'next_due': list([
'2024-12-14T23:00:00.000Z',
'2025-01-18T23:00:00.000Z',
'2025-02-15T23:00:00.000Z',
'2025-03-15T23:00:00.000Z',
'2025-04-19T23:00:00.000Z',
'2025-05-17T23:00:00.000Z',
'2024-12-14T23:00:00+00:00',
'2025-01-18T23:00:00+00:00',
'2025-02-15T23:00:00+00:00',
'2025-03-15T23:00:00+00:00',
'2025-04-19T23:00:00+00:00',
'2025-05-17T23:00:00+00:00',
]),
'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!',
'priority': 1,
@ -255,7 +303,7 @@
'th': False,
'w': False,
}),
'start_date': '2024-09-20T23:00:00.000Z',
'start_date': '2024-09-20T23:00:00+00:00',
'streak': 1,
'text': 'Arbeite an einem kreativen Projekt',
'type': 'daily',
@ -266,23 +314,39 @@
'yester_daily': True,
}),
'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,
'frequency': 'weekly',
'group': dict({
'assignedDate': None,
'assignedUsers': list([
]),
'completedBy': dict({
'assignedUsersDetail': dict({
}),
'assigningUsername': None,
'completedBy': dict({
'date': None,
'userId': None,
}),
'id': None,
'managerNotes': None,
'taskId': None,
}),
'is_due': True,
'next_due': list([
'2024-09-22T22:00:00.000Z',
'2024-09-23T22:00:00.000Z',
'2024-09-24T22:00:00.000Z',
'2024-09-25T22:00:00.000Z',
'2024-09-26T22:00:00.000Z',
'2024-09-27T22:00:00.000Z',
'2024-09-22T22:00:00+00:00',
'2024-09-23T22:00:00+00:00',
'2024-09-24T22:00:00+00:00',
'2024-09-25T22:00:00+00:00',
'2024-09-26T22:00:00+00:00',
'2024-09-27T22:00:00+00:00',
]),
'notes': 'Klicke um Deinen Terminplan festzulegen!',
'priority': 1,
@ -295,7 +359,7 @@
'th': 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',
'type': 'daily',
'value': -1.919611992979862,
@ -341,7 +405,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -387,7 +451,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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',
})
# ---
@ -437,7 +501,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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',
})
# ---
@ -453,7 +517,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
'state': '40',
})
# ---
# name: test_sensors[sensor.test_user_gold-entry]
@ -488,7 +552,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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',
})
# ---
@ -535,7 +599,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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',
})
# ---
@ -543,60 +607,160 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'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',
'group': dict({
'assignedDate': None,
'assignedUsers': list([
]),
'completedBy': dict({
'assignedUsersDetail': dict({
}),
'assigningUsername': None,
'completedBy': dict({
'date': None,
'userId': None,
}),
'id': None,
'managerNotes': None,
'taskId': None,
}),
'priority': 1,
'repeat': dict({
'f': False,
'm': True,
's': False,
'su': False,
't': True,
'th': False,
'w': True,
}),
'text': 'Eine kurze Pause machen',
'type': 'habit',
'up': True,
}),
'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,
'frequency': 'daily',
'group': dict({
'assignedDate': None,
'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',
'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',
'type': 'habit',
}),
'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',
'group': dict({
'assignedDate': None,
'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',
'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',
'type': 'habit',
'up': True,
}),
'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,
'frequency': 'daily',
'group': dict({
'assignedDate': None,
'assignedUsers': list([
]),
'completedBy': dict({
'assignedUsersDetail': dict({
}),
'assigningUsername': None,
'completedBy': dict({
'date': None,
'userId': None,
}),
'id': None,
'managerNotes': None,
'taskId': None,
}),
'priority': 1,
'repeat': dict({
'f': False,
'm': True,
's': False,
'su': False,
't': True,
'th': False,
'w': True,
}),
'text': 'Gesundes Essen/Junkfood',
'type': 'habit',
'up': True,
@ -644,7 +808,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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',
})
# ---
@ -659,7 +823,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
'state': '0.0',
})
# ---
# name: test_sensors[sensor.test_user_intelligence-entry]
@ -694,7 +858,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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',
})
# ---
@ -746,7 +910,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---
@ -795,7 +959,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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',
})
# ---
@ -842,7 +1006,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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',
})
# ---
@ -889,7 +1053,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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',
})
# ---
@ -939,7 +1103,7 @@
'previous_unique_id': None,
'supported_features': 0,
'translation_key': <HabitipySensorEntity.TRINKETS: 'trinkets'>,
'unique_id': '00000000-0000-0000-0000-000000000000_trinkets',
'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_trinkets',
'unit_of_measurement': '⧖',
})
# ---
@ -987,7 +1151,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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',
})
# ---
@ -1037,7 +1201,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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',
})
# ---
@ -1089,7 +1253,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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',
})
# ---
@ -1097,18 +1261,43 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'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({
'assignedDate': None,
'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!',
'priority': 1,
'repeat': dict({
'f': False,
'm': True,
's': False,
'su': False,
't': True,
'th': False,
'w': True,
}),
'text': 'Belohne Dich selbst',
'type': 'reward',
'value': 10,
'value': 10.0,
}),
'friendly_name': 'test-user Rewards',
'unit_of_measurement': 'tasks',
@ -1153,7 +1342,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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',
})
# ---
@ -1205,7 +1394,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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',
})
# ---
@ -1213,41 +1402,116 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'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({
'assignedDate': None,
'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.',
'priority': 1,
'repeat': dict({
'f': False,
'm': True,
's': False,
'su': False,
't': True,
'th': False,
'w': True,
}),
'text': 'Garten pflegen',
'type': 'todo',
}),
'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({
'assignedDate': None,
'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.',
'priority': 1,
'repeat': dict({
'f': False,
'm': True,
's': False,
'su': False,
't': True,
'th': False,
'w': True,
}),
'text': 'Rechnungen bezahlen',
'type': 'todo',
}),
'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({
'assignedDate': None,
'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.',
'priority': 1,
'repeat': dict({
'f': False,
'm': True,
's': False,
'su': False,
't': True,
'th': False,
'w': True,
}),
'tags': list([
'51076966-2970-4b40-b6ba-d58c6a756dd7',
]),
@ -1255,15 +1519,40 @@
'type': 'todo',
}),
'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({
'assignedDate': None,
'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.',
'priority': 1,
'repeat': dict({
'f': False,
'm': True,
's': False,
'su': False,
't': True,
'th': False,
'w': True,
}),
'text': 'Buch zu Ende lesen',
'type': 'todo',
}),

View File

@ -28,7 +28,7 @@
'previous_unique_id': None,
'supported_features': 0,
'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,
})
# ---

View File

@ -129,7 +129,7 @@
'previous_unique_id': None,
'supported_features': <TodoListEntityFeature: 92>,
'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,
})
# ---
@ -176,7 +176,7 @@
'previous_unique_id': None,
'supported_features': <TodoListEntityFeature: 95>,
'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,
})
# ---

View File

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

View File

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

View File

@ -3,6 +3,7 @@
from collections.abc import Generator
from unittest.mock import patch
from freezegun.api import freeze_time
import pytest
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")
@pytest.mark.usefixtures("mock_habitica")
@pytest.mark.freeze_time("2024-09-20T22:00:00.000Z")
@pytest.mark.usefixtures("habitica")
@freeze_time("2024-09-20T22:00:00.000Z")
async def test_calendar_platform(
hass: HomeAssistant,
config_entry: MockConfigEntry,
@ -70,8 +71,7 @@ async def test_calendar_platform(
"date range in the past",
],
)
@pytest.mark.freeze_time("2024-09-20T22:00:00.000Z")
@pytest.mark.usefixtures("mock_habitica")
@pytest.mark.usefixtures("habitica")
async def test_api_events(
hass: HomeAssistant,
snapshot: SnapshotAssertion,

View File

@ -1,14 +1,14 @@
"""Test the habitica config flow."""
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock
from aiohttp import ClientResponseError
import pytest
from homeassistant import config_entries
from homeassistant.components.habitica.const import CONF_API_USER, DEFAULT_URL, DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import (
CONF_API_KEY,
CONF_NAME,
CONF_PASSWORD,
CONF_URL,
CONF_USERNAME,
@ -17,23 +17,32 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
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 = {
CONF_USERNAME: "test-email@example.com",
CONF_PASSWORD: "test-password",
}
MOCK_DATA_ADVANCED_STEP = {
CONF_API_USER: "test-api-user",
CONF_API_KEY: "test-api-key",
CONF_API_USER: TEST_API_USER,
CONF_API_KEY: TEST_API_KEY,
CONF_URL: DEFAULT_URL,
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."""
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
@ -47,55 +56,41 @@ async def test_form_login(hass: HomeAssistant) -> None:
assert result["errors"] == {}
assert result["step_id"] == "login"
mock_obj = MagicMock()
mock_obj.user.auth.local.login.post = AsyncMock()
mock_obj.user.auth.local.login.post.return_value = {
"id": "test-api-user",
"apiToken": "test-api-key",
"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()
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["title"] == "test-username"
assert result["title"] == "test-user"
assert result["data"] == {
**MOCK_DATA_ADVANCED_STEP,
CONF_USERNAME: "test-username",
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 len(mock_setup.mock_calls) == 1
assert result["result"].unique_id == TEST_API_USER
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("raise_error", "text_error"),
[
(ClientResponseError(MagicMock(), (), status=400), "cannot_connect"),
(ClientResponseError(MagicMock(), (), status=401), "invalid_auth"),
(ERROR_BAD_REQUEST, "cannot_connect"),
(ERROR_NOT_AUTHORIZED, "invalid_auth"),
(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."""
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
@ -105,26 +100,40 @@ async def test_form_login_errors(hass: HomeAssistant, raise_error, text_error) -
DOMAIN, context={"source": "login"}
)
mock_obj = MagicMock()
mock_obj.user.auth.local.login.post = AsyncMock(side_effect=raise_error)
with patch(
"homeassistant.components.habitica.config_flow.HabitipyAsync",
return_value=mock_obj,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_DATA_LOGIN_STEP,
)
habitica.login.side_effect = raise_error
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_DATA_LOGIN_STEP,
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": text_error}
assert result["type"] is FlowResultType.FORM
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."""
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
@ -144,54 +153,41 @@ async def test_form_advanced(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
mock_obj = MagicMock()
mock_obj.user.get = AsyncMock()
mock_obj.user.get.return_value = {"auth": {"local": {"username": "test-username"}}}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_DATA_ADVANCED_STEP,
)
await hass.async_block_till_done()
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,
):
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 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 len(mock_setup.mock_calls) == 1
assert result["result"].unique_id == TEST_API_USER
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("raise_error", "text_error"),
[
(ClientResponseError(MagicMock(), (), status=400), "cannot_connect"),
(ClientResponseError(MagicMock(), (), status=401), "invalid_auth"),
(ERROR_BAD_REQUEST, "cannot_connect"),
(ERROR_NOT_AUTHORIZED, "invalid_auth"),
(IndexError(), "unknown"),
],
)
async def test_form_advanced_errors(
hass: HomeAssistant, raise_error, text_error
hass: HomeAssistant, habitica: AsyncMock, raise_error, text_error
) -> None:
"""Test we handle invalid credentials error."""
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
@ -201,17 +197,59 @@ async def test_form_advanced_errors(
DOMAIN, context={"source": "advanced"}
)
mock_obj = MagicMock()
mock_obj.user.get = AsyncMock(side_effect=raise_error)
habitica.get_user.side_effect = raise_error
with patch(
"homeassistant.components.habitica.config_flow.HabitipyAsync",
return_value=mock_obj,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_DATA_ADVANCED_STEP,
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_DATA_ADVANCED_STEP,
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": text_error}
assert result["type"] is FlowResultType.FORM
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
@pytest.mark.usefixtures("mock_habitica")
@pytest.mark.usefixtures("habitica")
async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,

View File

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

View File

@ -26,7 +26,7 @@ def sensor_only() -> Generator[None]:
yield
@pytest.mark.usefixtures("mock_habitica", "entity_registry_enabled_by_default")
@pytest.mark.usefixtures("habitica", "entity_registry_enabled_by_default")
async def test_sensors(
hass: HomeAssistant,
config_entry: MockConfigEntry,
@ -44,7 +44,7 @@ async def test_sensors(
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(
hass: HomeAssistant,
config_entry: MockConfigEntry,

View File

@ -1,10 +1,11 @@
"""Test Habitica actions."""
from collections.abc import Generator
from http import HTTPStatus
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
from homeassistant.components.habitica.const import (
@ -14,7 +15,6 @@ from homeassistant.components.habitica.const import (
ATTR_SKILL,
ATTR_TARGET,
ATTR_TASK,
DEFAULT_URL,
DOMAIN,
SERVICE_ABORT_QUEST,
SERVICE_ACCEPT_QUEST,
@ -31,10 +31,14 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
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.test_util.aiohttp import AiohttpClientMocker
REQUEST_EXCEPTION_MSG = "Unable to connect to Habitica, 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(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
habitica: AsyncMock,
services_only: Generator,
) -> None:
"""Load config entry."""
@ -75,55 +79,70 @@ def uuid_mock() -> Generator[None]:
@pytest.mark.parametrize(
("service_data", "item", "target_id"),
(
"service_data",
"call_args",
),
[
(
{
ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490",
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_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_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_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_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_SKILL: "smash",
},
"smash",
"2f6fcabc-f670-4ec3-ba65-817e8deea490",
{
"skill": Skill.BRUTAL_SMASH,
"target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"),
},
),
],
ids=[
@ -138,18 +157,12 @@ def uuid_mock() -> Generator[None]:
async def test_cast_skill(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
habitica: AsyncMock,
service_data: dict[str, Any],
item: str,
target_id: str,
call_args: dict[str, Any],
) -> None:
"""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(
DOMAIN,
SERVICE_CAST_SKILL,
@ -160,18 +173,13 @@ async def test_cast_skill(
return_response=True,
blocking=True,
)
assert mock_called_with(
mock_habitica,
"post",
f"{DEFAULT_URL}/api/v3/user/class/cast/{item}?targetId={target_id}",
)
habitica.cast_skill.assert_awaited_once_with(**call_args)
@pytest.mark.parametrize(
(
"service_data",
"http_status",
"raise_exception",
"expected_exception",
"expected_exception_msg",
),
@ -181,7 +189,7 @@ async def test_cast_skill(
ATTR_TASK: "task-not-found",
ATTR_SKILL: "smash",
},
HTTPStatus.OK,
None,
ServiceValidationError,
"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_SKILL: "smash",
},
HTTPStatus.TOO_MANY_REQUESTS,
ServiceValidationError,
ERROR_TOO_MANY_REQUESTS,
HomeAssistantError,
RATE_LIMIT_EXCEPTION_MSG,
),
(
@ -199,7 +207,7 @@ async def test_cast_skill(
ATTR_TASK: "Rechnungen bezahlen",
ATTR_SKILL: "smash",
},
HTTPStatus.NOT_FOUND,
ERROR_NOT_FOUND,
ServiceValidationError,
"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_SKILL: "smash",
},
HTTPStatus.UNAUTHORIZED,
ERROR_NOT_AUTHORIZED,
ServiceValidationError,
"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_SKILL: "smash",
},
HTTPStatus.BAD_REQUEST,
ERROR_BAD_REQUEST,
HomeAssistantError,
REQUEST_EXCEPTION_MSG,
),
],
)
@pytest.mark.usefixtures("mock_habitica")
async def test_cast_skill_exceptions(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
habitica: AsyncMock,
service_data: dict[str, Any],
http_status: HTTPStatus,
raise_exception: Exception,
expected_exception: Exception,
expected_exception_msg: str,
) -> None:
"""Test Habitica cast skill action exceptions."""
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/user/class/cast/smash?targetId=2f6fcabc-f670-4ec3-ba65-817e8deea490",
json={"success": True, "data": {}},
status=http_status,
)
habitica.cast_skill.side_effect = raise_exception
with pytest.raises(expected_exception, match=expected_exception_msg):
await hass.services.async_call(
DOMAIN,
@ -254,11 +256,9 @@ async def test_cast_skill_exceptions(
)
@pytest.mark.usefixtures("mock_habitica")
async def test_get_config_entry(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
) -> None:
"""Test Habitica config entry exceptions."""
@ -298,31 +298,24 @@ async def test_get_config_entry(
@pytest.mark.parametrize(
("service", "command"),
"service",
[
(SERVICE_ABORT_QUEST, "abort"),
(SERVICE_ACCEPT_QUEST, "accept"),
(SERVICE_CANCEL_QUEST, "cancel"),
(SERVICE_LEAVE_QUEST, "leave"),
(SERVICE_REJECT_QUEST, "reject"),
(SERVICE_START_QUEST, "force-start"),
SERVICE_ABORT_QUEST,
SERVICE_ACCEPT_QUEST,
SERVICE_CANCEL_QUEST,
SERVICE_LEAVE_QUEST,
SERVICE_REJECT_QUEST,
SERVICE_START_QUEST,
],
ids=[],
)
async def test_handle_quests(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
habitica: AsyncMock,
service: str,
command: str,
) -> None:
"""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(
DOMAIN,
service,
@ -331,63 +324,65 @@ async def test_handle_quests(
blocking=True,
)
assert mock_called_with(
mock_habitica,
"post",
f"{DEFAULT_URL}/api/v3/groups/party/quests/{command}",
)
getattr(habitica, service).assert_awaited_once()
@pytest.mark.parametrize(
(
"http_status",
"raise_exception",
"expected_exception",
"expected_exception_msg",
),
[
(
HTTPStatus.TOO_MANY_REQUESTS,
ServiceValidationError,
ERROR_TOO_MANY_REQUESTS,
HomeAssistantError,
RATE_LIMIT_EXCEPTION_MSG,
),
(
HTTPStatus.NOT_FOUND,
ERROR_NOT_FOUND,
ServiceValidationError,
"Unable to complete action, quest or group not found",
),
(
HTTPStatus.UNAUTHORIZED,
ERROR_NOT_AUTHORIZED,
ServiceValidationError,
"Action not allowed, only quest leader or group leader can perform this action",
),
(
HTTPStatus.BAD_REQUEST,
ERROR_BAD_REQUEST,
HomeAssistantError,
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(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
http_status: HTTPStatus,
habitica: AsyncMock,
raise_exception: Exception,
service: str,
expected_exception: Exception,
expected_exception_msg: str,
) -> None:
"""Test Habitica handle quests action exceptions."""
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/groups/party/quests/accept",
json={"success": True, "data": {}},
status=http_status,
)
getattr(habitica, service).side_effect = raise_exception
with pytest.raises(expected_exception, match=expected_exception_msg):
await hass.services.async_call(
DOMAIN,
SERVICE_ACCEPT_QUEST,
service,
service_data={ATTR_CONFIG_ENTRY: config_entry.entry_id},
return_response=True,
blocking=True,
@ -395,7 +390,7 @@ async def test_handle_quests_exceptions(
@pytest.mark.parametrize(
("service", "service_data", "task_id"),
("service", "service_data", "call_args"),
[
(
SERVICE_SCORE_HABIT,
@ -403,7 +398,10 @@ async def test_handle_quests_exceptions(
ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d",
ATTR_DIRECTION: "up",
},
"e97659e0-2c42-4599-a7bb-00282adc410d",
{
"task_id": UUID("e97659e0-2c42-4599-a7bb-00282adc410d"),
"direction": Direction.UP,
},
),
(
SERVICE_SCORE_HABIT,
@ -411,14 +409,20 @@ async def test_handle_quests_exceptions(
ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d",
ATTR_DIRECTION: "down",
},
"e97659e0-2c42-4599-a7bb-00282adc410d",
{
"task_id": UUID("e97659e0-2c42-4599-a7bb-00282adc410d"),
"direction": Direction.DOWN,
},
),
(
SERVICE_SCORE_REWARD,
{
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,
@ -426,7 +430,10 @@ async def test_handle_quests_exceptions(
ATTR_TASK: "Füge eine Aufgabe zu Habitica hinzu",
ATTR_DIRECTION: "up",
},
"e97659e0-2c42-4599-a7bb-00282adc410d",
{
"task_id": UUID("e97659e0-2c42-4599-a7bb-00282adc410d"),
"direction": Direction.UP,
},
),
(
SERVICE_SCORE_HABIT,
@ -434,7 +441,10 @@ async def test_handle_quests_exceptions(
ATTR_TASK: "create_a_task",
ATTR_DIRECTION: "up",
},
"e97659e0-2c42-4599-a7bb-00282adc410d",
{
"task_id": UUID("e97659e0-2c42-4599-a7bb-00282adc410d"),
"direction": Direction.UP,
},
),
],
ids=[
@ -448,18 +458,13 @@ async def test_handle_quests_exceptions(
async def test_score_task(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
habitica: AsyncMock,
service: str,
service_data: dict[str, Any],
task_id: str,
call_args: dict[str, Any],
) -> None:
"""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(
DOMAIN,
service,
@ -471,17 +476,13 @@ async def test_score_task(
blocking=True,
)
assert mock_called_with(
mock_habitica,
"post",
f"{DEFAULT_URL}/api/v3/tasks/{task_id}/score/{service_data.get(ATTR_DIRECTION, "up")}",
)
habitica.update_score.assert_awaited_once_with(**call_args)
@pytest.mark.parametrize(
(
"service_data",
"http_status",
"raise_exception",
"expected_exception",
"expected_exception_msg",
),
@ -491,7 +492,7 @@ async def test_score_task(
ATTR_TASK: "task does not exist",
ATTR_DIRECTION: "up",
},
HTTPStatus.OK,
None,
ServiceValidationError,
"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_DIRECTION: "up",
},
HTTPStatus.TOO_MANY_REQUESTS,
ServiceValidationError,
ERROR_TOO_MANY_REQUESTS,
HomeAssistantError,
RATE_LIMIT_EXCEPTION_MSG,
),
(
@ -509,7 +510,7 @@ async def test_score_task(
ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d",
ATTR_DIRECTION: "up",
},
HTTPStatus.BAD_REQUEST,
ERROR_BAD_REQUEST,
HomeAssistantError,
REQUEST_EXCEPTION_MSG,
),
@ -518,35 +519,24 @@ async def test_score_task(
ATTR_TASK: "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b",
ATTR_DIRECTION: "up",
},
HTTPStatus.UNAUTHORIZED,
ERROR_NOT_AUTHORIZED,
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(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
habitica: AsyncMock,
service_data: dict[str, Any],
http_status: HTTPStatus,
raise_exception: Exception,
expected_exception: Exception,
expected_exception_msg: str,
) -> None:
"""Test Habitica score task action exceptions."""
mock_habitica.post(
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,
)
habitica.update_score.side_effect = raise_exception
with pytest.raises(expected_exception, match=expected_exception_msg):
await hass.services.async_call(
DOMAIN,
@ -561,100 +551,119 @@ async def test_score_task_exceptions(
@pytest.mark.parametrize(
("service_data", "item", "target_id"),
("service_data", "call_args"),
[
(
{
ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303",
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_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_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_ITEM: "snowball",
},
"snowball",
"a380546a-94be-4b8e-8a0b-23e0d5c03303",
{
"skill": Skill.SNOWBALL,
"target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"),
},
),
(
{
ATTR_TARGET: "test-user",
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_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_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_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_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(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
habitica: AsyncMock,
service_data: dict[str, Any],
item: str,
target_id: str,
call_args: dict[str, Any],
) -> None:
"""Test Habitica user 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": {}},
)
"""Test Habitica use transformation item action."""
await hass.services.async_call(
DOMAIN,
@ -667,18 +676,14 @@ async def test_transformation(
blocking=True,
)
assert mock_called_with(
mock_habitica,
"post",
f"{DEFAULT_URL}/api/v3/user/class/cast/{item}?targetId={target_id}",
)
habitica.cast_skill.assert_awaited_once_with(**call_args)
@pytest.mark.parametrize(
(
"service_data",
"http_status_members",
"http_status_cast",
"raise_exception_members",
"raise_exception_cast",
"expected_exception",
"expected_exception_msg",
),
@ -688,8 +693,8 @@ async def test_transformation(
ATTR_TARGET: "user-not-found",
ATTR_ITEM: "spooky_sparkles",
},
HTTPStatus.OK,
HTTPStatus.OK,
None,
None,
ServiceValidationError,
"Unable to find target 'user-not-found' in your party",
),
@ -698,18 +703,8 @@ async def test_transformation(
ATTR_TARGET: "test-partymember-username",
ATTR_ITEM: "spooky_sparkles",
},
HTTPStatus.TOO_MANY_REQUESTS,
HTTPStatus.OK,
ServiceValidationError,
RATE_LIMIT_EXCEPTION_MSG,
),
(
{
ATTR_TARGET: "test-partymember-username",
ATTR_ITEM: "spooky_sparkles",
},
HTTPStatus.NOT_FOUND,
HTTPStatus.OK,
ERROR_NOT_FOUND,
None,
ServiceValidationError,
"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_ITEM: "spooky_sparkles",
},
HTTPStatus.BAD_REQUEST,
HTTPStatus.OK,
ERROR_BAD_REQUEST,
None,
HomeAssistantError,
"Unable to connect to Habitica, try again later",
),
@ -728,9 +723,9 @@ async def test_transformation(
ATTR_TARGET: "test-partymember-username",
ATTR_ITEM: "spooky_sparkles",
},
HTTPStatus.OK,
HTTPStatus.TOO_MANY_REQUESTS,
ServiceValidationError,
None,
ERROR_TOO_MANY_REQUESTS,
HomeAssistantError,
RATE_LIMIT_EXCEPTION_MSG,
),
(
@ -738,8 +733,8 @@ async def test_transformation(
ATTR_TARGET: "test-partymember-username",
ATTR_ITEM: "spooky_sparkles",
},
HTTPStatus.OK,
HTTPStatus.UNAUTHORIZED,
None,
ERROR_NOT_AUTHORIZED,
ServiceValidationError,
"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_ITEM: "spooky_sparkles",
},
HTTPStatus.OK,
HTTPStatus.BAD_REQUEST,
None,
ERROR_BAD_REQUEST,
HomeAssistantError,
"Unable to connect to Habitica, try again later",
),
],
)
@pytest.mark.usefixtures("mock_habitica")
async def test_transformation_exceptions(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
habitica: AsyncMock,
service_data: dict[str, Any],
http_status_members: HTTPStatus,
http_status_cast: HTTPStatus,
raise_exception_members: Exception,
raise_exception_cast: Exception,
expected_exception: Exception,
expected_exception_msg: str,
) -> None:
"""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):
await hass.services.async_call(
DOMAIN,

View File

@ -1,13 +1,11 @@
"""Tests for the Habitica switch platform."""
from collections.abc import Generator
from http import HTTPStatus
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.habitica.const import DEFAULT_URL
from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN,
SERVICE_TOGGLE,
@ -17,13 +15,12 @@ from homeassistant.components.switch import (
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID, Platform
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 .conftest import mock_called_with
from .conftest import ERROR_BAD_REQUEST, ERROR_TOO_MANY_REQUESTS
from tests.common import MockConfigEntry, snapshot_platform
from tests.test_util.aiohttp import AiohttpClientMocker
@pytest.fixture(autouse=True)
@ -36,7 +33,7 @@ def switch_only() -> Generator[None]:
yield
@pytest.mark.usefixtures("mock_habitica")
@pytest.mark.usefixtures("habitica")
async def test_switch(
hass: HomeAssistant,
config_entry: MockConfigEntry,
@ -66,14 +63,10 @@ async def test_turn_on_off_toggle(
hass: HomeAssistant,
config_entry: MockConfigEntry,
service_call: str,
mock_habitica: AiohttpClientMocker,
habitica: AsyncMock,
) -> None:
"""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)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
@ -87,7 +80,7 @@ async def test_turn_on_off_toggle(
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(
@ -99,19 +92,19 @@ async def test_turn_on_off_toggle(
],
)
@pytest.mark.parametrize(
("status_code", "exception"),
("raise_exception", "expected_exception"),
[
(HTTPStatus.TOO_MANY_REQUESTS, ServiceValidationError),
(HTTPStatus.BAD_REQUEST, HomeAssistantError),
(ERROR_TOO_MANY_REQUESTS, HomeAssistantError),
(ERROR_BAD_REQUEST, HomeAssistantError),
],
)
async def test_turn_on_off_toggle_exceptions(
hass: HomeAssistant,
config_entry: MockConfigEntry,
service_call: str,
mock_habitica: AiohttpClientMocker,
status_code: HTTPStatus,
exception: Exception,
habitica: AsyncMock,
raise_exception: Exception,
expected_exception: Exception,
) -> None:
"""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
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/user/sleep",
status=status_code,
json={"success": True, "data": False},
)
habitica.toggle_sleep.side_effect = raise_exception
with pytest.raises(expected_exception=exception):
with pytest.raises(expected_exception=expected_exception):
await hass.services.async_call(
SWITCH_DOMAIN,
service_call,
{ATTR_ENTITY_ID: "switch.test_user_rest_in_the_inn"},
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."""
from collections.abc import Generator
from http import HTTPStatus
import json
import re
from unittest.mock import patch
from datetime import date
from typing import Any
from unittest.mock import AsyncMock, patch
from uuid import UUID
from habiticalib import Direction, HabiticaTasksResponse, Task, TaskType
import pytest
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 (
ATTR_DESCRIPTION,
ATTR_DUE_DATE,
@ -25,15 +26,14 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_registry as er
from .conftest import mock_called_with
from .conftest import ERROR_NOT_FOUND
from tests.common import (
MockConfigEntry,
async_get_persistent_notifications,
load_json_object_fixture,
load_fixture,
snapshot_platform,
)
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import WebSocketGenerator
@ -47,7 +47,7 @@ def todo_only() -> Generator[None]:
yield
@pytest.mark.usefixtures("mock_habitica")
@pytest.mark.usefixtures("habitica")
async def test_todos(
hass: HomeAssistant,
config_entry: MockConfigEntry,
@ -72,7 +72,7 @@ async def test_todos(
"todo.test_user_dailies",
],
)
@pytest.mark.usefixtures("mock_habitica")
@pytest.mark.usefixtures("habitica")
async def test_todo_items(
hass: HomeAssistant,
config_entry: MockConfigEntry,
@ -111,7 +111,7 @@ async def test_todo_items(
async def test_complete_todo_item(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
habitica: AsyncMock,
snapshot: SnapshotAssertion,
entity_id: str,
uid: str,
@ -124,10 +124,6 @@ async def test_complete_todo_item(
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(
TODO_DOMAIN,
TodoServices.UPDATE_ITEM,
@ -136,9 +132,7 @@ async def test_complete_todo_item(
blocking=True,
)
assert mock_called_with(
mock_habitica, "post", f"{DEFAULT_URL}/api/v3/tasks/{uid}/score/up"
)
habitica.update_score.assert_awaited_once_with(UUID(uid), Direction.UP)
# Test notification for item drop
notifications = async_get_persistent_notifications(hass)
@ -158,7 +152,7 @@ async def test_complete_todo_item(
async def test_uncomplete_todo_item(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
habitica: AsyncMock,
entity_id: str,
uid: str,
) -> None:
@ -170,10 +164,6 @@ async def test_uncomplete_todo_item(
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(
TODO_DOMAIN,
TodoServices.UPDATE_ITEM,
@ -182,9 +172,7 @@ async def test_uncomplete_todo_item(
blocking=True,
)
assert mock_called_with(
mock_habitica, "post", f"{DEFAULT_URL}/api/v3/tasks/{uid}/score/down"
)
habitica.update_score.assert_called_once_with(UUID(uid), Direction.DOWN)
@pytest.mark.parametrize(
@ -198,7 +186,7 @@ async def test_uncomplete_todo_item(
async def test_complete_todo_item_exception(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
habitica: AsyncMock,
uid: str,
status: str,
) -> None:
@ -210,10 +198,7 @@ async def test_complete_todo_item_exception(
assert config_entry.state is ConfigEntryState.LOADED
mock_habitica.post(
re.compile(f"{DEFAULT_URL}/api/v3/tasks/{uid}/score/.+"),
status=HTTPStatus.NOT_FOUND,
)
habitica.update_score.side_effect = ERROR_NOT_FOUND
with pytest.raises(
expected_exception=ServiceValidationError,
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(
("entity_id", "uid", "date"),
("entity_id", "service_data", "call_args"),
[
(
"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",
"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(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
habitica: AsyncMock,
entity_id: str,
uid: str,
date: str,
service_data: dict[str, Any],
call_args: tuple[UUID, Task],
) -> 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)
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
mock_habitica.put(
f"{DEFAULT_URL}/api/v3/tasks/{uid}",
json={"data": {}, "success": True},
)
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.UPDATE_ITEM,
{
ATTR_ITEM: uid,
ATTR_RENAME: "test-summary",
ATTR_DESCRIPTION: "test-description",
ATTR_DUE_DATE: date,
},
service_data,
target={ATTR_ENTITY_ID: entity_id},
blocking=True,
)
mock_call = mock_called_with(
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",
}
habitica.update_task.assert_awaited_once_with(*call_args)
async def test_update_todo_item_exception(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
habitica: AsyncMock,
) -> None:
"""Test exception when update item on the todo list."""
uid = "88de7cd9-af2b-49ce-9afd-bf941d87336b"
@ -300,10 +324,7 @@ async def test_update_todo_item_exception(
assert config_entry.state is ConfigEntryState.LOADED
mock_habitica.put(
f"{DEFAULT_URL}/api/v3/tasks/{uid}",
status=HTTPStatus.NOT_FOUND,
)
habitica.update_task.side_effect = ERROR_NOT_FOUND
with pytest.raises(
expected_exception=ServiceValidationError,
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(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
habitica: AsyncMock,
) -> None:
"""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
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/tasks/user",
json={"data": {}, "success": True},
status=HTTPStatus.CREATED,
)
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.ADD_ITEM,
@ -352,24 +368,20 @@ async def test_add_todo_item(
blocking=True,
)
mock_call = mock_called_with(
mock_habitica,
"post",
f"{DEFAULT_URL}/api/v3/tasks/user",
habitica.create_task.assert_awaited_once_with(
Task(
date=date(2024, 7, 30),
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(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
habitica: AsyncMock,
) -> None:
"""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
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/tasks/user",
status=HTTPStatus.NOT_FOUND,
)
habitica.create_task.side_effect = ERROR_NOT_FOUND
with pytest.raises(
expected_exception=ServiceValidationError,
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(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
habitica: AsyncMock,
) -> None:
"""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
mock_habitica.delete(
f"{DEFAULT_URL}/api/v3/tasks/{uid}",
json={"data": {}, "success": True},
)
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.REMOVE_ITEM,
@ -426,15 +431,13 @@ async def test_delete_todo_item(
blocking=True,
)
assert mock_called_with(
mock_habitica, "delete", f"{DEFAULT_URL}/api/v3/tasks/{uid}"
)
habitica.delete_task.assert_awaited_once_with(UUID(uid))
async def test_delete_todo_item_exception(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
habitica: AsyncMock,
) -> None:
"""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
mock_habitica.delete(
f"{DEFAULT_URL}/api/v3/tasks/{uid}",
status=HTTPStatus.NOT_FOUND,
)
habitica.delete_task.side_effect = ERROR_NOT_FOUND
with pytest.raises(
expected_exception=ServiceValidationError,
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(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
habitica: AsyncMock,
) -> None:
"""Test deleting completed todo items from the todo list."""
config_entry.add_to_hass(hass)
@ -474,10 +475,6 @@ async def test_delete_completed_todo_items(
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(
TODO_DOMAIN,
TodoServices.REMOVE_COMPLETED_ITEMS,
@ -486,15 +483,13 @@ async def test_delete_completed_todo_items(
blocking=True,
)
assert mock_called_with(
mock_habitica, "post", f"{DEFAULT_URL}/api/v3/tasks/clearCompletedTodos"
)
habitica.delete_completed_todos.assert_awaited_once()
async def test_delete_completed_todo_items_exception(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
habitica: AsyncMock,
) -> None:
"""Test exception when deleting completed todo items from the todo list."""
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
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/tasks/clearCompletedTodos",
status=HTTPStatus.NOT_FOUND,
)
habitica.delete_completed_todos.side_effect = ERROR_NOT_FOUND
with pytest.raises(
expected_exception=ServiceValidationError,
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(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
habitica: AsyncMock,
hass_ws_client: WebSocketGenerator,
entity_id: str,
uid: str,
@ -553,12 +545,6 @@ async def test_move_todo_item(
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()
# move to second position
data = {
@ -572,6 +558,9 @@ async def test_move_todo_item(
resp = await client.receive_json()
assert resp.get("success")
habitica.reorder_task.assert_awaited_once_with(UUID(uid), 1)
habitica.reorder_task.reset_mock()
# move to top position
data = {
"id": id,
@ -583,18 +572,13 @@ async def test_move_todo_item(
resp = await client.receive_json()
assert resp.get("success")
for pos in (0, 1):
assert mock_called_with(
mock_habitica,
"post",
f"{DEFAULT_URL}/api/v3/tasks/{uid}/move/to/{pos}",
)
habitica.reorder_task.assert_awaited_once_with(UUID(uid), 0)
async def test_move_todo_item_exception(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_habitica: AiohttpClientMocker,
habitica: AsyncMock,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test exception when moving todo item."""
@ -606,11 +590,7 @@ async def test_move_todo_item_exception(
assert config_entry.state is ConfigEntryState.LOADED
mock_habitica.post(
f"{DEFAULT_URL}/api/v3/tasks/{uid}/move/to/0",
status=HTTPStatus.NOT_FOUND,
)
habitica.reorder_task.side_effect = ERROR_NOT_FOUND
client = await hass_ws_client()
data = {
@ -620,8 +600,15 @@ async def test_move_todo_item_exception(
"uid": uid,
}
await client.send_json_auto_id(data)
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(
@ -651,31 +638,18 @@ async def test_move_todo_item_exception(
async def test_next_due_date(
hass: HomeAssistant,
fixture: str,
calculated_due_date: tuple | None,
calculated_due_date: str | None,
config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
habitica: AsyncMock,
) -> None:
"""Test next_due_date calculation."""
dailies_entity = "todo.test_user_dailies"
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={"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),
)
habitica.get_tasks.side_effect = [
HabiticaTasksResponse.from_json(load_fixture(fixture, DOMAIN)),
HabiticaTasksResponse.from_dict({"success": True, "data": []}),
]
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)