mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68aa1f04ba | ||
|
|
3d41fe16b8 | ||
|
|
ac734688d4 | ||
|
|
2e2d353b04 |
Binary file not shown.
@@ -24,7 +24,7 @@ MAX_SIGN_PATHS = 200
|
||||
PLAN_ORPHAN_TTL_S = 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.45.1"
|
||||
VERSION = "1.45.3"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.45.1"
|
||||
"version": "1.45.3"
|
||||
}
|
||||
|
||||
@@ -63,14 +63,23 @@ def collect_plans(
|
||||
* any other unreferenced plan file is a rejected or abandoned upload, and
|
||||
is removed only once PLAN_ORPHAN_TTL_S has passed: a fresh one may
|
||||
belong to a transaction that has not committed yet.
|
||||
|
||||
Never raises: the configuration is already stored by the time this runs, so
|
||||
a file-system problem must not turn a durable commit into a failed call.
|
||||
"""
|
||||
if not plans_dir.is_dir():
|
||||
return 0
|
||||
new_refs = plan_refs(new_cfg)
|
||||
old_refs = plan_refs(old_cfg)
|
||||
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
|
||||
removed = 0
|
||||
for item in sorted(plans_dir.iterdir()):
|
||||
try:
|
||||
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
|
||||
except OSError as err:
|
||||
# The directory can vanish or turn unreadable between the check and the
|
||||
# walk. This is housekeeping running behind a commit that is already
|
||||
# durable, so it reports "nothing collected" instead of failing (R4-1).
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", plans_dir, err)
|
||||
return 0
|
||||
for item in items:
|
||||
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
|
||||
continue
|
||||
superseded = item.name in old_refs
|
||||
|
||||
@@ -207,7 +207,9 @@ MARKER_SCHEMA = vol.Schema(
|
||||
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
|
||||
vol.Optional("is_light"): vol.Any(bool, None),
|
||||
vol.Optional("room_id"): vol.Any(str, None),
|
||||
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", None),
|
||||
# keep in sync with DISPLAY_MODES in src/logic.ts — a cross-language test
|
||||
# asserts every option the editor offers is accepted here (issue #3)
|
||||
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", None),
|
||||
vol.Optional("ripple_color"): vol.Any(str, None),
|
||||
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
|
||||
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
|
||||
|
||||
@@ -363,11 +363,18 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
|
||||
# still holding the lock: the file system is not part of the store's
|
||||
# transaction, so collection has to be pinned to this commit (R3-1)
|
||||
await hass.async_add_executor_job(
|
||||
collect_plans, Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"]
|
||||
)
|
||||
# Still holding the lock: the file system is not part of the store's
|
||||
# transaction, so collection has to be pinned to this commit (R3-1).
|
||||
# It is best-effort housekeeping behind an already durable write — a
|
||||
# failure here must not withhold the event and the success response,
|
||||
# or the client retries an edit the server has already accepted and
|
||||
# gets a conflict for its trouble (R4-1).
|
||||
try:
|
||||
await hass.async_add_executor_job(
|
||||
collect_plans, Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"]
|
||||
)
|
||||
except Exception: # noqa: BLE001 — see above: the commit stands regardless
|
||||
_LOGGER.exception("House Plan: collecting superseded plan files failed")
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
|
||||
# refresh repair issues (broken plan references) without waiting for a restart
|
||||
entry = get_entry(hass)
|
||||
|
||||
@@ -51,19 +51,26 @@ const res = await page.evaluate(async () => {
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
out.hrefAfterFailedSign = await href();
|
||||
|
||||
// 2) повтор после ошибки: pending освобождён, вторая попытка проходит
|
||||
// 2) сразу повтора нет: после ошибки подпись уходит в backoff (ревью R4-2),
|
||||
// иначе нестабильный сокет получал бы по запросу на каждый рендер
|
||||
for (let i = 0; i < 5; i++) { card.requestUpdate(); await card.updateComplete; }
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
out.noRetryStorm = signCalls === 1;
|
||||
|
||||
// 3) после выдержки повтор проходит
|
||||
await new Promise((r) => setTimeout(r, 2100));
|
||||
card.requestUpdate(); await card.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
out.hrefAfterRetry = await href();
|
||||
out.retried = signCalls >= 2;
|
||||
out.retried = signCalls === 2;
|
||||
|
||||
// 3) повторный рендер не теряет подпись и не просит её заново
|
||||
// 4) повторный рендер не теряет подпись и не просит её заново
|
||||
const before = signCalls;
|
||||
card.requestUpdate(); await card.updateComplete;
|
||||
out.hrefStable = await href();
|
||||
out.noExtraSignOnRerender = signCalls === before;
|
||||
|
||||
// 4) протухшая подпись не отдаётся, стареющая — отдаётся, пока едет замена
|
||||
// 5) протухшая подпись не отдаётся, стареющая — отдаётся, пока едет замена
|
||||
const ent = card._signer.entries;
|
||||
ent[raw] = { url: raw + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
|
||||
card.requestUpdate(); await card.updateComplete;
|
||||
@@ -80,6 +87,7 @@ const res = await page.evaluate(async () => {
|
||||
// зафиксировано прогоном на v1.45.1 и сверено с кодом
|
||||
checkAll(res, {
|
||||
hrefAfterFailedSign: null,
|
||||
noRetryStorm: true,
|
||||
hrefAfterRetry: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
|
||||
retried: true,
|
||||
hrefStable: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+30
-30
File diff suppressed because one or more lines are too long
@@ -194,7 +194,7 @@ to a transaction still in flight. The `.` between id and token is load-bearing
|
||||
a space id cannot contain one, so `<space>.<token>.<ext>` can never be confused
|
||||
with the files of a space whose name merely starts the same way.
|
||||
|
||||
**Signed content urls are batched and aged** (reviews R2-2, R3-2). `ContentSigner`
|
||||
**Signed content urls are batched, aged and deduplicated** (reviews R2-2, R3-2, R4-2). `ContentSigner`
|
||||
in `src/signing.ts` is the single implementation, used by both cards; the
|
||||
duplicate inside houseplan-space-card signed correctly and never handed the
|
||||
result to its renderer, which is the failure mode a second copy invites. `MAX_SIGN_PATHS`
|
||||
@@ -204,6 +204,10 @@ signatures carry the time they were issued — an aging one keeps rendering whil
|
||||
its replacement is fetched, an expired one is dropped rather than served (it
|
||||
would 401 and raise a failed-login warning). The cache is pruned to the urls the
|
||||
live config references, so it cannot grow past the cap through history alone.
|
||||
Queued and in-flight are distinct states: a render happening while a request is
|
||||
out must not queue the same url again, a failure backs off rather than retrying
|
||||
on the next frame, and an in-flight entry expires after `SIGN_INFLIGHT_MS` so a
|
||||
promise that never settles cannot block retries forever.
|
||||
|
||||
**Room climate is one pass per hass snapshot** (review R2-3). `areaClimateMap()`
|
||||
classifies the whole registry once and returns `Map<area, {temp, hum}>`; the
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
# Changelog
|
||||
|
||||
## v1.45.3 — 2026-07-27
|
||||
- **"Value instead of an icon" could not be saved (issue #3).** The option was
|
||||
added to the device editor in v1.26.0, but the server-side schema only ever
|
||||
accepted `badge`, `ripple` and `icon_ripple`. Choosing it produced
|
||||
`not a valid value for dictionary value @ data['config']['markers'][n]['display']`
|
||||
— and because a single rejected marker fails the whole configuration write,
|
||||
the plan could not be saved at all until the setting was undone. Thanks to
|
||||
@RemyRoux for the report and the exact error text.
|
||||
- **The option lists now live in one place and are checked across languages.**
|
||||
`DISPLAY_MODES`, `TAP_ACTIONS`, `SPACE_FILL_MODES` and `ROOM_FILL_MODES` are
|
||||
exported from the card and read by a backend test that asserts the schema
|
||||
accepts every value a user can actually pick. Adding an option to an editor
|
||||
and forgetting the schema now fails the test suite instead of surfacing a year
|
||||
later through somebody's error message.
|
||||
|
||||
## v1.45.2 — 2026-07-27 (hardening from the v1.45.1 review: R4-1, R4-2)
|
||||
- **A failed cleanup no longer reports an accepted save as an error (R4-1).**
|
||||
Collecting superseded plan files runs after the configuration is already
|
||||
stored, but an error while listing the directory — it can vanish or turn
|
||||
unreadable between the check and the walk — propagated out of `config/set`.
|
||||
The client then saw a failure for a revision the server had committed, and its
|
||||
retry came back as a conflict. The collector now reports "nothing collected"
|
||||
instead of raising, and `config/set` logs and proceeds: the event fires and
|
||||
the new revision is returned.
|
||||
- **One signing request per url instead of one per render (R4-2).** The pending
|
||||
set was cleared when the batch went out rather than when it came back, so
|
||||
while a `content/sign` call was in flight every re-render queued another one —
|
||||
six calls where one was needed, and far worse on a socket that is slow rather
|
||||
than merely busy. Queued and in-flight are now separate states, a failure
|
||||
backs off (2 s doubling to 60 s) instead of retrying on the next frame, and a
|
||||
request that never settles stops blocking retries after 15 s. A late answer
|
||||
arriving after the card was torn down no longer triggers a render.
|
||||
- Tests: eight unit tests for the signer with hand-settled promises (four fail
|
||||
on v1.45.1), a backend test asserting a broken collector still yields a
|
||||
successful save with a usable revision, and the pure-collector test extended
|
||||
to a disappearing directory.
|
||||
|
||||
## v1.45.1 — 2026-07-27 (follow-up review of v1.45.0: R3-1, R3-2)
|
||||
- **Collecting old plan files moved into the config transaction (R3-1, high).**
|
||||
v1.45.0 made the upload safe but handed the deletion to the client: after a
|
||||
|
||||
@@ -6,6 +6,45 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.45.3 — 2026-07-27
|
||||
- **«Значение вместо иконки» невозможно было сохранить (issue #3).** Опция
|
||||
появилась в редакторе устройств ещё в v1.26.0, но серверная схема всё это
|
||||
время принимала только `badge`, `ripple` и `icon_ripple`. При её выборе
|
||||
сохранение падало с
|
||||
`not a valid value for dictionary value @ data['config']['markers'][n]['display']`,
|
||||
а поскольку один отвергнутый маркер валит всю запись конфигурации, план не
|
||||
сохранялся вообще, пока настройку не отменишь. Спасибо @RemyRoux за отчёт и
|
||||
точный текст ошибки.
|
||||
- **Списки опций теперь в одном месте и сверяются между языками.**
|
||||
`DISPLAY_MODES`, `TAP_ACTIONS`, `SPACE_FILL_MODES` и `ROOM_FILL_MODES`
|
||||
экспортируются из карточки, и backend-тест читает их, проверяя, что схема
|
||||
принимает каждое значение, которое пользователь реально может выбрать.
|
||||
Теперь добавить опцию в редактор и забыть про схему — значит уронить тесты, а
|
||||
не узнать об этом через полтора года из чужого сообщения об ошибке.
|
||||
|
||||
## v1.45.2 — 2026-07-27 (закалка по ревью v1.45.1: R4-1, R4-2)
|
||||
- **Сбой уборки больше не превращает принятое сохранение в ошибку (R4-1).**
|
||||
Сборка вытесненных файлов плана идёт уже после того, как конфигурация
|
||||
сохранена, но ошибка при обходе каталога — он может исчезнуть или стать
|
||||
недоступным между проверкой и обходом — вылетала наружу из `config/set`.
|
||||
Клиент видел неудачу для ревизии, которую сервер закоммитил, а его повтор
|
||||
возвращался с конфликтом. Теперь сборщик сообщает «ничего не убрано» вместо
|
||||
исключения, а `config/set` пишет в лог и продолжает: событие уходит, новая
|
||||
ревизия возвращается.
|
||||
- **Один запрос подписи на ссылку вместо одного на рендер (R4-2).** Множество
|
||||
ожидающих очищалось в момент отправки батча, а не по возвращении, поэтому
|
||||
пока запрос `content/sign` был в полёте, каждая перерисовка ставила ещё
|
||||
один — шесть вызовов там, где нужен один, и куда хуже на медленном (а не
|
||||
просто занятом) сокете. Состояния «в очереди» и «в полёте» теперь разделены,
|
||||
после ошибки включается выдержка (2 с с удвоением до 60 с) вместо повтора на
|
||||
следующем кадре, а запрос, который так и не завершился, перестаёт блокировать
|
||||
повторы через 15 с. Поздний ответ, пришедший после размонтирования карточки,
|
||||
больше не вызывает перерисовку.
|
||||
- Тесты: восемь юнит-тестов подписывателя с ручным разрешением promise (четыре
|
||||
падают на v1.45.1), backend-тест на то, что сломанный сборщик оставляет
|
||||
сохранение успешным с рабочей ревизией, и проверка исчезнувшего каталога в
|
||||
тестах чистого сборщика.
|
||||
|
||||
## v1.45.1 — 2026-07-27 (повторное ревью v1.45.0: R3-1, R3-2)
|
||||
- **Уборка старых файлов плана перенесена внутрь транзакции конфига (R3-1,
|
||||
high).** v1.45.0 сделала загрузку безопасной, но отдала удаление клиенту:
|
||||
|
||||
+2
-2
@@ -15,12 +15,12 @@
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.45.1** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Version | **v1.45.3** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — `main` = releases up to **v1.40.1**; `dev` ahead with v1.40.2+ (speaker icons, kiosk). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
|
||||
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.45.1** via direct copy (HACS custom repo also installed) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.45.3** via direct copy (HACS custom repo also installed) |
|
||||
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
|
||||
| Tests | 121 frontend (node:test) + 12 pure backend + 12 HA-harness (CI, py3.13); ~30 demo smoke suites (headless chromium) |
|
||||
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
|
||||
|
||||
@@ -234,6 +234,20 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
are only reachable through /api/houseplan/content/… with a session; the
|
||||
old /houseplan_files/plans|files paths return 404 after a restart; old
|
||||
stored URLs keep working (rewritten on read) [auto+manual]
|
||||
- [ ] Every editor option is storable (v1.45.3, issue #3): set a sensor to
|
||||
"value instead of an icon" and save — no validation error, the value shows
|
||||
on the plan after a reload. Same for each tap action and each fill mode
|
||||
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
|
||||
neighbours, test_a_marker_showing_its_value_can_be_saved]
|
||||
- [ ] Signing does not amplify on a bad connection (v1.45.2, review R4-2): with
|
||||
the WebSocket slow or refusing, the card issues ONE sign request per url
|
||||
and backs off after a failure instead of asking again on every render; a
|
||||
request that never answers stops blocking retries after 15 s
|
||||
[auto: unit: signing.test + smoke_space_card_bg]
|
||||
- [ ] A broken plans directory does not fail a save (v1.45.2, review R4-1): make
|
||||
the plans folder unreadable and save the configuration — the save
|
||||
succeeds, the revision is usable, and the next save does not conflict
|
||||
[auto: backend test_a_failing_collector_does_not_undo_an_accepted_save]
|
||||
- [ ] Two editors, one plan (v1.45.1, review R3-1): with the same space open in
|
||||
two tabs, attach a background in each in turn — the plan last saved is the
|
||||
one served, and neither commit deletes the other's file. A rejected upload
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.45.1",
|
||||
"version": "1.45.3",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
||||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||||
referencedContentUrls,
|
||||
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
|
||||
} from './logic';
|
||||
import { ContentSigner } from './signing';
|
||||
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, type AreaClimate } from './devices';
|
||||
@@ -34,7 +35,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.45.1';
|
||||
const CARD_VERSION = '1.45.3';
|
||||
const LS_KEY = 'houseplan_card_layout_v1';
|
||||
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||
@@ -4824,7 +4825,7 @@ class HouseplanCard extends LitElement {
|
||||
<label>${this._t('marker.tap_label')}</label>
|
||||
<select class="areasel"
|
||||
@change=${(e: Event) => (this._markerDialog = { ...d, tapAction: (e.target as HTMLSelectElement).value })}>
|
||||
${[['info', 'tap.info'], ['more-info', 'tap.more_info'], ['toggle', 'tap.toggle']].map(
|
||||
${TAP_ACTIONS.map((v) => [v, 'tap.' + v.replace('-', '_')] as const).map(
|
||||
([v, k]) => html`<option value=${v} ?selected=${(d.tapAction || d.defaultTap) === v}>${this._t(k as any)}</option>`,
|
||||
)}
|
||||
</select>
|
||||
@@ -4895,7 +4896,7 @@ class HouseplanCard extends LitElement {
|
||||
<label>${this._t('marker.display_label')}</label>
|
||||
<select class="areasel"
|
||||
@change=${(e: Event) => (this._markerDialog = { ...d, display: (e.target as HTMLSelectElement).value as any })}>
|
||||
${[['badge', 'display.badge'], ['ripple', 'display.ripple'], ['icon_ripple', 'display.icon_ripple'], ['value', 'display.value']].map(
|
||||
${DISPLAY_MODES.map((v) => [v, 'display.' + v] as const).map(
|
||||
([v, k]) => html`<option value=${v} ?selected=${d.display === v}>${this._t(k as any)}</option>`,
|
||||
)}
|
||||
</select>
|
||||
@@ -5072,7 +5073,7 @@ class HouseplanCard extends LitElement {
|
||||
<span class="opv">${Math.round(d.roomOpacity * 100)}%</span>
|
||||
</div>
|
||||
<label>${this._t('space.fill_label')}</label>
|
||||
${[['none', 'fill.none'], ['lqi', 'fill.lqi'], ['light', 'fill.light'], ['temp', 'fill.temp'], ['glow', 'fill.glow']].map(
|
||||
${SPACE_FILL_MODES.map((v) => [v, 'fill.' + v] as const).map(
|
||||
([v, k]) => html`<label class="srcrow">
|
||||
<input type="radio" name="fillmode" .checked=${d.fillMode === v}
|
||||
@change=${() => (this._spaceDialog = { ...d, fillMode: v as any })} />
|
||||
@@ -5243,7 +5244,7 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
<label class="dispsection">${this._t('room.settings_section')}</label>
|
||||
<label>${this._t('room.fill_label')}</label>
|
||||
${([['', 'fill.inherit'], ['none', 'fill.none'], ['lqi', 'fill.lqi'], ['light', 'fill.light'], ['temp', 'fill.temp']] as const).map(
|
||||
${([['', 'fill.inherit'], ...ROOM_FILL_MODES.map((v) => [v, 'fill.' + v])] as const).map(
|
||||
([v, k]) => html`<label class="srcrow inline">
|
||||
<input type="radio" name="rfill" .checked=${this._roomFill === v}
|
||||
@change=${() => { this._roomFill = v as any; this.requestUpdate(); }} />
|
||||
|
||||
@@ -531,6 +531,24 @@ export function safeUrl(url: string | null | undefined): string | null {
|
||||
export type TapAction = 'info' | 'more-info' | 'toggle';
|
||||
|
||||
/** Domains a card-wide `tap_action: toggle` may toggle (accidental-tap safe). */
|
||||
/**
|
||||
* The option lists the editors offer, in one place — and the reason they are
|
||||
* here rather than inline in the templates.
|
||||
*
|
||||
* `display` gained 'value' in v1.26.0 ("show the measurement instead of the
|
||||
* icon") but the backend schema still only accepted badge/ripple/icon_ripple,
|
||||
* so saving any marker configured that way was rejected outright — the feature
|
||||
* was unusable for a year and a half and only surfaced through a user's error
|
||||
* message (issue #3, 2026-07-27). The lists are exported so a backend test can
|
||||
* read them and assert the schema accepts every value a user can pick.
|
||||
* Adding an option here and forgetting the schema now fails the test suite.
|
||||
*/
|
||||
export const DISPLAY_MODES = ['badge', 'ripple', 'icon_ripple', 'value'] as const;
|
||||
export const TAP_ACTIONS = ['info', 'more-info', 'toggle'] as const;
|
||||
/** Space-level fill: 'glow' is a whole-space light model, not a per-room one. */
|
||||
export const SPACE_FILL_MODES = ['none', 'lqi', 'light', 'temp', 'glow'] as const;
|
||||
export const ROOM_FILL_MODES = ['none', 'lqi', 'light', 'temp'] as const;
|
||||
|
||||
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier']);
|
||||
|
||||
/**
|
||||
|
||||
+53
-13
@@ -1,5 +1,11 @@
|
||||
import { contentUrl, chunk, MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS } from './logic';
|
||||
|
||||
/** A request older than this is presumed lost, and the url may be asked again. */
|
||||
export const SIGN_INFLIGHT_MS = 15000;
|
||||
/** After a failure, wait before retrying; doubles up to the cap. */
|
||||
export const SIGN_BACKOFF_MIN_MS = 2000;
|
||||
export const SIGN_BACKOFF_MAX_MS = 60000;
|
||||
|
||||
/**
|
||||
* Signed urls for the authenticated content endpoint, shared by both cards.
|
||||
*
|
||||
@@ -19,14 +25,23 @@ import { contentUrl, chunk, MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS } from
|
||||
* replacement is fetched while the old url keeps rendering, past SIGN_TTL_MS
|
||||
* the entry is dropped rather than served (it would 401 and raise a
|
||||
* failed-login warning for the viewer's own IP);
|
||||
* - `pending` is always released, so one failed request does not wedge a url
|
||||
* forever.
|
||||
* - a url that is queued or already in flight is not asked for again: renders
|
||||
* are frequent and a slow socket used to turn every one of them into another
|
||||
* `content/sign` call (review R4-2). An in-flight entry expires after
|
||||
* SIGN_INFLIGHT_MS so a promise that never settles cannot block retries
|
||||
* forever, and a failure backs off instead of retrying on the next frame.
|
||||
*/
|
||||
export class ContentSigner {
|
||||
private signed: Record<string, { url: string; at: number }> = {};
|
||||
private pending = new Set<string>();
|
||||
/** Waiting for the batch timer to fire. */
|
||||
private queued = new Set<string>();
|
||||
/** Sent and not settled yet: url -> when the request went out. */
|
||||
private inFlight = new Map<string, number>();
|
||||
/** After a failure: when this url may be asked again, and the current delay. */
|
||||
private retry = new Map<string, { notBefore: number; delay: number }>();
|
||||
private batchTimer?: ReturnType<typeof setTimeout>;
|
||||
private resignTimer?: ReturnType<typeof setInterval>;
|
||||
private disposed = false;
|
||||
|
||||
/**
|
||||
* @param onUpdate schedule a re-render (a signature arriving changes the DOM)
|
||||
@@ -36,15 +51,18 @@ export class ContentSigner {
|
||||
|
||||
/** Start the periodic re-sign. `referenced` prunes the cache on each tick. */
|
||||
start(hass: () => any, referenced: () => Set<string>): void {
|
||||
this.disposed = false; // an element can be reconnected after a disconnect
|
||||
this.stopTimer();
|
||||
this.resignTimer = setInterval(() => this.resign(hass(), referenced()), SIGN_REFRESH_MS / 2);
|
||||
}
|
||||
|
||||
/** Release every timer; the cache survives a reconnect, the timers must not. */
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.stopTimer();
|
||||
clearTimeout(this.batchTimer);
|
||||
this.pending.clear();
|
||||
this.queued.clear();
|
||||
this.inFlight.clear();
|
||||
}
|
||||
|
||||
private stopTimer(): void {
|
||||
@@ -72,13 +90,18 @@ export class ContentSigner {
|
||||
}
|
||||
|
||||
private request(hass: any, url: string): void {
|
||||
if (this.pending.has(url) || !hass?.callWS) return;
|
||||
this.pending.add(url);
|
||||
if (!hass?.callWS || this.queued.has(url)) return;
|
||||
const now = this.now();
|
||||
const sent = this.inFlight.get(url);
|
||||
if (sent !== undefined && now - sent < SIGN_INFLIGHT_MS) return; // already asking
|
||||
const back = this.retry.get(url);
|
||||
if (back && now < back.notBefore) return; // still backing off
|
||||
this.queued.add(url);
|
||||
clearTimeout(this.batchTimer);
|
||||
// batch: switching space asks for several urls in the same tick
|
||||
this.batchTimer = setTimeout(() => {
|
||||
const paths = [...this.pending];
|
||||
this.pending.clear();
|
||||
const paths = [...this.queued];
|
||||
this.queued.clear();
|
||||
this.sign(hass, paths);
|
||||
}, 30);
|
||||
}
|
||||
@@ -86,21 +109,32 @@ export class ContentSigner {
|
||||
private sign(hass: any, paths: string[]): void {
|
||||
if (!paths.length || !hass?.callWS) return;
|
||||
for (const batch of chunk(paths, MAX_SIGN_PATHS)) {
|
||||
const sentAt = this.now();
|
||||
for (const p of batch) this.inFlight.set(p, sentAt);
|
||||
hass
|
||||
.callWS({ type: 'houseplan/content/sign', paths: batch })
|
||||
.then((r: any) => {
|
||||
if (!r?.urls) return;
|
||||
for (const p of batch) this.retry.delete(p);
|
||||
if (!r?.urls || this.disposed) return;
|
||||
const at = this.now();
|
||||
const next = { ...this.signed };
|
||||
for (const [k, v] of Object.entries<string>(r.urls)) next[k] = { url: v, at };
|
||||
this.signed = next;
|
||||
this.onUpdate();
|
||||
})
|
||||
.catch(() => undefined) // a retry happens on the next render
|
||||
.catch(() => {
|
||||
// back off rather than retry on the very next frame: a socket that
|
||||
// is refusing sign requests would otherwise be hammered per render
|
||||
const now = this.now();
|
||||
for (const p of batch) {
|
||||
const prev = this.retry.get(p)?.delay || 0;
|
||||
const delay = Math.min(SIGN_BACKOFF_MAX_MS, prev ? prev * 2 : SIGN_BACKOFF_MIN_MS);
|
||||
this.retry.set(p, { notBefore: now + delay, delay });
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
// never leave a url wedged in `pending`: the first failure would
|
||||
// otherwise make it unrequestable for the life of the page (R3-2)
|
||||
for (const p of batch) this.pending.delete(p);
|
||||
// release only our own attempt: a later one may have superseded it
|
||||
for (const p of batch) if (this.inFlight.get(p) === sentAt) this.inFlight.delete(p);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -117,6 +151,7 @@ export class ContentSigner {
|
||||
if (referenced.has(k) && now - v.at < SIGN_TTL_MS) kept[k] = v;
|
||||
}
|
||||
this.signed = kept;
|
||||
this.retry.clear(); // a scheduled refresh is a fresh chance for everything
|
||||
this.sign(hass, Object.keys(kept));
|
||||
}
|
||||
|
||||
@@ -124,4 +159,9 @@ export class ContentSigner {
|
||||
get entries(): Record<string, { url: string; at: number }> {
|
||||
return this.signed;
|
||||
}
|
||||
|
||||
/** Test/debug view of what is currently being asked for. */
|
||||
get inFlightUrls(): string[] {
|
||||
return [...this.inFlight.keys()];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ContentSigner, SIGN_INFLIGHT_MS, SIGN_BACKOFF_MIN_MS } from '../test-build/signing.js';
|
||||
import { SIGN_TTL_MS, SIGN_REFRESH_MS } from '../test-build/logic.js';
|
||||
|
||||
const URL_A = '/api/houseplan/content/plans/_/f1.tok.svg';
|
||||
const URL_B = '/api/houseplan/content/files/m1/manual.pdf';
|
||||
const tick = () => new Promise((r) => setTimeout(r, 45)); // > the 30 ms batch timer
|
||||
|
||||
/** hass whose sign call is resolved/rejected by hand. */
|
||||
function makeHass() {
|
||||
const calls = [];
|
||||
const hass = {
|
||||
callWS(m) {
|
||||
let settle;
|
||||
const p = new Promise((res, rej) => { settle = { res, rej }; });
|
||||
calls.push({ paths: m.paths, ...settle });
|
||||
return p;
|
||||
},
|
||||
};
|
||||
return { hass, calls };
|
||||
}
|
||||
|
||||
function signer(now = () => Date.now()) {
|
||||
let updates = 0;
|
||||
const s = new ContentSigner(() => { updates++; }, now);
|
||||
return { s, updates: () => updates };
|
||||
}
|
||||
|
||||
test('R4-2: renders during one unresolved request do not multiply it', async () => {
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer();
|
||||
for (let i = 0; i < 6; i++) assert.equal(s.display(hass, URL_A), '');
|
||||
await tick();
|
||||
assert.equal(calls.length, 1, 'one batched request');
|
||||
// more renders while it is still in flight
|
||||
for (let i = 0; i < 5; i++) s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 1, 'still one: the url is in flight');
|
||||
assert.deepEqual(s.inFlightUrls, [URL_A]);
|
||||
|
||||
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=OK' } });
|
||||
await tick();
|
||||
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=OK');
|
||||
assert.equal(calls.length, 1, 'a resolved signature starts no extra request');
|
||||
assert.deepEqual(s.inFlightUrls, []);
|
||||
});
|
||||
|
||||
test('R4-2: a rejection frees the url but backs off before retrying', async () => {
|
||||
let t = 1_000_000;
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer(() => t);
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
calls[0].rej(new Error('socket'));
|
||||
await tick();
|
||||
assert.deepEqual(s.inFlightUrls, [], 'released, not wedged');
|
||||
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 1, 'the very next render must not retry');
|
||||
|
||||
t += SIGN_BACKOFF_MIN_MS + 1;
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 2, 'retried once the backoff has passed');
|
||||
|
||||
// the second failure waits longer than the first
|
||||
calls[1].rej(new Error('socket'));
|
||||
await tick();
|
||||
t += SIGN_BACKOFF_MIN_MS + 1;
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 2, 'backoff doubled');
|
||||
t += SIGN_BACKOFF_MIN_MS * 2;
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 3);
|
||||
|
||||
calls[2].res({ urls: { [URL_A]: URL_A + '?authSig=OK' } });
|
||||
await tick();
|
||||
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=OK');
|
||||
});
|
||||
|
||||
test('R4-2: a request that never settles stops blocking after the timeout', async () => {
|
||||
let t = 1_000_000;
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer(() => t);
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 1);
|
||||
|
||||
t += SIGN_INFLIGHT_MS - 1;
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 1, 'still presumed in flight');
|
||||
|
||||
t += 2;
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 2, 'presumed lost — asked again');
|
||||
|
||||
// the FIRST promise finally answers: it must not resurrect a stale in-flight
|
||||
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=LATE' } });
|
||||
await tick();
|
||||
assert.deepEqual(s.inFlightUrls, [URL_A], 'only the second attempt is in flight');
|
||||
});
|
||||
|
||||
test('signatures age: fresh, aging, expired', async () => {
|
||||
let t = 1_000_000;
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer(() => t);
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=ONE' } });
|
||||
await tick();
|
||||
|
||||
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=ONE');
|
||||
assert.equal(calls.length, 1, 'a fresh signature asks for nothing');
|
||||
|
||||
t += SIGN_REFRESH_MS + 1; // aging: keep rendering, fetch a replacement
|
||||
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=ONE');
|
||||
await tick();
|
||||
assert.equal(calls.length, 2);
|
||||
|
||||
t += SIGN_TTL_MS; // expired: rendering it would 401
|
||||
assert.equal(s.display(hass, URL_A), '');
|
||||
assert.equal(s.entries[URL_A], undefined, 'the dead entry is dropped');
|
||||
});
|
||||
|
||||
test('a non-content url is passed straight through, nothing is signed', async () => {
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer();
|
||||
assert.equal(s.display(hass, '/local/plan.png'), '/local/plan.png');
|
||||
assert.equal(s.display(hass, ''), '');
|
||||
assert.equal(s.display(hass, null), '');
|
||||
await tick();
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('legacy urls are normalised before signing', async () => {
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer();
|
||||
s.display(hass, '/houseplan_files/plans/f1.svg');
|
||||
await tick();
|
||||
assert.deepEqual(calls[0].paths, ['/api/houseplan/content/plans/_/f1.svg']);
|
||||
});
|
||||
|
||||
test('resign prunes to the referenced set and gives everything a fresh chance', async () => {
|
||||
let t = 1_000_000;
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer(() => t);
|
||||
s.display(hass, URL_A);
|
||||
s.display(hass, URL_B);
|
||||
await tick();
|
||||
calls[0].res({ urls: { [URL_A]: URL_A + '?s=1', [URL_B]: URL_B + '?s=1' } });
|
||||
await tick();
|
||||
assert.equal(Object.keys(s.entries).length, 2);
|
||||
|
||||
s.resign(hass, new Set([URL_A]));
|
||||
await tick();
|
||||
assert.deepEqual(Object.keys(s.entries), [URL_A], 'the unreferenced url is dropped');
|
||||
assert.deepEqual(calls[1].paths, [URL_A]);
|
||||
});
|
||||
|
||||
test('dispose(): a late answer neither renders nor throws, start() revives it', async () => {
|
||||
const { hass, calls } = makeHass();
|
||||
const { s, updates } = signer();
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
s.dispose();
|
||||
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=LATE' } });
|
||||
await tick();
|
||||
assert.equal(updates(), 0, 'no re-render after teardown');
|
||||
|
||||
s.start(() => hass, () => new Set([URL_A]));
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 2, 'a reconnected card asks again');
|
||||
calls[1].res({ urls: { [URL_A]: URL_A + '?authSig=NEW' } });
|
||||
await tick();
|
||||
assert.equal(updates(), 1);
|
||||
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=NEW');
|
||||
s.dispose();
|
||||
});
|
||||
@@ -396,6 +396,72 @@ async def test_collection_ignores_files_that_are_not_plans(
|
||||
assert (plans / "readme").is_file()
|
||||
|
||||
|
||||
async def test_a_marker_showing_its_value_can_be_saved(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""issue #3: display='value' was rejected, and one bad marker fails the lot.
|
||||
|
||||
A user could not save the configuration at all after setting any sensor to
|
||||
"value instead of an icon" — the editor offered the option, the schema had
|
||||
never heard of it.
|
||||
"""
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
cfg = await _cfg([{"id": "f1", "plan_url": None}])
|
||||
cfg["markers"] = [
|
||||
{"id": "sensor.t", "binding": "entity:sensor.t", "display": "value"},
|
||||
{"id": "sensor.h", "binding": "entity:sensor.h", "display": "badge"},
|
||||
]
|
||||
ok = await _save(client, cfg, 0)
|
||||
assert ok["success"], ok.get("error")
|
||||
|
||||
await client.send_json_auto_id({"type": "houseplan/config/get"})
|
||||
got = await client.receive_json()
|
||||
assert [m["display"] for m in got["result"]["config"]["markers"]] == ["value", "badge"]
|
||||
|
||||
|
||||
async def test_a_failing_collector_does_not_undo_an_accepted_save(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch
|
||||
) -> None:
|
||||
"""review R4-1: garbage collection runs behind an already durable write.
|
||||
|
||||
If it raised, the client got an error for a revision the store had already
|
||||
accepted — and its retry then failed with `conflict`, because the server had
|
||||
moved on. The commit stands and the event fires regardless.
|
||||
"""
|
||||
from custom_components.houseplan import websocket_api as wsapi
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
events = []
|
||||
hass.bus.async_listen("houseplan_config_updated", lambda ev: events.append(ev.data))
|
||||
|
||||
def _boom(*_a, **_k):
|
||||
raise OSError("the plans directory is on fire")
|
||||
|
||||
monkeypatch.setattr(wsapi, "collect_plans", _boom)
|
||||
|
||||
cfg = await _cfg([{"id": "r5", "plan_url": None}])
|
||||
ok = await _save(client, cfg, 0)
|
||||
assert ok["success"], "an accepted revision must be reported as accepted"
|
||||
rev = ok["result"]["rev"]
|
||||
|
||||
await hass.async_block_till_done()
|
||||
assert events and events[-1]["rev"] == rev, "the update event still fires"
|
||||
|
||||
# the store really holds the new revision, and the reported rev is usable
|
||||
await client.send_json_auto_id({"type": "houseplan/config/get"})
|
||||
got = await client.receive_json()
|
||||
assert got["result"]["rev"] == rev
|
||||
assert [sp["id"] for sp in got["result"]["config"]["spaces"]] == ["r5"]
|
||||
|
||||
monkeypatch.undo()
|
||||
again = await _save(client, cfg, rev)
|
||||
assert again["success"], "the next CAS on the reported revision goes through"
|
||||
|
||||
|
||||
async def test_content_signed_path_opens_without_a_bearer_header(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client_no_auth
|
||||
) -> None:
|
||||
|
||||
@@ -292,3 +292,92 @@ def test_collect_plans_survives_a_missing_directory(tmp_path):
|
||||
collect_plans = plans.collect_plans
|
||||
|
||||
assert collect_plans(tmp_path / "nope", _cfg(), _cfg()) == 0
|
||||
|
||||
|
||||
def test_collect_plans_never_raises_when_the_directory_disappears(tmp_path, monkeypatch):
|
||||
"""review R4-1: it runs behind a durable commit, so it may only report 0."""
|
||||
collect_plans = plans.collect_plans
|
||||
d = tmp_path / "plans"
|
||||
d.mkdir()
|
||||
|
||||
def _boom(self):
|
||||
raise OSError("gone")
|
||||
|
||||
monkeypatch.setattr(type(d), "iterdir", _boom, raising=False)
|
||||
assert collect_plans(d, _cfg("/p/a.png"), _cfg("/p/b.png")) == 0
|
||||
|
||||
|
||||
# ---------- the editor's options must be storable (issue #3) ----------
|
||||
|
||||
|
||||
def _ts_list(name):
|
||||
"""Read one `export const NAME = [...] as const;` list out of src/logic.ts.
|
||||
|
||||
Deliberately reads the TypeScript source rather than duplicating the values:
|
||||
a list that lives in two places drifts, which is exactly what happened here.
|
||||
"""
|
||||
import re
|
||||
|
||||
src = os.path.join(os.path.dirname(os.path.dirname(__file__)), "src", "logic.ts")
|
||||
with open(src, encoding="utf-8") as fh:
|
||||
text = fh.read()
|
||||
m = re.search(rf"export const {name} = \[(.*?)\] as const;", text, re.S)
|
||||
assert m, f"{name} not found in src/logic.ts"
|
||||
return re.findall(r"'([^']+)'", m.group(1))
|
||||
|
||||
|
||||
def _marker(**extra):
|
||||
return {"id": "m1", "binding": "entity:sensor.x", **extra}
|
||||
|
||||
|
||||
def test_every_display_mode_the_editor_offers_is_accepted():
|
||||
"""issue #3: 'value' was added to the card in v1.26.0 and never to the schema.
|
||||
|
||||
Saving a sensor set to "value instead of an icon" failed with
|
||||
"not a valid value for dictionary value @ data['config']['markers'][n]['display']",
|
||||
and because one bad marker rejects the whole config, the user could not save
|
||||
at all. Reported 2026-07-27.
|
||||
"""
|
||||
modes = _ts_list("DISPLAY_MODES")
|
||||
assert "value" in modes, "the regression this test exists for"
|
||||
for mode in modes:
|
||||
v.MARKER_SCHEMA(_marker(display=mode))
|
||||
v.MARKER_SCHEMA(_marker(display=None))
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.MARKER_SCHEMA(_marker(display="wat"))
|
||||
|
||||
|
||||
def test_every_tap_action_the_editor_offers_is_accepted():
|
||||
for action in _ts_list("TAP_ACTIONS"):
|
||||
v.MARKER_SCHEMA(_marker(tap_action=action))
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.MARKER_SCHEMA(_marker(tap_action="launch-missiles"))
|
||||
|
||||
|
||||
def _space(**settings):
|
||||
return {
|
||||
"id": "f1", "title": "F1", "aspect": 1.4, "view_box": [0, 0, 1, 1],
|
||||
"rooms": [], "settings": settings,
|
||||
}
|
||||
|
||||
|
||||
def test_every_fill_mode_the_editor_offers_is_accepted():
|
||||
for mode in _ts_list("SPACE_FILL_MODES"):
|
||||
v.SPACE_SCHEMA(_space(fill_mode=mode))
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.SPACE_SCHEMA(_space(fill_mode="rainbow"))
|
||||
|
||||
|
||||
def test_every_room_fill_mode_the_editor_offers_is_accepted():
|
||||
def room(mode):
|
||||
return {
|
||||
"id": "f1", "title": "F1", "aspect": 1.4, "view_box": [0, 0, 1, 1],
|
||||
"rooms": [{"id": "r1", "name": "R", "x": 0.1, "y": 0.1, "w": 0.2, "h": 0.2,
|
||||
"settings": {"fill_mode": mode}}],
|
||||
}
|
||||
|
||||
for mode in _ts_list("ROOM_FILL_MODES"):
|
||||
v.SPACE_SCHEMA(room(mode))
|
||||
v.SPACE_SCHEMA(room(None)) # inherit from the space
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.SPACE_SCHEMA(room("rainbow"))
|
||||
|
||||
+2
-1
@@ -12,6 +12,7 @@
|
||||
"src/rules.ts",
|
||||
"src/devices.ts",
|
||||
"src/types.ts",
|
||||
"src/space-geometry.ts"
|
||||
"src/space-geometry.ts",
|
||||
"src/signing.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user