mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 05:41:34 +00:00
Release v1.46.2
Re-check of v1.46.1: the scheduled sweep now collects unreferenced attachments and plans against the stored configuration, and a drag in flight survives a concurrent remote position change.
This commit is contained in:
@@ -20,9 +20,9 @@ from .const import (
|
||||
PLANS_URL,
|
||||
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 .store import HouseplanConfigEntry, create_data
|
||||
from .store import HouseplanConfigEntry, create_data, get_data
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -101,19 +101,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
|
||||
await async_check_plan_files(hass, entry)
|
||||
|
||||
# Abandoned upload temporaries (HP-1460-02). A request cleans up after
|
||||
# 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
|
||||
# transfer would sit there for good, and on an instance nobody edits, so
|
||||
# would a cancelled attachment. Once at startup, then daily.
|
||||
# Scheduled collection of everything nobody ended up referencing.
|
||||
#
|
||||
# A commit collects what that commit superseded, which is the right rule for
|
||||
# a commit — but it only ever runs when somebody saves. Cancel a dialog
|
||||
# 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:
|
||||
files_dir = Path(hass.config.path(FILES_DIR))
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
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:
|
||||
_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
|
||||
_LOGGER.exception("House Plan: sweeping upload temporaries failed")
|
||||
_LOGGER.exception("House Plan: sweeping unreferenced files failed")
|
||||
|
||||
await _sweep()
|
||||
entry.async_on_unload(
|
||||
|
||||
Binary file not shown.
@@ -24,7 +24,7 @@ MAX_SIGN_PATHS = 200
|
||||
PLAN_ORPHAN_TTL_S = 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.46.1"
|
||||
VERSION = "1.46.2"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.46.1"
|
||||
"version": "1.46.2"
|
||||
}
|
||||
|
||||
@@ -56,14 +56,35 @@ const res = await page.evaluate(async () => {
|
||||
out.ownWriteNoReload = gets === before;
|
||||
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._dirtyPos.add('dev_a'); // локально подвинули, ещё не отправили
|
||||
c._dirtyPos.add('dev_a');
|
||||
c._persistLayout(); // debounce запланирован, не сброшен вручную
|
||||
layout = { ...layout, dev_b: { x: 99, y: 99 } }; rev += 1;
|
||||
subs.forEach((f) => f({ data: { rev } }));
|
||||
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 });
|
||||
|
||||
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;
|
||||
});
|
||||
// зафиксировано прогоном на v1.46.1 и сверено с кодом
|
||||
@@ -73,7 +94,10 @@ checkAll(res, {
|
||||
revFollowed: true,
|
||||
ownWriteNoReload: true,
|
||||
ownWriteKept: true,
|
||||
localDragSurvived: true,
|
||||
dragKeptWhileWriteInFlight: true,
|
||||
remoteChangeApplied: true,
|
||||
localDragSurvived: true,
|
||||
serverAgrees: true,
|
||||
sentPosDrained: true,
|
||||
});
|
||||
await finish(browser);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -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
|
||||
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
|
||||
every exit path of the request, and are swept at startup and daily for the
|
||||
cases a process death leaves behind. A new icon has no id yet, so its
|
||||
every exit path of the request (including cancellation, which is a
|
||||
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
|
||||
write is accepted — the same copy → save → cleanup order as a rebind.
|
||||
`config/set` collects what its commit superseded, and anything unreferenced and
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# 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)
|
||||
- **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
|
||||
|
||||
@@ -6,6 +6,32 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. 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)
|
||||
- **Две загрузки с одинаковым именем больше не сталкиваются (HP-1460-01).**
|
||||
v1.46.0 перестала затирать вложения, но выбор свободного имени и его занятие
|
||||
|
||||
+2
-2
@@ -15,12 +15,12 @@
|
||||
|
||||
| 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) |
|
||||
| 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.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 |
|
||||
| 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 |
|
||||
|
||||
@@ -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
|
||||
[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]
|
||||
- [ ] 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
|
||||
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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.46.1",
|
||||
"version": "1.46.2",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+20
-4
@@ -35,7 +35,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
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_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||
@@ -873,13 +873,23 @@ class HouseplanCard extends LitElement {
|
||||
*/
|
||||
private async _reloadLayoutOnly(): Promise<void> {
|
||||
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();
|
||||
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 {
|
||||
const resp = await this.hass.callWS({ type: 'houseplan/layout/get' });
|
||||
const remote = resp?.layout || {};
|
||||
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._layoutRev = resp?.rev ?? this._layoutRev;
|
||||
this._cacheSnapshot();
|
||||
@@ -890,6 +900,8 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
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(() => {
|
||||
if (this._serverStorage) {
|
||||
@@ -899,10 +911,14 @@ class HouseplanCard extends LitElement {
|
||||
for (const id of ids) {
|
||||
const pos = this._layout[id];
|
||||
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
|
||||
.callWS({ type: 'houseplan/layout/update', device_id: id, pos })
|
||||
.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();
|
||||
} else {
|
||||
|
||||
@@ -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 hass.async_block_till_done()
|
||||
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"))
|
||||
|
||||
Reference in New Issue
Block a user