Add control entities: box to-do list, skip switch, donate button, subscriptions
The to-do list exists so Home Assistant's own conversation agent can manage the order through HassListAddItem rather than through anything bespoke. Skip is a switch because skipped/not-skipped is state worth reading back; donate is a button because it cannot be undone. Un-skip raises rather than guessing at an endpoint that has never been observed. Fixes subscription parsing, which matched the heading row and so reported zero on an account that has one.
This commit is contained in:
@@ -18,6 +18,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
site, refreshing the matrix in README and opening an issue on drift.
|
||||
- `FreshHarvestClient.async_fetch`, restored as the shared authenticated-fetch
|
||||
primitive that both the snapshot read and every write action build on.
|
||||
- `todo.fresh_harvest_box`: the order as a to-do list, so Home Assistant's own
|
||||
conversation agent can add and remove items with no bespoke voice code.
|
||||
Adding resolves the name against the site's search index first.
|
||||
- `switch.fresh_harvest_skip_next_order` (a switch, not a button, so the state
|
||||
is readable and reversible) and `button.fresh_harvest_donate_next_order`
|
||||
(a button, because donating cannot be undone).
|
||||
- `sensor.fresh_harvest_subscriptions` and `sensor.fresh_harvest_vacation_holds`,
|
||||
each listing the detail in attributes.
|
||||
- A `freshharvest_action` event fired after every write action, carrying
|
||||
action/success/target/detail so automations can notify on the outcome.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Subscription parsing matched `.account-item-multi-fields`, which is the
|
||||
heading row, so an account with a live subscription reported zero. Cells are
|
||||
now picked by semantic class. Regression covered in `tests/test_actions.py`.
|
||||
|
||||
### Known gaps
|
||||
|
||||
- Un-skip raises rather than guessing: the portal's restore control only
|
||||
appears once an order is skipped, so its endpoint has never been observed.
|
||||
- Entities are deliberately NOT exposed to the conversation agent yet.
|
||||
|
||||
### Notes
|
||||
|
||||
|
||||
@@ -10,7 +10,13 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from .api import FreshHarvestClient
|
||||
from .coordinator import FreshHarvestCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.TODO,
|
||||
]
|
||||
|
||||
type FreshHarvestConfigEntry = ConfigEntry[FreshHarvestCoordinator]
|
||||
|
||||
|
||||
@@ -112,25 +112,39 @@ def _find_form(soup: BeautifulSoup, action: str):
|
||||
|
||||
|
||||
def parse_subscriptions(html: str) -> list[Subscription]:
|
||||
"""Read /p/dashboard/manage-subscriptions."""
|
||||
"""Read /p/dashboard/manage-subscriptions.
|
||||
|
||||
Cells are picked by their semantic class rather than column position:
|
||||
`.account-item-multi-fields` is the HEADING row, and matching on it
|
||||
silently yields zero subscriptions on an account that has some.
|
||||
"""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
account = soup.select_one(".account")
|
||||
if account is None:
|
||||
return []
|
||||
|
||||
def cell(row, *classes) -> str | None:
|
||||
for cls in classes:
|
||||
found = row.select_one(f".account-item-text.{cls}")
|
||||
if found is not None:
|
||||
text = found.get_text(" ", strip=True)
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
subs: list[Subscription] = []
|
||||
for row in account.select(".account-item-multi-fields"):
|
||||
cells = [c.get_text(" ", strip=True) for c in row.select(".account-item-text")]
|
||||
cells = [c for c in cells if c]
|
||||
if len(cells) < 2:
|
||||
for row in account.select(".account-item-container"):
|
||||
name = cell(row, "account-item-description")
|
||||
if not name:
|
||||
continue
|
||||
qty = cells[0]
|
||||
qty = cell(row, "account-item-history-qty")
|
||||
subs.append(
|
||||
Subscription(
|
||||
name=cells[1],
|
||||
quantity=int(qty) if qty.isdigit() else None,
|
||||
arriving=cells[2] if len(cells) > 2 else None,
|
||||
partner=cells[3] if len(cells) > 3 else None,
|
||||
frequency=cells[4] if len(cells) > 4 else None,
|
||||
name=name,
|
||||
quantity=int(qty) if (qty or "").isdigit() else None,
|
||||
arriving=cell(row, "account-item-history"),
|
||||
partner=cell(row, "account-item-history-vendor"),
|
||||
frequency=cell(row, "center"),
|
||||
)
|
||||
)
|
||||
return subs
|
||||
|
||||
@@ -135,6 +135,10 @@ class AccountSnapshot:
|
||||
# have not reached it, so it is read once and applied to every order.
|
||||
free_delivery_threshold: float | None = None
|
||||
orders: list[DeliveryOrder] = field(default_factory=list)
|
||||
# Populated by the coordinator from separate pages. Typed loosely because
|
||||
# actions.py imports this module, so it cannot be imported back from here.
|
||||
subscriptions: list = field(default_factory=list)
|
||||
vacation_holds: list = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def next_order(self) -> DeliveryOrder | None:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""One-way delivery actions.
|
||||
|
||||
Donating is a button rather than a switch because it genuinely cannot be undone
|
||||
from the portal — there is no state to toggle back.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import FreshHarvestConfigEntry
|
||||
from .api import FreshHarvestError
|
||||
from .const import DOMAIN, EVENT_ACTION
|
||||
from .coordinator import FreshHarvestCoordinator
|
||||
from .entity import FreshHarvestEntity
|
||||
|
||||
DESCRIPTION = ButtonEntityDescription(
|
||||
key="donate_open_order",
|
||||
translation_key="donate_open_order",
|
||||
icon="mdi:hand-heart",
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FreshHarvestConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the button platform."""
|
||||
async_add_entities([FreshHarvestDonate(entry.runtime_data, entry)])
|
||||
|
||||
|
||||
class FreshHarvestDonate(FreshHarvestEntity, ButtonEntity):
|
||||
"""Donate the upcoming box to Share the Harvest."""
|
||||
|
||||
entity_description = DESCRIPTION
|
||||
|
||||
def __init__(
|
||||
self, coordinator: FreshHarvestCoordinator, entry: FreshHarvestConfigEntry
|
||||
) -> None:
|
||||
super().__init__(coordinator, entry, DESCRIPTION.key)
|
||||
self._entry = entry
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return super().available and self.coordinator.data.open_order is not None
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Donate. Not reversible."""
|
||||
try:
|
||||
result = await self.coordinator.actions.async_donate(dry_run=False)
|
||||
except FreshHarvestError as err:
|
||||
self._fire(False, str(err))
|
||||
raise HomeAssistantError(f"could not donate: {err}") from err
|
||||
self._fire(True, result.detail)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
def _fire(self, ok: bool, detail: str) -> None:
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_ACTION,
|
||||
{"domain": DOMAIN, "entry_id": self._entry.entry_id,
|
||||
"action": "donate", "success": ok, "target": "open order",
|
||||
"detail": detail},
|
||||
)
|
||||
@@ -10,3 +10,7 @@ DOMAIN = "freshharvest"
|
||||
# schedules change on the order of days, and the customization cutoff is the
|
||||
# only time-sensitive value.
|
||||
UPDATE_INTERVAL = timedelta(hours=6)
|
||||
|
||||
# Fired after every write action so automations can notify on success or
|
||||
# failure. Data: domain, entry_id, action, success, target, detail.
|
||||
EVENT_ACTION = "freshharvest_action"
|
||||
|
||||
@@ -9,6 +9,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .actions import FreshHarvestActions
|
||||
from .api import (
|
||||
AccountSnapshot,
|
||||
FreshHarvestAuthError,
|
||||
@@ -34,10 +35,15 @@ class FreshHarvestCoordinator(DataUpdateCoordinator[AccountSnapshot]):
|
||||
config_entry=entry,
|
||||
)
|
||||
self.client = client
|
||||
self.actions = FreshHarvestActions(client)
|
||||
|
||||
async def _async_update_data(self) -> AccountSnapshot:
|
||||
try:
|
||||
return await self.client.async_get_snapshot()
|
||||
snapshot = await self.client.async_get_snapshot()
|
||||
# Two extra pages per refresh, so three requests every six hours.
|
||||
snapshot.subscriptions = await self.actions.async_list_subscriptions()
|
||||
snapshot.vacation_holds = await self.actions.async_list_vacation_holds()
|
||||
return snapshot
|
||||
except FreshHarvestAuthError as err:
|
||||
raise ConfigEntryAuthFailed(str(err)) from err
|
||||
except FreshHarvestError as err:
|
||||
|
||||
@@ -93,6 +93,38 @@ SENSORS: tuple[FreshHarvestSensorDescription, ...] = (
|
||||
"free_delivery_threshold": s.free_delivery_threshold,
|
||||
},
|
||||
),
|
||||
FreshHarvestSensorDescription(
|
||||
key="subscriptions",
|
||||
translation_key="subscriptions",
|
||||
scope="account",
|
||||
icon="mdi:autorenew",
|
||||
native_unit_of_measurement="items",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda s: len(s.subscriptions),
|
||||
attrs_fn=lambda s: {
|
||||
"items": [
|
||||
" ".join(
|
||||
p for p in (str(sub.quantity or ""), sub.name,
|
||||
f"({sub.frequency})" if sub.frequency else "")
|
||||
if p
|
||||
)
|
||||
for sub in s.subscriptions
|
||||
],
|
||||
"partners": sorted({sub.partner for sub in s.subscriptions if sub.partner}),
|
||||
},
|
||||
),
|
||||
FreshHarvestSensorDescription(
|
||||
key="vacation_holds",
|
||||
translation_key="vacation_holds",
|
||||
scope="account",
|
||||
icon="mdi:airplane",
|
||||
native_unit_of_measurement="holds",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda s: len(s.vacation_holds),
|
||||
attrs_fn=lambda s: {
|
||||
"ranges": [f"{h.start} to {h.end}" for h in s.vacation_holds]
|
||||
},
|
||||
),
|
||||
FreshHarvestSensorDescription(
|
||||
key="delivery_day",
|
||||
translation_key="delivery_day",
|
||||
|
||||
@@ -57,12 +57,33 @@
|
||||
},
|
||||
"shop_window": {
|
||||
"name": "Shopping window"
|
||||
},
|
||||
"subscriptions": {
|
||||
"name": "Subscriptions"
|
||||
},
|
||||
"vacation_holds": {
|
||||
"name": "Vacation holds"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
"order_open": {
|
||||
"name": "Order open"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"skip_open_order": {
|
||||
"name": "Skip next order"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"donate_open_order": {
|
||||
"name": "Donate next order"
|
||||
}
|
||||
},
|
||||
"todo": {
|
||||
"box": {
|
||||
"name": "Box"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Skip control for the upcoming delivery.
|
||||
|
||||
A switch rather than a button: skipped/not-skipped is state the conversation
|
||||
agent can read back and reverse, where a button would be fire-and-forget.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import FreshHarvestConfigEntry
|
||||
from .api import FreshHarvestError
|
||||
from .const import DOMAIN, EVENT_ACTION
|
||||
from .coordinator import FreshHarvestCoordinator
|
||||
from .entity import FreshHarvestEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DESCRIPTION = SwitchEntityDescription(
|
||||
key="skip_open_order",
|
||||
translation_key="skip_open_order",
|
||||
icon="mdi:calendar-remove",
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FreshHarvestConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the switch platform."""
|
||||
async_add_entities([FreshHarvestSkip(entry.runtime_data, entry)])
|
||||
|
||||
|
||||
class FreshHarvestSkip(FreshHarvestEntity, SwitchEntity):
|
||||
"""On means the next changeable delivery is skipped."""
|
||||
|
||||
entity_description = DESCRIPTION
|
||||
|
||||
def __init__(
|
||||
self, coordinator: FreshHarvestCoordinator, entry: FreshHarvestConfigEntry
|
||||
) -> None:
|
||||
super().__init__(coordinator, entry, DESCRIPTION.key)
|
||||
self._entry = entry
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Only meaningful while there is an order that can still be changed."""
|
||||
return super().available and self.coordinator.data.open_order is not None
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""A skipped order stops offering a shopping window."""
|
||||
order = self.coordinator.data.open_order
|
||||
return order is not None and not order.is_open
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Skip the next changeable delivery."""
|
||||
order = self.coordinator.data.open_order
|
||||
if order is None or order.delivery_date is None:
|
||||
raise HomeAssistantError("no delivery is currently skippable")
|
||||
try:
|
||||
result = await self.coordinator.actions.async_skip(
|
||||
order.delivery_date, dry_run=False
|
||||
)
|
||||
except FreshHarvestError as err:
|
||||
self._fire("skip", False, str(order.delivery_date), str(err))
|
||||
raise HomeAssistantError(f"could not skip: {err}") from err
|
||||
self._fire("skip", True, str(order.delivery_date), result.detail)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Un-skip — deliberately not implemented.
|
||||
|
||||
The portal's restore control only appears once an order is already
|
||||
skipped, so its endpoint has never been observed. Guessing at the way
|
||||
back from a skip is exactly the kind of thing that should fail loudly
|
||||
rather than post something plausible at a real order.
|
||||
"""
|
||||
raise HomeAssistantError(
|
||||
"un-skipping is not supported yet: the restore endpoint has not "
|
||||
"been confirmed. Restore it on freshharvest.com for now."
|
||||
)
|
||||
|
||||
def _fire(self, action: str, ok: bool, target: str, detail: str) -> None:
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_ACTION,
|
||||
{"domain": DOMAIN, "entry_id": self._entry.entry_id, "action": action,
|
||||
"success": ok, "target": target, "detail": detail},
|
||||
)
|
||||
@@ -0,0 +1,172 @@
|
||||
"""The upcoming box as a to-do list.
|
||||
|
||||
This exists so Home Assistant's own conversation agent can manage the order with
|
||||
no bespoke voice work: the built-in assistant already knows how to add and
|
||||
remove to-do items, so "add bananas to my Fresh Harvest box" routes through
|
||||
`HassListAddItem` and lands here.
|
||||
|
||||
Adding an item resolves the spoken name against the site's own search index,
|
||||
then adds the match to the open order. Removing one takes it back out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import urllib.parse
|
||||
|
||||
from homeassistant.components.todo import (
|
||||
TodoItem,
|
||||
TodoItemStatus,
|
||||
TodoListEntity,
|
||||
TodoListEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import FreshHarvestConfigEntry
|
||||
from .api import FreshHarvestError
|
||||
from .const import DOMAIN, EVENT_ACTION
|
||||
from .coordinator import FreshHarvestCoordinator
|
||||
from .entity import FreshHarvestEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ALGOLIA_JS = "/_home/JavaScript/search_algolia.js"
|
||||
_CREDS_RE = re.compile(r'algoliasearch\(\s*"([^"]+)"\s*,\s*"([^"]+)"', re.S)
|
||||
_INDEX_RE = re.compile(r'indexName:\s*"([^"]+)"')
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FreshHarvestConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the to-do platform."""
|
||||
async_add_entities([FreshHarvestBox(entry.runtime_data, entry)])
|
||||
|
||||
|
||||
class FreshHarvestBox(FreshHarvestEntity, TodoListEntity):
|
||||
"""Everything in the upcoming delivery, as a list."""
|
||||
|
||||
_attr_translation_key = "box"
|
||||
_attr_supported_features = (
|
||||
TodoListEntityFeature.CREATE_TODO_ITEM
|
||||
| TodoListEntityFeature.DELETE_TODO_ITEM
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self, coordinator: FreshHarvestCoordinator, entry: FreshHarvestConfigEntry
|
||||
) -> None:
|
||||
super().__init__(coordinator, entry, "box")
|
||||
self._entry = entry
|
||||
|
||||
@property
|
||||
def todo_items(self) -> list[TodoItem] | None:
|
||||
"""The open order's contents, or the next delivery's once it locks."""
|
||||
snapshot = self.coordinator.data
|
||||
order = snapshot.open_order or snapshot.next_order
|
||||
if order is None:
|
||||
return None
|
||||
items: list[TodoItem] = []
|
||||
for item in order.all_items:
|
||||
label = " ".join(
|
||||
p for p in (str(item.quantity or ""), item.name, item.unit) if p
|
||||
)
|
||||
if item.price is not None:
|
||||
label = f"{label} — ${item.price:,.2f}"
|
||||
items.append(
|
||||
TodoItem(
|
||||
summary=label,
|
||||
uid=item.name,
|
||||
# The box is a standing order, not a checklist: nothing here
|
||||
# is ever "done", it either is or is not in the delivery.
|
||||
status=TodoItemStatus.NEEDS_ACTION,
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
async def _algolia_lookup(self, query: str) -> tuple[str, str]:
|
||||
"""Resolve a spoken name to (item_id, item_name) via the site's index.
|
||||
|
||||
Credentials are read from the site's own JS rather than hardcoded, so a
|
||||
key rotation fixes itself and a third party's key never enters this repo.
|
||||
"""
|
||||
client = self.coordinator.client
|
||||
js = await client.async_fetch(ALGOLIA_JS)
|
||||
creds, index = _CREDS_RE.search(js), _INDEX_RE.search(js)
|
||||
if not (creds and index):
|
||||
raise HomeAssistantError(
|
||||
"could not read the catalogue configuration from the site"
|
||||
)
|
||||
app, key = creds.groups()
|
||||
payload = json.dumps(
|
||||
{"params": urllib.parse.urlencode({"query": query, "hitsPerPage": 5})}
|
||||
).encode()
|
||||
session = client._session # noqa: SLF001 — same package
|
||||
async with session.post(
|
||||
f"https://{app}-dsn.algolia.net/1/indexes/{index.group(1)}/query",
|
||||
data=payload,
|
||||
headers={
|
||||
"X-Algolia-API-Key": key,
|
||||
"X-Algolia-Application-Id": app,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
hits = (await resp.json()).get("hits") or []
|
||||
if not hits:
|
||||
raise HomeAssistantError(f"nothing in the catalogue matches {query!r}")
|
||||
return str(hits[0]["ID"]), str(hits[0].get("Name") or query)
|
||||
|
||||
async def async_create_todo_item(self, item: TodoItem) -> None:
|
||||
"""Add an item to the order.
|
||||
|
||||
The site only renders an add control for something orderable right now,
|
||||
so an out-of-stock item fails here rather than silently doing nothing.
|
||||
"""
|
||||
query = (item.summary or "").strip()
|
||||
if not query:
|
||||
raise HomeAssistantError("no item name given")
|
||||
|
||||
item_id, name = await self._algolia_lookup(query)
|
||||
try:
|
||||
result = await self.coordinator.actions.async_add_item(
|
||||
item_id, dry_run=False
|
||||
)
|
||||
except FreshHarvestError as err:
|
||||
self._fire(EVENT_ACTION, "add_item", False, query, str(err))
|
||||
raise HomeAssistantError(f"could not add {name}: {err}") from err
|
||||
|
||||
self._fire(EVENT_ACTION, "add_item", True, name, result.detail)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_delete_todo_items(self, uids: list[str]) -> None:
|
||||
"""Take items back out of the order."""
|
||||
for uid in uids:
|
||||
item_id, name = await self._algolia_lookup(uid)
|
||||
try:
|
||||
await self.coordinator.actions.async_remove_item(
|
||||
item_id, dry_run=False
|
||||
)
|
||||
except FreshHarvestError as err:
|
||||
self._fire(EVENT_ACTION, "remove_item", False, uid, str(err))
|
||||
raise HomeAssistantError(f"could not remove {name}: {err}") from err
|
||||
self._fire(EVENT_ACTION, "remove_item", True, name, "removed")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
def _fire(self, event: str, action: str, ok: bool, target: str, detail: str) -> None:
|
||||
"""Announce the outcome so automations can notify on it."""
|
||||
self.hass.bus.async_fire(
|
||||
event,
|
||||
{
|
||||
"domain": DOMAIN,
|
||||
"entry_id": self._entry.entry_id,
|
||||
"action": action,
|
||||
"success": ok,
|
||||
"target": target,
|
||||
"detail": detail,
|
||||
},
|
||||
)
|
||||
@@ -57,12 +57,33 @@
|
||||
},
|
||||
"shop_window": {
|
||||
"name": "Shopping window"
|
||||
},
|
||||
"subscriptions": {
|
||||
"name": "Subscriptions"
|
||||
},
|
||||
"vacation_holds": {
|
||||
"name": "Vacation holds"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
"order_open": {
|
||||
"name": "Order open"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"skip_open_order": {
|
||||
"name": "Skip next order"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"donate_open_order": {
|
||||
"name": "Donate next order"
|
||||
}
|
||||
},
|
||||
"todo": {
|
||||
"box": {
|
||||
"name": "Box"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Tests for the subscription and vacation-hold parsers.
|
||||
|
||||
Written after a real bug: the first version matched `.account-item-multi-fields`,
|
||||
which is the HEADING row, so an account with a live subscription reported zero.
|
||||
A count of nothing looks exactly like an account with nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
COMPONENT = Path(__file__).parent.parent / "custom_components"
|
||||
|
||||
|
||||
def _load():
|
||||
if "aiohttp" not in sys.modules:
|
||||
stub = types.ModuleType("aiohttp")
|
||||
stub.ClientSession = object
|
||||
stub.ClientError = Exception
|
||||
sys.modules["aiohttp"] = stub
|
||||
sys.path.insert(0, str(COMPONENT))
|
||||
pkg = types.ModuleType("fh_pkg")
|
||||
pkg.__path__ = [str(COMPONENT / "freshharvest")]
|
||||
sys.modules["fh_pkg"] = pkg
|
||||
return importlib.import_module("fh_pkg.actions")
|
||||
|
||||
|
||||
actions = _load()
|
||||
|
||||
SUBS_HTML = """
|
||||
<div class='account'>
|
||||
<div class='account-item'>
|
||||
<div class='account-item-multi-fields'>
|
||||
<div class='account-item-heading account-item-history-qty'>Qty</div>
|
||||
<div class='account-item-heading account-item-description'>Item</div>
|
||||
<div class='account-item-heading account-item-history'>Arriving</div>
|
||||
<div class='account-item-heading account-item-history-vendor'>Partner</div>
|
||||
<div class='account-item-heading account-item-history center'>Frequency</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='account-item'>
|
||||
<div class='account-item-container'>
|
||||
<div class='account-item-text account-item-history-qty'><div class='center'>2</div></div>
|
||||
<div class='account-item-text account-item-description'>Georgia Grown Small Box</div>
|
||||
<div class='account-item-text account-item-history'>tomorrow</div>
|
||||
<div class='account-item-text account-item-history-vendor'>Various Partners</div>
|
||||
<div class='account-item-text account-item-history center'>Weekly</div>
|
||||
<div class='account-item-action account-item-history-action right'>Change Basket</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def test_heading_row_is_not_a_subscription():
|
||||
"""The regression: the header row must not be counted, and must not hide the real one."""
|
||||
subs = actions.parse_subscriptions(SUBS_HTML)
|
||||
assert len(subs) == 1
|
||||
assert subs[0].name == "Georgia Grown Small Box"
|
||||
|
||||
|
||||
def test_subscription_fields():
|
||||
sub = actions.parse_subscriptions(SUBS_HTML)[0]
|
||||
assert (sub.quantity, sub.frequency) == (2, "Weekly")
|
||||
assert sub.partner == "Various Partners"
|
||||
assert sub.arriving == "tomorrow"
|
||||
|
||||
|
||||
def test_no_subscriptions_is_empty_not_an_error():
|
||||
assert actions.parse_subscriptions("<html><body>nothing</body></html>") == []
|
||||
|
||||
|
||||
def test_vacation_holds_need_two_dates():
|
||||
html = "<div class='account-item-container'>2026-09-01 to 2026-09-14</div>"
|
||||
holds = actions.parse_vacation_holds(html)
|
||||
assert len(holds) == 1 and holds[0].start == "2026-09-01"
|
||||
assert holds[0].end == "2026-09-14"
|
||||
assert actions.parse_vacation_holds("<div class='account-item-container'>none</div>") == []
|
||||
|
||||
|
||||
def test_frequency_names_map_to_site_values():
|
||||
assert actions.FREQUENCIES["weekly"] == "1"
|
||||
# id='FrequencyID' but the POST field is popup-toggle; posting the id does nothing.
|
||||
assert actions.FREQUENCY_FIELD == "popup-toggle"
|
||||
@@ -13,7 +13,12 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
COMPONENT = Path(__file__).parent.parent / "custom_components" / "freshharvest"
|
||||
PLATFORMS = {"sensor": "sensor.py", "binary_sensor": "binary_sensor.py"}
|
||||
PLATFORMS = {
|
||||
"sensor": "sensor.py",
|
||||
"binary_sensor": "binary_sensor.py",
|
||||
"switch": "switch.py",
|
||||
"button": "button.py",
|
||||
}
|
||||
|
||||
|
||||
def declared_keys(filename: str) -> set[str]:
|
||||
@@ -29,6 +34,22 @@ def declared_keys(filename: str) -> set[str]:
|
||||
}
|
||||
|
||||
|
||||
def attr_keys(filename: str) -> set[str]:
|
||||
"""Collect `_attr_translation_key = "..."` assignments (entities without a
|
||||
description object declare their name this way)."""
|
||||
tree = ast.parse((COMPONENT / filename).read_text(encoding="utf-8"))
|
||||
return {
|
||||
node.value.value
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Assign)
|
||||
and any(
|
||||
getattr(t, "id", getattr(t, "attr", None)) == "_attr_translation_key"
|
||||
for t in node.targets
|
||||
)
|
||||
and isinstance(node.value, ast.Constant)
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(name="strings")
|
||||
def strings_fixture() -> dict:
|
||||
return json.loads((COMPONENT / "strings.json").read_text(encoding="utf-8"))
|
||||
@@ -64,3 +85,8 @@ def test_manifest_is_well_formed():
|
||||
assert manifest[key].startswith("https://github.com/"), (
|
||||
f"{key} must be a public URL, got {manifest[key]}"
|
||||
)
|
||||
|
||||
|
||||
def test_todo_entity_is_named(strings):
|
||||
"""todo.py declares its name via _attr_translation_key, not a description."""
|
||||
assert attr_keys("todo.py") == set(strings["entity"]["todo"])
|
||||
|
||||
Reference in New Issue
Block a user