From 75279308c1d6387a54dc7b10d9d626a109b826aa Mon Sep 17 00:00:00 2001 From: Matysh Date: Tue, 28 Jul 2026 19:31:29 +0300 Subject: [PATCH] =?UTF-8?q?v1.46.3:=20re-check=20of=20v1.46.2=20=E2=80=94?= =?UTF-8?q?=20HP-1462-01?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The startup sweep resolved its runtime data with get_data(hass), which lists only LOADED entries — during async_setup_entry the entry is still SETUP_IN_PROGRESS, so it always got None and degraded to removing streaming temporaries. The real collection was then 24 hours away, and an instance that restarts more often than that never ran it at all. It closes over the object created a few lines above instead; the callback is unregistered with the entry, so that matches the lifecycle. The test that was meant to prove the previous fix passed for the wrong reason: it seeded the strays BEFORE config/set, which collects too, so nothing was left for the restart to find. Now seeded after the save, plus two more — one firing the interval callback on its own, and one running a reload and a save concurrently to assert the accepted config never references a file the sweep removed (they share the write lock; this pins that they must). Docs: CHANGELOG.md + CHANGELOG.ru.md + TESTING.md + STATUS.md. --- custom_components/houseplan/__init__.py | 14 +- .../__pycache__/const.cpython-310.pyc | Bin 816 -> 816 bytes custom_components/houseplan/const.py | 2 +- .../houseplan/frontend/houseplan-card.js | 2 +- custom_components/houseplan/manifest.json | 2 +- demo/srv/assets/houseplan-card.js | 2 +- dist/houseplan-card.js | 2 +- docs/CHANGELOG.md | 15 ++ docs/CHANGELOG.ru.md | 15 ++ docs/STATUS.md | 4 +- docs/TESTING.md | 13 +- package.json | 2 +- src/houseplan-card.ts | 2 +- tests_backend/test_ha_websocket.py | 156 ++++++++++++++---- 14 files changed, 176 insertions(+), 55 deletions(-) diff --git a/custom_components/houseplan/__init__.py b/custom_components/houseplan/__init__.py index 9779c39..dadd696 100755 --- a/custom_components/houseplan/__init__.py +++ b/custom_components/houseplan/__init__.py @@ -22,7 +22,7 @@ from .const import ( ) from .plans import collect_attachments, collect_plans, sweep_upload_temps from .repairs import async_check_plan_files -from .store import HouseplanConfigEntry, create_data, get_data +from .store import HouseplanConfigEntry, create_data _LOGGER = logging.getLogger(__name__) @@ -118,10 +118,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> files_dir = Path(hass.config.path(FILES_DIR)) plans_dir = Path(hass.config.path(PLANS_DIR)) try: - data = get_data(hass) - if data is None: # entry unloaded — nothing authoritative to compare against - await hass.async_add_executor_job(sweep_upload_temps, files_dir) - return + # `data` from the closure, NOT get_data(hass): during + # async_setup_entry the entry is still SETUP_IN_PROGRESS, so + # async_loaded_entries() does not list it and the lookup returned + # None. The startup pass then silently degraded to removing + # streaming temporaries only, and the real collection waited a full + # day — restarting more often than that meant it never ran at all + # (HP-1462-01). The callback is unregistered with the entry, so + # closing over its runtime data matches the lifecycle exactly. async with data.write_lock: stored = await data.config_store.async_load() or {} cfg = stored.get("config") or {} diff --git a/custom_components/houseplan/__pycache__/const.cpython-310.pyc b/custom_components/houseplan/__pycache__/const.cpython-310.pyc index 44d98d4c15ab4de2a60856f32b04d0f34a4c1422..89b6d826f261f97dcfbcf7f7091775044551b8af 100644 GIT binary patch delta 26 gcmdnMwtHq)$ diff --git a/custom_components/houseplan/const.py b/custom_components/houseplan/const.py index 884314a..bb20014 100755 --- a/custom_components/houseplan/const.py +++ b/custom_components/houseplan/const.py @@ -24,7 +24,7 @@ MAX_SIGN_PATHS = 200 PLAN_ORPHAN_TTL_S = 3600 FILES_DIR = "houseplan/files" CONF_ADMIN_ONLY = "admin_only" -VERSION = "1.46.2" +VERSION = "1.46.3" DEFAULT_CONFIG: dict = { "spaces": [], diff --git a/custom_components/houseplan/frontend/houseplan-card.js b/custom_components/houseplan/frontend/houseplan-card.js index 8a9cfdd..d5d985b 100755 --- a/custom_components/houseplan/frontend/houseplan-card.js +++ b/custom_components/houseplan/frontend/houseplan-card.js @@ -2561,4 +2561,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow `} - `}}ps.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ps._touchSeen=!1,ps._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ps.styles=Ui,customElements.get("houseplan-card")||customElements.define("houseplan-card",ps),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.46.2 ","background:#3ea6ff;color:#04121f;font-weight:700",""); + `}}ps.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ps._touchSeen=!1,ps._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ps.styles=Ui,customElements.get("houseplan-card")||customElements.define("houseplan-card",ps),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.46.3 ","background:#3ea6ff;color:#04121f;font-weight:700",""); diff --git a/custom_components/houseplan/manifest.json b/custom_components/houseplan/manifest.json index 8d43f39..7ad3b3e 100755 --- a/custom_components/houseplan/manifest.json +++ b/custom_components/houseplan/manifest.json @@ -16,5 +16,5 @@ "issue_tracker": "https://github.com/Matysh/houseplan-card/issues", "requirements": [], "single_config_entry": true, - "version": "1.46.2" + "version": "1.46.3" } diff --git a/demo/srv/assets/houseplan-card.js b/demo/srv/assets/houseplan-card.js index 8a9cfdd..d5d985b 100755 --- a/demo/srv/assets/houseplan-card.js +++ b/demo/srv/assets/houseplan-card.js @@ -2561,4 +2561,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow `} - `}}ps.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ps._touchSeen=!1,ps._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ps.styles=Ui,customElements.get("houseplan-card")||customElements.define("houseplan-card",ps),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.46.2 ","background:#3ea6ff;color:#04121f;font-weight:700",""); + `}}ps.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ps._touchSeen=!1,ps._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ps.styles=Ui,customElements.get("houseplan-card")||customElements.define("houseplan-card",ps),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.46.3 ","background:#3ea6ff;color:#04121f;font-weight:700",""); diff --git a/dist/houseplan-card.js b/dist/houseplan-card.js index 8a9cfdd..d5d985b 100755 --- a/dist/houseplan-card.js +++ b/dist/houseplan-card.js @@ -2561,4 +2561,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow `} - `}}ps.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ps._touchSeen=!1,ps._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ps.styles=Ui,customElements.get("houseplan-card")||customElements.define("houseplan-card",ps),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.46.2 ","background:#3ea6ff;color:#04121f;font-weight:700",""); + `}}ps.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ps._touchSeen=!1,ps._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ps.styles=Ui,customElements.get("houseplan-card")||customElements.define("houseplan-card",ps),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.46.3 ","background:#3ea6ff;color:#04121f;font-weight:700",""); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 53f0aec..6061ad2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## v1.46.3 — 2026-07-28 (re-check of v1.46.2: HP-1462-01) +- **The cleanup at startup now actually cleans up.** It looked its own runtime + data up by domain, and during startup Home Assistant does not yet consider + the integration loaded — so the lookup came back empty and the pass quietly + degraded to removing half-finished transfers, leaving the real work to a timer + 24 hours away. Restart more often than that and it never ran at all. It uses + the object it was given at startup now. +- **The test that was supposed to prove this was passing for the wrong + reason.** It created the stray files *before* saving the configuration — and + saving collects too, so everything was already gone by the time the restart + happened. Rewritten to seed after the save, plus a second test that fires the + scheduled timer on its own, and a third that runs a restart and a save at the + same time and asserts the accepted configuration never points at a file the + cleanup removed. + ## v1.46.2 — 2026-07-28 (re-check of v1.46.1: HP-1461-01, -02) - **A file nobody ended up using is now cleaned up even if nothing is ever saved again (HP-1461-01).** Collection is tied to a configuration write, diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 1808835..d4fe062 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -6,6 +6,21 @@ > **Правило проекта:** оба файла пополняются в одном коммите с самим > изменением — как и остальная документация (см. docs/STATUS.md). +## v1.46.3 — 2026-07-28 (перепроверка v1.46.2: HP-1462-01) +- **Уборка при старте действительно убирает.** Она искала свои же runtime-данные + по домену, а во время запуска Home Assistant ещё не считает интеграцию + загруженной — поэтому поиск возвращал пустоту, и проход тихо вырождался в + удаление незавершённых передач, оставляя настоящую работу таймеру через + 24 часа. При перезапусках чаще, чем раз в сутки, она не выполнялась вообще. + Теперь используется объект, который у неё и так был на руках. +- **Тест, который должен был это доказать, проходил по неверной причине.** Он + создавал лишние файлы *до* сохранения конфигурации, а сохранение тоже + собирает мусор — так что к моменту перезапуска убирать было уже нечего. + Переписан: файлы создаются после сохранения; добавлен второй тест, который + дёргает плановый таймер отдельно, и третий, который запускает перезапуск и + сохранение одновременно и проверяет, что принятая конфигурация никогда не + ссылается на удалённый уборкой файл. + ## v1.46.2 — 2026-07-28 (перепроверка v1.46.1: HP-1461-01, -02) - **Файл, который в итоге никому не понадобился, убирается, даже если больше ничего не сохраняют (HP-1461-01).** Сборка привязана к записи конфигурации — diff --git a/docs/STATUS.md b/docs/STATUS.md index b4a8d78..9dc71d9 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -15,12 +15,12 @@ | Item | State | |---|---| -| Version | **v1.46.2** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance | +| Version | **v1.46.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` 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.46.2** via direct copy (HACS custom repo also installed) | +| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.46.3** via direct copy (HACS custom repo also installed) | | Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover | | 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 | diff --git a/docs/TESTING.md b/docs/TESTING.md index 018e045..6f0f06f 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -239,11 +239,14 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi 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] -- [ ] Nothing accumulates on an idle instance (v1.46.2, HP-1461-01): attach a - file, cancel the dialog, and do not save anything else — an hour later (or - after a restart) the file is gone, while every file the configuration - still references is untouched - [auto: backend test_scheduled_sweep_collects_what_no_commit_will] +- [ ] Nothing accumulates on an idle instance (v1.46.2/v1.46.3, HP-1461-01, + HP-1462-01): attach a file, cancel the dialog, and do not save anything + else — the file is gone after a restart AND after the daily pass, while + every file the configuration still references is untouched. Seed the + strays AFTER the last save, or `config/set` collects them and the check + proves nothing + [auto: backend test_startup_sweep_collects_what_no_commit_will, + test_daily_sweep_callback_collects_too, test_sweep_and_a_config_write_do_not_race] - [ ] A drag wins over a concurrent remote move (v1.46.2, HP-1461-02): drag an icon and, while the save is still in flight, have another window move a different icon — your icon stays where you put it and the other one diff --git a/package.json b/package.json index c74ab75..de2d53d 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "houseplan-card", - "version": "1.46.2", + "version": "1.46.3", "description": "Interactive house plan Lovelace card for Home Assistant", "license": "MIT", "type": "module", diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 6a5f63f..9ab9997 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -35,7 +35,7 @@ import './space-card'; import { cardStyles } from './styles'; import { langOf, t, type I18nKey } from './i18n'; -const CARD_VERSION = '1.46.2'; +const CARD_VERSION = '1.46.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'; diff --git a/tests_backend/test_ha_websocket.py b/tests_backend/test_ha_websocket.py index 21b4b01..acc3c37 100644 --- a/tests_backend/test_ha_websocket.py +++ b/tests_backend/test_ha_websocket.py @@ -855,61 +855,145 @@ async def test_cancelling_an_upload_takes_its_temporary_with_it( assert entry -async def test_scheduled_sweep_collects_what_no_commit_will( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator -) -> None: - """HP-1461-01: a commit collects what it superseded — but only when it runs. - - Cancel a dialog after the file uploaded, or lose the connection right after, - and nothing references the file and no future write notices it. The daily - sweep used to remove only streaming temporaries, so the promise that a - cancelled attachment goes after an hour did not hold on an instance nobody - edits. - """ +async def _seed_aged(hass, names) -> None: + """Create the given files and backdate them past the orphan TTL.""" import os import time - from custom_components.houseplan.const import FILES_DIR, PLANS_DIR, PLAN_ORPHAN_TTL_S + from custom_components.houseplan.const import PLAN_ORPHAN_TTL_S - await _setup(hass) - client = await hass_ws_client(hass) - files = hass.config.path(FILES_DIR) - plans = hass.config.path(PLANS_DIR) old = time.time() - PLAN_ORPHAN_TTL_S - 60 - def _seed() -> None: - os.makedirs(os.path.join(files, "m5"), exist_ok=True) - os.makedirs(os.path.join(files, "up_cancelled"), exist_ok=True) - os.makedirs(plans, exist_ok=True) - for path in ( - os.path.join(files, "m5", "kept.pdf"), - os.path.join(files, "up_cancelled", "manual.pdf"), - os.path.join(plans, "s5.tok.png"), - os.path.join(plans, "s5.orphan.png"), - ): + def _do() -> None: + for path in names: + os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "wb") as fh: fh.write(b"x") os.utime(path, (old, old)) - await hass.async_add_executor_job(_seed) + await hass.async_add_executor_job(_do) + +def _paths(hass): + import os + + from custom_components.houseplan.const import FILES_DIR, PLANS_DIR + + files = hass.config.path(FILES_DIR) + plans = hass.config.path(PLANS_DIR) + return files, plans, { + "kept_file": os.path.join(files, "m5", "kept.pdf"), + "kept_plan": os.path.join(plans, "s5.tok.png"), + "orphan_file": os.path.join(files, "up_cancelled", "manual.pdf"), + "orphan_plan": os.path.join(plans, "s5.orphan.png"), + } + + +async def _referenced_config() -> dict: cfg = await _cfg([{"id": "s5", "plan_url": "/api/houseplan/content/plans/_/s5.tok.png"}]) cfg["markers"] = [ {"id": "m5", "binding": "virtual", "pdfs": [{"name": "k", "url": "/api/houseplan/content/files/m5/kept.pdf"}]} ] - assert (await _save(client, cfg, 0))["success"] + return cfg + + +async def _assert_swept(hass, p) -> None: + import os + + assert await hass.async_add_executor_job(os.path.isfile, p["kept_file"]), "referenced file kept" + assert await hass.async_add_executor_job(os.path.isfile, p["kept_plan"]), "referenced plan kept" + assert not await hass.async_add_executor_job(os.path.isfile, p["orphan_file"]) + assert not await hass.async_add_executor_job(os.path.isfile, p["orphan_plan"]) + + +async def test_startup_sweep_collects_what_no_commit_will( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """HP-1461-01 / HP-1462-01: collection must not depend on a future save. + + The files are seeded AFTER the configuration is stored. The previous version + of this test seeded them before, and `config/set` collects too — so it + passed without the startup pass doing anything, hiding HP-1462-01: during + setup the entry is not "loaded" yet, so looking its runtime data up by + domain returned None and the pass degraded to removing streaming + temporaries only. + """ + import os + + await _setup(hass) + client = await hass_ws_client(hass) + files, _plans, p = _paths(hass) + assert (await _save(client, await _referenced_config(), 0))["success"] + + await _seed_aged(hass, list(p.values())) - # reload the entry: that is what runs the sweep at startup entry = hass.config_entries.async_entries(DOMAIN)[0] assert await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() - exists = lambda p: os.path.isfile(p) # noqa: E731 - assert await hass.async_add_executor_job(exists, os.path.join(files, "m5", "kept.pdf")) - assert await hass.async_add_executor_job(exists, os.path.join(plans, "s5.tok.png")) + await _assert_swept(hass, p) assert not await hass.async_add_executor_job( - exists, os.path.join(files, "up_cancelled", "manual.pdf") - ), "an aged cancelled attachment is collected without any further config write" - assert not await hass.async_add_executor_job(exists, os.path.join(plans, "s5.orphan.png")) - assert not await hass.async_add_executor_job(os.path.isdir, os.path.join(files, "up_cancelled")) + os.path.isdir, os.path.join(files, "up_cancelled") + ), "the emptied staging folder goes with its last file" + + +async def test_daily_sweep_callback_collects_too( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """The interval path, exercised on its own by firing the scheduled callback.""" + import datetime as dt + + from homeassistant.util import dt as dt_util + from pytest_homeassistant_custom_component.common import async_fire_time_changed + + await _setup(hass) + client = await hass_ws_client(hass) + _files, _plans, p = _paths(hass) + assert (await _save(client, await _referenced_config(), 0))["success"] + + await _seed_aged(hass, list(p.values())) + + async_fire_time_changed(hass, dt_util.utcnow() + dt.timedelta(hours=25)) + await hass.async_block_till_done() + + await _assert_swept(hass, p) + + +async def test_sweep_and_a_config_write_do_not_race( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Both take the same write lock, so an accepted config cannot lose a file. + + Without it the sweep could decide a file is unreferenced, a commit could + start referencing it, and the file would go — leaving the accepted revision + pointing at nothing. + """ + import asyncio + import os + + await _setup(hass) + client = await hass_ws_client(hass) + _files, plans, p = _paths(hass) + rev = (await _save(client, await _referenced_config(), 0))["result"]["rev"] + + # an aged, currently unreferenced plan that the commit below adopts + newcomer = os.path.join(plans, "s5.newcomer.png") + await _seed_aged(hass, [p["kept_file"], p["kept_plan"], newcomer]) + + cfg2 = await _referenced_config() + cfg2["spaces"][0]["plan_url"] = "/api/houseplan/content/plans/_/s5.newcomer.png" + + entry = hass.config_entries.async_entries(DOMAIN)[0] + await asyncio.gather( + hass.config_entries.async_reload(entry.entry_id), # runs the sweep + _save(client, cfg2, rev), + ) + await hass.async_block_till_done() + + await client.send_json_auto_id({"type": "houseplan/config/get"}) + stored = (await client.receive_json())["result"]["config"] + referenced = stored["spaces"][0]["plan_url"].rsplit("/", 1)[-1] + assert await hass.async_add_executor_job( + os.path.isfile, os.path.join(plans, referenced) + ), f"the accepted config points at {referenced}, which must exist"