Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72966bf9ae | ||
|
|
0d2e0d6f26 | ||
|
|
1911b2bf6e | ||
|
|
89f517b3ad | ||
|
|
edca25affa | ||
|
|
7c62c50526 | ||
|
|
ca93176768 | ||
|
|
e508c80465 | ||
|
|
3a2e6faa69 | ||
|
|
5092369d49 | ||
|
|
f6166f25d9 | ||
|
|
bf84793a1c | ||
|
|
9d3cb30617 | ||
|
|
b913772007 | ||
|
|
d2f9c50930 | ||
|
|
19a17b647d |
@@ -4,3 +4,4 @@ __pycache__/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
*.metadata.json
|
||||
frontend/node_modules/
|
||||
|
||||
@@ -60,13 +60,101 @@ triggers:
|
||||
event_type: nestquest_child_day_complete
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ trigger.payload.child_name == 'Declan' }}"
|
||||
value_template: "{{ trigger.event.data.child_name == 'Declan' }}"
|
||||
actions:
|
||||
- action: notify.mobile_app_declans_phone
|
||||
data:
|
||||
message: "Every quest cleared — legendary!"
|
||||
```
|
||||
|
||||
## Automation Blueprints
|
||||
|
||||
NestQuest ships four automation blueprints in
|
||||
`custom_components/nestquest/blueprints/automation/`. Import them in
|
||||
Home Assistant under **Settings → Automations & Scenes → Blueprints**
|
||||
(or place the files in your `config/blueprints/automation/` folder),
|
||||
then create one automation per blueprint, filling the inputs with your
|
||||
household's choices.
|
||||
|
||||
### Wiring to the integration's notification settings
|
||||
|
||||
The integration's options flow records your notification choices —
|
||||
notify target, morning/afternoon/end-of-day times, and a toggle per
|
||||
automation. The blueprints take their own inputs at import time;
|
||||
fill them from the same values you stored in the options flow (the
|
||||
integration does not push its settings into imported automations):
|
||||
|
||||
| Blueprint input | Options-flow counterpart |
|
||||
|---|---|
|
||||
| `morning_time` (default 08:00) | `morning_summary_time` |
|
||||
| `afternoon_time` (default 15:00) | `afternoon_reminder_time` |
|
||||
| `report_time` (default 20:00) | `end_of_day_report_time` |
|
||||
| `send_summary` / `send_reminder` / `send_report` / `celebrate` | the four `*_enabled` toggles |
|
||||
| `notify_target` | `notify_target` (your notify service, e.g. `notify.mobile_app_your_phone`) |
|
||||
|
||||
The end-of-day report deliberately runs BEFORE the midnight missed
|
||||
sweep and reads the same day's data — do not schedule it at the
|
||||
day-rollover time.
|
||||
|
||||
### The four blueprints
|
||||
|
||||
1. **Morning summary** (`morning_summary.yaml`) — at the configured
|
||||
time, lists every child whose `binary_sensor.nestquest_<child>_present_today`
|
||||
is on and what they owe from `sensor.nestquest_<child>_quests_remaining_today`.
|
||||
Children not present today are omitted entirely.
|
||||
|
||||
2. **Afternoon reminder** (`afternoon_reminder.yaml`) — at the
|
||||
configured time, notifies only about present children who still
|
||||
have open quests. Sends nothing at all when every present child is
|
||||
clear.
|
||||
|
||||
3. **End-of-day report** (`end_of_day_report.yaml`) — one message per
|
||||
evening: each present child's completion count and the quests they
|
||||
still owe (the ones the midnight sweep will mark
|
||||
`nestquest_quest_missed`). Absent children are omitted. Sends
|
||||
nothing on an all-clear day unless `send_even_when_clear` is on.
|
||||
|
||||
4. **Day-complete celebration** (`day_complete_celebration.yaml`) —
|
||||
triggers on the `nestquest_child_day_complete` event (which only
|
||||
fires when quests were owed and all are complete, so a zero-quest
|
||||
day never celebrates). Create one `input_text` helper
|
||||
(`nestquest_last_celebrated`) for the dedupe: the automation
|
||||
records each celebrated child as `<child_id>:<today>` and
|
||||
celebrates each child at most once per day — a re-cleared day does
|
||||
not re-fire, but a second child clearing the same day still
|
||||
celebrates.
|
||||
|
||||
### Example package
|
||||
|
||||
```yaml
|
||||
# One automation per blueprint — Settings → Automations & Scenes →
|
||||
# Create → Blueprint, then pick each NestQuest blueprint.
|
||||
#
|
||||
# morning_summary: morning_time 08:00, notify_target notify.mobile_app_your_phone
|
||||
# afternoon_reminder: afternoon_time 15:00, same notify target
|
||||
# end_of_day_report: report_time 20:00, send_even_when_clear false, same target
|
||||
# day_complete_celebration: input_text.nestquest_last_celebrated,
|
||||
# celebration_message "Legendary! {{ trigger.event.data.child_name }} cleared every quest today!"
|
||||
#
|
||||
# Every blueprint has its own send/celebrate toggle input; turn an
|
||||
# automation off entirely from HA's automation menu.
|
||||
```
|
||||
|
||||
### Events the automations rely on
|
||||
|
||||
| Event | Payload |
|
||||
|---|---|
|
||||
| `nestquest_quest_completed` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at (UTC ISO-8601), was_on_time |
|
||||
| `nestquest_quest_uncompleted` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at |
|
||||
| `nestquest_child_day_complete` | child_id, child_name, quests_due, quests_completed, occurred_at |
|
||||
| `nestquest_quest_missed` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at (fired by the nightly sweep) |
|
||||
|
||||
Sensors the automations read: `sensor.nestquest_<child>_quests_due_today`
|
||||
(attributes: `child_id`, `child_name`, `present`, `instances`,
|
||||
`admin_instances`), `..._quests_remaining_today`,
|
||||
`..._quests_completed_today`, and
|
||||
`binary_sensor.nestquest_<child>_present_today`.
|
||||
|
||||
## Developer Setup and Testing
|
||||
|
||||
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
|
||||
@@ -84,6 +172,20 @@ To set up the development environment and install dependencies:
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Frontend cards
|
||||
|
||||
The Lovelace cards live in `frontend/` (TypeScript + Lit) and build to a
|
||||
single bundle at `custom_components/nestquest/www/nestquest-cards.js`. The
|
||||
integration registers that bundle as a Lovelace resource automatically on
|
||||
setup — do not add it by hand. Card YAML is documented in
|
||||
`design/ENTITIES-AND-SERVICES.md` §4.
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
Run the test suite with `pytest`:
|
||||
|
||||
@@ -28,6 +28,7 @@ from .const import (
|
||||
LOGGER,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .frontend import async_register_frontend
|
||||
from .coordinator import NestQuestCoordinator
|
||||
from .db import NestQuestDatabase
|
||||
from .materialize import materialize as _materialize_run
|
||||
@@ -410,11 +411,13 @@ def _deregister_services(hass: HomeAssistant) -> None:
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||
"""Set up the NestQuest integration. YAML configuration is not used, returns True."""
|
||||
await async_register_frontend(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up NestQuest from a config entry."""
|
||||
await async_register_frontend(hass)
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
existing = hass.data[DOMAIN].get(entry.entry_id)
|
||||
if existing is not None:
|
||||
|
||||
@@ -135,6 +135,17 @@ class NestQuestPresentTodaySensor(_NestQuestChildBinarySensor):
|
||||
return None
|
||||
return child.present
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Base attributes plus, for a child away today, the ISO date
|
||||
of their next present day (the panel's away plate renders
|
||||
``Returns <weekday>, <Mon D>`` from it)."""
|
||||
base = super().extra_state_attributes
|
||||
if base is None:
|
||||
return None
|
||||
child = self._child
|
||||
return {**base, "next_present": child.next_present if child else None}
|
||||
|
||||
|
||||
def _child_binary_sensors(
|
||||
coordinator: NestQuestCoordinator, child: ChildDaySnapshot
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
blueprint:
|
||||
name: NestQuest day complete celebration
|
||||
description: >-
|
||||
Celebrates the moment a child clears every quest for the day: the
|
||||
automation is driven by the nestquest_child_day_complete event,
|
||||
which NestQuest only fires when quests were owed and all of them
|
||||
are complete — a zero-quest day can never trigger this
|
||||
celebration. Once-per-child-per-day dedupe: point
|
||||
last_celebrated_helper at ONE input_text helper (create it under
|
||||
Settings, Devices and Services, Helpers — plain text). The
|
||||
automation records each celebrated child as
|
||||
"<child_id>:<today>" inside that helper, and a child who
|
||||
un-completes a quest and completes it again the same day is
|
||||
celebrated once, not twice — while a SECOND child clearing their
|
||||
day the same date is still celebrated in full. The helper is
|
||||
pruned to today's entries on every celebration, so it never
|
||||
grows past one entry per child per day. A helper that does not
|
||||
exist yet, or has never been set, lets the first celebration
|
||||
through. The notification message may use the event payload's
|
||||
child_name, quests_due and quests_completed. The notify service
|
||||
is chosen by the household when importing this blueprint —
|
||||
NestQuest never hard-codes a personal notify target.
|
||||
domain: automation
|
||||
input:
|
||||
notify_target:
|
||||
name: Notify service
|
||||
description: >-
|
||||
The Home Assistant notify service to call for the celebration,
|
||||
for example notify.mobile_app_your_phone — a placeholder;
|
||||
pick the notify service for your own device.
|
||||
selector:
|
||||
text: {}
|
||||
celebration_message:
|
||||
name: Celebration message
|
||||
description: >-
|
||||
The message sent when a child clears every quest today. May
|
||||
use the event payload: trigger.event.data.child_name,
|
||||
trigger.event.data.quests_due and trigger.event.data.quests_completed.
|
||||
default: "Legendary! {{ trigger.event.data.child_name }} cleared every quest today!"
|
||||
selector:
|
||||
text: {}
|
||||
celebrate:
|
||||
name: Send the celebration
|
||||
description: >-
|
||||
Turn off to keep the automation installed but skip the
|
||||
notification entirely.
|
||||
default: true
|
||||
selector:
|
||||
boolean: {}
|
||||
last_celebrated_helper:
|
||||
name: Last-celebrated helper
|
||||
description: >-
|
||||
A single input_text helper remembering which children were
|
||||
already celebrated today, as "<child_id>:<date>" entries
|
||||
separated by commas. The automation skips a child whose entry
|
||||
for today is already stored, and rewrites the helper with only
|
||||
today's entries plus the new one after each celebration.
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
- domain: input_text
|
||||
|
||||
mode: queued
|
||||
|
||||
variables:
|
||||
celebrate: !input celebrate
|
||||
last_celebrated_helper: !input last_celebrated_helper
|
||||
|
||||
triggers:
|
||||
- trigger: event
|
||||
event_type: nestquest_child_day_complete
|
||||
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ celebrate | bool }}"
|
||||
# Once-per-child-per-day dedupe: celebrate only when THIS child has
|
||||
# no equal entry for today in the helper. Entries compare as whole
|
||||
# comma-separated tokens — a substring check would wrongly suppress
|
||||
# child 1 after child 11 was celebrated. An unset or missing helper
|
||||
# reads as '' and celebrates.
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ (trigger.event.data.child_id ~ ':' ~ now().strftime('%Y-%m-%d'))
|
||||
not in ((states(last_celebrated_helper) or '')
|
||||
.split(',')
|
||||
| select('ne', '')
|
||||
| list) }}
|
||||
|
||||
actions:
|
||||
- action: !input notify_target
|
||||
data:
|
||||
title: NestQuest day complete
|
||||
message: !input celebration_message
|
||||
# Record this child's celebration, keeping only today's entries so the
|
||||
# helper never outgrows its text limit and stale entries cannot block
|
||||
# next week's celebrations.
|
||||
- action: input_text.set_value
|
||||
target:
|
||||
entity_id: !input last_celebrated_helper
|
||||
data:
|
||||
value: >-
|
||||
{%- set today = now().strftime('%Y-%m-%d') -%}
|
||||
{%- set ns = namespace(entries=[]) -%}
|
||||
{%- for entry in (states(last_celebrated_helper) or '').split(',') -%}
|
||||
{%- if entry and entry.endswith(':' ~ today) -%}
|
||||
{%- set ns.entries = ns.entries + [entry] -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{ (ns.entries + [trigger.event.data.child_id ~ ':' ~ today]) | join(',') }}
|
||||
@@ -0,0 +1,121 @@
|
||||
blueprint:
|
||||
name: NestQuest end-of-day report
|
||||
description: >-
|
||||
Sends the parent an end-of-day report on how the day went. It runs
|
||||
deliberately BEFORE NestQuest's midnight missed-quest sweep, so it
|
||||
reads the same day's data (what is still open at report time, and
|
||||
how much of the day was completed) rather than tomorrow-morning's
|
||||
hindsight. Every PRESENT child gets a completion line, so the
|
||||
parent always receives the day's summary; children who still owe
|
||||
quests get their open count called out on that line, and children
|
||||
who are not present today are omitted entirely. A child whose
|
||||
remaining-count sensor is unavailable is reported with the
|
||||
completion line only. By default the report is skipped when every
|
||||
present child is already done — turn on send-even-when-clear to
|
||||
always get the day's completion summary. The notify service is
|
||||
chosen by the household when importing this blueprint — NestQuest
|
||||
never hard-codes a personal notify target.
|
||||
domain: automation
|
||||
input:
|
||||
report_time:
|
||||
name: Report time
|
||||
description: >-
|
||||
Local time of day the report is sent — deliberately before the
|
||||
midnight missed-quest sweep, so the report reads the same
|
||||
day's data.
|
||||
default: "20:00"
|
||||
selector:
|
||||
time: {}
|
||||
notify_target:
|
||||
name: Notify service
|
||||
description: >-
|
||||
The Home Assistant notify service to call for the report,
|
||||
for example notify.mobile_app_your_phone — a placeholder;
|
||||
pick the notify service for your own device.
|
||||
selector:
|
||||
text: {}
|
||||
send_report:
|
||||
name: Send the report
|
||||
description: >-
|
||||
Turn off to keep the automation installed but skip the
|
||||
notification entirely.
|
||||
default: true
|
||||
selector:
|
||||
boolean: {}
|
||||
send_even_when_clear:
|
||||
name: Send even when clear
|
||||
description: >-
|
||||
Send the report even when every present child has already
|
||||
finished their quests today. When off (the default), an
|
||||
all-clear day sends nothing.
|
||||
default: false
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
mode: single
|
||||
|
||||
variables:
|
||||
send_report: !input send_report
|
||||
send_even_when_clear: !input send_even_when_clear
|
||||
|
||||
triggers:
|
||||
- trigger: time
|
||||
at: !input report_time
|
||||
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ send_report | bool }}"
|
||||
# Skip an all-clear day unless the household opted into
|
||||
# send-even-when-clear: the guard is false only when every present
|
||||
# child has a usable remaining count of zero. A MISSING or
|
||||
# non-numeric remaining sensor reads as -1 and counts as open —
|
||||
# a degraded state must reach the parent, not look like all-clear.
|
||||
- condition: template
|
||||
value_template: |
|
||||
{%- set ns = namespace(any_open=false) -%}
|
||||
{%- for due in states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
|
||||
{%- if due.attributes.get('present') -%}
|
||||
{%- set remaining_values = states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
|
||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||
| map(attribute='state')
|
||||
| list -%}
|
||||
{%- if not remaining_values or (remaining_values | first | int(-1)) != 0 -%}
|
||||
{%- set ns.any_open = true -%}
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{- send_even_when_clear | bool or ns.any_open -}}
|
||||
|
||||
actions:
|
||||
- action: !input notify_target
|
||||
data:
|
||||
title: NestQuest end-of-day report
|
||||
message: |
|
||||
{%- set ns = namespace(lines=[]) -%}
|
||||
{%- for due in states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
|
||||
{%- if due.attributes.get('present') -%}
|
||||
{%- set due_count = due.state | int -%}
|
||||
{%- set completed_values = states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_completed_today(_\\d+)?$')
|
||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||
| map(attribute='state')
|
||||
| list -%}
|
||||
{%- set remaining_values = states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
|
||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||
| map(attribute='state')
|
||||
| list -%}
|
||||
{%- set completed_count = (completed_values | first | int) if completed_values else 0 -%}
|
||||
{%- set remaining_count = (remaining_values | first | int) if remaining_values else none -%}
|
||||
{%- if remaining_count is not none and remaining_count > 0 -%}
|
||||
{%- set line = due.attributes.child_name ~ ': ' ~ remaining_count ~ ' still open (' ~ completed_count ~ '/' ~ due_count ~ ' done).' -%}
|
||||
{%- else -%}
|
||||
{%- set line = due.attributes.child_name ~ ': ' ~ completed_count ~ '/' ~ due_count ~ ' done.' -%}
|
||||
{%- endif -%}
|
||||
{%- set ns.lines = ns.lines + [line] -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{- ns.lines | join('\n') -}}
|
||||
@@ -50,6 +50,7 @@ from .dao_rules import QuestDefinitionsDao
|
||||
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
||||
from .db import NestQuestDatabase
|
||||
from .presence import (
|
||||
MAX_PREVIEW_SCAN_DAYS,
|
||||
PresenceEngine,
|
||||
PresenceOverride,
|
||||
PresenceSchedule,
|
||||
@@ -86,6 +87,11 @@ class ChildDaySnapshot:
|
||||
completed_today: int
|
||||
remaining_today: int
|
||||
completion_pct: int
|
||||
#: For a child away today: the ISO date of their next present day
|
||||
#: (the panel's away plate renders ``Returns <weekday>, <Mon D>``
|
||||
#: from it). ``None`` when the child is present today or no
|
||||
#: present day exists within the presence engine's scan cap.
|
||||
next_present: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -162,10 +168,14 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
schedules[child.id] = PresenceSchedule.decode(
|
||||
child.id, schedule.anchor_date, schedule.pattern
|
||||
)
|
||||
todays = await PresenceOverridesDao(
|
||||
window_end = today + datetime.timedelta(
|
||||
days=MAX_PREVIEW_SCAN_DAYS
|
||||
)
|
||||
window_end_iso = window_end.isoformat()
|
||||
ranged = await PresenceOverridesDao(
|
||||
self.database
|
||||
).list_by_child_and_range(child.id, today_iso, today_iso)
|
||||
if todays:
|
||||
).list_by_child_and_range(child.id, today_iso, window_end_iso)
|
||||
if ranged:
|
||||
# The engine validates MODEL objects, not DAO records;
|
||||
# each record converts losslessly (the schema guarantees
|
||||
# end >= start and a real is_present).
|
||||
@@ -177,7 +187,7 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
record.is_present,
|
||||
record.note,
|
||||
)
|
||||
for record in todays
|
||||
for record in ranged
|
||||
]
|
||||
engine = PresenceEngine(schedules, overrides)
|
||||
|
||||
@@ -231,18 +241,28 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
due = len(views)
|
||||
completed = sum(1 for view in views if view.state == "done")
|
||||
remaining = due - completed
|
||||
child_present = engine.is_present(child.id, today)
|
||||
next_present: str | None = None
|
||||
if not child_present:
|
||||
try:
|
||||
next_present = engine.next_present_dates(
|
||||
child.id, today, 1
|
||||
)[0].isoformat()
|
||||
except ValueError:
|
||||
next_present = None
|
||||
snapshots.append(
|
||||
ChildDaySnapshot(
|
||||
child_id=child.id,
|
||||
child_name=child.display_name,
|
||||
instances=tuple(views),
|
||||
present=engine.is_present(child.id, today),
|
||||
present=child_present,
|
||||
due_today=due,
|
||||
completed_today=completed,
|
||||
remaining_today=remaining,
|
||||
completion_pct=(
|
||||
round(completed * 100 / due) if due else 100
|
||||
),
|
||||
next_present=next_present,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Lovelace card bundle registration for NestQuest."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
WWW_DIR = Path(__file__).parent / "www"
|
||||
FRONTEND_URL_BASE = "/nestquest-static"
|
||||
BUNDLE_FILENAME = "nestquest-cards.js"
|
||||
BUNDLE_URL = f"{FRONTEND_URL_BASE}/{BUNDLE_FILENAME}"
|
||||
_REGISTERED_ATTR = "_nestquest_frontend_registered"
|
||||
|
||||
|
||||
async def async_register_frontend(hass: HomeAssistant) -> None:
|
||||
"""Serve the card bundle and inject it into every Lovelace page.
|
||||
|
||||
Idempotent across config entries: static paths and extra JS URLs are
|
||||
process-global, so a second entry must not register them again.
|
||||
"""
|
||||
if getattr(hass, _REGISTERED_ATTR, False):
|
||||
return
|
||||
await hass.http.async_register_static_paths(
|
||||
[
|
||||
StaticPathConfig(
|
||||
FRONTEND_URL_BASE,
|
||||
str(WWW_DIR),
|
||||
cache_headers=False,
|
||||
)
|
||||
]
|
||||
)
|
||||
add_extra_js_url(hass, BUNDLE_URL)
|
||||
setattr(hass, _REGISTERED_ATTR, True)
|
||||
@@ -5,6 +5,10 @@
|
||||
"@overseer"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": [
|
||||
"frontend",
|
||||
"http"
|
||||
],
|
||||
"documentation": "https://github.com/talon2king/NestQuest",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,76 @@
|
||||
/* ============================================================
|
||||
NestQuest — Parent admin card tokens
|
||||
Surface: phone, 390x844 reference. Plain CubeCraft styling,
|
||||
no tabletop treatment, plain domain words in the UI.
|
||||
Scope on the card host: nestquest-admin-card.
|
||||
Derived from the approved mockups (admin screens 1d-1i).
|
||||
Two densities were built; COMPACT is the default. Flip
|
||||
--nq-a-density-* to the roomy values for the alternative.
|
||||
============================================================ */
|
||||
|
||||
:host {
|
||||
/* ── Brand + neutrals (CubeCraft tokens, verbatim) ─────── */
|
||||
--nq-brand-blue: #3B3AB8;
|
||||
--nq-brand-purple: #A035CC;
|
||||
--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));
|
||||
--nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple));
|
||||
|
||||
--nq-a-page: #F4F4F6;
|
||||
--nq-a-surface: #FFFFFF;
|
||||
--nq-a-surface-subtle: #FAFAFA;
|
||||
--nq-a-selected: #EBEBF8; /* row being edited */
|
||||
--nq-a-border: #E4E4EA;
|
||||
--nq-a-divider: #EDEDF1;
|
||||
|
||||
--nq-a-ink: #18181D;
|
||||
--nq-a-ink-secondary: #52525E;
|
||||
--nq-a-ink-tertiary: #70707E; /* inactive rows, unselected nav icons */
|
||||
|
||||
--nq-a-success: #15803D;
|
||||
--nq-a-danger: #DC2626;
|
||||
--nq-a-danger-strong: #B91C1C;
|
||||
--nq-a-danger-bg: #FEE2E2;
|
||||
--nq-a-warning-ink: #92400E;
|
||||
--nq-a-warning-ink-2: #78350F;
|
||||
--nq-a-warning-bg: #FEF3C7;
|
||||
--nq-a-warning-border: rgba(217,119,6,.35);
|
||||
--nq-a-info-bg: #DBEAFE;
|
||||
--nq-a-info-ink: #1E3A8A;
|
||||
--nq-a-info-icon: #1D4ED8;
|
||||
--nq-a-reversal: #7D28A0; /* un-complete events in history */
|
||||
|
||||
/* ── Type ─────────────────────────────────────────────── */
|
||||
--nq-a-font: Nunito, system-ui, sans-serif;
|
||||
--nq-a-size-screen: 24px; /* 800, screen title */
|
||||
--nq-a-size-hero: 32px; /* 800, roomy "8 of 12 done" */
|
||||
--nq-a-size-card-title: 19px; /* 800, roomy child card */
|
||||
--nq-a-size-stat: 22px; /* 800, compact stat number */
|
||||
--nq-a-size-row: 13.5px;/* 700, list row title */
|
||||
--nq-a-size-meta: 11px; /* 600, row meta */
|
||||
--nq-a-size-label: 10px; /* 700, uppercase field label */
|
||||
--nq-a-track-label: .10em;
|
||||
|
||||
/* ── Density: COMPACT (default) ───────────────────────── */
|
||||
--nq-a-density-page-pad: 14px 16px 92px; /* bottom clears the 70px tab bar */
|
||||
--nq-a-density-gap: 10px;
|
||||
--nq-a-density-card-pad: 13px;
|
||||
--nq-a-density-row-pad: 11px 13px;
|
||||
--nq-a-density-radius: 8px;
|
||||
--nq-a-density-shadow: none;
|
||||
/* Density: ROOMY — page-pad 18px 20px 92px; gap 16px;
|
||||
card-pad 18px; radius 12px; shadow 0 4px 12px rgba(0,0,0,.06) */
|
||||
|
||||
/* ── Metrics ──────────────────────────────────────────── */
|
||||
--nq-a-tabbar-height: 70px; /* + 12px safe-area padding */
|
||||
--nq-a-tap-min: 44px; /* every row/control clears this */
|
||||
--nq-a-radius-pill: 9999px;
|
||||
--nq-a-radius-sheet: 20px 20px 0 0;
|
||||
--nq-a-sheet-shadow: 0 -8px 28px rgba(9,9,11,.18);
|
||||
--nq-a-progress-height: 6px; /* 8px roomy, 10px hero */
|
||||
|
||||
/* ── Motion ───────────────────────────────────────────── */
|
||||
--nq-ease-out: cubic-bezier(0, 0, .2, 1);
|
||||
--nq-dur-micro: 120ms;
|
||||
--nq-dur-base: 200ms;
|
||||
--nq-dur-modal: 350ms;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
@font-face {
|
||||
font-family: "Nunito";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("./fonts/nunito-latin-400-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Nunito";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("./fonts/nunito-latin-600-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Nunito";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("./fonts/nunito-latin-700-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Nunito";
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("./fonts/nunito-latin-800-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cinzel";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("./fonts/cinzel-latin-600-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cinzel";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("./fonts/cinzel-latin-700-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cinzel";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("./fonts/cinzel-latin-900-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cinzel Decorative";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("./fonts/cinzel-decorative-latin-700-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cinzel Decorative";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("./fonts/cinzel-decorative-latin-900-normal.woff2") format("woff2");
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/* ============================================================
|
||||
NestQuest — Kids' panel card tokens
|
||||
Surface: DAKboard DAKOS wall panel, 1920x1080 landscape,
|
||||
viewed from ~3 ft, no login, read/complete only.
|
||||
Scope these on the card host: nestquest-quest-log-card,
|
||||
nestquest-party-board-card.
|
||||
Derived from the approved mockups (panel screens 1a, 1b, 1c, 1j).
|
||||
============================================================ */
|
||||
|
||||
:host {
|
||||
/* ── Parchment ground ─────────────────────────────────── */
|
||||
--nq-p-parchment-top: #f0e4c7;
|
||||
--nq-p-parchment-bottom: #ddcca3;
|
||||
--nq-p-parchment: radial-gradient(ellipse at 25% 10%, rgba(255,255,255,.5), transparent 55%),
|
||||
radial-gradient(ellipse at 85% 90%, rgba(120,86,44,.28), transparent 60%),
|
||||
repeating-linear-gradient(93deg, rgba(150,115,70,.05) 0 2px, transparent 2px 6px),
|
||||
repeating-linear-gradient(2deg, rgba(150,115,70,.04) 0 3px, transparent 3px 7px),
|
||||
linear-gradient(var(--nq-p-parchment-top), var(--nq-p-parchment-bottom));
|
||||
--nq-p-vignette: inset 0 0 200px rgba(80,52,20,.35);
|
||||
|
||||
/* Card surfaces sitting on parchment */
|
||||
--nq-p-card-open: linear-gradient(#fdf7e6, #f2e7c9); /* incomplete quest */
|
||||
--nq-p-card-done: linear-gradient(#f0e8d3, #e6dcc2); /* sealed quest */
|
||||
--nq-p-card-panel: linear-gradient(#fcf6e6, #f1e6ca); /* adventurer plate, stat block */
|
||||
--nq-p-card-border: rgba(120,88,48,.38);
|
||||
--nq-p-panel-border: rgba(92,62,26,.40);
|
||||
--nq-p-card-shadow: 0 3px 0 rgba(120,88,48,.20), inset 0 1px 0 rgba(255,255,255,.70);
|
||||
--nq-p-panel-shadow: 0 6px 0 rgba(92,62,26,.18), inset 0 2px 0 rgba(255,255,255,.70);
|
||||
--nq-p-frame-outer: 2px solid rgba(92,62,26,.45);
|
||||
--nq-p-frame-inner: 1px solid rgba(92,62,26,.28);
|
||||
--nq-p-rule: 2px solid rgba(92,62,26,.35);
|
||||
|
||||
/* ── Ink (all values >= 4.5:1 on parchment) ───────────── */
|
||||
--nq-p-ink: #2b1f14; /* titles, quest names */
|
||||
--nq-p-ink-secondary: #5c452a; /* meta, window labels, counts */
|
||||
--nq-p-ink-muted: #6f6455; /* sealed quest titles only */
|
||||
--nq-p-ink-away: #4d4433; /* away adventurer name */
|
||||
--nq-p-ink-late: #8f1526; /* overdue line */
|
||||
|
||||
/* ── Brand (CubeCraft, toned down: accents only) ──────── */
|
||||
--nq-brand-blue: #3B3AB8;
|
||||
--nq-brand-purple: #A035CC;
|
||||
--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));
|
||||
--nq-p-icon-tile: var(--nq-brand-gradient); /* open quest icon tile */
|
||||
--nq-p-icon-tile-done: rgba(92,62,26,.16);
|
||||
--nq-p-crest: var(--nq-brand-gradient);
|
||||
--nq-p-crest-away: linear-gradient(135deg, #6b6b7a, #7a7286);
|
||||
|
||||
/* ── Wax seal (completed marker) ──────────────────────── */
|
||||
--nq-p-seal: radial-gradient(circle at 35% 30%, #a8283a, #6d1322);
|
||||
--nq-p-seal-shadow: 0 4px 10px rgba(60,10,20,.40), inset 0 0 0 4px rgba(255,255,255,.14);
|
||||
--nq-p-seal-size: 78px; /* 168px on the quest-complete screen */
|
||||
|
||||
/* ── Dock (bottom strip, non-interactive) ─────────────── */
|
||||
--nq-p-dock-bg: rgba(43,31,20,.90);
|
||||
--nq-p-dock-ink: #f7efdb;
|
||||
--nq-p-dock-ink-secondary: #d8c9a6;
|
||||
--nq-p-dock-divider: rgba(233,220,189,.30);
|
||||
--nq-p-dock-height: 84px;
|
||||
|
||||
/* ── Type ─────────────────────────────────────────────── */
|
||||
--nq-p-font-display: 'Cinzel Decorative', Cinzel, serif; /* NestQuest, names, Quest complete */
|
||||
--nq-p-font-heading: Cinzel, serif; /* section heads, quest titles, numerals */
|
||||
--nq-p-font-body: Nunito, system-ui, sans-serif; /* meta lines, dock */
|
||||
|
||||
--nq-p-size-hero: 86px; /* 900 weight, "Quest complete" */
|
||||
--nq-p-size-wordmark: 66px; /* 900 weight */
|
||||
--nq-p-size-title: 56px; /* 700, "Declan's Quest Log" */
|
||||
--nq-p-size-name: 48px; /* 700, adventurer name */
|
||||
--nq-p-size-section: 32px; /* 700, Morning / Afternoon / Evening */
|
||||
--nq-p-size-quest: 30px; /* 700, quest title */
|
||||
--nq-p-size-body: 22px; /* 600 Nunito, meta — PANEL MINIMUM */
|
||||
--nq-p-size-label: 21px; /* 600 Cinzel, uppercase, tracked */
|
||||
--nq-p-track-label: .14em;
|
||||
--nq-p-track-kicker: .30em;
|
||||
|
||||
/* ── Metrics: touch ───────────────────────────────────── */
|
||||
--nq-p-quest-min-height: 116px; /* whole card is the tap target */
|
||||
--nq-p-quest-gap: 24px; /* separation between tap targets */
|
||||
--nq-p-quest-pad: 22px; /* padding inside a quest card */
|
||||
--nq-p-tile-size: 64px; /* quest icon tile */
|
||||
--nq-p-button-height: 72px; /* "Complete" button on a quest card */
|
||||
--nq-p-button-min-width: 150px;
|
||||
--nq-p-confirm-button: 108px; /* confirm dialog buttons */
|
||||
--nq-p-radius-card: 12px;
|
||||
--nq-p-radius-panel: 20px;
|
||||
--nq-p-radius-pill: 9999px;
|
||||
--nq-p-page-inset: 62px;
|
||||
--nq-p-frame-inset: 26px;
|
||||
|
||||
/* ── Motion (CubeCraft easing) ────────────────────────── */
|
||||
--nq-ease-out: cubic-bezier(0, 0, .2, 1);
|
||||
--nq-dur-micro: 120ms;
|
||||
--nq-dur-base: 200ms;
|
||||
--nq-dur-modal: 350ms;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
# Panel empty & error states — design addendum
|
||||
|
||||
Closes the gap DESIGN-HANDOFF.md lists under "Open questions": *"Empty and error
|
||||
states are unspecified by request (child with no quests, everyone away, integration
|
||||
unconfigured, DB unreachable). Panel must not render a blank parchment — decide
|
||||
before shipping the card."*
|
||||
|
||||
All decisions below reuse PANEL-SPEC.md's approved aesthetic — parchment ground,
|
||||
double frame, dock, panel tokens, no text below 22px, no alpha-muted ink — and add
|
||||
one shared component per card: a **notice panel** (radius 20px, `--nq-p-card-panel`,
|
||||
2px `--nq-p-panel-border`, `--nq-p-panel-shadow`, centred icon + headline + body).
|
||||
The frame, wordmark row, kicker and dock always render; only the content area
|
||||
changes. **No state renders a blank parchment.**
|
||||
|
||||
## 1. What the data can say
|
||||
|
||||
The cards read `sensor.nestquest_<child>_quests_due_today` (plus siblings). One of
|
||||
four things comes back, and the card must tell them apart:
|
||||
|
||||
| Backend condition | What the card sees | Card's interpretation |
|
||||
|---|---|---|
|
||||
| Resolving (DB fine) | numeric state + attributes (`child_name`, `present`, `instances`) | normal, all states below |
|
||||
| Integration not configured | entity does not exist at all | **not set up** |
|
||||
| Backend unreachable (DB locked, refresh failed) | state `unavailable` | **records unreachable** |
|
||||
| Child deactivated / absent from snapshot | state `unknown` | **records unreachable** (same panel voice: the records do not answer) |
|
||||
|
||||
An away child (presence `present: false`, from the due-sensor attributes with the
|
||||
presence binary sensor as fallback) is *not* an error: it is a first-class panel
|
||||
state ("On travels"). Away children get no instances (materialiser skips them), so
|
||||
away is detected from the presence flag, never from a zero quest count.
|
||||
|
||||
## 2. Quest log — state priority
|
||||
|
||||
`nestquest-quest-log-card` resolves exactly one state, in this order:
|
||||
|
||||
1. **No adventurer in the path** (direct navigation to a URL with no child slug —
|
||||
misconfigured `board_path`/`quest_log_path`) → notice *"No adventurer chosen"*,
|
||||
body *"Open The Party and tap your crest to open your quest log."*
|
||||
2. **Not set up** (entity missing) → notice *"NestQuest is not set up yet"*, body
|
||||
*"A parent needs to finish setting up NestQuest."*
|
||||
3. **Records unreachable** (`unavailable`/`unknown`) → notice *"The records cannot
|
||||
be reached"*, body *"The party's records are quiet right now. NestQuest will
|
||||
return shortly."*
|
||||
4. **Away** (present flag false) → away notice: dashed muted panel (the away-plate
|
||||
gradient), tent icon, *"<Name> is on travels"*, *"The quest log unlocks when
|
||||
they return."* and, when `next_present` is known, *"Returns <weekday>, <Mon D>"*.
|
||||
Direct navigation is the one path that can reach this state (the board's away
|
||||
plate is not tappable); the log answers it honestly instead of showing an empty
|
||||
column set. Header renders with the crest in away colours and the sub line
|
||||
`… · On travels`; the remaining-count block is hidden (it is meaningless while
|
||||
away).
|
||||
5. **No quests today** (present, due = 0) → all-clear notice, sun icon, *"No quests
|
||||
today"*, body *"Nothing was posted for today, <Name>. Enjoy the day's rest!"* —
|
||||
never three bare empty columns. The header and remaining block stay (0 quests
|
||||
left, party progress), so the log still reads like the log.
|
||||
6. **All quests done** (due > 0, remaining = 0) → the fallback celebration that
|
||||
shows until the 1c quest-complete screen takes over: centred wax-seal mark (the
|
||||
`--nq-p-seal` tokens at 78px, check glyph, −6°) with headline *"Quest complete"*
|
||||
— the same phrase screen 1c uses — and body *"Every quest is claimed, <Name>.
|
||||
The seal is set for today. Returning to The Party shortly."* The idle return
|
||||
(§3.5) is what actually navigates back; 1c remains the designed takeover.
|
||||
7. **Normal** → the three-window column layout of PANEL-SPEC §3.1 unchanged. A
|
||||
window with no quests still renders its head alone, exactly as the spec says.
|
||||
|
||||
Notices sit in the columns' content area, vertically centred. Error notices (states
|
||||
1–3) replace the header entirely — there is no honest name to headline when the
|
||||
records do not answer — while states 4–6 keep the full header.
|
||||
|
||||
## 3. Party board — state priority
|
||||
|
||||
`nestquest-party-board-card` resolves per configured child, then at board level:
|
||||
|
||||
1. **No children configured** (`child_order` empty) → board notice *"NestQuest is
|
||||
not set up yet"*, body *"A parent needs to finish setting up NestQuest before
|
||||
the party can gather."*
|
||||
2. **Every configured child unresolvable** → one board notice instead of a grid of
|
||||
broken plates: *"NestQuest is not set up yet"* when the entities are missing,
|
||||
*"The records cannot be reached"* when they answer `unavailable`/`unknown`.
|
||||
3. **Partial** (some children resolve, some do not) → the board still renders the
|
||||
resolving plates; an unresolvable child renders a muted, dashed plate in the
|
||||
away geometry with a compass icon and the pill label **"Unknown"**, progress
|
||||
line `—`, empty bar, **not tappable**. It must not claim "Home today" (0 of 0)
|
||||
and must not lie "On travels": the records simply do not say.
|
||||
4. **Normal** → the plates grid of PANEL-SPEC §2 unchanged (present + away plates).
|
||||
|
||||
The hint line ("Tap your crest…") is hidden in board-level notice states — there
|
||||
is nothing to tap.
|
||||
|
||||
## 4. Shared rules
|
||||
|
||||
- **Never a blank parchment.** Every render path emits the frames, wordmark,
|
||||
kicker, dock, and either content or a notice panel. The dock already degrades to
|
||||
date + time when weather is unset (PANEL-SPEC §2.3); that behaviour is unchanged.
|
||||
- **Notice panel type scale:** icon 46px, headline 40px/700 Cinzel
|
||||
(`--nq-p-font-heading`, `--nq-p-ink`), body 24px/600 Nunito
|
||||
(`--nq-p-ink-secondary`), `role="status"` so assistive tech announces it.
|
||||
- **Tone.** The panel speaks tabletop (PANEL-SPEC vocabulary) but never lies: an
|
||||
error says the records cannot be reached, not that everything is fine. Error
|
||||
copy names the actor who can fix it (a parent) or promises the return of the
|
||||
data — no stack traces, no entity IDs.
|
||||
- **No interaction in error/away states.** No confirm dialog, no idle return
|
||||
changes, no navigation attempts. Notices are static.
|
||||
- Both cards implement the same resolution rules, so the board and the log never
|
||||
disagree about whether the integration is up.
|
||||
@@ -77,6 +77,8 @@
|
||||
/* ── Metrics: touch ───────────────────────────────────── */
|
||||
--nq-p-quest-min-height: 116px; /* whole card is the tap target */
|
||||
--nq-p-quest-gap: 24px; /* separation between tap targets */
|
||||
--nq-p-quest-pad: 22px; /* padding inside a quest card */
|
||||
--nq-p-tile-size: 64px; /* quest icon tile */
|
||||
--nq-p-button-height: 72px; /* "Complete" button on a quest card */
|
||||
--nq-p-button-min-width: 150px;
|
||||
--nq-p-confirm-button: 108px; /* confirm dialog buttons */
|
||||
|
||||
Generated
+1232
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "nestquest-cards",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"lit": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { LitElement, html, unsafeCSS } from "lit";
|
||||
import adminTokens from "../../../custom_components/nestquest/www/nestquest-admin-tokens.css?inline";
|
||||
|
||||
import { registerCustomCard, type CardConfig, type HassLike } from "../types";
|
||||
|
||||
export class NestQuestAdminCard extends LitElement {
|
||||
static properties = {
|
||||
hass: { attribute: false },
|
||||
_config: { state: true },
|
||||
};
|
||||
|
||||
static styles = [unsafeCSS(adminTokens)];
|
||||
|
||||
hass?: HassLike;
|
||||
_config?: CardConfig;
|
||||
|
||||
setConfig(config: CardConfig): void {
|
||||
if (!config || typeof config !== "object") {
|
||||
throw new Error("Invalid configuration");
|
||||
}
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
getCardSize(): number {
|
||||
return 8;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<div>NestQuest Admin</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("nestquest-admin-card", NestQuestAdminCard);
|
||||
registerCustomCard({
|
||||
type: "nestquest-admin-card",
|
||||
name: "NestQuest Admin",
|
||||
description: "Parent phone view for today, tasks, schedule, and history.",
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
import "./cards/party-board";
|
||||
import "./cards/quest-log";
|
||||
import "./cards/admin";
|
||||
|
||||
const FONTS_HREF = "/nestquest-static/nestquest-fonts.css";
|
||||
if (!document.querySelector('link[data-nq-fonts=""]')) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = FONTS_HREF;
|
||||
link.dataset.nqFonts = "";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export type HassLike = Record<string, unknown>;
|
||||
|
||||
export type CardConfig = Record<string, unknown>;
|
||||
|
||||
export type CustomCardInfo = {
|
||||
type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
customCards?: CustomCardInfo[];
|
||||
}
|
||||
}
|
||||
|
||||
export function registerCustomCard(info: CustomCardInfo): void {
|
||||
window.customCards = window.customCards ?? [];
|
||||
window.customCards.push(info);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: "../custom_components/nestquest/www",
|
||||
emptyOutDir: false,
|
||||
sourcemap: false,
|
||||
lib: {
|
||||
entry: "src/nestquest-cards.ts",
|
||||
formats: ["es"],
|
||||
fileName: () => "nestquest-cards.js",
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -51,6 +51,8 @@ _HA_MODULES = (
|
||||
"homeassistant.components",
|
||||
"homeassistant.components.sensor",
|
||||
"homeassistant.components.binary_sensor",
|
||||
"homeassistant.components.http",
|
||||
"homeassistant.components.frontend",
|
||||
)
|
||||
|
||||
def _find_spec(name: str):
|
||||
@@ -96,6 +98,8 @@ for _parent, _child in (
|
||||
("homeassistant", "components"),
|
||||
("homeassistant.components", "sensor"),
|
||||
("homeassistant.components", "binary_sensor"),
|
||||
("homeassistant.components", "http"),
|
||||
("homeassistant.components", "frontend"),
|
||||
):
|
||||
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
|
||||
|
||||
@@ -429,6 +433,28 @@ _sensor_mock.SensorStateClass = SensorStateClass
|
||||
_binary_sensor_mock = _ha_mock("homeassistant.components.binary_sensor")
|
||||
_binary_sensor_mock.BinarySensorEntity = BinarySensorEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StaticPathConfig:
|
||||
"""Stand-in mirroring homeassistant.components.http.StaticPathConfig."""
|
||||
|
||||
url_path: str
|
||||
path: str
|
||||
cache_headers: bool = True
|
||||
|
||||
|
||||
def _add_extra_js_url(hass, url, es5=False):
|
||||
"""Stand-in mirroring homeassistant.components.frontend.add_extra_js_url."""
|
||||
urls = getattr(hass, "extra_js_urls", None)
|
||||
if urls is None:
|
||||
hass.extra_js_urls = []
|
||||
urls = hass.extra_js_urls
|
||||
urls.append(url)
|
||||
|
||||
|
||||
_ha_mock("homeassistant.components.http").StaticPathConfig = StaticPathConfig
|
||||
_ha_mock("homeassistant.components.frontend").add_extra_js_url = _add_extra_js_url
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
|
||||
@@ -549,6 +575,16 @@ def _async_track_time_change(hass, action, hour=None, minute=None, second=None):
|
||||
_ha_mock("homeassistant.helpers.event").async_track_time_change = _async_track_time_change
|
||||
|
||||
|
||||
class HttpRegistry:
|
||||
"""Models HA's ``hass.http`` static-path registration."""
|
||||
|
||||
def __init__(self):
|
||||
self.static_paths: list[StaticPathConfig] = []
|
||||
|
||||
async def async_register_static_paths(self, configs):
|
||||
self.static_paths.extend(list(configs))
|
||||
|
||||
|
||||
class EventBus:
|
||||
"""Models HA's ``hass.bus`` event bus.
|
||||
|
||||
@@ -697,6 +733,8 @@ def make_hass() -> tuple:
|
||||
hass.time_change = TimeChangeRegistry(hass)
|
||||
hass.services = ServiceRegistry(hass)
|
||||
hass.bus = EventBus()
|
||||
hass.http = HttpRegistry()
|
||||
hass.extra_js_urls: list[str] = []
|
||||
# A valid IANA time zone (the day-rollover listener reads it to
|
||||
# compute "today" in HA local time).
|
||||
hass.config.time_zone = "UTC"
|
||||
|
||||
@@ -6,7 +6,7 @@ import datetime
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.children import create_child
|
||||
from custom_components.nestquest.children import create_child, list_children
|
||||
from custom_components.nestquest.const import DOMAIN
|
||||
from custom_components.nestquest.dao_presence import PresenceSchedulesDao
|
||||
from custom_components.nestquest.materialize import materialize
|
||||
@@ -128,7 +128,57 @@ async def test_binary_sensor_identity_and_attributes(hass, make_entry) -> None:
|
||||
assert present.name == "NestQuest Ada present today"
|
||||
for entity in (all_done, present):
|
||||
assert entity.device_info.name == "NestQuest Ada"
|
||||
assert entity.extra_state_attributes == {
|
||||
assert all_done.extra_state_attributes == {
|
||||
"child_id": ada.id,
|
||||
"child_name": "Ada",
|
||||
}
|
||||
assert present.extra_state_attributes == {
|
||||
"child_id": ada.id,
|
||||
"child_name": "Ada",
|
||||
"next_present": None,
|
||||
}
|
||||
|
||||
|
||||
async def test_present_today_carries_next_present_when_away(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A child away today carries the ISO date of their next present
|
||||
day (the panel's away plate renders ``Returns <weekday>, <Mon D>``
|
||||
from it); an always-absent pattern carries ``None``."""
|
||||
entry = await _setup_entry(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
ada, _bo = await _seed_two_children(database)
|
||||
await create_child(database, "Cory")
|
||||
today = datetime.date.today()
|
||||
pattern = ",".join(
|
||||
str(day) for day in range(7) if day != today.weekday()
|
||||
)
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
ada.id, 1, today.isoformat(), pattern
|
||||
)
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
await _cory_id(database), 1, today.isoformat(), ""
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
assert _binary(hass, ada.id, "present_today").is_on is False
|
||||
assert _binary(hass, ada.id, "present_today").extra_state_attributes[
|
||||
"next_present"
|
||||
] == (today + datetime.timedelta(days=1)).isoformat()
|
||||
|
||||
cory = [
|
||||
child for child in coordinator.data.children
|
||||
if child.child_name == "Cory"
|
||||
][0]
|
||||
assert _binary(hass, cory.child_id, "present_today").is_on is False
|
||||
assert _binary(hass, cory.child_id, "present_today").extra_state_attributes[
|
||||
"next_present"
|
||||
] is None
|
||||
|
||||
|
||||
async def _cory_id(database) -> int:
|
||||
for child in await list_children(database):
|
||||
if child.display_name == "Cory":
|
||||
return child.id
|
||||
raise AssertionError("Cory was not created")
|
||||
|
||||
@@ -241,4 +241,28 @@ def test_afternoon_reminder_never_hard_codes_notify_target() -> None:
|
||||
assert re.search(r"notify\.[a-z_]+", target_description), (
|
||||
"the input description should show a placeholder example"
|
||||
)
|
||||
assert single_action(bp)["action"] == BlueprintInput("notify_target")
|
||||
assert single_action(bp)["action"] == BlueprintInput("notify_target")
|
||||
|
||||
def test_afternoon_reminder_absent_child_regression() -> None:
|
||||
"""The Feature 11 guardrail, stated as one regression test: no
|
||||
reminder message is ever produced for an absent child."""
|
||||
states = make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_bob_quests_due_today",
|
||||
"3",
|
||||
{"child_name": "Bob", "child_id": 1, "present": False},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_bob_quests_remaining_today",
|
||||
"3",
|
||||
{"child_id": 1},
|
||||
),
|
||||
],
|
||||
)
|
||||
guard = afternoon_conditions(afternoon_reminder())[-1]
|
||||
assert render(guard["value_template"], states=states).strip() == "False"
|
||||
message = render(
|
||||
notify_data(afternoon_reminder())["message"], states=states
|
||||
)
|
||||
assert "Bob" not in message
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Tests for the NestQuest day-complete celebration automation blueprint.
|
||||
|
||||
The celebration fires on the ``nestquest_child_day_complete`` event —
|
||||
which NestQuest only fires when quests were owed and all are complete,
|
||||
so a zero-quest day can never celebrate. Dedupe design (per-child,
|
||||
per-day): the household points ONE ``input_text`` helper at the
|
||||
blueprint; the condition celebrates only when THIS child has no
|
||||
``<child_id>:<today>`` entry stored, and the action rewrites the
|
||||
helper with only today's entries plus the new one — so a child who
|
||||
un-completes and re-completes the same day is celebrated once, not
|
||||
twice, while a second child clearing the same day is still celebrated.
|
||||
|
||||
The harness (YAML ``!input`` loader, state fixtures, jinja rendering)
|
||||
is shared in :mod:`tests.blueprint_helpers`; the directory-wide shape
|
||||
and entity-discipline tests in test_blueprints.py cover this file
|
||||
too. ``now()`` in the dedupe condition is injected by the test as a
|
||||
fixed callable, keeping the render deterministic.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import re
|
||||
|
||||
from custom_components.nestquest.const import EVENT_CHILD_DAY_COMPLETE
|
||||
|
||||
from tests.blueprint_helpers import (
|
||||
BlueprintInput,
|
||||
notify_data,
|
||||
blueprint,
|
||||
iter_strings,
|
||||
make_states,
|
||||
render,
|
||||
TemplateState,
|
||||
)
|
||||
|
||||
CELEBRATION_TITLE = "NestQuest day complete"
|
||||
|
||||
HELPER_ENTITY = "input_text.nestquest_last_celebrated"
|
||||
|
||||
#: The fixed "now" every dedupe render uses: 2026-09-19 15:00 local.
|
||||
FIXED_NOW = datetime.datetime(2026, 9, 19, 15, 0, 0)
|
||||
|
||||
|
||||
def celebration() -> dict:
|
||||
"""Return the parsed day-complete-celebration blueprint."""
|
||||
return blueprint("day_complete_celebration")
|
||||
|
||||
|
||||
def celebration_conditions(bp: dict) -> list[dict]:
|
||||
"""Return the blueprint's conditions: toggle first, dedupe second."""
|
||||
conditions = bp["conditions"]
|
||||
assert isinstance(conditions, list) and len(conditions) == 2
|
||||
return conditions
|
||||
|
||||
|
||||
def dedupe_condition(bp: dict) -> str:
|
||||
"""Return the once-per-child-per-day dedupe template (true = celebrate)."""
|
||||
return celebration_conditions(bp)[1]["value_template"]
|
||||
|
||||
|
||||
def stamp_value_template(bp: dict) -> str:
|
||||
"""Return the helper-stamp action's value template."""
|
||||
stamp = bp["actions"][1]
|
||||
return stamp["data"]["value"]
|
||||
|
||||
|
||||
def trigger_payload(child_id: int, child_name: str) -> dict:
|
||||
"""A nestquest_child_day_complete trigger fixture: HA exposes an
|
||||
event trigger's payload as trigger.event.data."""
|
||||
return {
|
||||
"event": {
|
||||
"data": {
|
||||
"child_id": child_id,
|
||||
"child_name": child_name,
|
||||
"quests_due": 3,
|
||||
"quests_completed": 3,
|
||||
"occurred_at": "2026-09-19T15:00:00+00:00",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def render_dedupe(
|
||||
helper_state: str | None, child_id: int = 1
|
||||
) -> str:
|
||||
"""Render the dedupe condition with the helper holding helper_state.
|
||||
|
||||
``None`` models a helper entity the fixture does not hold (never
|
||||
created or not yet set), which HA exposes as a missing state.
|
||||
"""
|
||||
states = make_states(
|
||||
input_texts=(
|
||||
[]
|
||||
if helper_state is None
|
||||
else [TemplateState(HELPER_ENTITY, helper_state)]
|
||||
)
|
||||
)
|
||||
return render(
|
||||
dedupe_condition(celebration()),
|
||||
states=states,
|
||||
last_celebrated_helper=HELPER_ENTITY,
|
||||
now=lambda: FIXED_NOW,
|
||||
trigger=trigger_payload(child_id, "Ada"),
|
||||
).strip()
|
||||
|
||||
|
||||
def render_stamp(helper_state: str | None, child_id: int = 1) -> str:
|
||||
"""Render the stamp action's value with the helper holding helper_state."""
|
||||
states = make_states(
|
||||
input_texts=(
|
||||
[]
|
||||
if helper_state is None
|
||||
else [TemplateState(HELPER_ENTITY, helper_state)]
|
||||
)
|
||||
)
|
||||
return render(
|
||||
stamp_value_template(celebration()),
|
||||
states=states,
|
||||
last_celebrated_helper=HELPER_ENTITY,
|
||||
now=lambda: FIXED_NOW,
|
||||
trigger=trigger_payload(child_id, "Ada"),
|
||||
).strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trigger, inputs, defaults, and wiring.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_celebration_triggers_on_the_child_day_complete_event() -> None:
|
||||
"""The event trigger references the real nestquest_child_day_complete."""
|
||||
trigger = celebration()["triggers"][0]
|
||||
assert trigger["trigger"] == "event"
|
||||
assert trigger["event_type"] == "nestquest_child_day_complete"
|
||||
assert trigger["event_type"] == EVENT_CHILD_DAY_COMPLETE
|
||||
|
||||
|
||||
def test_celebration_declares_inputs_with_selectors() -> None:
|
||||
"""The four inputs exist, carry selectors, and default sanely."""
|
||||
bp = celebration()
|
||||
inputs = bp["blueprint"]["input"]
|
||||
expected = {
|
||||
"notify_target",
|
||||
"celebration_message",
|
||||
"celebrate",
|
||||
"last_celebrated_helper",
|
||||
}
|
||||
assert set(inputs) == expected
|
||||
for name, spec in inputs.items():
|
||||
assert isinstance(spec["selector"], dict) and spec["selector"], name
|
||||
assert inputs["celebrate"]["default"] is True
|
||||
assert "text" in inputs["notify_target"]["selector"]
|
||||
assert "text" in inputs["celebration_message"]["selector"]
|
||||
assert "boolean" in inputs["celebrate"]["selector"]
|
||||
assert inputs["last_celebrated_helper"]["selector"]["entity"]["filter"] == [
|
||||
{"domain": "input_text"}
|
||||
]
|
||||
# The description documents the zero-quest-day rule and the dedupe.
|
||||
description = bp["blueprint"]["description"]
|
||||
assert "zero-quest day" in description
|
||||
assert "once" in description.lower()
|
||||
|
||||
|
||||
def test_celebration_wires_inputs_not_literals() -> None:
|
||||
"""Notify and stamp actions both use !input; the dedupe reads the helper."""
|
||||
bp = celebration()
|
||||
assert bp["variables"]["celebrate"] == BlueprintInput("celebrate")
|
||||
assert bp["variables"]["last_celebrated_helper"] == BlueprintInput(
|
||||
"last_celebrated_helper"
|
||||
)
|
||||
|
||||
toggle, dedupe = celebration_conditions(bp)
|
||||
assert toggle["condition"] == "template"
|
||||
assert "celebrate" in toggle["value_template"]
|
||||
assert dedupe["condition"] == "template"
|
||||
assert "last_celebrated_helper" in dedupe["value_template"]
|
||||
assert "child_id" in dedupe["value_template"], (
|
||||
"the dedupe must be per child, not per household"
|
||||
)
|
||||
|
||||
notify_action, stamp_action = bp["actions"]
|
||||
assert notify_action["action"] == BlueprintInput("notify_target")
|
||||
data = notify_data(bp)
|
||||
assert data["title"] == CELEBRATION_TITLE
|
||||
assert data["message"] == BlueprintInput("celebration_message")
|
||||
|
||||
assert stamp_action["action"] == "input_text.set_value"
|
||||
assert stamp_action["target"]["entity_id"] == BlueprintInput(
|
||||
"last_celebrated_helper"
|
||||
)
|
||||
assert "now()" in stamp_action["data"]["value"]
|
||||
|
||||
|
||||
def test_celebration_message_default_renders_the_child_name() -> None:
|
||||
"""The default message renders the trigger payload's child_name."""
|
||||
default_message = celebration()["blueprint"]["input"]["celebration_message"][
|
||||
"default"
|
||||
]
|
||||
rendered = render(default_message, trigger=trigger_payload(1, "Ada"))
|
||||
assert rendered == "Legendary! Ada cleared every quest today!"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The once-per-child-per-day dedupe condition.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_celebration_dedupe_true_when_child_not_celebrated_today() -> None:
|
||||
"""A helper without THIS child's entry for today lets the
|
||||
celebration through — including when another child was celebrated
|
||||
earlier the same day."""
|
||||
assert render_dedupe(None) == "True"
|
||||
assert render_dedupe("") == "True"
|
||||
assert render_dedupe("2:2026-09-19") == "True", (
|
||||
"a second child clearing the same day must still celebrate"
|
||||
)
|
||||
assert render_dedupe("1:2026-09-18") == "True", (
|
||||
"yesterday's entry never blocks today"
|
||||
)
|
||||
|
||||
|
||||
def test_celebration_dedupe_false_when_child_already_celebrated_today() -> None:
|
||||
"""A child already celebrated today is suppressed — the un-complete
|
||||
and re-complete the same day case."""
|
||||
assert render_dedupe("1:2026-09-19") == "False"
|
||||
assert render_dedupe("2:2026-09-19,1:2026-09-19") == "False"
|
||||
|
||||
|
||||
def test_celebration_stamp_prunes_to_today_and_appends() -> None:
|
||||
"""The stamp keeps only today's entries plus the new one: yesterday's
|
||||
entries are dropped (the helper never outgrows its text limit and
|
||||
stale entries cannot block next week), and the celebrated child is
|
||||
recorded."""
|
||||
assert render_stamp(None, child_id=1) == "1:2026-09-19"
|
||||
assert render_stamp("1:2026-09-18", child_id=2) == "2:2026-09-19"
|
||||
assert render_stamp("1:2026-09-19", child_id=2) == (
|
||||
"1:2026-09-19,2:2026-09-19"
|
||||
)
|
||||
assert render_stamp("1:2026-09-18,2:2026-09-18", child_id=3) == (
|
||||
"3:2026-09-19"
|
||||
)
|
||||
|
||||
|
||||
def test_celebration_celebrate_toggle_renders() -> None:
|
||||
"""The enable condition renders truthy/false from the input value."""
|
||||
toggle = celebration_conditions(celebration())[0]
|
||||
assert render(toggle["value_template"], celebrate=True).strip() == "True"
|
||||
assert render(toggle["value_template"], celebrate=False).strip() == "False"
|
||||
|
||||
|
||||
def test_celebration_never_hard_codes_notify_target() -> None:
|
||||
"""No notify service literal outside the input's placeholder text."""
|
||||
bp = celebration()
|
||||
spec = bp["blueprint"]
|
||||
assert not re.search(r"notify\.[a-z_]+", spec["description"])
|
||||
|
||||
outside_input = {k: v for k, v in bp.items() if k != "blueprint"}
|
||||
for value in iter_strings(outside_input):
|
||||
assert not re.search(r"notify\.[a-z_.]+", value), value
|
||||
|
||||
target_description = spec["input"]["notify_target"]["description"]
|
||||
assert re.search(r"notify\.[a-z_]+", target_description), (
|
||||
"the input description should show a placeholder example"
|
||||
)
|
||||
assert bp["actions"][0]["action"] == BlueprintInput("notify_target")
|
||||
|
||||
def test_celebration_dedupe_entry_equality_not_substring() -> None:
|
||||
"""Child ids sharing a prefix must not suppress each other: after
|
||||
child 11 is celebrated, child 1 still celebrates (whole-entry
|
||||
comparison, not substring)."""
|
||||
assert render_dedupe("11:2026-09-19", child_id=11) == "False"
|
||||
assert render_dedupe("11:2026-09-19", child_id=1) == "True", (
|
||||
"child 1's entry must not be found inside child 11's entry"
|
||||
)
|
||||
assert render_dedupe("1:2026-09-19", child_id=11) == "True"
|
||||
|
||||
|
||||
def test_celebration_mode_is_queued() -> None:
|
||||
"""Two children completing at nearly the same time must both be
|
||||
processed: queued mode does not drop the second event."""
|
||||
assert celebration()["mode"] == "queued"
|
||||
|
||||
|
||||
def test_every_blueprint_event_reference_is_real() -> None:
|
||||
"""Every event a blueprint triggers on is one NestQuest actually
|
||||
fires or defines (Feature 10/11) — no invented event names."""
|
||||
from tests.blueprint_helpers import load_blueprints
|
||||
|
||||
real_events = {
|
||||
"nestquest_quest_completed",
|
||||
"nestquest_quest_uncompleted",
|
||||
"nestquest_quest_missed",
|
||||
"nestquest_child_day_complete",
|
||||
}
|
||||
for name, bp in load_blueprints().items():
|
||||
for trigger in bp.get("triggers", []):
|
||||
if trigger.get("trigger") == "event":
|
||||
assert trigger["event_type"] in real_events, (
|
||||
f"{name}: invented event {trigger['event_type']!r}"
|
||||
)
|
||||
assert celebration()["triggers"][0]["event_type"] == (
|
||||
"nestquest_child_day_complete"
|
||||
)
|
||||
|
||||
|
||||
def test_celebration_uses_the_event_trigger_data_path() -> None:
|
||||
"""HA exposes an event trigger's payload as trigger.event.data —
|
||||
never trigger.payload (Codex review regression)."""
|
||||
bp = celebration()
|
||||
for value in iter_strings(bp):
|
||||
assert "trigger.payload" not in value, value
|
||||
default_message = bp["blueprint"]["input"]["celebration_message"][
|
||||
"default"
|
||||
]
|
||||
assert "trigger.event.data.child_name" in default_message
|
||||
for condition in celebration_conditions(bp):
|
||||
assert "trigger.payload" not in condition["value_template"]
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Tests for the NestQuest end-of-day report automation blueprint.
|
||||
|
||||
The end-of-day report closes the day for the parent: every PRESENT
|
||||
child gets a completion line (so the parent always receives the day's
|
||||
summary when the report runs), children who still owe quests get their
|
||||
open count called out on that line, and absent children are omitted
|
||||
entirely. The report runs deliberately before the midnight
|
||||
missed-quest sweep so it reads the same day's data, and by default an
|
||||
all-clear day sends nothing — unless the household opts into
|
||||
send-even-when-clear. The harness (YAML ``!input`` loader, state
|
||||
fixtures, jinja rendering) is shared in
|
||||
:mod:`tests.blueprint_helpers`; the directory-wide shape and
|
||||
entity-discipline tests in test_blueprints.py cover this file too.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from custom_components.nestquest.const import DEFAULT_END_OF_DAY_REPORT_TIME
|
||||
|
||||
from tests.blueprint_helpers import (
|
||||
BlueprintInput,
|
||||
notify_data,
|
||||
blueprint,
|
||||
iter_strings,
|
||||
make_states,
|
||||
render,
|
||||
single_action,
|
||||
TemplateState,
|
||||
)
|
||||
|
||||
REPORT_TITLE = "NestQuest end-of-day report"
|
||||
|
||||
|
||||
def end_of_day_report() -> dict:
|
||||
"""Return the parsed end-of-day-report blueprint."""
|
||||
return blueprint("end_of_day_report")
|
||||
|
||||
|
||||
def report_conditions(bp: dict) -> list[dict]:
|
||||
"""Return the blueprint's conditions: toggle first, guard second."""
|
||||
conditions = bp["conditions"]
|
||||
assert isinstance(conditions, list) and len(conditions) == 2
|
||||
return conditions
|
||||
|
||||
|
||||
def clear_guard(bp: dict) -> str:
|
||||
"""Return the template guarding an all-clear day (false = no report)."""
|
||||
return report_conditions(bp)[1]["value_template"]
|
||||
|
||||
|
||||
def owing_clear_absent_fixture():
|
||||
"""Alice owing (1/3 done, 2 open), Carol present and clear, Bob absent."""
|
||||
return make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_due_today",
|
||||
"3",
|
||||
{"child_name": "Alice", "child_id": 1, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_remaining_today",
|
||||
"2",
|
||||
{"child_id": 1},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_completed_today",
|
||||
"1",
|
||||
{"child_id": 1},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_carol_quests_due_today",
|
||||
"2",
|
||||
{"child_name": "Carol", "child_id": 2, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_carol_quests_remaining_today",
|
||||
"0",
|
||||
{"child_id": 2},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_carol_quests_completed_today",
|
||||
"2",
|
||||
{"child_id": 2},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_bob_quests_due_today",
|
||||
"2",
|
||||
{"child_name": "Bob", "child_id": 3, "present": False},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_bob_quests_remaining_today",
|
||||
"1",
|
||||
{"child_id": 3},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_bob_quests_completed_today",
|
||||
"1",
|
||||
{"child_id": 3},
|
||||
),
|
||||
TemplateState("sensor.nestquest_household_quests_due_today", "7"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def all_clear_fixture():
|
||||
"""Alice present with everything done; nobody owes anything."""
|
||||
return make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_due_today",
|
||||
"3",
|
||||
{"child_name": "Alice", "child_id": 1, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_remaining_today",
|
||||
"0",
|
||||
{"child_id": 1},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_completed_today",
|
||||
"3",
|
||||
{"child_id": 1},
|
||||
),
|
||||
TemplateState("sensor.nestquest_household_quests_due_today", "3"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inputs, defaults, and wiring.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_end_of_day_report_declares_inputs_with_selectors() -> None:
|
||||
"""The four inputs exist, carry selectors, and default sanely."""
|
||||
bp = end_of_day_report()
|
||||
inputs = bp["blueprint"]["input"]
|
||||
expected = {"report_time", "notify_target", "send_report", "send_even_when_clear"}
|
||||
assert set(inputs) == expected
|
||||
for name, spec in inputs.items():
|
||||
assert isinstance(spec["selector"], dict) and spec["selector"], name
|
||||
assert inputs["report_time"]["default"] == DEFAULT_END_OF_DAY_REPORT_TIME
|
||||
assert DEFAULT_END_OF_DAY_REPORT_TIME == "20:00"
|
||||
assert inputs["send_report"]["default"] is True
|
||||
assert inputs["send_even_when_clear"]["default"] is False
|
||||
assert "time" in inputs["report_time"]["selector"]
|
||||
assert "text" in inputs["notify_target"]["selector"]
|
||||
assert "boolean" in inputs["send_report"]["selector"]
|
||||
assert "boolean" in inputs["send_even_when_clear"]["selector"]
|
||||
# The description must note the deliberate ordering: the report runs
|
||||
# before the midnight missed sweep, reading the same day's data.
|
||||
description = bp["blueprint"]["description"].lower()
|
||||
assert "before" in description
|
||||
assert "sweep" in description
|
||||
assert "same day" in description
|
||||
|
||||
|
||||
def test_end_of_day_report_wires_inputs_not_literals() -> None:
|
||||
"""Time trigger, toggle and guard conditions, and notify action use !input."""
|
||||
bp = end_of_day_report()
|
||||
assert bp["variables"]["send_report"] == BlueprintInput("send_report")
|
||||
assert bp["variables"]["send_even_when_clear"] == BlueprintInput(
|
||||
"send_even_when_clear"
|
||||
)
|
||||
|
||||
trigger = bp["triggers"][0]
|
||||
assert trigger["trigger"] == "time"
|
||||
assert trigger["at"] == BlueprintInput("report_time")
|
||||
|
||||
toggle, guard = report_conditions(bp)
|
||||
assert toggle["condition"] == "template"
|
||||
assert "send_report" in toggle["value_template"]
|
||||
assert guard["condition"] == "template"
|
||||
assert "send_even_when_clear" in guard["value_template"]
|
||||
|
||||
action = single_action(bp)
|
||||
assert action["action"] == BlueprintInput("notify_target")
|
||||
data = notify_data(end_of_day_report())
|
||||
assert data["title"] == REPORT_TITLE
|
||||
assert isinstance(data["message"], str) and data["message"].strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rendering the day's summary.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_end_of_day_report_renders_owing_clear_and_absent_children() -> None:
|
||||
"""The owing child's open count is called out with the completion
|
||||
ratio; the present-clear child still gets the day's completion
|
||||
line; the absent child is omitted entirely."""
|
||||
rendered = render(
|
||||
notify_data(end_of_day_report())["message"],
|
||||
states=owing_clear_absent_fixture(),
|
||||
)
|
||||
assert rendered.split("\n") == [
|
||||
"Alice: 2 still open (1/3 done).",
|
||||
"Carol: 2/2 done.",
|
||||
]
|
||||
assert "Bob" not in rendered
|
||||
assert "household" not in rendered
|
||||
|
||||
|
||||
def test_end_of_day_report_all_clear_guard_renders_false_by_default() -> None:
|
||||
"""An all-clear day with send_even_when_clear off sends nothing:
|
||||
the guard condition is false."""
|
||||
rendered = render(
|
||||
clear_guard(end_of_day_report()),
|
||||
states=all_clear_fixture(),
|
||||
send_even_when_clear=False,
|
||||
)
|
||||
assert rendered.strip() == "False"
|
||||
|
||||
|
||||
def test_end_of_day_report_all_clear_sends_summary_when_opted_in() -> None:
|
||||
"""With send_even_when_clear on, the guard lets an all-clear day
|
||||
through and the message renders the day's completion summary."""
|
||||
guard = clear_guard(end_of_day_report())
|
||||
assert (
|
||||
render(guard, states=all_clear_fixture(), send_even_when_clear=True).strip()
|
||||
== "True"
|
||||
)
|
||||
message = render(
|
||||
notify_data(end_of_day_report())["message"],
|
||||
states=all_clear_fixture(),
|
||||
)
|
||||
assert message == "Alice: 3/3 done."
|
||||
|
||||
|
||||
def test_end_of_day_report_guard_true_when_a_present_child_owes() -> None:
|
||||
"""A present child with open quests passes the guard even with
|
||||
send_even_when_clear off."""
|
||||
rendered = render(
|
||||
clear_guard(end_of_day_report()),
|
||||
states=owing_clear_absent_fixture(),
|
||||
send_even_when_clear=False,
|
||||
)
|
||||
assert rendered.strip() == "True"
|
||||
|
||||
|
||||
def test_end_of_day_report_collision_suffixed_duplicate_names() -> None:
|
||||
"""Two children sharing a display name (HA registry suffix _2 on the
|
||||
second one's entity ids) both appear, each with their own counts."""
|
||||
states = make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_ada_quests_due_today",
|
||||
"2",
|
||||
{"child_name": "Ada", "child_id": 1, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_ada_quests_remaining_today",
|
||||
"1",
|
||||
{"child_id": 1},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_ada_quests_completed_today",
|
||||
"1",
|
||||
{"child_id": 1},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_ada_quests_due_today_2",
|
||||
"3",
|
||||
{"child_name": "Ada", "child_id": 2, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_ada_quests_remaining_today_2",
|
||||
"0",
|
||||
{"child_id": 2},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_ada_quests_completed_today_2",
|
||||
"3",
|
||||
{"child_id": 2},
|
||||
),
|
||||
],
|
||||
)
|
||||
rendered = render(notify_data(end_of_day_report())["message"], states=states)
|
||||
assert rendered.split("\n") == [
|
||||
"Ada: 1 still open (1/2 done).",
|
||||
"Ada: 3/3 done.",
|
||||
]
|
||||
|
||||
|
||||
def test_end_of_day_report_never_hard_codes_notify_target() -> None:
|
||||
"""No notify service literal outside the input's placeholder text."""
|
||||
bp = end_of_day_report()
|
||||
spec = bp["blueprint"]
|
||||
assert not re.search(r"notify\.[a-z_]+", spec["description"])
|
||||
|
||||
outside_input = {k: v for k, v in bp.items() if k != "blueprint"}
|
||||
for value in iter_strings(outside_input):
|
||||
assert not re.search(r"notify\.[a-z_.]+", value), value
|
||||
|
||||
target_description = spec["input"]["notify_target"]["description"]
|
||||
assert re.search(r"notify\.[a-z_]+", target_description), (
|
||||
"the input description should show a placeholder example"
|
||||
)
|
||||
assert single_action(bp)["action"] == BlueprintInput("notify_target")
|
||||
|
||||
def test_end_of_day_report_guard_true_when_remaining_unavailable() -> None:
|
||||
"""A present child whose remaining sensor is missing or reads
|
||||
'unavailable' must NOT look like all-clear: the degraded state
|
||||
reaches the parent (Codex review regression)."""
|
||||
states = make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_due_today",
|
||||
"3",
|
||||
{"child_name": "Alice", "child_id": 1, "present": True},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_remaining_today",
|
||||
"unavailable",
|
||||
{"child_id": 1},
|
||||
),
|
||||
],
|
||||
)
|
||||
rendered = render(
|
||||
clear_guard(end_of_day_report()),
|
||||
states=states,
|
||||
send_even_when_clear=False,
|
||||
)
|
||||
assert rendered.strip() == "True"
|
||||
|
||||
# A missing remaining sensor entirely: also report-worthy.
|
||||
states_missing = make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_due_today",
|
||||
"3",
|
||||
{"child_name": "Alice", "child_id": 1, "present": True},
|
||||
),
|
||||
],
|
||||
)
|
||||
rendered_missing = render(
|
||||
clear_guard(end_of_day_report()),
|
||||
states=states_missing,
|
||||
send_even_when_clear=False,
|
||||
)
|
||||
assert rendered_missing.strip() == "True"
|
||||
@@ -78,9 +78,43 @@ async def test_coordinator_snapshot_counts_and_states(hass, make_entry) -> None:
|
||||
assert instance.title == "Brush teeth"
|
||||
assert instance.window == "morning"
|
||||
assert ada.present is True, "no schedule means present every day"
|
||||
assert ada.next_present is None, "present child has no returns date"
|
||||
assert snapshot.cycle_day == 0, "no schedule anywhere"
|
||||
|
||||
|
||||
async def test_coordinator_next_present_for_away_child(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A child absent today carries the ISO date of their next present
|
||||
day; an always-absent pattern carries None instead."""
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
await create_child(database, "Ada")
|
||||
ada = (await list_children(database))[0]
|
||||
today = datetime.date.today()
|
||||
pattern = ",".join(
|
||||
str(day) for day in range(7) if day != today.weekday()
|
||||
)
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
ada.id, 1, today.isoformat(), pattern
|
||||
)
|
||||
await create_child(database, "Bo")
|
||||
bo = (await list_children(database))[1]
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
bo.id, 1, today.isoformat(), ""
|
||||
)
|
||||
snapshot = await coordinator._async_update_data()
|
||||
by_name = {child.child_name: child for child in snapshot.children}
|
||||
assert by_name["Ada"].present is False
|
||||
assert by_name["Ada"].next_present == (
|
||||
today + datetime.timedelta(days=1)
|
||||
).isoformat()
|
||||
assert by_name["Bo"].present is False
|
||||
assert by_name["Bo"].next_present is None
|
||||
|
||||
|
||||
async def test_coordinator_reflects_completion_and_uncompletion(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
@@ -184,6 +218,37 @@ async def test_coordinator_presence_resolves_schedule_and_override(
|
||||
assert snapshot.children[0].present is True
|
||||
|
||||
|
||||
async def test_coordinator_next_present_honours_future_override(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
await create_child(database, "Ada")
|
||||
ada = (await list_children(database))[0]
|
||||
today = datetime.date.today()
|
||||
tomorrow = today + datetime.timedelta(days=1)
|
||||
# Scheduled present tomorrow (single-day cycle, weekday set set below
|
||||
# covers every day), but overridden absent tomorrow.
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
ada.id, 1, today.isoformat(), "0,1,2,3,4,5,6"
|
||||
)
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
)
|
||||
|
||||
await PresenceOverridesDao(database).create(
|
||||
ada.id, tomorrow.isoformat(), tomorrow.isoformat(), False
|
||||
)
|
||||
snapshot = await coordinator._async_update_data()
|
||||
child = snapshot.children[0]
|
||||
assert child.present is True
|
||||
assert child.next_present != tomorrow.isoformat(), (
|
||||
"a future absent override must remove tomorrow from the preview"
|
||||
)
|
||||
|
||||
|
||||
async def test_coordinator_derives_missed_for_past_due_open(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Tests for the Lovelace card toolchain and auto-registered resource."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup, async_setup_entry
|
||||
from custom_components.nestquest.frontend import (
|
||||
BUNDLE_FILENAME,
|
||||
BUNDLE_URL,
|
||||
FRONTEND_URL_BASE,
|
||||
WWW_DIR,
|
||||
async_register_frontend,
|
||||
)
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
FRONTEND_DIR = REPO_ROOT / "frontend"
|
||||
BUNDLE_PATH = WWW_DIR / BUNDLE_FILENAME
|
||||
CARD_TYPES = (
|
||||
"nestquest-party-board-card",
|
||||
"nestquest-quest-log-card",
|
||||
"nestquest-admin-card",
|
||||
)
|
||||
|
||||
|
||||
def test_frontend_package_documents_vite_lit_build() -> None:
|
||||
package = json.loads((FRONTEND_DIR / "package.json").read_text(encoding="utf-8"))
|
||||
assert package["scripts"]["build"] == "vite build"
|
||||
assert "lit" in package["dependencies"]
|
||||
assert "typescript" in package["devDependencies"]
|
||||
assert "vite" in package["devDependencies"]
|
||||
|
||||
|
||||
def test_card_modules_exist_as_separate_sources() -> None:
|
||||
src = FRONTEND_DIR / "src"
|
||||
assert (src / "nestquest-cards.ts").is_file()
|
||||
assert (src / "cards" / "party-board.ts").is_file()
|
||||
assert (src / "cards" / "quest-log.ts").is_file()
|
||||
assert (src / "cards" / "admin.ts").is_file()
|
||||
entry = (src / "nestquest-cards.ts").read_text(encoding="utf-8")
|
||||
assert "./cards/party-board" in entry
|
||||
assert "./cards/quest-log" in entry
|
||||
assert "./cards/admin" in entry
|
||||
|
||||
|
||||
def test_card_sources_have_no_visual_editor() -> None:
|
||||
for path in (FRONTEND_DIR / "src" / "cards").glob("*.ts"):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
assert "getConfigElement" not in text
|
||||
assert "setConfig" in text
|
||||
assert "getCardSize" in text
|
||||
|
||||
|
||||
def test_built_bundle_contains_all_three_cards() -> None:
|
||||
assert BUNDLE_PATH.is_file()
|
||||
bundle = BUNDLE_PATH.read_text(encoding="utf-8")
|
||||
for card_type in CARD_TYPES:
|
||||
assert card_type in bundle
|
||||
|
||||
|
||||
async def test_setup_registers_static_path_and_extra_js(hass, make_entry) -> None:
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert len(hass.http.static_paths) == 1
|
||||
registered = hass.http.static_paths[0]
|
||||
assert registered.url_path == FRONTEND_URL_BASE
|
||||
assert registered.path == str(WWW_DIR)
|
||||
assert hass.extra_js_urls == [BUNDLE_URL]
|
||||
|
||||
|
||||
async def test_async_setup_registers_frontend_without_a_config_entry(hass) -> None:
|
||||
assert await async_setup(hass, {}) is True
|
||||
assert hass.extra_js_urls == [BUNDLE_URL]
|
||||
assert hass.http.static_paths[0].url_path == FRONTEND_URL_BASE
|
||||
|
||||
|
||||
async def test_frontend_registration_is_idempotent(hass, make_entry) -> None:
|
||||
await async_register_frontend(hass)
|
||||
await async_register_frontend(hass)
|
||||
entry_a = wire_entry_to_registry(make_entry(entry_id="a"), hass.registry)
|
||||
entry_b = wire_entry_to_registry(make_entry(entry_id="b"), hass.registry)
|
||||
assert await async_setup_entry(hass, entry_a) is True
|
||||
assert await async_setup_entry(hass, entry_b) is True
|
||||
assert len(hass.http.static_paths) == 1
|
||||
assert hass.extra_js_urls == [BUNDLE_URL]
|
||||
|
||||
|
||||
def test_readme_documents_npm_build() -> None:
|
||||
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
|
||||
assert "npm run build" in readme
|
||||
assert "nestquest-cards.js" in readme
|
||||
|
||||
|
||||
WOFF2_FILES = (
|
||||
"nunito-latin-400-normal.woff2",
|
||||
"nunito-latin-600-normal.woff2",
|
||||
"nunito-latin-700-normal.woff2",
|
||||
"nunito-latin-800-normal.woff2",
|
||||
"cinzel-latin-600-normal.woff2",
|
||||
"cinzel-latin-700-normal.woff2",
|
||||
"cinzel-latin-900-normal.woff2",
|
||||
"cinzel-decorative-latin-700-normal.woff2",
|
||||
"cinzel-decorative-latin-900-normal.woff2",
|
||||
)
|
||||
CDN_MARKERS = (
|
||||
"fonts.googleapis.com",
|
||||
"fonts.gstatic.com",
|
||||
"cdn.jsdelivr.net",
|
||||
"unpkg.com",
|
||||
"cdnjs.cloudflare.com",
|
||||
)
|
||||
|
||||
|
||||
def test_www_contains_required_woff2_fonts() -> None:
|
||||
fonts_dir = WWW_DIR / "fonts"
|
||||
for name in WOFF2_FILES:
|
||||
path = fonts_dir / name
|
||||
assert path.is_file(), name
|
||||
assert path.read_bytes()[:4] == b"wOF2"
|
||||
|
||||
|
||||
def test_font_css_declares_swap_and_local_urls() -> None:
|
||||
css = (WWW_DIR / "nestquest-fonts.css").read_text(encoding="utf-8")
|
||||
assert css.count("font-display: swap") == 9
|
||||
assert css.count("@font-face") == 9
|
||||
assert 'font-family: "Nunito"' in css
|
||||
assert 'font-family: "Cinzel"' in css
|
||||
assert 'font-family: "Cinzel Decorative"' in css
|
||||
for name in WOFF2_FILES:
|
||||
assert f"./fonts/{name}" in css
|
||||
assert "http://" not in css
|
||||
assert "https://" not in css
|
||||
|
||||
|
||||
def test_token_css_copied_verbatim_from_design() -> None:
|
||||
design = REPO_ROOT / "design" / "tokens"
|
||||
for name in ("nestquest-panel-tokens.css", "nestquest-admin-tokens.css"):
|
||||
expected = (design / name).read_bytes()
|
||||
actual = (WWW_DIR / name).read_bytes()
|
||||
assert actual == expected
|
||||
|
||||
|
||||
def test_frontend_and_www_have_no_cdn_font_references() -> None:
|
||||
roots = (FRONTEND_DIR, WWW_DIR)
|
||||
for root in roots:
|
||||
for path in root.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if "node_modules" in path.parts:
|
||||
continue
|
||||
if path.suffix.lower() in {".woff2", ".png", ".jpg"}:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
for marker in CDN_MARKERS:
|
||||
assert marker not in text, f"{path}: {marker}"
|
||||
Reference in New Issue
Block a user