From 5406f5a626e7b7d09871649fbd8cddadc9d7b484 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Sat, 7 Nov 2026 22:47:23 +0000 Subject: [PATCH 1/4] fix(feedback): detect Marketplace installs on HA 2026.11+ HA 2026.11 removes HACS and builds it in as the Marketplace, so the Help & feedback page reported every Marketplace install as manual. Read the same repository list from the marketplace config entry's runtime_data, falling back to hass.data["hacs"] on older Home Assistant. --- custom_components/taskmate/feedback.py | 35 ++++++++++++++++++++------ tests/test_feedback_info.py | 28 +++++++++++++++++++++ wiki/Help-and-Feedback.md | 2 +- 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/custom_components/taskmate/feedback.py b/custom_components/taskmate/feedback.py index 1794319d..60125f73 100644 --- a/custom_components/taskmate/feedback.py +++ b/custom_components/taskmate/feedback.py @@ -47,24 +47,43 @@ async def _installation_type(hass: HomeAssistant) -> tuple[str, str]: return _INSTALL_TYPES.get(raw, "unknown"), raw +def _store_repositories(hass: HomeAssistant): + """The Marketplace's or HACS's repository list, or None when neither is loaded. + + Home Assistant 2026.11 replaced the HACS custom integration with the built-in + Marketplace, which keeps the same repository list on its config entry's + runtime_data. Older installs still have HACS in hass.data["hacs"]. + """ + if "marketplace" in hass.config.components: + for entry in hass.config_entries.async_entries("marketplace"): + manager = getattr(entry, "runtime_data", None) + if manager is not None: + return manager.repositories + raise LookupError("Marketplace is loaded but has no runtime data") + if "hacs" in hass.config.components: + return hass.data.get("hacs").repositories + return None + + def _install_method(hass: HomeAssistant) -> str: """How TaskMate itself was installed: hacs_default, hacs_custom, manual or unknown. - Reads HACS's in-memory repository list. That is HACS's internal API, so - any surprise in its shape degrades to "unknown" rather than failing. + hacs_default/hacs_custom cover both the Marketplace and HACS (the panel + labels them "Marketplace / HACS"). Both expose only internal APIs, so any + surprise in their shape degrades to "unknown" rather than failing. """ - if "hacs" not in hass.config.components: - return "manual" try: - hacs = hass.data.get("hacs") - repo = hacs.repositories.get_by_full_name(REPO_FULL_NAME) + repositories = _store_repositories(hass) + if repositories is None: + return "manual" + repo = repositories.get_by_full_name(REPO_FULL_NAME) if repo is None: return "manual" if not getattr(repo.data, "installed", True): return "manual" - return "hacs_default" if hacs.repositories.is_default(str(repo.data.id)) else "hacs_custom" + return "hacs_default" if repositories.is_default(str(repo.data.id)) else "hacs_custom" except Exception: # noqa: BLE001 - _LOGGER.debug("TaskMate feedback: could not read HACS repositories", exc_info=True) + _LOGGER.debug("TaskMate feedback: could not read Marketplace/HACS repositories", exc_info=True) return "unknown" diff --git a/tests/test_feedback_info.py b/tests/test_feedback_info.py index 90c050ce..a3ed89a3 100644 --- a/tests/test_feedback_info.py +++ b/tests/test_feedback_info.py @@ -139,6 +139,34 @@ def test_hacs_internals_changing_shape_is_unknown(): assert feedback._install_method(_hass(["hacs"], {})) == "unknown" +def _marketplace_hass(store, *, data=None): + hass = _hass(["marketplace"], data) + entry = SimpleNamespace(runtime_data=store) + hass.config_entries.async_entries.return_value = [entry] + return hass + + +def test_install_method_reads_the_marketplace(): + # HA 2026.11+: HACS is gone, the Marketplace keeps the list on runtime_data. + assert feedback._install_method(_marketplace_hass(_hacs(default=True))) == "hacs_default" + assert feedback._install_method(_marketplace_hass(_hacs(default=False))) == "hacs_custom" + assert feedback._install_method(_marketplace_hass(_hacs(found=False))) == "manual" + assert feedback._install_method(_marketplace_hass(_hacs(installed=False))) == "manual" + + +def test_marketplace_wins_over_a_leftover_hacs(): + hass = _marketplace_hass(_hacs(default=True), data={"hacs": _hacs(default=False)}) + hass.config.components.add("hacs") + assert feedback._install_method(hass) == "hacs_default" + + +def test_marketplace_internals_changing_shape_is_unknown(): + assert feedback._install_method(_marketplace_hass(object())) == "unknown" + hass = _hass(["marketplace"]) + hass.config_entries.async_entries.return_value = [] + assert feedback._install_method(hass) == "unknown" + + def test_lovelace_mode_dataclass_and_legacy_dict(): assert feedback._lovelace_mode(_hass(data={"lovelace": SimpleNamespace(resource_mode="storage")})) == "storage" # Before the rename the dataclass called it `mode`. diff --git a/wiki/Help-and-Feedback.md b/wiki/Help-and-Feedback.md index 0832fbfc..9ce5a1fe 100644 --- a/wiki/Help-and-Feedback.md +++ b/wiki/Help-and-Feedback.md @@ -64,7 +64,7 @@ The number of children you use TaskMate with is filled in for you. |--------|---------------------| | TaskMate and Home Assistant versions | The integration | | Home Assistant installation | Home Assistant's system info (OS, Supervised, Container or Core) | -| How TaskMate was installed | HACS, if HACS is installed and knows about TaskMate; otherwise manual | +| How TaskMate was installed | The Marketplace (or HACS before Home Assistant 2026.11), if it knows about TaskMate; otherwise manual | | Dashboard (Lovelace) mode | Storage (edited in the UI) or YAML | | Family | How many children, adults, chores and rewards. **Counts only** | | Browser | A summary like "Chrome 141 on Windows", never the full browser string | From 9cb650c56599d199ac204001460564939ba8ca39 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Sun, 8 Nov 2026 10:10:31 +0000 Subject: [PATCH 2/4] fix(chores): keep a recurring first-come chore done for the whole pool A child's completion only stamped their own recurrence record, so a weekly first_come chore closed for the child who did it but came back for the rest of the pool the next day. The recurrence window now reads the latest completion across the pool. Rejecting or undoing it reopens the chore for everyone. Fixes #1239 --- custom_components/taskmate/coord_chores.py | 22 ++++ tests/test_first_come_weekly_pool.py | 121 +++++++++++++++++++++ wiki/Chore-Scheduling.md | 1 + 3 files changed, 144 insertions(+) create mode 100644 tests/test_first_come_weekly_pool.py diff --git a/custom_components/taskmate/coord_chores.py b/custom_components/taskmate/coord_chores.py index a63bbd58..c0fda435 100644 --- a/custom_components/taskmate/coord_chores.py +++ b/custom_components/taskmate/coord_chores.py @@ -2473,6 +2473,24 @@ def is_chore_available_for_child(self, chore, child_id: str) -> bool: return self._recurrence_open_on(chore, child_id, dt_util.as_local(dt_util.now()).date(), override) + def _first_come_last_completed(self, chore, own: dict) -> dict: + """The latest last_completed record across a first_come chore's pool. + + A child's completion only stamps their own record, so without this a + weekly first_come chore stayed open for the rest of the pool the day + after one of them did it (#1239). A parent's completion already stamps + every pool member. Rejecting or undoing a completion rewinds that + child's own record, so the shared window reopens with it. + """ + best = own or {} + best_dt = parse_datetime(best.get("current") or "") + for cid in self._chore_assignment_pool(chore): + rec = self.storage.get_last_completed(chore.id, cid) or {} + rec_dt = parse_datetime(rec.get("current") or "") + if rec_dt is not None and (best_dt is None or rec_dt > best_dt): + best, best_dt = rec, rec_dt + return best + def _recurrence_open_on(self, chore, child_id: str, today: date, override) -> bool: """Whether a recurring chore's window is open for ``child_id`` on ``today``. @@ -2489,6 +2507,10 @@ def _recurrence_open_on(self, chore, child_id: str, today: date, override) -> bo window_days = interval_days or {"weekly": 7, "every_2_weeks": 14}.get(recurrence, 7) record = self.storage.get_last_completed(chore.id, child_id) + if getattr(chore, "assignment_mode", "everyone") == "first_come": + # One shared job (#1239): whoever in the pool did it last closes the + # window for everyone, not just for themselves. + record = self._first_come_last_completed(chore, record) current_iso = record.get("current") if override is True: diff --git a/tests/test_first_come_weekly_pool.py b/tests/test_first_come_weekly_pool.py new file mode 100644 index 00000000..d6325b5a --- /dev/null +++ b/tests/test_first_come_weekly_pool.py @@ -0,0 +1,121 @@ +"""A weekly first_come chore stays done for the whole pool (#1239). + +first_come shares one job across its pool. The daily check only counted +today's completions, and a child's completion only stamped that child's own +recurrence record, so a weekly first_come chore closed for the child who did +it but came back for everyone else the next day. A parent's completion already +stamped the whole pool, so the two paths disagreed. + +The recurrence window now reads the latest completion across the pool. +""" + +from __future__ import annotations + +import datetime as dt +from unittest.mock import AsyncMock, patch + +from .test_first_come_shared_limit import _make_system, _now, run + +DAY = dt.timedelta(days=1) + + +def _at(mod, when): + return patch.object(mod.dt_util, "now", return_value=when) + + +def _coop(coord, mod, *, mode="first_come", recurrence="weekly", requires_approval=False): + coord.notifications = AsyncMock() + with _at(mod, _now()): + a = run(coord.async_add_child("Ava")) + b = run(coord.async_add_child("Ben")) + chore = run( + coord.async_add_chore( + "Clean the coop", + points=5, + requires_approval=requires_approval, + assignment_mode=mode, + assigned_to=[a.id, b.id], + schedule_mode="recurring", + recurrence=recurrence, + ) + ) + return chore, a, b + + +def _complete(coord, mod, when, chore, child_id): + with _at(mod, when): + return run(coord.async_complete_chore(chore.id, child_id)) + + +def _available(coord, mod, when, chore, child_id): + with _at(mod, when): + return coord.is_chore_available_for_child(chore, child_id) + + +def test_one_childs_completion_closes_the_week_for_the_pool(): + coord, _storage, mod = _make_system() + chore, a, b = _coop(coord, mod) + assert _complete(coord, mod, _now(), chore, a.id) is not None + + assert _available(coord, mod, _now() + DAY, chore, a.id) is False + assert _available(coord, mod, _now() + DAY, chore, b.id) is False + assert _available(coord, mod, _now() + 6 * DAY, chore, b.id) is False + assert _complete(coord, mod, _now() + DAY, chore, b.id) is None + + +def test_it_reopens_for_everyone_when_the_window_ends(): + coord, _storage, mod = _make_system() + chore, a, b = _coop(coord, mod) + _complete(coord, mod, _now(), chore, a.id) + + assert _available(coord, mod, _now() + 7 * DAY, chore, a.id) is True + assert _available(coord, mod, _now() + 7 * DAY, chore, b.id) is True + + +def test_every_two_weeks_shares_its_fortnight(): + coord, _storage, mod = _make_system() + chore, a, b = _coop(coord, mod, recurrence="every_2_weeks") + _complete(coord, mod, _now(), chore, a.id) + + assert _available(coord, mod, _now() + 13 * DAY, chore, b.id) is False + assert _available(coord, mod, _now() + 14 * DAY, chore, b.id) is True + + +def test_a_completion_awaiting_approval_counts(): + coord, _storage, mod = _make_system() + chore, a, b = _coop(coord, mod, requires_approval=True) + completion = _complete(coord, mod, _now(), chore, a.id) + assert completion is not None and not completion.approved + + assert _available(coord, mod, _now() + DAY, chore, b.id) is False + + +def test_rejecting_the_completion_reopens_it_for_the_pool(): + coord, _storage, mod = _make_system() + chore, a, b = _coop(coord, mod, requires_approval=True) + completion = _complete(coord, mod, _now(), chore, a.id) + + with _at(mod, _now() + DAY): + run(coord.async_reject_chore(completion.id)) + assert _available(coord, mod, _now() + DAY, chore, b.id) is True + assert _available(coord, mod, _now() + DAY, chore, a.id) is True + + +def test_the_latest_completion_in_the_pool_sets_the_window(): + """Ben did it two weeks ago; Ava did it yesterday. Ava's counts.""" + coord, storage, mod = _make_system() + chore, a, b = _coop(coord, mod) + storage.set_last_completed(chore.id, b.id, (_now() - 14 * DAY).isoformat()) + storage.set_last_completed(chore.id, a.id, (_now() - DAY).isoformat()) + + assert _available(coord, mod, _now(), chore, b.id) is False + assert _available(coord, mod, _now() + 6 * DAY, chore, b.id) is True + + +def test_everyone_mode_keeps_its_own_window_per_child(): + coord, _storage, mod = _make_system() + chore, a, b = _coop(coord, mod, mode="everyone") + _complete(coord, mod, _now(), chore, a.id) + + assert _available(coord, mod, _now() + DAY, chore, a.id) is False + assert _available(coord, mod, _now() + DAY, chore, b.id) is True diff --git a/wiki/Chore-Scheduling.md b/wiki/Chore-Scheduling.md index af893c3e..bb33c929 100644 --- a/wiki/Chore-Scheduling.md +++ b/wiki/Chore-Scheduling.md @@ -217,6 +217,7 @@ The parent dashboard applies the same filters. - **Shared daily limit** — from **v7.6.0** a first-come chore's daily limit is shared by the whole pool. A limit of `2` means two completions in total today, whoever makes them: one each, or both by the same child. Every completion pays the child who made it, and once the limit is used up the chore locks for everyone. A pool-wide **Parent did it** from the admin panel uses one of the slots. - **Race losers** — if another child tries to complete it after the limit is used up, the attempt is rejected with *"Chore '…' was already completed by another child."* No points are awarded to the loser. - **Daily reset** — the lock is per-day; the chore opens up to the pool again the next day. +- **Recurring first-come chores** — with a **Recurring** schedule (for example weekly or every 2 weeks), one completion closes the chore for the **whole pool** until the recurrence window ends, whoever did it. A weekly chore Ava does on Saturday stays off everyone's list until the next Saturday. A completion still waiting for approval counts; rejecting or undoing it reopens the chore for everyone. For example, to have the dishwasher emptied twice a day by whichever child gets there first, set the chore up in the admin panel's chore dialog like this: From 29e54d90eef90c4e48fc12fbdf3f38b4da190e21 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Sun, 8 Nov 2026 22:24:10 +0000 Subject: [PATCH 3/4] fix: keep other children's chores out of the Routine card (#1240) An "everyone" chore assigned to named children was marked available to every child in the chore_availability matrix, because is_chore_available_for_child only checked assigned_to for rotation modes. The Routine card trusted that matrix and listed a sibling's chores; roulette could pick one too. - is_chore_available_for_child returns False for a child outside a non-empty assigned_to (a make-it-right offer still wins). A parent completing on behalf skips only that check via ignore_assignment. - chore_next_open skips children the chore isn't assigned to. - The Routine card applies the child card's assigned_to filter as well. Fixes #1240 --- custom_components/taskmate/coord_chores.py | 20 +++- custom_components/taskmate/sensor.py | 4 + .../taskmate/www/taskmate-routine-card.js | 3 + .../taskmate-routine-card-assigned.test.cjs | 45 +++++++++ tests/test_assigned_availability_1240.py | 99 +++++++++++++++++++ 5 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 tests/frontend/taskmate-routine-card-assigned.test.cjs create mode 100644 tests/test_assigned_availability_1240.py diff --git a/custom_components/taskmate/coord_chores.py b/custom_components/taskmate/coord_chores.py index a63bbd58..c62d4bf5 100644 --- a/custom_components/taskmate/coord_chores.py +++ b/custom_components/taskmate/coord_chores.py @@ -982,7 +982,7 @@ async def async_complete_chore( # Check recurrence window for Mode B chores if getattr(chore, "schedule_mode", "specific_days") == "recurring" and not self.is_chore_available_for_child( - chore, child_id + chore, child_id, ignore_assignment=as_parent ): _LOGGER.debug( "complete_chore no-op: '%s' not available yet (recurrence window)", @@ -992,7 +992,7 @@ async def async_complete_chore( # Check availability for one-shot chores if getattr(chore, "schedule_mode", "specific_days") == "one_shot" and not self.is_chore_available_for_child( - chore, child_id + chore, child_id, ignore_assignment=as_parent ): _LOGGER.debug( "complete_chore no-op: '%s' not available (one-shot done or expired)", @@ -2370,7 +2370,7 @@ def unmet_dependencies(self, chore, child_id: str) -> list[str]: ) ] - def is_chore_available_for_child(self, chore, child_id: str) -> bool: + def is_chore_available_for_child(self, chore, child_id: str, *, ignore_assignment: bool = False) -> bool: """Check if a recurring chore is available for a child to complete. Mode A (specific_days): day filtering is handled by the child card. @@ -2378,7 +2378,10 @@ def is_chore_available_for_child(self, chore, child_id: str) -> bool: Mode B (recurring): checks rolling window from last completion date (midnight-rounded). Window lengths in days per recurrence type. - Both modes also check visibility_entity if configured. + Both modes also check visibility_entity if configured, and a chore + with a non-empty ``assigned_to`` is unavailable to every child not in it. + ``ignore_assignment`` skips only that last check, for a parent completing + on behalf of a child outside the list (the schedule window still applies). """ # Vacation / pause mode: while away, all chores are hidden. Covers the @@ -2412,6 +2415,15 @@ def is_chore_available_for_child(self, chore, child_id: str) -> bool: if self.open_redemption_for(chore.id, child_id) is not None: return True + # Assignment (#1240): a chore assigned to named children is never + # available to anyone else, whatever its mode. Rotation modes already + # draw their pool from assigned_to, but an "everyone" chore used to be + # available to every child, so the chore_availability matrix offered a + # sibling's chore to any card that trusted it (the Routine card did). + assigned = getattr(chore, "assigned_to", []) or [] + if assigned and child_id not in assigned and not ignore_assignment: + return False + # Dynamic assignment — only the active child(ren) see alternating/random chores if getattr(chore, "assignment_mode", "everyone") != "everyone": active = self._compute_active_children(chore) diff --git a/custom_components/taskmate/sensor.py b/custom_components/taskmate/sensor.py index e1727b99..dbc31504 100644 --- a/custom_components/taskmate/sensor.py +++ b/custom_components/taskmate/sensor.py @@ -683,6 +683,10 @@ def _build_chore_next_open(coordinator: TaskMateCoordinator, common: dict, avail for child in common["children"]: if (availability.get(c.id) or {}).get(child.id, True): continue + # Not this child's chore at all (#1240): it never "opens" for them. + assigned = getattr(c, "assigned_to", []) or [] + if assigned and child.id not in assigned: + continue day = coordinator.recurrence_next_open(c, child.id) if day is not None: per_child[child.id] = day.isoformat() diff --git a/custom_components/taskmate/www/taskmate-routine-card.js b/custom_components/taskmate/www/taskmate-routine-card.js index efe3afa1..65c1eea0 100644 --- a/custom_components/taskmate/www/taskmate-routine-card.js +++ b/custom_components/taskmate/www/taskmate-routine-card.js @@ -235,6 +235,9 @@ class TaskMateRoutineCard extends LitElement { // Anything finished in this run stays visible as a completed step. if (this._runCompleted.has(id)) return true; if (!availability[id] || availability[id][childId] !== true) return false; + // Only this child's chores (#1240), as the child card filters. + const assigned = Array.isArray(chore.assigned_to) ? chore.assigned_to.map(String) : []; + if (assigned.length && !assigned.includes(childId)) return false; if (completedToday.has(id)) return false; if (period !== "all" && chore.time_category !== period) return false; return true; diff --git a/tests/frontend/taskmate-routine-card-assigned.test.cjs b/tests/frontend/taskmate-routine-card-assigned.test.cjs new file mode 100644 index 00000000..8ca8d29b --- /dev/null +++ b/tests/frontend/taskmate-routine-card-assigned.test.cjs @@ -0,0 +1,45 @@ +const assert = require("node:assert/strict"); +const { test } = require("node:test"); + +const { loadCard } = require("./harness.cjs"); + +// #1240: the Routine card listed chores assigned to other children, because it +// trusted chore_availability alone and that said "available" for every child +// on an "everyone" chore. It now applies the child card's assigned_to filter. + +const CustomEvent = class { constructor(type, init) { this.type = type; this.detail = init?.detail; } }; +const RoutineCard = loadCard("taskmate-routine-card.js", { sandbox: { CustomEvent } }).get("taskmate-routine-card"); +const ENTITY = "sensor.taskmate_overview"; + +const chore = (id, assigned_to) => ({ id, name: id, points: 5, time_category: "morning", assigned_to }); + +function routineFor(childId, chores) { + const card = new RoutineCard(); + card.setConfig({ entity: ENTITY, child_id: childId, time_category: "morning" }); + const availability = Object.fromEntries(chores.map((c) => [c.id, { a: true, b: true }])); + card.hass = { + states: { + [ENTITY]: { + state: "ok", + attributes: { + children: [{ id: "a", name: "Ava", chore_order: [] }, { id: "b", name: "Ben", chore_order: [] }], + chores, + chore_availability: availability, + todays_completions: [], + }, + }, + }, + }; + return card; +} + +test("a sibling's chore is left out even when availability says true", () => { + const chores = [chore("shower", ["a"]), chore("teeth", ["b"]), chore("dress", [])]; + assert.deepEqual(routineFor("b", chores)._tasks().map((c) => c.id), ["dress", "teeth"]); + assert.deepEqual(routineFor("a", chores)._tasks().map((c) => c.id), ["dress", "shower"]); +}); + +test("a chore with no assigned_to list belongs to everyone", () => { + const chores = [{ ...chore("dress", []), assigned_to: undefined }]; + assert.deepEqual(routineFor("b", chores)._tasks().map((c) => c.id), ["dress"]); +}); diff --git a/tests/test_assigned_availability_1240.py b/tests/test_assigned_availability_1240.py new file mode 100644 index 00000000..805471ae --- /dev/null +++ b/tests/test_assigned_availability_1240.py @@ -0,0 +1,99 @@ +"""A chore assigned to named children is never available to anyone else (#1240). + +``is_chore_available_for_child`` only checked ``assigned_to`` for rotation +modes, so an "everyone" chore assigned to one child showed as available to +every child in the ``chore_availability`` matrix. The Routine card trusted that +matrix and offered a sibling's chore; roulette could hand one out too. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from custom_components.taskmate import sensor as sensor_mod +from custom_components.taskmate.models import Chore + +from .test_completion_eligibility import _make_system, _now, _two_children, run + + +def _system(): + coord, storage, mod = _make_system() + with patch.object(mod.dt_util, "now", return_value=_now()): + alice, bob = _two_children(coord) + return coord, storage, mod, alice, bob + + +def _at(mod): + return patch.object(mod.dt_util, "now", return_value=_now()) + + +@pytest.mark.parametrize("schedule_mode", ["specific_days", "recurring", "one_shot"]) +def test_everyone_chore_is_unavailable_to_a_child_not_assigned(schedule_mode): + coord, storage, mod, alice, bob = _system() + chore = Chore(name="Shower", schedule_mode=schedule_mode, recurrence="weekly", assigned_to=[alice.id]) + storage.add_chore(chore) + with _at(mod): + assert coord.is_chore_available_for_child(chore, alice.id) is True + assert coord.is_chore_available_for_child(chore, bob.id) is False + # A parent completing on behalf skips only the assignment check. + assert coord.is_chore_available_for_child(chore, bob.id, ignore_assignment=True) is True + + +def test_an_unassigned_chore_stays_available_to_everyone(): + coord, storage, mod, alice, bob = _system() + chore = Chore(name="Tidy up", assigned_to=[]) + storage.add_chore(chore) + with _at(mod): + assert coord.is_chore_available_for_child(chore, alice.id) is True + assert coord.is_chore_available_for_child(chore, bob.id) is True + + +def test_an_open_make_it_right_offer_still_wins(): + coord, storage, mod, alice, bob = _system() + chore = Chore(name="Shower", assigned_to=[alice.id]) + storage.add_chore(chore) + with _at(mod), patch.object(coord, "open_redemption_for", return_value={"id": "offer"}): + assert coord.is_chore_available_for_child(chore, bob.id) is True + + +def test_the_availability_matrix_marks_a_siblings_chore_false(): + coord, storage, mod, alice, bob = _system() + chore = Chore(name="Shower", assigned_to=[alice.id]) + storage.add_chore(chore) + common = {"chores": [chore], "children": [alice, bob]} + with _at(mod): + matrix = sensor_mod._build_chore_availability(coord, common) + assert matrix == {chore.id: {alice.id: True, bob.id: False}} + + +def test_next_open_is_not_published_for_a_child_not_assigned(): + coord, storage, mod, alice, bob = _system() + chore = Chore(name="Water plants", schedule_mode="recurring", recurrence="every_3_days", assigned_to=[alice.id]) + storage.add_chore(chore) + common = {"chores": [chore], "children": [alice, bob]} + with _at(mod): + availability = sensor_mod._build_chore_availability(coord, common) + out = sensor_mod._build_chore_next_open(coord, common, availability) + # Alice's window is open, Bob's chore it isn't: nothing to publish for either. + assert out == {} + + +def test_roulette_never_picks_a_siblings_chore(): + coord, storage, mod, alice, bob = _system() + mine = Chore(name="Feed the cat", assigned_to=[bob.id]) + theirs = Chore(name="Shower", assigned_to=[alice.id]) + storage.add_chore(mine) + storage.add_chore(theirs) + with _at(mod): + picks = [c.id for c in coord._roulette_candidates(bob.id)] + assert picks == [mine.id] + + +def test_a_child_still_cannot_complete_a_siblings_chore(): + coord, storage, mod, alice, bob = _system() + with _at(mod): + chore = run(coord.async_add_chore("Shower", points=5, requires_approval=False, assigned_to=[alice.id])) + assert run(coord.async_complete_chore(chore.id, bob.id)) is None + assert storage.get_child(bob.id).points == 0 From dcef9fbbe42bd9cff623f0c00e01cc378352b3df Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Sun, 8 Nov 2026 22:45:21 +0000 Subject: [PATCH 4/4] chore: release 7.8.3 --- custom_components/taskmate/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/taskmate/manifest.json b/custom_components/taskmate/manifest.json index b50199e8..d5dc36ee 100644 --- a/custom_components/taskmate/manifest.json +++ b/custom_components/taskmate/manifest.json @@ -17,5 +17,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/tempus2016/taskmate/issues", "requirements": [], - "version": "7.8.2" + "version": "7.8.3" }