v1.46.2: re-check of v1.46.1 — HP-1461-01, -02
Validate / hacs (push) Failing after 23s
Validate / hassfest (push) Failing after 22s
Validate / frontend (push) Successful in 1m40s
Validate / backend (push) Failing after 7m16s
Validate / smoke (push) Failing after 7m13s

HP-1461-01: collection was tied to config/set, which is the right scope for
what a commit supersedes but leaves a file nobody references with no future
write to notice it — cancel a dialog after the upload finished, drop the
connection just after, or call the upload API directly. The daily sweep added
in v1.46.1 only removed streaming temporaries, so the documented 'a cancelled
attachment is collected an hour later' did not hold on an instance nobody
edits. The scheduled pass now loads the stored config under the same write_lock
a commit uses and runs collect_attachments/collect_plans with it as BOTH sides:
nothing counts as superseded, referenced files are preserved, aged unreferenced
ones go. Doing it under the lock keeps it from deciding on a snapshot a commit
is about to replace.

HP-1461-02: _reloadLayoutOnly captured the dirty set AFTER flushing the pending
write, and the flush empties it first — so during a real drag (where a write is
already scheduled) the snapshot was empty and the server's older position was
merged over the user's move. The snapshot is taken before the flush, by value,
and a _sentPos map now holds positions that are sent but unacknowledged, which
closes the same window for a write that was already in flight.

