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 44d98d4..89b6d82 100644 Binary files a/custom_components/houseplan/__pycache__/const.cpython-310.pyc and b/custom_components/houseplan/__pycache__/const.cpython-310.pyc differ 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"