Compare commits

...
5 Commits
Author SHA1 Message Date
Matysh e4e300adaa Release v1.45.4
Validate / hacs (push) Failing after 1m19s
Validate / hassfest (push) Failing after 1m18s
Validate / frontend (push) Successful in 2m13s
Validate / backend (push) Failing after 10m58s
Validate / smoke (push) Failing after 6m7s
Review of v1.45.3: R5-1 a partial signing answer no longer skips the backoff,
R5-2 the status snapshot matches the repository and no longer carries counts
that go stale.
2026-07-28 08:51:57 +03:00
Matysh 96d387ff1d v1.45.4: review of v1.45.3 — R5-1, R5-2
Validate / hassfest (push) Failing after 49s
Validate / hacs (push) Failing after 52s
Validate / frontend (push) Successful in 1m43s
Validate / backend (push) Failing after 8m30s
Validate / smoke (push) Successful in 4m52s
R5-1: the backend signs each path independently and answers successfully with
whatever it managed, skipping (and logging) the rest. The card read any
successful call as 'the batch is done', cleared the backoff for every path in
it, then wrote only the urls that came back — so a path the backend kept
skipping was asked for again on every render, the exact amplification the
backoff was added to stop. A path now counts as signed only when the answer
carries a url for it; the others back off individually, keys that were not
requested are ignored, and onUpdate fires only when a new signature landed.

R5-2: docs/STATUS.md still described main as holding releases up to v1.40.1 and
quoted test counts several releases old, while the version line beside them was
kept current — a handoff reader got a wrong branch model and less coverage than
exists. Branch roles are now accurate, and the counts are gone rather than
corrected: scripts/inventory.mjs (npm run inventory) prints them from the tree,
so there is nothing left to drift.

Tests: three unit cases for empty/partial/foreign-key answers, verified to fail
against a v1.45.3 checkout; a backend test pinning the partial-success contract
by making async_sign_path raise for one path of two.
Docs: CHANGELOG.md + CHANGELOG.ru.md + TESTING.md + STATUS.md.
2026-07-28 08:49:11 +03:00
Matysh 8b531db3f5 docs: the value-display bug lived six days, not a year and a half
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 1m44s
Validate / backend (push) Failing after 6m15s
Validate / smoke (push) Failing after 12m28s
Version distance is not calendar distance. v1.26.0 shipped 2026-07-21 and the
report came in on 2026-07-27; the project itself is three weeks old. The point
stands and is unchanged — nothing in the suite could have caught it, because the
option list and the schema were written in two languages and never compared —
but the 'year and a half' was wrong.
2026-07-28 00:29:40 +03:00
Matysh 68aa1f04ba Release v1.45.3
Validate / hacs (push) Failing after 5s
Validate / hassfest (push) Failing after 5s
Validate / frontend (push) Successful in 1m34s
Validate / backend (push) Failing after 6m18s
Validate / smoke (push) Failing after 10m27s
issue #3: display='value' was offered by the editor since v1.26.0 but rejected
by the schema, which blocked saving the configuration entirely. Option lists
are now shared and checked across languages.
2026-07-28 00:26:27 +03:00
Matysh 3d41fe16b8 v1.45.3: 'value instead of an icon' could never be saved (issue #3)
The device editor has offered display='value' since v1.26.0; MARKER_SCHEMA
accepted only badge/ripple/icon_ripple. Picking it produced

  not a valid value for dictionary value @ data['config']['markers'][n]['display']

and since one rejected marker fails the whole config write, the user could not
save the plan at all until the setting was undone. Reported by @RemyRoux with
the exact error text, 2026-07-27 — a year and a half after the feature shipped.

The schema now accepts it, and the class of bug is closed rather than the
instance: DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES and ROOM_FILL_MODES are
exported from src/logic.ts, the editors render their options from them, and a
backend test parses those lists out of the TypeScript source and asserts the
schema accepts every one (and rejects a bogus value). Reverting the one-word
schema fix fails that test, which is the check that was missing.

Plus an HA-harness test saving a config that contains a value-display marker —
the exact call the user's card was making.
Docs: CHANGELOG.md + CHANGELOG.ru.md + TESTING.md + STATUS.md.
2026-07-28 00:23:37 +03:00
19 changed files with 477 additions and 129 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"
}
+3 -1
View File
@@ -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
+35 -35
View File
File diff suppressed because one or more lines are too long
+34
View File
@@ -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
+36
View File
@@ -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
View File
@@ -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 |
+10
View File
@@ -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
View File
@@ -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",
+24
View File
@@ -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}`);
+6 -5
View File
@@ -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(); }} />
+21
View File
@@ -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
View File
@@ -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
+60
View File
@@ -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]);
});
+66
View File
@@ -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"
+76
View File
@@ -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"))