diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f83918..9adb09b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/custom_components/freshharvest/__init__.py b/custom_components/freshharvest/__init__.py index ffc4605..6d99036 100644 --- a/custom_components/freshharvest/__init__.py +++ b/custom_components/freshharvest/__init__.py @@ -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] diff --git a/custom_components/freshharvest/actions.py b/custom_components/freshharvest/actions.py index c585f6c..118a4a1 100644 --- a/custom_components/freshharvest/actions.py +++ b/custom_components/freshharvest/actions.py @@ -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 diff --git a/custom_components/freshharvest/api.py b/custom_components/freshharvest/api.py index 0ba37dd..714048b 100644 --- a/custom_components/freshharvest/api.py +++ b/custom_components/freshharvest/api.py @@ -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: diff --git a/custom_components/freshharvest/button.py b/custom_components/freshharvest/button.py new file mode 100644 index 0000000..9a7eed1 --- /dev/null +++ b/custom_components/freshharvest/button.py @@ -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}, + ) diff --git a/custom_components/freshharvest/const.py b/custom_components/freshharvest/const.py index b3b2df5..128b4e4 100644 --- a/custom_components/freshharvest/const.py +++ b/custom_components/freshharvest/const.py @@ -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" diff --git a/custom_components/freshharvest/coordinator.py b/custom_components/freshharvest/coordinator.py index c7d1aea..ded4b1d 100644 --- a/custom_components/freshharvest/coordinator.py +++ b/custom_components/freshharvest/coordinator.py @@ -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: diff --git a/custom_components/freshharvest/sensor.py b/custom_components/freshharvest/sensor.py index 97c63c4..a0afa88 100644 --- a/custom_components/freshharvest/sensor.py +++ b/custom_components/freshharvest/sensor.py @@ -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", diff --git a/custom_components/freshharvest/strings.json b/custom_components/freshharvest/strings.json index d82d789..a709cce 100644 --- a/custom_components/freshharvest/strings.json +++ b/custom_components/freshharvest/strings.json @@ -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" + } } } } diff --git a/custom_components/freshharvest/switch.py b/custom_components/freshharvest/switch.py new file mode 100644 index 0000000..835ecf9 --- /dev/null +++ b/custom_components/freshharvest/switch.py @@ -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}, + ) diff --git a/custom_components/freshharvest/todo.py b/custom_components/freshharvest/todo.py new file mode 100644 index 0000000..ed03ada --- /dev/null +++ b/custom_components/freshharvest/todo.py @@ -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, + }, + ) diff --git a/custom_components/freshharvest/translations/en.json b/custom_components/freshharvest/translations/en.json index d82d789..a709cce 100644 --- a/custom_components/freshharvest/translations/en.json +++ b/custom_components/freshharvest/translations/en.json @@ -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" + } } } } diff --git a/tests/test_actions.py b/tests/test_actions.py new file mode 100644 index 0000000..17143b7 --- /dev/null +++ b/tests/test_actions.py @@ -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 = """ +
+
+ +
+
+ +
+
+""" + + +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("nothing") == [] + + +def test_vacation_holds_need_two_dates(): + html = "
2026-09-01 to 2026-09-14
" + 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("
none
") == [] + + +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" diff --git a/tests/test_translations.py b/tests/test_translations.py index 2c52b51..5229381 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -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"])