mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4e300adaa | ||
|
|
96d387ff1d | ||
|
|
8b531db3f5 | ||
|
|
68aa1f04ba | ||
|
|
3d41fe16b8 |
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.2"
|
||||
VERSION = "1.45.4"
|
||||
|
||||
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.2"
|
||||
"version": "1.45.4"
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+35
-35
File diff suppressed because one or more lines are too long
@@ -1,5 +1,39 @@
|
||||
# Changelog
|
||||
|
||||
## v1.45.4 — 2026-07-28 (review of v1.45.3: R5-1, R5-2)
|
||||
- **A partly successful signing answer no longer skips the backoff (R5-1).**
|
||||
The backend signs each path independently: one it cannot sign is logged,
|
||||
skipped, and the call still succeeds with the remaining urls. The card took
|
||||
any successful call as "the whole batch is done", cleared the backoff for
|
||||
every path in it, and then wrote only the urls that came back — so a path the
|
||||
backend kept skipping was requested again on every single render, which is
|
||||
exactly the amplification v1.45.2 added the backoff to prevent. A path is now
|
||||
counted as signed only if the answer actually carries a url for it; the rest
|
||||
back off individually, keys nobody asked for are ignored, and a re-render is
|
||||
only triggered when at least one new signature arrived.
|
||||
- **The status snapshot no longer contradicts the repository (R5-2).** It still
|
||||
described `main` as carrying releases up to v1.40.1 and quoted test counts
|
||||
from several releases back, while the version line right beside them was kept
|
||||
current — a maintainer or an agent reading it for handoff got a wrong branch
|
||||
model and a smaller picture of the coverage than exists. The branch roles are
|
||||
described accurately, and the counts are gone: `npm run inventory` prints them
|
||||
from the tree, so there is nothing left to go stale.
|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
@@ -6,6 +6,42 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.45.4 — 2026-07-28 (ревью v1.45.3: R5-1, R5-2)
|
||||
- **Частично успешный ответ на подпись больше не пропускает выдержку (R5-1).**
|
||||
Бэкенд подписывает каждый путь независимо: тот, что подписать не удалось,
|
||||
логируется, пропускается, и вызов всё равно завершается успешно с остальными
|
||||
ссылками. Карточка считала любой успешный вызов «весь батч готов», сбрасывала
|
||||
выдержку для всех путей в нём и записывала только вернувшиеся ссылки — и путь,
|
||||
который бэкенд стабильно пропускал, запрашивался заново на каждом рендере, то
|
||||
есть ровно то усиление, ради которого выдержка и вводилась в v1.45.2. Теперь
|
||||
путь считается подписанным, только если в ответе действительно есть ссылка на
|
||||
него; остальные уходят в выдержку по отдельности, ключи, которых не просили,
|
||||
игнорируются, а перерисовка запускается лишь при появлении хотя бы одной новой
|
||||
подписи.
|
||||
- **Снимок состояния больше не противоречит репозиторию (R5-2).** Там всё ещё
|
||||
было написано, что в `main` лежат релизы только до v1.40.1, и приводились
|
||||
счётчики тестов на несколько релизов назад — при том что строка версии рядом
|
||||
исправно обновлялась. Читающий его человек или агент получал неверную
|
||||
модель веток и заниженное представление о покрытии. Роли веток описаны точно,
|
||||
а счётчики убраны: `npm run inventory` печатает их из дерева, и устаревать
|
||||
больше нечему.
|
||||
|
||||
## 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).**
|
||||
Сборка вытесненных файлов плана идёт уже после того, как конфигурация
|
||||
|
||||
+5
-5
@@ -11,18 +11,18 @@
|
||||
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
|
||||
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
|
||||
|
||||
## Snapshot (2026-07-27)
|
||||
## Snapshot (2026-07-28)
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.45.2** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Version | **v1.45.4** 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) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). 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.2** via direct copy (HACS custom repo also installed) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.45.4** 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) |
|
||||
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
|
||||
| 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 |
|
||||
| Product scope | docs/SCOPE.md (2026-07-22) is the feature guard rail — check before accepting any feature |
|
||||
|
||||
|
||||
@@ -234,6 +234,16 @@ 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]
|
||||
- [ ] A path the backend cannot sign does not become a request loop (v1.45.4,
|
||||
review R5-1): when `content/sign` answers successfully but omits a path,
|
||||
the card backs that path off individually and keeps the urls it did get;
|
||||
a re-render asks only for what is still missing, and only after the wait
|
||||
[auto: unit: signing.test + backend test_signing_one_path_may_fail_without_failing_the_request]
|
||||
- [ ] 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
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.45.2",
|
||||
"version": "1.45.4",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -8,7 +8,8 @@
|
||||
"build": "tsc --noEmit && rollup -c",
|
||||
"watch": "rollup -c --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs"
|
||||
"test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs",
|
||||
"inventory": "node scripts/inventory.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Current test inventory, printed on demand.
|
||||
//
|
||||
// docs/STATUS.md used to carry these numbers inline; they went stale within a
|
||||
// couple of releases while the version line next to them was kept current,
|
||||
// which is worse than no number at all — a maintainer reading the snapshot
|
||||
// underestimates the coverage that exists (review R5-2). The counts live here
|
||||
// now, one command away, and STATUS.md describes the layers instead.
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
|
||||
const count = (dir, match, re) =>
|
||||
readdirSync(dir)
|
||||
.filter((f) => match.test(f))
|
||||
.reduce((n, f) => n + (readFileSync(`${dir}/${f}`, 'utf8').match(re) || []).length, 0);
|
||||
|
||||
const files = (dir, match) => readdirSync(dir).filter((f) => match.test(f)).length;
|
||||
|
||||
const rows = [
|
||||
['frontend unit (node:test)', count('test', /\.test\.mjs$/, /^test\(/gm)],
|
||||
['pure backend (pytest, no HA)', count('tests_backend', /^test_validation\.py$/, /^def test_/gm)],
|
||||
['HA-harness backend (CI, py3.13)', count('tests_backend', /^test_ha_.*\.py$/, /^async def test_|^def test_/gm)],
|
||||
['browser smokes (headless chromium)', files('demo', /^smoke_.*\.mjs$/)],
|
||||
];
|
||||
const w = Math.max(...rows.map(([n]) => n.length));
|
||||
for (const [name, n] of rows) console.log(`${name.padEnd(w)} ${n}`);
|
||||
@@ -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.2';
|
||||
const CARD_VERSION = '1.45.4';
|
||||
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,27 @@ 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 — and since
|
||||
* one bad marker fails the whole config write, the plan could not be saved at
|
||||
* all. Shipped 2026-07-21, found by a user on 2026-07-27: six days, and only
|
||||
* because they pasted the error text. Nothing in the suite could have caught
|
||||
* it, because the option list and the schema that stores it were written in
|
||||
* two languages and never compared. They are exported here 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']);
|
||||
|
||||
/**
|
||||
|
||||
+26
-9
@@ -114,23 +114,33 @@ export class ContentSigner {
|
||||
hass
|
||||
.callWS({ type: 'houseplan/content/sign', paths: batch })
|
||||
.then((r: any) => {
|
||||
for (const p of batch) this.retry.delete(p);
|
||||
if (!r?.urls || this.disposed) return;
|
||||
if (this.disposed) return;
|
||||
// A successful call does NOT mean every path was signed: the backend
|
||||
// skips a path it cannot sign, logs it and still answers `{urls: …}`
|
||||
// with the rest. Treating the whole batch as done then cleared the
|
||||
// backoff for the missing ones, so every later render asked again —
|
||||
// the very amplification the backoff exists to stop (review R5-1).
|
||||
const at = this.now();
|
||||
const next = { ...this.signed };
|
||||
for (const [k, v] of Object.entries<string>(r.urls)) next[k] = { url: v, at };
|
||||
let accepted = 0;
|
||||
for (const p of batch) {
|
||||
const url = r?.urls?.[p]; // only keys we asked for
|
||||
if (typeof url === 'string' && url) {
|
||||
next[p] = { url, at };
|
||||
this.retry.delete(p);
|
||||
accepted++;
|
||||
} else {
|
||||
this.backOff(p);
|
||||
}
|
||||
}
|
||||
if (!accepted) return;
|
||||
this.signed = next;
|
||||
this.onUpdate();
|
||||
})
|
||||
.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 });
|
||||
}
|
||||
for (const p of batch) this.backOff(p);
|
||||
})
|
||||
.finally(() => {
|
||||
// release only our own attempt: a later one may have superseded it
|
||||
@@ -139,6 +149,13 @@ export class ContentSigner {
|
||||
}
|
||||
}
|
||||
|
||||
/** Next attempt for this url waits, and each failure waits twice as long. */
|
||||
private backOff(url: string): void {
|
||||
const prev = this.retry.get(url)?.delay || 0;
|
||||
const delay = Math.min(SIGN_BACKOFF_MAX_MS, prev ? prev * 2 : SIGN_BACKOFF_MIN_MS);
|
||||
this.retry.set(url, { notBefore: this.now() + delay, delay });
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-sign what is still in use. A wall tablet outlives a signature, and an
|
||||
* entry for a plan replaced months ago must not consume a slot in the capped
|
||||
|
||||
@@ -183,3 +183,63 @@ test('dispose(): a late answer neither renders nor throws, start() revives it',
|
||||
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=NEW');
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('R5-1: an empty but successful answer still backs off', async () => {
|
||||
let t = 1_000_000;
|
||||
const { hass, calls } = makeHass();
|
||||
const { s, updates } = signer(() => t);
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
calls[0].res({ urls: {} }); // the backend skipped the path it could not sign
|
||||
await tick();
|
||||
assert.equal(updates(), 0, 'nothing was signed, so nothing to re-render for');
|
||||
|
||||
for (let i = 0; i < 5; i++) { s.display(hass, URL_A); await tick(); }
|
||||
assert.equal(calls.length, 1, 'five renders, still one request');
|
||||
|
||||
t += SIGN_BACKOFF_MIN_MS + 1;
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 2, 'retried after the backoff');
|
||||
});
|
||||
|
||||
test('R5-1: a partial answer backs off only the path that is missing', 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();
|
||||
assert.deepEqual(calls[0].paths.sort(), [URL_B, URL_A].sort());
|
||||
|
||||
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=OK' } }); // B was skipped
|
||||
await tick();
|
||||
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=OK');
|
||||
assert.equal(s.display(hass, URL_B), '');
|
||||
await tick();
|
||||
assert.equal(calls.length, 1, 'the missing path is in backoff, not re-asked');
|
||||
|
||||
t += SIGN_BACKOFF_MIN_MS + 1;
|
||||
s.display(hass, URL_A);
|
||||
s.display(hass, URL_B);
|
||||
await tick();
|
||||
assert.deepEqual(calls[1].paths, [URL_B], 'only the missing path is retried');
|
||||
|
||||
calls[1].res({ urls: { [URL_B]: URL_B + '?authSig=OK' } });
|
||||
await tick();
|
||||
assert.equal(s.display(hass, URL_B), URL_B + '?authSig=OK');
|
||||
t += SIGN_BACKOFF_MIN_MS * 8;
|
||||
s.display(hass, URL_B);
|
||||
await tick();
|
||||
assert.equal(calls.length, 2, 'a success clears the backoff state, no stray retry');
|
||||
});
|
||||
|
||||
test('R5-1: a key we never asked for is ignored', async () => {
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer();
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=OK', '/api/houseplan/content/files/x/evil.pdf': 'nope' } });
|
||||
await tick();
|
||||
assert.deepEqual(Object.keys(s.entries), [URL_A]);
|
||||
});
|
||||
|
||||
@@ -396,6 +396,31 @@ 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:
|
||||
@@ -479,3 +504,44 @@ async def test_content_signed_path_opens_without_a_bearer_header(
|
||||
)
|
||||
resp2 = await client.receive_json()
|
||||
assert resp2["success"] and resp2["result"]["urls"] == {}
|
||||
|
||||
|
||||
async def test_signing_one_path_may_fail_without_failing_the_request(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch
|
||||
) -> None:
|
||||
"""review R5-1: pin the contract the card now codes against.
|
||||
|
||||
One unsignable path must NOT fail the whole call — a single bad url would
|
||||
otherwise block the signatures of every other file in the batch. The answer
|
||||
is a partial map, and the card treats a path missing from it as a failure
|
||||
for that path (backing off) rather than as success.
|
||||
"""
|
||||
from custom_components.houseplan import websocket_api as wsapi
|
||||
from custom_components.houseplan.const import CONTENT_URL
|
||||
|
||||
await _setup(hass)
|
||||
good = f"{CONTENT_URL}/plans/_/good.png"
|
||||
bad = f"{CONTENT_URL}/plans/_/bad.png"
|
||||
|
||||
real = wsapi.async_sign_path if hasattr(wsapi, "async_sign_path") else None
|
||||
assert real is None # imported inside the handler, so patch the source module
|
||||
|
||||
import homeassistant.components.http.auth as ha_auth
|
||||
|
||||
original = ha_auth.async_sign_path
|
||||
|
||||
def _sign(hass_, *args, **kwargs):
|
||||
path = next((a for a in args if isinstance(a, str) and a.startswith("/")), "")
|
||||
if path == bad:
|
||||
raise ValueError("cannot sign this one")
|
||||
return original(hass_, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(ha_auth, "async_sign_path", _sign)
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id({"type": "houseplan/content/sign", "paths": [good, bad]})
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"], "one bad path must not fail the batch"
|
||||
urls = resp["result"]["urls"]
|
||||
assert good in urls and "authSig=" in urls[good]
|
||||
assert bad not in urls, "an unsignable path is absent, never an unsigned url"
|
||||
|
||||
@@ -305,3 +305,79 @@ def test_collect_plans_never_raises_when_the_directory_disappears(tmp_path, monk
|
||||
|
||||
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"))
|
||||
|
||||
Reference in New Issue
Block a user