Tests: the upload test now cancels the request task for real (the previous one
claimed to and only walked error paths); smoke_layout_sync schedules a genuine
debounced write and delays it — verified failing on a v1.46.1 build with
exactly the reported symptom; a new backend test reloads the entry and asserts
the scheduled sweep takes an aged cancelled attachment and an orphan plan while
keeping everything the config still references.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
This commit is contained in:
Matysh
2026-07-28 17:44:03 +03:00
parent d3db9e30e6
commit 379fb68db2
16 changed files with 289 additions and 31 deletions
+33 -10
View File
@@ -20,9 +20,9 @@ from .const import (
PLANS_URL, PLANS_URL,
VERSION, VERSION,
) )
from .plans import sweep_upload_temps from .plans import collect_attachments, collect_plans, sweep_upload_temps
from .repairs import async_check_plan_files from .repairs import async_check_plan_files
from .store import HouseplanConfigEntry, create_data from .store import HouseplanConfigEntry, create_data, get_data
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -101,19 +101,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
await async_check_plan_files(hass, entry) await async_check_plan_files(hass, entry)
# Abandoned upload temporaries (HP-1460-02). A request cleans up after # Scheduled collection of everything nobody ended up referencing.
# itself; a hard kill mid-upload does not, and the commit-scoped collector #
# only walks marker folders — so without this a `.upload-*` from a crashed # A commit collects what that commit superseded, which is the right rule for
# transfer would sit there for good, and on an instance nobody edits, so # a commit — but it only ever runs when somebody saves. Cancel a dialog
# would a cancelled attachment. Once at startup, then daily. # after the file has already uploaded, lose the connection after the upload
# succeeded, or call the API directly, and the file is unreferenced with no
# future write to notice it (HP-1461-01). The earlier version of this sweep
# only removed streaming temporaries, which are a different, narrower case.
#
# Passing the CURRENT configuration as both sides means "nothing was
# superseded": every referenced file is preserved and only unreferenced ones
# past PLAN_ORPHAN_TTL_S go. It runs under the same lock as a config write,
# so it cannot decide from a snapshot that a commit is about to replace.
async def _sweep(_now=None) -> None: async def _sweep(_now=None) -> None:
files_dir = Path(hass.config.path(FILES_DIR)) files_dir = Path(hass.config.path(FILES_DIR))
plans_dir = Path(hass.config.path(PLANS_DIR))
try: try:
n = await hass.async_add_executor_job(sweep_upload_temps, files_dir) 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
async with data.write_lock:
stored = await data.config_store.async_load() or {}
cfg = stored.get("config") or {}
def _collect() -> int:
n = sweep_upload_temps(files_dir)
n += collect_attachments(files_dir, cfg, cfg)
n += collect_plans(plans_dir, cfg, cfg)
return n
n = await hass.async_add_executor_job(_collect)
if n: if n:
_LOGGER.info("House Plan: removed %s abandoned upload temporaries", n) _LOGGER.info("House Plan: removed %s unreferenced file(s)", n)
except Exception: # noqa: BLE001 — housekeeping must never fail a setup except Exception: # noqa: BLE001 — housekeeping must never fail a setup
_LOGGER.exception("House Plan: sweeping upload temporaries failed") _LOGGER.exception("House Plan: sweeping unreferenced files failed")
await _sweep() await _sweep()
entry.async_on_unload( entry.async_on_unload(
+1 -1
View File
@@ -24,7 +24,7 @@ MAX_SIGN_PATHS = 200
PLAN_ORPHAN_TTL_S = 3600 PLAN_ORPHAN_TTL_S = 3600
FILES_DIR = "houseplan/files" FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only" CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.46.1" VERSION = "1.46.2"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "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", "issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [], "requirements": [],
"single_config_entry": true, "single_config_entry": true,
"version": "1.46.1" "version": "1.46.2"
} }
+28 -4
View File
@@ -56,14 +56,35 @@ const res = await page.evaluate(async () => {
out.ownWriteNoReload = gets === before; out.ownWriteNoReload = gets === before;
out.ownWriteKept = JSON.stringify(c._layout.dev_b) === JSON.stringify({ x: 31, y: 32 }); out.ownWriteKept = JSON.stringify(c._layout.dev_b) === JSON.stringify({ x: 31, y: 32 });
// 3) чужая ревизия во время неотправленного перетаскивания не съедает его // 3) НАСТОЯЩЕЕ перетаскивание: debounce запланирован, запись задержана, а
// layout/get отвечает мгновенно. Именно этот порядок и терял позицию:
// flush() внутри перечитывания опустошал _dirtyPos ДО снятия снимка.
let releaseUpdate;
const updateGate = new Promise((r) => { releaseUpdate = r; });
let delayUpdate = true;
const plain = hass.callWS;
c.hass = { ...hass, callWS: async (m) => {
if (m.type === 'houseplan/layout/update' && delayUpdate) {
delayUpdate = false;
await updateGate;
}
return plain(m);
} };
c._layout = { ...c._layout, dev_a: { x: 5, y: 6 } }; c._layout = { ...c._layout, dev_a: { x: 5, y: 6 } };
c._dirtyPos.add('dev_a'); // локально подвинули, ещё не отправили c._dirtyPos.add('dev_a');
c._persistLayout(); // debounce запланирован, не сброшен вручную
layout = { ...layout, dev_b: { x: 99, y: 99 } }; rev += 1; layout = { ...layout, dev_b: { x: 99, y: 99 } }; rev += 1;
subs.forEach((f) => f({ data: { rev } })); subs.forEach((f) => f({ data: { rev } }));
await new Promise((r) => setTimeout(r, 400)); await new Promise((r) => setTimeout(r, 400));
out.localDragSurvived = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 5, y: 6 }); out.dragKeptWhileWriteInFlight = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
out.remoteChangeApplied = JSON.stringify(c._layout.dev_b) === JSON.stringify({ x: 99, y: 99 }); out.remoteChangeApplied = JSON.stringify(c._layout.dev_b) === JSON.stringify({ x: 99, y: 99 });
releaseUpdate();
await new Promise((r) => setTimeout(r, 350));
out.localDragSurvived = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
out.serverAgrees = JSON.stringify(layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
out.sentPosDrained = c._sentPos.size === 0;
return out; return out;
}); });
// зафиксировано прогоном на v1.46.1 и сверено с кодом // зафиксировано прогоном на v1.46.1 и сверено с кодом
@@ -73,7 +94,10 @@ checkAll(res, {
revFollowed: true, revFollowed: true,
ownWriteNoReload: true, ownWriteNoReload: true,
ownWriteKept: true, ownWriteKept: true,
localDragSurvived: true, dragKeptWhileWriteInFlight: true,
remoteChangeApplied: true, remoteChangeApplied: true,
localDragSurvived: true,
serverAgrees: true,
sentPosDrained: true,
}); });
await finish(browser); await finish(browser);
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
+7 -2
View File
@@ -198,8 +198,13 @@ uploads agree on one name and quietly overwrite each other. It also budgets the
length so the result survives the sanitiser the content view applies to the length so the result survives the sanitiser the content view applies to the
request, since a name the view rewrites is a file written and never served. request, since a name the view rewrites is a file written and never served.
Streaming temporaries live in the files root under `.upload-`, are removed on Streaming temporaries live in the files root under `.upload-`, are removed on
every exit path of the request, and are swept at startup and daily for the every exit path of the request (including cancellation, which is a
cases a process death leaves behind. A new icon has no id yet, so its BaseException and slips past `except Exception`), and are swept at startup and
daily. That scheduled pass also runs the two collectors with the stored
configuration as *both* sides — nothing superseded, so every referenced file is
kept and only aged unreferenced ones go. Without it, collection would only ever
happen when somebody saves, and a file uploaded into a dialog that was then
cancelled would wait for a write that may never come. A new icon has no id yet, so its
files go to a per-dialog staging folder and move to the real id once the config files go to a per-dialog staging folder and move to the real id once the config
write is accepted — the same copy → save → cleanup order as a rebind. write is accepted — the same copy → save → cleanup order as a rebind.
`config/set` collects what its commit superseded, and anything unreferenced and `config/set` collects what its commit superseded, and anything unreferenced and
+25
View File
@@ -1,5 +1,30 @@
# Changelog # Changelog
## 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,
which is right for what a write supersedes but leaves a gap: cancel a dialog
after the file has already uploaded, lose the connection just after, or call
the upload API directly, and nothing references the file and no future write
notices it. The daily sweep added in v1.46.1 only removed half-finished
transfers, so the promise that a cancelled attachment disappears after an hour
did not hold on an instance nobody edits. The sweep now compares against the
stored configuration — under the same lock a write uses — and collects aged
unreferenced attachments and plans as well.
- **A drag is no longer undone by someone else's move (HP-1461-02).** When the
full card learned to follow position changes in v1.46.1, it protected the
positions you had moved but not yet sent — except it read that list *after*
flushing the pending write, and flushing empties it first. In a real drag,
where a write is already scheduled, the list was therefore empty and the
server's older position was painted over your move. The card now takes the
snapshot before flushing and also holds on to positions that are sent but not
yet acknowledged: until the server confirms a position, the card that moved it
is the authority on it.
- Two tests grew up to their docstrings: the upload test now actually cancels
the request task instead of only exercising error paths, and the position-sync
smoke schedules a real debounced write and delays it, which is the ordering
that lost the drag.
## v1.46.1 — 2026-07-28 (re-check of v1.46.0: HP-1460-01 … -03) ## v1.46.1 — 2026-07-28 (re-check of v1.46.0: HP-1460-01 … -03)
- **Two uploads of the same file name can no longer collide (HP-1460-01).** - **Two uploads of the same file name can no longer collide (HP-1460-01).**
v1.46.0 stopped overwriting attachments, but choosing a free name and taking v1.46.0 stopped overwriting attachments, but choosing a free name and taking
+26
View File
@@ -6,6 +6,32 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим > **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md). > изменением — как и остальная документация (см. docs/STATUS.md).
## v1.46.2 — 2026-07-28 (перепроверка v1.46.1: HP-1461-01, -02)
- **Файл, который в итоге никому не понадобился, убирается, даже если больше
ничего не сохраняют (HP-1461-01).** Сборка привязана к записи конфигурации —
это верно для того, что запись вытесняет, но оставляет зазор: отмените диалог
после того, как файл уже загрузился, потеряйте связь сразу после, или
вызовите API загрузки напрямую — и на файл никто не ссылается, а будущей
записи, которая бы это заметила, нет. Добавленное в v1.46.1 ежедневное
подметание убирало только незавершённые передачи, поэтому обещание «отменённое
вложение исчезнет через час» не выполнялось там, где никто ничего не правит.
Теперь подметание сверяется с сохранённой конфигурацией — под той же
блокировкой, что и запись, — и собирает устаревшие непривязанные вложения и
планы тоже.
- **Перетаскивание больше не отменяется чужим перемещением (HP-1461-02).** Когда
в v1.46.1 полная карточка научилась следить за позициями, она защищала те,
что вы подвинули, но ещё не отправили, — вот только читала этот список *после*
сброса отложенной записи, а сброс его первым делом опустошает. При настоящем
перетаскивании, когда запись уже запланирована, список оказывался пустым, и
старая серверная позиция закрашивала ваше движение. Теперь снимок снимается до
сброса, и вдобавок удерживаются позиции, отправленные, но ещё не
подтверждённые: пока сервер не подтвердил позицию, авторитет по ней — та
карточка, которая её подвинула.
- Два теста доросли до своих же описаний: тест загрузки теперь действительно
отменяет задачу запроса, а не только проходит по путям ошибок, а смоук
синхронизации позиций планирует настоящую отложенную запись и задерживает её —
именно этот порядок и терял перетаскивание.
## v1.46.1 — 2026-07-28 (перепроверка v1.46.0: HP-1460-01 … -03) ## v1.46.1 — 2026-07-28 (перепроверка v1.46.0: HP-1460-01 … -03)
- **Две загрузки с одинаковым именем больше не сталкиваются (HP-1460-01).** - **Две загрузки с одинаковым именем больше не сталкиваются (HP-1460-01).**
v1.46.0 перестала затирать вложения, но выбор свободного имени и его занятие v1.46.0 перестала затирать вложения, но выбор свободного имени и его занятие
+2 -2
View File
@@ -15,12 +15,12 @@
| Item | State | | Item | State |
|---|---| |---|---|
| Version | **v1.46.1** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance | | Version | **v1.46.2** 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) | | 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) | | 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 | | 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 13 months (checked 2026-07-24) | | HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 13 months (checked 2026-07-24) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.46.1** via direct copy (HACS custom repo also installed) | | Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.46.2** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover | | 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 | | 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 | | 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 |
+9
View File
@@ -239,6 +239,15 @@ 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 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 [auto: backend test_every_display_mode_the_editor_offers_is_accepted and
neighbours, test_a_marker_showing_its_value_can_be_saved] 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]
- [ ] 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
updates [auto: smoke_layout_sync]
- [ ] Concurrent uploads of one name (v1.46.1, HP-1460-01): attach the same - [ ] Concurrent uploads of one name (v1.46.1, HP-1460-01): attach the same
file from two browser tabs at once — two attachments, two sets of bytes, file from two browser tabs at once — two attachments, two sets of bytes,
neither lost. A file whose name is at the length limit still downloads neither lost. A file whose name is at the length limit still downloads
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.46.1", "version": "1.46.2",
"description": "Interactive house plan Lovelace card for Home Assistant", "description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+20 -4
View File
@@ -35,7 +35,7 @@ import './space-card';
import { cardStyles } from './styles'; import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n'; import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.46.1'; const CARD_VERSION = '1.46.2';
const LS_KEY = 'houseplan_card_layout_v1'; 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_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1'; const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -873,13 +873,23 @@ class HouseplanCard extends LitElement {
*/ */
private async _reloadLayoutOnly(): Promise<void> { private async _reloadLayoutOnly(): Promise<void> {
if (!this._serverStorage || !this.hass?.callWS) return; if (!this._serverStorage || !this.hass?.callWS) return;
// Snapshot BEFORE flushing. `flush()` runs the debounced writer
// synchronously, and the first thing that does is empty `_dirtyPos` — so
// reading the dirty set afterwards found nothing to protect and the server's
// older position was painted over the drag the user had just made
// (HP-1461-02). Positions are captured by value for the same reason.
const mine = new Map<string, any>();
for (const id of this._dirtyPos) if (this._layout[id]) mine.set(id, this._layout[id]);
if (this._persistLayout.pending()) this._persistLayout.flush(); if (this._persistLayout.pending()) this._persistLayout.flush();
const mine = new Set(this._dirtyPos); // …and again after the flush: what was dirty is now in flight, and a write
// sent before this reload was even scheduled is in there too. Until the
// server acknowledges a position, this card is the authority on it.
for (const [id, pos] of this._sentPos) mine.set(id, pos);
try { try {
const resp = await this.hass.callWS({ type: 'houseplan/layout/get' }); const resp = await this.hass.callWS({ type: 'houseplan/layout/get' });
const remote = resp?.layout || {}; const remote = resp?.layout || {};
const merged: Record<string, any> = { ...remote }; const merged: Record<string, any> = { ...remote };
for (const id of mine) if (this._layout[id]) merged[id] = this._layout[id]; for (const [id, pos] of mine) merged[id] = pos;
this._layout = merged; this._layout = merged;
this._layoutRev = resp?.rev ?? this._layoutRev; this._layoutRev = resp?.rev ?? this._layoutRev;
this._cacheSnapshot(); this._cacheSnapshot();
@@ -890,6 +900,8 @@ class HouseplanCard extends LitElement {
} }
private _dirtyPos = new Set<string>(); private _dirtyPos = new Set<string>();
/** Positions sent to the server and not acknowledged yet (HP-1461-02). */
private _sentPos = new Map<string, { s?: string; x: number; y: number }>();
private _persistLayout = debounce(() => { private _persistLayout = debounce(() => {
if (this._serverStorage) { if (this._serverStorage) {
@@ -899,10 +911,14 @@ class HouseplanCard extends LitElement {
for (const id of ids) { for (const id of ids) {
const pos = this._layout[id]; const pos = this._layout[id];
if (!pos) continue; if (!pos) continue;
// in flight until the server answers: a layout reload triggered in the
// meantime must keep this position, not the one the server still has
this._sentPos.set(id, pos);
this.hass this.hass
.callWS({ type: 'houseplan/layout/update', device_id: id, pos }) .callWS({ type: 'houseplan/layout/update', device_id: id, pos })
.then((r: any) => this._noteLayoutRev(r)) .then((r: any) => this._noteLayoutRev(r))
.catch((e: any) => this._showToast(this._t('toast.pos_save_failed', { err: this._errText(e) }))); .catch((e: any) => this._showToast(this._t('toast.pos_save_failed', { err: this._errText(e) })))
.finally(() => { if (this._sentPos.get(id) === pos) this._sentPos.delete(id); });
} }
this._cacheSnapshot(); this._cacheSnapshot();
} else { } else {
+130
View File
@@ -783,3 +783,133 @@ async def test_repair_issue_goes_when_its_space_does(
await _save(client, await _cfg([{"id": "other", "plan_url": None}]), rev) await _save(client, await _cfg([{"id": "other", "plan_url": None}]), rev)
await hass.async_block_till_done() await hass.async_block_till_done()
assert registry.async_get_issue(HP_DOMAIN, "broken_plan_r7") is None assert registry.async_get_issue(HP_DOMAIN, "broken_plan_r7") is None
async def test_cancelling_an_upload_takes_its_temporary_with_it(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""HP-1460-02, properly this time: cancellation, not an ordinary error.
`asyncio.CancelledError` is a BaseException, so the old `except Exception`
never saw it and an aborted transfer stranded its `.upload-*`. The previous
test claimed to cover this and did not it only exercised error paths.
"""
import asyncio
import os
from custom_components.houseplan.const import FILES_DIR
from custom_components.houseplan.http_api import HouseplanUploadView
from custom_components.houseplan.plans import TMP_PREFIX
entry = await _setup(hass)
root = hass.config.path(FILES_DIR)
os.makedirs(root, exist_ok=True)
started = asyncio.Event()
class _Part:
name = "file"
filename = "big.pdf"
async def read_chunk(self, _size):
started.set()
await asyncio.sleep(3600) # the client stopped sending; we wait
class _Reader:
def __aiter__(self):
return self
async def __anext__(self):
if getattr(self, "_done", False):
raise StopAsyncIteration
self._done = True
return _Part()
from custom_components.houseplan import http_api as hp_http
class _User:
is_admin = True
class _Request:
app = {hp_http.KEY_HASS: hass}
def get(self, _key, default=None):
return _User()
async def multipart(self):
return _Reader()
task = hass.async_create_task(HouseplanUploadView().post(_Request()))
await started.wait()
await asyncio.sleep(0)
assert [n for n in os.listdir(root) if n.startswith(TMP_PREFIX)], "temp exists mid-upload"
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
await hass.async_block_till_done()
assert [n for n in os.listdir(root) if n.startswith(TMP_PREFIX)] == [], (
"a cancelled upload must not leave its temporary behind"
)
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.
"""
import os
import time
from custom_components.houseplan.const import FILES_DIR, PLANS_DIR, 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"),
):
with open(path, "wb") as fh:
fh.write(b"x")
os.utime(path, (old, old))
await hass.async_add_executor_job(_seed)
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"]
# 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"))
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"))