Compare commits

...
Author SHA1 Message Date
claude[bot] 0bca32076b docs: review document for #403
Issue: #403
User-Visible: no
2026-09-01 22:27:27 +00:00
claude[bot] e9938029de docs: review document for #403
Проверка (CI) / Классификация изменённых файлов (push) Successful in 24s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m5s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m12s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 16m6s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #403
User-Visible: no
2026-09-01 15:08:19 +00:00
Sergey Matyuninandclaude[bot] 9c4d07abe2 docs: refresh canonical screenshots
Docs screenshots run 33521193808; all 10 pairs were visually reviewed. The explicit no-witnesses reason is recorded in screenshots.json because the prior rasterization baseline is no longer reproducible.

Issue: #403
User-Visible: no
2026-09-01 14:53:37 +00:00
Sergey Matyuninandclaude[bot] 71bbc95309 fix: preserve area relocation state
Issue: #403
User-Visible: yes
2026-09-01 14:53:37 +00:00
claude[bot] 6b5158003a docs: review document for #403
Issue: #403
User-Visible: no
2026-09-01 14:53:37 +00:00
Sergey Matyuninandclaude[bot] 94502d3d67 docs: revise area relocation safety spec
Issue: #403
User-Visible: no
2026-09-01 14:53:37 +00:00
claude[bot] caddeb08c1 docs: review document for #403
Issue: #403
User-Visible: no
2026-09-01 14:53:37 +00:00
Codexandclaude[bot] 0d338b9c82 docs: specify area relocation safety (#403)
User-Visible: no
Issue: #403
2026-09-01 14:53:37 +00:00
43 changed files with 1753 additions and 207 deletions
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "ffd36ef35f81a27598b236218bc3ea18d4b46ed77613c5fcb3197c5104fc232b",
"fingerprint": "bd405d5ce3531f7484e3d780d84bf9a6120e8bf7226acbfbcd15eb29ef573e40",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-gMOWmAJQ.js",
"houseplan-assets/houseplan-card-cmbllkdu.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 287386,
"initialViewGzipBytes": 287575,
"lazyFiles": [
"houseplan-assets/backdrop-pick-BmALn_WC.js",
"houseplan-assets/de-Drq02WaY.js",
"houseplan-assets/editor-BOsi41KI.js",
"houseplan-assets/fr-Cul7kCHo.js",
"houseplan-assets/houseplan-editor-runtime-DtAVtOU4.js",
"houseplan-assets/houseplan-onboarding-runtime-BtE_1V6_.js"
"houseplan-assets/backdrop-pick-D66Ner2L.js",
"houseplan-assets/de-C9HvYpJv.js",
"houseplan-assets/editor-CyKPZvNS.js",
"houseplan-assets/fr-BjAKtQwB.js",
"houseplan-assets/houseplan-editor-runtime-bwdFd5UE.js",
"houseplan-assets/houseplan-onboarding-runtime-B7KlxNo_.js"
],
"lazyGzipBytes": 199836,
"lazyGzipBytes": 199808,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-BmALn_WC.js",
"houseplan-assets/editor-BOsi41KI.js",
"houseplan-assets/houseplan-editor-runtime-DtAVtOU4.js"
"houseplan-assets/backdrop-pick-D66Ner2L.js",
"houseplan-assets/editor-CyKPZvNS.js",
"houseplan-assets/houseplan-editor-runtime-bwdFd5UE.js"
],
"lazyEditorGzipBytes": 144691,
"lazyEditorGzipBytes": 144676,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-BmALn_WC.js",
"houseplan-assets/houseplan-onboarding-runtime-BtE_1V6_.js"
"houseplan-assets/backdrop-pick-D66Ner2L.js",
"houseplan-assets/houseplan-onboarding-runtime-B7KlxNo_.js"
],
"lazyOnboardingGzipBytes": 14034,
"lazyOnboardingGzipBytes": 14024,
"lazyLocaleFiles": [
"houseplan-assets/de-Drq02WaY.js",
"houseplan-assets/fr-Cul7kCHo.js"
"houseplan-assets/de-C9HvYpJv.js",
"houseplan-assets/fr-BjAKtQwB.js"
],
"lazyLocaleGzipBytes": 48184,
"lazyLocaleGzipBytes": 48176,
"files": [
{
"path": "houseplan-assets/backdrop-pick-BmALn_WC.js",
"sha256": "4dc555f222d405b87ad4ae04f3dc07126e48195c66f8eac9465a6bb3e771804c",
"path": "houseplan-assets/backdrop-pick-D66Ner2L.js",
"sha256": "6f8e893fe459b025f2f89ef3cf48afd07be82699a34489d446e6d2bbea1e0f44",
"rawBytes": 20636,
"gzipBytes": 7073,
"gzipBytes": 7068,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-gMOWmAJQ.js"
"houseplan-assets/houseplan-card-cmbllkdu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-Drq02WaY.js",
"sha256": "359a18eef16eab9a5856018014ac7b95a51d03a8c4ce3dc5fdcf1eb327f0e7a7",
"path": "houseplan-assets/de-C9HvYpJv.js",
"sha256": "c48bad47855be263551852c9e4350e6148c3bc0206ddd63136982d4414110c48",
"rawBytes": 81096,
"gzipBytes": 24346,
"gzipBytes": 24342,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-BOsi41KI.js",
"sha256": "dc2cc78e84f8114cfd84adf29b45619a1d6e226d72c6831fbbeff722a25d81fc",
"path": "houseplan-assets/editor-CyKPZvNS.js",
"sha256": "f285f0141083e5ea3bb7069126474c1e9cfb7e9073f58cb8fed8963cc131848a",
"rawBytes": 3826,
"gzipBytes": 1582,
"gzipBytes": 1578,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-gMOWmAJQ.js"
"houseplan-assets/houseplan-card-cmbllkdu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-Cul7kCHo.js",
"sha256": "9e32a98d1f402f1fb02e62744d910c962c9e3d88b58bbe7795b08cbc53ec6285",
"path": "houseplan-assets/fr-BjAKtQwB.js",
"sha256": "b443ec61052a852b1f7d1982b402ee3cf85f9600382dd317b55caa0543da39e2",
"rawBytes": 83283,
"gzipBytes": 23838,
"gzipBytes": 23834,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-gMOWmAJQ.js",
"sha256": "deb12ad2f9171e39418fa0aa90ae1f7e5509df3d7ac8ad8031914171ea4c817b",
"rawBytes": 1019048,
"gzipBytes": 286589,
"path": "houseplan-assets/houseplan-card-cmbllkdu.js",
"sha256": "1ac69d06e6d8717d0daa9634bb0ce911e4fded918904b06c34ac8290b3842d75",
"rawBytes": 1019911,
"gzipBytes": 286781,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-Drq02WaY.js",
"houseplan-assets/editor-BOsi41KI.js",
"houseplan-assets/fr-Cul7kCHo.js",
"houseplan-assets/houseplan-editor-runtime-DtAVtOU4.js",
"houseplan-assets/houseplan-onboarding-runtime-BtE_1V6_.js"
"houseplan-assets/de-C9HvYpJv.js",
"houseplan-assets/editor-CyKPZvNS.js",
"houseplan-assets/fr-BjAKtQwB.js",
"houseplan-assets/houseplan-editor-runtime-bwdFd5UE.js",
"houseplan-assets/houseplan-onboarding-runtime-B7KlxNo_.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-DtAVtOU4.js",
"sha256": "7a55d9593731d6feaf4a8ca11eb8b3d8a7eaf1c9760aa97f719002dbb5707d01",
"path": "houseplan-assets/houseplan-editor-runtime-bwdFd5UE.js",
"sha256": "01529edd319c84ce373d6086db952d6bdcad27d303c96c0dadb717bf16f9306e",
"rawBytes": 527184,
"gzipBytes": 136036,
"gzipBytes": 136030,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-BmALn_WC.js",
"houseplan-assets/houseplan-card-gMOWmAJQ.js"
"houseplan-assets/backdrop-pick-D66Ner2L.js",
"houseplan-assets/houseplan-card-cmbllkdu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-BtE_1V6_.js",
"sha256": "d55a2c25eed774e5af1db32e9a0b31e32eec9713eff91dc77156e8929f7d0042",
"path": "houseplan-assets/houseplan-onboarding-runtime-B7KlxNo_.js",
"sha256": "67bbf22592e7fcb3060012f104c0ff3ebe2b6ea29fa771e650d2fae8b4085ccd",
"rawBytes": 28088,
"gzipBytes": 6961,
"gzipBytes": 6956,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-BmALn_WC.js",
"houseplan-assets/houseplan-card-gMOWmAJQ.js"
"houseplan-assets/backdrop-pick-D66Ner2L.js",
"houseplan-assets/houseplan-card-cmbllkdu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "8329cbc9b7807d654eb773c7db7f5b63fd88ec99f6c3d00edcb1c880add37958",
"sha256": "46e873cfe52720c02c77556c3e39ec7f96b7171f7be332c59116216b49009691",
"rawBytes": 1183,
"gzipBytes": 797,
"gzipBytes": 794,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-gMOWmAJQ.js"
"houseplan-assets/houseplan-card-cmbllkdu.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
@@ -1,14 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ffd36ef35f81a27598b236218bc3ea18d4b46ed77613c5fcb3197c5104fc232b";import{b as e,l as o,t,d8 as s,A as a,d9 as i,da as l,db as n,E as r,c}from"./houseplan-card-gMOWmAJQ.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="bd405d5ce3531f7484e3d780d84bf9a6120e8bf7226acbfbcd15eb29ef573e40";import{b as e,l as o,t,d8 as s,A as a,d9 as i,da as l,db as n,E as r,c}from"./houseplan-card-cmbllkdu.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>f[e.name]||e.name}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(h.slice(0,3))}
${d(f.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(h.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ffd36ef35f81a27598b236218bc3ea18d4b46ed77613c5fcb3197c5104fc232b";try{await import("./houseplan-assets/houseplan-card-gMOWmAJQ.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="bd405d5ce3531f7484e3d780d84bf9a6120e8bf7226acbfbcd15eb29ef573e40";try{await import("./houseplan-assets/houseplan-card-cmbllkdu.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+9 -4
View File
@@ -105,15 +105,20 @@ const res = await page.evaluate(async () => {
},
},
};
c._layout = { ...c._layout, d_kettle: { s: 'f1', x: 0.72, y: 0.15 } };
const kettlePoint = { s: 'f1', x: 0.72, y: 0.15 };
c._layout = { ...c._layout, d_kettle: kettlePoint };
c._regSignature = '';
c._maybeRebuildDevices();
rejectKettleRelocation = true;
window.__setRegistryArea('device', 'd_kettle', 'living_room');
await wait(500);
const failedConfigRetryable = !c._layout.d_kettle
const failedConfigRestoredAndRetryable = c._layout.d_kettle?.s === kettlePoint.s
&& c._layout.d_kettle?.x === kettlePoint.x
&& c._layout.d_kettle?.y === kettlePoint.y
&& c._serverCfg.settings.marker_area_snapshot?.d_kettle?.area === 'kitchen'
&& c._areaRelocationSyncKey === '';
&& c._areaRelocationSyncKey === ''
&& calls.some((message) => message.type === 'houseplan/layout/update'
&& message.device_id === 'd_kettle');
window.__setRegistryArea('device', 'd_kettle', 'living_room');
await wait(500);
await paint();
@@ -326,7 +331,7 @@ const res = await page.evaluate(async () => {
roomDraftRefreshed,
noStaleExplicitOverride: !!savedLamp
&& savedLamp.area === undefined && savedLamp.room_id === undefined,
failedConfigRetryable,
failedConfigRestoredAndRetryable,
configRetrySucceeded,
crossSpaceRelocated: crossSpaceDevice?.space === 'f1'
&& crossSpaceDevice?.area === 'living_room'
+239
View File
@@ -0,0 +1,239 @@
// #403: a rejected Area-provenance write restores deleted manual placement,
// while relocation invalidates only history commands owned by that device.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 850 }, 1);
const res = await page.evaluate(async () => {
const c = window.__card;
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
const paint = async () => {
c.requestUpdate();
await c.updateComplete;
};
const settleRelocation = async () => {
await c._areaRelocationWrite.catch(() => undefined);
await wait(80);
await paint();
};
const samePlacement = (left, right) => !!left && !!right
&& left.s === right.s && left.x === right.x && left.y === right.y;
let serverConfig = structuredClone(c._serverCfg);
let serverLayout = structuredClone(c._layout);
let configRev = Math.max(30, Number(c._cfgRev) || 0);
let layoutRev = Math.max(40, Number(c._layoutRev) || 0);
const calls = [];
let rejectConfig = null;
let rejectRestoreId = null;
let delayConfig = null;
let releaseDelayedConfig = null;
const baseCallWS = c.hass.callWS;
c.hass = { ...c.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/get') {
calls.push({ type: 'config/get' });
return { config: structuredClone(serverConfig), rev: configRev, can_write: true };
}
if (message.type === 'houseplan/layout/get') {
return { layout: structuredClone(serverLayout), rev: layoutRev };
}
if (message.type === 'houseplan/layout/delete') {
calls.push({ type: 'layout/delete', id: message.device_id });
delete serverLayout[message.device_id];
return { ok: true, rev: ++layoutRev };
}
if (message.type === 'houseplan/layout/update') {
const row = { type: 'layout/update', id: message.device_id };
calls.push(row);
if (rejectRestoreId === message.device_id) {
rejectRestoreId = null;
row.result = 'rejected';
throw new Error('synthetic placement restore failure');
}
serverLayout[message.device_id] = structuredClone(message.pos);
return { ok: true, rev: ++layoutRev };
}
if (message.type === 'houseplan/config/set') {
const row = {
type: 'config/set',
snapshot: structuredClone(message.config?.settings?.marker_area_snapshot || {}),
attention: [...(message.config?.settings?.new_device_ids || [])],
};
calls.push(row);
if (rejectConfig
&& row.snapshot[rejectConfig.id]?.area === rejectConfig.area) {
const rejection = rejectConfig;
rejectConfig = null;
row.result = 'rejected';
const error = new Error('synthetic config failure');
if (rejection.code) error.code = rejection.code;
throw error;
}
if (delayConfig && row.snapshot[delayConfig.id]?.area === delayConfig.area) {
const delayed = delayConfig;
delayConfig = null;
row.result = 'delayed';
await new Promise((resolve) => { releaseDelayedConfig = resolve; });
row.result = 'accepted';
if (delayed.id === '') throw new Error('unreachable delayed fixture');
}
serverConfig = structuredClone(message.config);
return { ok: true, rev: ++configRev };
}
return baseCallWS(message);
} };
c._serverStorage = true;
c._serverCanWrite = true;
c._layoutRev = layoutRev;
c._cfgRev = configRev;
const installScenario = async ({ id, oldArea, point }) => {
await settleRelocation();
window.__setRegistryArea('device', id, oldArea);
await wait(120);
c._serverCfg = {
...c._serverCfg,
settings: {
...c._serverCfg.settings,
marker_area_snapshot: {
...(c._serverCfg.settings.marker_area_snapshot || {}),
[id]: { binding: `device:${id}`, area: oldArea },
},
new_device_ids: (c._serverCfg.settings.new_device_ids || [])
.filter((candidate) => candidate !== id),
},
};
c._layout = { ...c._layout, [id]: structuredClone(point) };
serverConfig = structuredClone(c._serverCfg);
serverLayout = structuredClone(c._layout);
c._cfgContentFingerprint = '';
c._layoutContentFingerprint = '';
c._areaRelocationSyncKey = '';
c._regSignature = '';
c._maybeRebuildDevices();
await settleRelocation();
};
const pushMove = (id, name, fromX, toX) => {
const device = c._devices.find((candidate) => candidate.id === id);
if (!device) return false;
c._devicePositionHistory.push({
name,
before: { deviceId: id, spaceId: device.space,
placement: { s: device.space, x: fromX, y: 0.31 } },
after: { deviceId: id, spaceId: device.space,
placement: { s: device.space, x: toX, y: 0.31 } },
});
return true;
};
const stableId = 'd_lamp';
const relocatedId = 'd_light1';
const original = { s: 'f1', x: 0.22, y: 0.22 };
await installScenario({ id: relocatedId, oldArea: 'living_room', point: original });
c._devicePositionHistory.clear();
const historyFixtureReady = pushMove(stableId, 'Move lamp', 0.28, 0.34)
&& pushMove(relocatedId, 'Move relocating light', 0.22, 0.27);
rejectConfig = { id: relocatedId, area: 'kitchen', code: null };
window.__setRegistryArea('device', relocatedId, 'kitchen');
await settleRelocation();
const rejectedConfigIndex = calls.findIndex((row) => row.type === 'config/set'
&& row.result === 'rejected' && row.snapshot[relocatedId]?.area === 'kitchen');
const restoreIndex = calls.findIndex((row, index) => index > rejectedConfigIndex
&& row.type === 'layout/update' && row.id === relocatedId);
const restoredLocally = samePlacement(c._layout[relocatedId], original);
const restoredOnServer = samePlacement(serverLayout[relocatedId], original);
const unrelatedHistorySurvives = historyFixtureReady
&& c._devicePositionHistory.size === 1
&& c._devicePositionHistory.undoName === 'Move lamp';
const relocatedHistoryRemoved = c._devicePositionHistory.undoName !== 'Move relocating light'
&& !c._devicePositionHistory.canRedo;
const beforeUndoRelocated = structuredClone(c._layout[relocatedId]);
await c._runDevicePositionHistory('undo');
const unrelatedUndoStillWorks = c._devicePositionHistory.canRedo
&& samePlacement(c._layout[relocatedId], beforeUndoRelocated);
// Retry with a delayed config response. Between delete and acknowledgement
// the old point must not reappear; after release the ordinary #126 result
// (advanced snapshot + attention) must complete.
delayConfig = { id: relocatedId, area: 'kitchen' };
c._regSignature = '';
c._maybeRebuildDevices();
for (let attempt = 0; attempt < 80 && !releaseDelayedConfig; attempt += 1) await wait(10);
await paint();
const movedDuringDelayedWrite = c._devices.find((device) => device.id === relocatedId);
const stalePointSuppressedWhilePending = !!releaseDelayedConfig
&& !c._layout[relocatedId]
&& movedDuringDelayedWrite?.area === 'kitchen';
releaseDelayedConfig?.();
releaseDelayedConfig = null;
await settleRelocation();
const successfulRetry = !serverLayout[relocatedId]
&& c._serverCfg.settings.marker_area_snapshot?.[relocatedId]?.area === 'kitchen'
&& c._serverCfg.settings.new_device_ids?.includes(relocatedId);
// The conflict branch must restore before it reloads and retries. Call
// ordering is the durable witness even though the retry then deletes the
// restored point again and completes normally.
const conflictId = 'd_kettle';
const conflictPoint = { s: 'f1', x: 0.72, y: 0.15 };
await installScenario({ id: conflictId, oldArea: 'kitchen', point: conflictPoint });
c._devicePositionHistory.clear();
pushMove(stableId, 'Move lamp before conflict', 0.30, 0.36);
const conflictStart = calls.length;
rejectConfig = { id: conflictId, area: 'living_room', code: 'conflict' };
window.__setRegistryArea('device', conflictId, 'living_room');
await settleRelocation();
const conflictRows = calls.slice(conflictStart);
const conflictReject = conflictRows.findIndex((row) => row.type === 'config/set'
&& row.result === 'rejected' && row.snapshot[conflictId]?.area === 'living_room');
const conflictRestore = conflictRows.findIndex((row, index) => index > conflictReject
&& row.type === 'layout/update' && row.id === conflictId);
const conflictReload = conflictRows.findIndex((row, index) => index > conflictRestore
&& row.type === 'config/get');
const conflictRetryDelete = conflictRows.findIndex((row, index) => index > conflictReload
&& row.type === 'layout/delete' && row.id === conflictId);
const conflictRestoresBeforeRetry = conflictReject >= 0
&& conflictRestore > conflictReject
&& conflictReload > conflictRestore
&& conflictRetryDelete > conflictReload;
const conflictEventuallyCompletes = !serverLayout[conflictId]
&& c._serverCfg.settings.marker_area_snapshot?.[conflictId]?.area === 'living_room'
&& c._serverCfg.settings.new_device_ids?.includes(conflictId);
const conflictKeepsUnrelatedHistory = c._devicePositionHistory.undoName
=== 'Move lamp before conflict';
// If even layout/update fails, the existing attention channel is persisted
// instead of losing the manual point silently.
const fallbackId = 'd_motion';
const fallbackPoint = { s: 'f1', x: 0.40, y: 0.75 };
await installScenario({ id: fallbackId, oldArea: 'living_room', point: fallbackPoint });
rejectConfig = { id: fallbackId, area: 'kitchen', code: null };
rejectRestoreId = fallbackId;
window.__setRegistryArea('device', fallbackId, 'kitchen');
await settleRelocation();
const fallbackEl = c.renderRoot.querySelector(`.dev[data-id="${fallbackId}"]`);
const failedRestoreLeavesAttention = !serverLayout[fallbackId]
&& c._serverCfg.settings.new_device_ids?.includes(fallbackId)
&& serverConfig.settings.new_device_ids?.includes(fallbackId)
&& c._newIds.has(fallbackId)
&& !!fallbackEl?.querySelector('.newdot');
return {
historyFixtureReady,
rejectedWriteRestoresAfterDelete: rejectedConfigIndex >= 0
&& restoreIndex > rejectedConfigIndex && restoredLocally && restoredOnServer,
unrelatedHistorySurvives,
relocatedHistoryRemoved,
unrelatedUndoStillWorks,
stalePointSuppressedWhilePending,
successfulRetry,
conflictRestoresBeforeRetry,
conflictEventuallyCompletes,
conflictKeepsUnrelatedHistory,
failedRestoreLeavesAttention,
};
});
checkAll(res);
await finish(browser, res);
+56 -56
View File
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "ffd36ef35f81a27598b236218bc3ea18d4b46ed77613c5fcb3197c5104fc232b",
"fingerprint": "bd405d5ce3531f7484e3d780d84bf9a6120e8bf7226acbfbcd15eb29ef573e40",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-gMOWmAJQ.js",
"houseplan-assets/houseplan-card-cmbllkdu.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 287386,
"initialViewGzipBytes": 287575,
"lazyFiles": [
"houseplan-assets/backdrop-pick-BmALn_WC.js",
"houseplan-assets/de-Drq02WaY.js",
"houseplan-assets/editor-BOsi41KI.js",
"houseplan-assets/fr-Cul7kCHo.js",
"houseplan-assets/houseplan-editor-runtime-DtAVtOU4.js",
"houseplan-assets/houseplan-onboarding-runtime-BtE_1V6_.js"
"houseplan-assets/backdrop-pick-D66Ner2L.js",
"houseplan-assets/de-C9HvYpJv.js",
"houseplan-assets/editor-CyKPZvNS.js",
"houseplan-assets/fr-BjAKtQwB.js",
"houseplan-assets/houseplan-editor-runtime-bwdFd5UE.js",
"houseplan-assets/houseplan-onboarding-runtime-B7KlxNo_.js"
],
"lazyGzipBytes": 199836,
"lazyGzipBytes": 199808,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-BmALn_WC.js",
"houseplan-assets/editor-BOsi41KI.js",
"houseplan-assets/houseplan-editor-runtime-DtAVtOU4.js"
"houseplan-assets/backdrop-pick-D66Ner2L.js",
"houseplan-assets/editor-CyKPZvNS.js",
"houseplan-assets/houseplan-editor-runtime-bwdFd5UE.js"
],
"lazyEditorGzipBytes": 144691,
"lazyEditorGzipBytes": 144676,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-BmALn_WC.js",
"houseplan-assets/houseplan-onboarding-runtime-BtE_1V6_.js"
"houseplan-assets/backdrop-pick-D66Ner2L.js",
"houseplan-assets/houseplan-onboarding-runtime-B7KlxNo_.js"
],
"lazyOnboardingGzipBytes": 14034,
"lazyOnboardingGzipBytes": 14024,
"lazyLocaleFiles": [
"houseplan-assets/de-Drq02WaY.js",
"houseplan-assets/fr-Cul7kCHo.js"
"houseplan-assets/de-C9HvYpJv.js",
"houseplan-assets/fr-BjAKtQwB.js"
],
"lazyLocaleGzipBytes": 48184,
"lazyLocaleGzipBytes": 48176,
"files": [
{
"path": "houseplan-assets/backdrop-pick-BmALn_WC.js",
"sha256": "4dc555f222d405b87ad4ae04f3dc07126e48195c66f8eac9465a6bb3e771804c",
"path": "houseplan-assets/backdrop-pick-D66Ner2L.js",
"sha256": "6f8e893fe459b025f2f89ef3cf48afd07be82699a34489d446e6d2bbea1e0f44",
"rawBytes": 20636,
"gzipBytes": 7073,
"gzipBytes": 7068,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-gMOWmAJQ.js"
"houseplan-assets/houseplan-card-cmbllkdu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-Drq02WaY.js",
"sha256": "359a18eef16eab9a5856018014ac7b95a51d03a8c4ce3dc5fdcf1eb327f0e7a7",
"path": "houseplan-assets/de-C9HvYpJv.js",
"sha256": "c48bad47855be263551852c9e4350e6148c3bc0206ddd63136982d4414110c48",
"rawBytes": 81096,
"gzipBytes": 24346,
"gzipBytes": 24342,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-BOsi41KI.js",
"sha256": "dc2cc78e84f8114cfd84adf29b45619a1d6e226d72c6831fbbeff722a25d81fc",
"path": "houseplan-assets/editor-CyKPZvNS.js",
"sha256": "f285f0141083e5ea3bb7069126474c1e9cfb7e9073f58cb8fed8963cc131848a",
"rawBytes": 3826,
"gzipBytes": 1582,
"gzipBytes": 1578,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-gMOWmAJQ.js"
"houseplan-assets/houseplan-card-cmbllkdu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-Cul7kCHo.js",
"sha256": "9e32a98d1f402f1fb02e62744d910c962c9e3d88b58bbe7795b08cbc53ec6285",
"path": "houseplan-assets/fr-BjAKtQwB.js",
"sha256": "b443ec61052a852b1f7d1982b402ee3cf85f9600382dd317b55caa0543da39e2",
"rawBytes": 83283,
"gzipBytes": 23838,
"gzipBytes": 23834,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-gMOWmAJQ.js",
"sha256": "deb12ad2f9171e39418fa0aa90ae1f7e5509df3d7ac8ad8031914171ea4c817b",
"rawBytes": 1019048,
"gzipBytes": 286589,
"path": "houseplan-assets/houseplan-card-cmbllkdu.js",
"sha256": "1ac69d06e6d8717d0daa9634bb0ce911e4fded918904b06c34ac8290b3842d75",
"rawBytes": 1019911,
"gzipBytes": 286781,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-Drq02WaY.js",
"houseplan-assets/editor-BOsi41KI.js",
"houseplan-assets/fr-Cul7kCHo.js",
"houseplan-assets/houseplan-editor-runtime-DtAVtOU4.js",
"houseplan-assets/houseplan-onboarding-runtime-BtE_1V6_.js"
"houseplan-assets/de-C9HvYpJv.js",
"houseplan-assets/editor-CyKPZvNS.js",
"houseplan-assets/fr-BjAKtQwB.js",
"houseplan-assets/houseplan-editor-runtime-bwdFd5UE.js",
"houseplan-assets/houseplan-onboarding-runtime-B7KlxNo_.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-DtAVtOU4.js",
"sha256": "7a55d9593731d6feaf4a8ca11eb8b3d8a7eaf1c9760aa97f719002dbb5707d01",
"path": "houseplan-assets/houseplan-editor-runtime-bwdFd5UE.js",
"sha256": "01529edd319c84ce373d6086db952d6bdcad27d303c96c0dadb717bf16f9306e",
"rawBytes": 527184,
"gzipBytes": 136036,
"gzipBytes": 136030,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-BmALn_WC.js",
"houseplan-assets/houseplan-card-gMOWmAJQ.js"
"houseplan-assets/backdrop-pick-D66Ner2L.js",
"houseplan-assets/houseplan-card-cmbllkdu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-BtE_1V6_.js",
"sha256": "d55a2c25eed774e5af1db32e9a0b31e32eec9713eff91dc77156e8929f7d0042",
"path": "houseplan-assets/houseplan-onboarding-runtime-B7KlxNo_.js",
"sha256": "67bbf22592e7fcb3060012f104c0ff3ebe2b6ea29fa771e650d2fae8b4085ccd",
"rawBytes": 28088,
"gzipBytes": 6961,
"gzipBytes": 6956,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-BmALn_WC.js",
"houseplan-assets/houseplan-card-gMOWmAJQ.js"
"houseplan-assets/backdrop-pick-D66Ner2L.js",
"houseplan-assets/houseplan-card-cmbllkdu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "8329cbc9b7807d654eb773c7db7f5b63fd88ec99f6c3d00edcb1c880add37958",
"sha256": "46e873cfe52720c02c77556c3e39ec7f96b7171f7be332c59116216b49009691",
"rawBytes": 1183,
"gzipBytes": 797,
"gzipBytes": 794,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-gMOWmAJQ.js"
"houseplan-assets/houseplan-card-cmbllkdu.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,14 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ffd36ef35f81a27598b236218bc3ea18d4b46ed77613c5fcb3197c5104fc232b";import{b as e,l as o,t,d8 as s,A as a,d9 as i,da as l,db as n,E as r,c}from"./houseplan-card-gMOWmAJQ.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="bd405d5ce3531f7484e3d780d84bf9a6120e8bf7226acbfbcd15eb29ef573e40";import{b as e,l as o,t,d8 as s,A as a,d9 as i,da as l,db as n,E as r,c}from"./houseplan-card-cmbllkdu.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>f[e.name]||e.name}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(h.slice(0,3))}
${d(f.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(h.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ffd36ef35f81a27598b236218bc3ea18d4b46ed77613c5fcb3197c5104fc232b";try{await import("./houseplan-assets/houseplan-card-gMOWmAJQ.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="bd405d5ce3531f7484e3d780d84bf9a6120e8bf7226acbfbcd15eb29ef573e40";try{await import("./houseplan-assets/houseplan-card-cmbllkdu.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+5 -1
View File
@@ -110,7 +110,11 @@ the same profiler available between stable promotions.
`device-area-relocation` resolver. Its pending ids override stale layout in
both interactive and hosted-static projections immediately; a writer then
deletes those layout entries before advancing bounded Area provenance in
config. Limited registry snapshots are read-only and never infer movement.
config. If that config write is rejected, every successfully deleted manual
point is restored through the layout store before a retry; a failed restore
falls back to the existing attention marker. Relocation invalidates only
Undo/Redo commands owned by the moved device. Limited registry snapshots
are read-only and never infer movement.
5. **One modal contract.** Card modals render through `hp-dialog`. In Home
Assistant it delegates surface semantics and trapping to `ha-dialog`; the
standalone demo falls back to native `<dialog>`. The wrapper owns the title,
+7
View File
@@ -2,6 +2,13 @@
## Unreleased
- A failed config write while following a device to another Home Assistant
Area no longer silently discards its manually placed position: House Plan
restores the point before retrying, or marks the marker for attention if the
restore also fails. The move now removes Undo/Redo entries only for that
marker instead of clearing position history for every device
([#403](https://github.com/Matysh/houseplan-card/issues/403)).
- Confirmations now work before the first space exists: deleting a stored plan
during onboarding asks, and answers, instead of doing nothing
([#402](https://github.com/Matysh/houseplan-card/issues/402)).
+7
View File
@@ -8,6 +8,13 @@
## Не выпущено
- Отказ записи конфига при переносе устройства в другую HA-зону больше не
стирает молча поставленную вручную позицию: House Plan восстанавливает точку
перед повтором, а если не удалось и это — отмечает маркер для внимания.
Перенос теперь удаляет Undo/Redo только этого маркера, а не очищает историю
позиций всех устройств
([#403](https://github.com/Matysh/houseplan-card/issues/403)).
- Подтверждения работают и до создания первого пространства: удаление
сохранённого плана в онбординге теперь спрашивает и отвечает, а не молчит
([#402](https://github.com/Matysh/houseplan-card/issues/402)).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 344 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 KiB

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 116 KiB

+39 -22
View File
@@ -2,8 +2,8 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"oxipng": null,
"sourceFingerprint": "bcb7458ac0d4a3acd9b6283ea278b296db7eb148dd6240df5f908a6c039498ee",
"oxipng": "oxipng 10.2.0",
"sourceFingerprint": "a66d7a0dfa661afdec7abb4de2c5a55a5320aec6b1cb40b34725b0fb40e343b1",
"captureScriptSha256": "9229c467130e1f3d3abd042e2dcabaca9d4d5a918d0d77eadc814848df597ba6",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -15,8 +15,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "bcb7458ac0d4a3acd9b6283ea278b296db7eb148dd6240df5f908a6c039498ee",
"imageSha256": "0ca982495a7749907c48ebbddfd089c54385a4347f79f64e94af2c5a8d5c03eb"
"sourceSha256": "a66d7a0dfa661afdec7abb4de2c5a55a5320aec6b1cb40b34725b0fb40e343b1",
"imageSha256": "9119ab87502038f787529f621c39e1e0d01f3bc3b0289051c3791a1886e97a6b"
},
"view-touch": {
"file": "02-view-touch.png",
@@ -26,8 +26,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "bcb7458ac0d4a3acd9b6283ea278b296db7eb148dd6240df5f908a6c039498ee",
"imageSha256": "3be239a5a494f6776540a9c0049694ee6f9d2ba927c8bcb4b26ee5a69a228a7d"
"sourceSha256": "a66d7a0dfa661afdec7abb4de2c5a55a5320aec6b1cb40b34725b0fb40e343b1",
"imageSha256": "21c6fe7d79ff5c878ade8e98a64cc90dfd7bcc66338c864fb5a0bd411471313d"
},
"space-create": {
"file": "03-space-create.png",
@@ -37,8 +37,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "bcb7458ac0d4a3acd9b6283ea278b296db7eb148dd6240df5f908a6c039498ee",
"imageSha256": "4e1d23a307d4ebddb93c52144c64b219889463583462a8e20808e850290dc867"
"sourceSha256": "a66d7a0dfa661afdec7abb4de2c5a55a5320aec6b1cb40b34725b0fb40e343b1",
"imageSha256": "fb32ea4a785fedb0e99b0bca6dbcf19022b32ced763ed875a57eb453be550bf5"
},
"room-contour-close": {
"file": "04-room-contour-close.png",
@@ -48,8 +48,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "bcb7458ac0d4a3acd9b6283ea278b296db7eb148dd6240df5f908a6c039498ee",
"imageSha256": "d7b3d90a2d01ce4575dc2192792bbdd89a5785e39fb1085f200854fe40c131e6"
"sourceSha256": "a66d7a0dfa661afdec7abb4de2c5a55a5320aec6b1cb40b34725b0fb40e343b1",
"imageSha256": "75e0bc3dedff68e29be46531df0cc205f69988e86229580cc021017932049b34"
},
"plan-context-tray": {
"file": "05-plan-context-tray.png",
@@ -59,8 +59,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "bcb7458ac0d4a3acd9b6283ea278b296db7eb148dd6240df5f908a6c039498ee",
"imageSha256": "e5a7517de84c9301cc344d676bf05b46894d712f8008d2407f9690c9ce89d22d"
"sourceSha256": "a66d7a0dfa661afdec7abb4de2c5a55a5320aec6b1cb40b34725b0fb40e343b1",
"imageSha256": "5fc29f4a71572ed43221131cd6e5f9a3da19fcc9bf63c879b12664bee98e292a"
},
"device-editor": {
"file": "06-device-editor.png",
@@ -70,8 +70,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "bcb7458ac0d4a3acd9b6283ea278b296db7eb148dd6240df5f908a6c039498ee",
"imageSha256": "ab651351d6d56b84ddc823684c5ad5b77c34f23eea8b1e3ff627f2f3c3abfc8c"
"sourceSha256": "a66d7a0dfa661afdec7abb4de2c5a55a5320aec6b1cb40b34725b0fb40e343b1",
"imageSha256": "f9a8c003ed0fbd6ba7d26cd5642207beabb78a3b62b972e35505ac95830a9a95"
},
"device-display-preview": {
"file": "06-device-display-preview.png",
@@ -81,8 +81,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "bcb7458ac0d4a3acd9b6283ea278b296db7eb148dd6240df5f908a6c039498ee",
"imageSha256": "5450aa8deaae055b64f33fa05d4d5299ba18dcd349613d70de0482e319beb7d0"
"sourceSha256": "a66d7a0dfa661afdec7abb4de2c5a55a5320aec6b1cb40b34725b0fb40e343b1",
"imageSha256": "7185c75cf0e3629c7bc880381d1eab1d1f67a2484ca3d17e86ad53e0ed6387c8"
},
"background-editor": {
"file": "07-background-editor.png",
@@ -92,8 +92,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "bcb7458ac0d4a3acd9b6283ea278b296db7eb148dd6240df5f908a6c039498ee",
"imageSha256": "fb1c2bcb8bf253275e2fd3316328fb86cee97a6834d59d122ce56de85834b4e4"
"sourceSha256": "a66d7a0dfa661afdec7abb4de2c5a55a5320aec6b1cb40b34725b0fb40e343b1",
"imageSha256": "3d0d088f89da53a73efd13ef5bb5403f407011ab918e88671974459b5967ca25"
},
"room-card": {
"file": "08-room-card.png",
@@ -103,8 +103,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "bcb7458ac0d4a3acd9b6283ea278b296db7eb148dd6240df5f908a6c039498ee",
"imageSha256": "504600892f95a6da086836b909dd2d16583967f85ac48b7318d3f04b3e8cfc58"
"sourceSha256": "a66d7a0dfa661afdec7abb4de2c5a55a5320aec6b1cb40b34725b0fb40e343b1",
"imageSha256": "93d88ed84e2e3f9071e94e4b74cc3f3a86c7f898a76d1fb26bcc499e7dc4f538"
},
"device-info": {
"file": "09-device-info.png",
@@ -114,8 +114,25 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "bcb7458ac0d4a3acd9b6283ea278b296db7eb148dd6240df5f908a6c039498ee",
"imageSha256": "f94424127921a96b71d7b6babd7840f6dcfcb7b6fea8a7a4d42e06f2b1777aca"
"sourceSha256": "a66d7a0dfa661afdec7abb4de2c5a55a5320aec6b1cb40b34725b0fb40e343b1",
"imageSha256": "4d2257e49b28d3be517ed382269569b0e3bc18cec9d84d56b5f407c505a9b398"
}
},
"acceptance": {
"declared": [
"view-desktop",
"view-touch",
"space-create",
"room-contour-close",
"plan-context-tray",
"device-editor",
"device-display-preview",
"background-editor",
"room-card",
"device-info"
],
"witnesses": 0,
"floor": 1,
"witnessesSkippedBecause": "Полная смена растеризации канонического Linux runner: прежний baseline не воспроизводится ни CI, ни локальной контрольной съемкой; все 10 пар визуально проверены, продуктовых изменений нет"
}
}
+214
View File
@@ -0,0 +1,214 @@
# CODE-REVIEW-403-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/403
- ТЗ: `docs/specs/403-area-relocation-safety.md` (ревью: `SPEC-REVIEW-403-r1.md` жёлтый → `SPEC-REVIEW-403-r2.md` зелёный)
- Диапазон: `origin/dev..HEAD`, SHA на момент вердикта — `9c4d07abe27de7d55b8fd78947dcf21579e8055a`
- Ветка: `issue/403-area-relocation-safety`
- Заход: r1 (первый код-ревью этой задачи; спек-ревью не тратит бюджет код-ревью, §7.2 PROCESS.md)
- Ребейз: ветка приведена к `dev` конвейером до ревью (024114fd → 9c4d07ab, +1 коммит `dev`) — разбор ниже полный, не по дельте, как того требует §7.2/§2.9
## Скоуп
Две находки одного аудита (`AUDIT-2026-08-31-v1700beta2.md`), одна поверхность —
`_syncAreaRelocations` в `src/houseplan-card.ts` и стек Undo позиций устройств:
- **C2 (High)**: отказ `houseplan/config/set` после успешного удаления layout
ранее терял ручную позицию устройства без следа и без метки внимания.
- **M1 (Medium)**: переезд area одного устройства чистил весь стек
Undo/Redo позиций, а не только записи переехавшего устройства.
Коммиты диапазона: `0d338b9c` (ТЗ), `caddeb08`/`6b515800` (документы спек-ревью,
кладутся конвейером), `94502d3d` (правка ТЗ по H1 спек-ревью r1),
`71bbc953` (**код**: `src/houseplan-card.ts`, `src/command-stack.ts`,
`test/command-stack.test.mjs`, `scripts/mutation-gate.mjs`,
`demo/smoke_area_relocation_safety.mjs` (новый), `demo/smoke_area_relocation.mjs`
(правка одной ветки), `docs/ARCHITECTURE.md`, `docs/CHANGELOG.md`/`.ru.md`,
бандл-копии), `9c4d07ab` (docs: пересъёмка скриншотов, `Issue: #403`,
`User-Visible: no`, обязательна т.к. `check-docs.mjs` считает отпечаток по всему
`src/**`).
## Как проверялось
### Гейты (все на SHA `9c4d07ab`)
| Гейт | Команда | Результат |
|---|---|---|
| typecheck | `npx tsc --noEmit` | 0 ошибок |
| unit | `npm test` | `pass 1697 · fail 0 · skipped 1` (`# tests 1698`) |
| build + сверка бандла | `npm run build`; `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js`; сверка `houseplan-assets.json` и списка файлов `houseplan-assets/`; затем `npm run bundle:sync` (пересборка + синхронизация трёх копий, включая `demo/srv/assets`) | все копии побайтово совпали **до** пересборки — в дереве уже лежит актуальный бандл; `git status --short` после пересборки пуст |
| docs-фингерпринт (обязателен, diff трогает `src/**`) | `node scripts/check-docs.mjs` | `Documentation checks passed (7 files, 10 external links)` |
| новый `any` | `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | `Проверено добавленных строк: 52 в 2 файл(ах). Новых any нет` |
| провенанс коммитов/веток | `node scripts/process-gate.mjs --base origin/dev --head HEAD` | `коммитов 6, гейт пройден, предупреждений 0` (проверка 8 — статус issue — не запускалась, не нужна для ревью) |
| выбор смоков по diff | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | 22 «прямых совпадения» из 212; см. решение по каждому ниже |
| смок AC1–AC6 (новый) | `node demo/smoke_area_relocation_safety.mjs` | все 11 полей `true`, `OK` |
| смок AC7 (существующий #126) | `node demo/smoke_area_relocation.mjs` | все 18 полей `true`, `OK`, включая переименованное `failedConfigRestoredAndRetryable` |
| смок по прямому совпадению символа (`_devicePositionHistory`, `_persistDevicePlacement`) | `node demo/smoke_device_position_history.mjs` | все 32 поля `true`, `OK` |
| мутант 1 | `node scripts/mutation-gate.mjs --id=area-relocation-loses-position-on-refusal` | `тест покраснел, как обязан`, поймано 1/1 |
| мутант 2 | `node scripts/mutation-gate.mjs --id=area-relocation-clears-whole-history` | `тест покраснел, как обязан`, поймано 1/1 |
### Что НЕ прогонялось и почему
- **Остальные 19 «прямых совпадений» `smoke-select`** (`smoke_optimize_coordinate_canonicalization`,
`smoke_room_resize`, `smoke_cold_view_toggle`, `smoke_cold_view_vacuum`,
`smoke_help_affordance`, `smoke_junction_limits`, `smoke_lattice_write_barrier`,
`smoke_linked_virtual_light`, `smoke_partition_openings`, `smoke_plan_upload_race`,
`smoke_resize_audit_1550`, `smoke_resize_pointer_real_plan`, `smoke_save_race`,
`smoke_space_tab_reorder`, `smoke_tap_ctx`, `smoke_v8_draft_write`,
`smoke_virtual_light_toggle`, `smoke_ws_resilience`, `smoke_zero_wall_migration_unblocked`)
— совпали только по одиночным широко используемым символам (`_showToast`,
`_writeConfig`, `_reloadConfigOnly`), которые diff не переопределяет и вызывает
теми же сигнатурами, что и раньше. Слабая связь по PROCESS.md §8 — повод
посмотреть, не обязанность прогонять; ни один из этих смоков не касается
area-relocation/undo-истории по теме. Полная матрица (212 смоков) —
предрелизный гейт.
- **`npm run invariants -- --config <export>`** — диф трогает `layout` (позиции
устройств), формально попадающий под правило PROCESS.md §доп. «трогает
геометрию/ссылки на неё» (`marker.space`, `layout`). Решение: не запускать,
явно. Причина: правка не вычисляет новые координаты/ключи и не производит
новых записей толщины/рёбер — `_persistDevicePlacement(id, placement)` в
ветке восстановления вызывается с тем же объектом `before`, захваченным
`devicePlacement(this._layout, decision.id)` до удаления (`houseplan-card.ts:5180`),
т.е. записывается обратно **байт-в-байт то же значение**, которое уже прошло
инварианты до удаления; ключ (`deviceId`) не меняется. Сама логика инвариантов
(`checkReferences`, `latticeProfile`) не тронута и продолжает проверяться
юнит-тестами (`test/model-invariants.test.mjs` и смежные — в зелёном `npm test`
выше). Ни `src/device-area-relocation.ts` (резолвер, вне скоупа ТЗ), ни
wall/thickness/open_spans код не задеты.
- **`npm run golden:verify`** — ТЗ прямо утверждает «Видимого оформления не
меняем» (раздел UX), и чтение диффа это подтверждает: нет правок CSS,
разметки, `render()`; единственная пользовательская связь — переиспользуемый
toast (`toast.pos_save_failed`, уже существующий ключ) и существующая метка
внимания (`.newdot`, уже существующая разметка — её же проверяет наш смок
через `fallbackEl?.querySelector('.newdot')`).
- **`python -m pytest tests_backend`** — diff не трогает `custom_components/**/*.py`.
- **Performance-профили** — не названы в AC; ТЗ утверждает «нет доп. циклов,
подписок, сетевых вызовов сверх уже выполняемых `_syncAreaRelocations` /
`_writeConfig`» — проверено чтением: цикл восстановления идёт по
`deletedPlacements` (Map, максимум по числу решений `relocate` в одном
проходе — уже ограничено существующим циклом `for (const decision of
current.decisions)`), доп. запись конфига только при `restoreFailed.size > 0`
(редкая ветка, не на каждый тик).
## Находки
Нет. High: 0, Medium: 0, Low: 0 (нет находок, требующих правки или списания записью).
Ниже — отдельно, для прозрачности разбора: одна проанализированная, но не
блокирующая деталь.
**Наблюдение (не находка).** Тройной отказ подряд — отказ `config/set`, затем
отказ восстановления `layout/update`, затем отказ второй, дополнительной
записи конфига с меткой внимания (`houseplan-card.ts:5273-5283`) — оставляет
локальное состояние (`_serverCfg.settings.new_device_ids` содержит id) впереди
серверного на один цикл: сама эта последняя запись не откатывается при
неудаче, в отличие от первой (`previousSnapshot`/`previousAttention`
restore чуть выше). Разобрано по коду, не воспроизведено намеренно (третий
отказ подряд у одного и того же устройства — вырожденный случай, вне
разумной проверки браузерным смоком). Самолечится: `_regSignature = ''` и
`_areaRelocationSyncKey = ''` сброшены в этой же ветке, так что следующий
authoritative rebuild пересчитает резолюцию заново и либо успешно допишет
attention на сервер (через обычный путь #126 — `nextAttention` включает
любой `committed` id независимо от исхода восстановления позиции), либо
покажет очередной toast. Тот же риск закрыт в разделе «Риски» ТЗ
(«Восстановление на сервере тоже может отказать... этот случай обязан
оставлять метку внимания» — оставляет, пусть и не гарантированно с первой
попытки). Не Low, потому что не расходится с контрактом ТЗ и не имеет
пользовательского воспроизводимого сценария потери данных — в худшем случае
лишний toast и задержка в один rebuild-тик.
## Разбор по AC
Все доказательства ниже — исполнением (браузерный смок `demo/smoke_area_relocation_safety.mjs`,
запущен выше), кроме отдельно помеченных «прочитано, не исполнено».
- **AC1** (отказ `config/set` не оставляет устройство без позиции: либо
позиция на месте, либо `new_device_ids`). Доказано смоком:
`rejectedWriteRestoresAfterDelete: true` (позиция восстановлена локально
**и** на сервере — `layout/update` вызван после отказа `config/set`,
порядок проверен по индексу вызова) и `failedRestoreLeavesAttention: true`
(второй сценарий: отказывает и восстановление — устройство попадает в
`new_device_ids` **и на сервере**, `.newdot` в DOM). Мутант
`area-relocation-loses-position-on-refusal` красит именно этот смок при
удалении восстановления — доказательство не тавтологично.
- **AC2** (успешный переезд работает как раньше). Доказано полным прогоном
`demo/smoke_area_relocation.mjs` (18/18 `true`, включая
`staleLayoutDeleted`, `provenanceAdvanced`, `attentionShown`) и
`successfulRetry: true` в новом смоке (тот же сценарий после снятия
задержки ответа сервера).
- **AC3** (delete-first: устаревшая точка не выигрывает, пока ответ сервера
в пути). Доказано: `stalePointSuppressedWhilePending: true` — во время
задержанного `config/set` `c._layout[relocatedId]` пуст, а устройство
вычисляется в новой area, то есть старая точка не «воскресает» до
подтверждения.
- **AC4** (ветка `conflict` подчиняется AC1). Доказано:
`conflictRestoresBeforeRetry: true` — порядок вызовов
restore(`layout/update`) → `config/get` (reload) → повторный
`layout/delete` подтверждён по индексам в логе WS-вызовов, не только по
финальному состоянию; `conflictEventuallyCompletes: true`. Код-путь
проверен построчно: `await this._reloadConfigOnly(true)` (было `void`) —
сделано намеренно awaited, чтобы блок восстановления attention ниже читал
уже перезагруженный `this._settings`, а не гонялся с ним.
- **AC5** (переезд одного не гасит историю другого). Доказано:
`unrelatedHistorySurvives: true`, `unrelatedUndoStillWorks: true`
(Undo лампы после её drag остаётся рабочим после переезда другого
устройства), `conflictKeepsUnrelatedHistory: true` в ветке conflict.
Дополнительно юнит-тестом `test/command-stack.test.mjs` — `removeWhere`
оставляет несвязанные undo/redo записи (`stack.undoName === 'lamp'`,
`canRedo === true` после удаления записи `sensor`).
- **AC6** (записи переехавшего устройства удалены из истории). Доказано:
`relocatedHistoryRemoved: true` (после удаления записи `undoName` уже не
«Move relocating light», `canRedo` пуст). Юнит-тест то же самое проверяет
на уровне `CommandStack.removeWhere` изолированно. Мутант
`area-relocation-clears-whole-history` красит смок при возврате
безусловного `clear()` — доказательство не тавтологично.
- **AC7** (существующее поведение #126 не сломано). Доказано полным зелёным
прогоном `demo/smoke_area_relocation.mjs`; единственное изменённое
утверждение (`failedConfigRetryable` → `failedConfigRestoredAndRetryable`)
соответствует тексту ТЗ буквально — сверено построчно с
`docs/specs/403-area-relocation-safety.md:174-180`.
## Что проверено и корректно
- **Тип `DevicePlacement`/`deviceId`** — прочитано, не исполнено:
`src/device-position-history.ts:1-20` подтверждает поля `x/y/s` у
`DevicePlacement` и `deviceId/spaceId/placement` у `DevicePositionState`,
что делает `removeWhere(({before, after}) => relocating.has(before.deviceId) ...)`
типобезопасным (подтверждено также нулём ошибок `tsc --noEmit`).
- **Идентичность восстанавливаемого значения** — прочитано: `before` в цикле
удаления (`houseplan-card.ts:5180`) берётся через `devicePlacement(this._layout, decision.id)`
**до** вызова удаления, и то же самое значение (без пересчёта) передаётся в
`_persistDevicePlacement(id, placement)` при восстановлении — ветка
восстановления не может записать испорченные координаты.
- **i18n** — использованные ключи (`toast.pos_save_failed`, `toast.cfg_save_failed`,
`toast.conflict`) уже существуют во всех четырёх словарях
(`src/i18n/{en,ru,de,fr}.json`, сверено `grep`) — новых строк diff не
добавляет, что соответствует разделу «i18n» ТЗ.
- **Трейлеры и changelog** — `71bbc953` несёт `Issue: #403` / `User-Visible: yes`
и правит `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` в том же коммите;
текст точен относительно кода (сверено построчно — «restores the point
before retrying, or marks the marker for attention if the restore also
fails» соответствует именно реализованной логике, а не общей фразе).
`9c4d07ab` (скриншоты) несёт `Issue: #403` / `User-Visible: no`, что верно
— рендер не менялся.
- **`docs/ARCHITECTURE.md`** — абзац про area-relocation writer дополнен
точным описанием восстановления и точечной инвалидации истории, без
расхождений с кодом.
- **Одно число — один источник**: diff не добавляет и не меняет ни одной
видимой пользователю величины (позиция маркера не отображается как число,
Undo-кнопка — булев enabled/disabled без числа); неприменимо к этому diff.
## Унаследовано (без повторной проверки)
Не применимо — это первый код-ревью задачи (r1). Вердикт и находки
`SPEC-REVIEW-403-r1.md`/`SPEC-REVIEW-403-r2.md` относятся к другому этапу
(§7.2 PROCESS.md: цикл считается по этапу, спек-ревью не тратит бюджет
код-ревью и не подменяет его разбор) — их выводы не наследуются автоматически
в код-ревью, но диагноз C2/M1 и AC1–AC7 из зелёного `SPEC-REVIEW-403-r2.md`
использованы как контракт, против которого проверялся код в разделе «Разбор
по AC» выше.
## Вердикт
Зелёный. Обе находки аудита устранены и доказаны исполнением с
мутационным контролем (не тавтологичны); AC1–AC7 доказаны; гейты, обязательные
для объёма этой задачи, зелёные; трейлеры и changelog на месте.
+264
View File
@@ -0,0 +1,264 @@
# SPEC-REVIEW-403-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/403
- Артефакт ТЗ: `docs/specs/403-area-relocation-safety.md` (полный трек, класс A;
без метки `small`/`trivial`)
- Материал: SHA `1f9d9014` (ветка `issue/403-area-relocation-safety`,
коммит «docs: specify area relocation safety (#403)»)
- Заход: r1 · лимит циклов ревью ТЗ для полного трека — 4 (§4), израсходовано
до этого раунда — 0 (пять предыдущих запусков конвейера падали до первого
обращения к модели — `is_error: true`, `modelUsage: {}` — вердикта не было
ни разу, бюджет не тратили, см. комментарии issue)
- Ревьюер: Claude (роль «ревьюер ТЗ»), независимая сессия, без устных
пояснений автора
- Первый раунд — разбор полный, раздела «дельта» нет (§2.10 применяется
начиная со второго захода)
## Скоуп ревью
Bug P1, класс A, полный трек (issue не помечен `small`): две находки
свежего кода #126 на одной поверхности (`src/houseplan-card.ts`,
`_syncAreaRelocations` и обработка отказов):
- **C2 (High из аудита)** — отказ `houseplan/config/set` во время переезда
area оставляет `layout` уже удалённым (удаление успело пройти раньше) и
не восстанавливает позицию и не помечает устройство как требующее
внимания — ручная расстановка маркера теряется молча, самовоспроизводяще
(снапшот откатывается на старую area → следующий authoritative-проход
снова решает `relocate`).
- **M1 (Medium из аудита)** — переезд area **любого** устройства чистит
**весь** стек Undo позиций (`_devicePositionHistory.clear()`), включая
записи устройств, которых переезд не касался, и без уведомления (в
отличие от соседнего класса очистки истории — `history.device_stale`).
Задача ложится на J6 `docs/SCOPE.md` («Keep the plan true as the home
evolves» — оптимистичная блокировка, ручная расстановка маркеров) и на
стоящее правило SCOPE.md «никогда не удалять данные пользователя по
догадке» — обе находки именно про это: ручная позиция маркера — данные,
введённые пользователем руками.
## Как проверялось
1. Прочитаны целиком `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md` (§1, §2.4,
§2.5, §4, §5, §6, §7.1, §7.2, §8), тело issue #403 и все восемь
комментариев (включая последовательность из шести неудачных запусков
конвейера — учтено при подсчёте бюджета циклов, ни один из них вердикта
не дал).
2. Прочитан весь текст ТЗ `docs/specs/403-area-relocation-safety.md`.
3. Каждое фактическое утверждение ТЗ о коде сверено построчно с деревом на
`1f9d9014`:
- `_maybeRebuildDevices`/`_syncAreaRelocations`
(`src/houseplan-card.ts:5016-5254`) прочитаны целиком;
- подтверждено: ветка отказа **удаления** layout восстанавливает позицию
(`applyDevicePlacement(before)`, фактическая строка `:5184`, ТЗ называет
`:5183` — расхождение в одну строку, не искажает факт);
- подтверждено: ветка отказа **записи конфига** (`catch` на `:5223-5248`,
ТЗ называет тот же диапазон точно) восстанавливает только
`marker_area_snapshot`/`new_device_ids` в памяти (если фингерпринт не
разошёлся) и **не восстанавливает** удалённую позицию — заявление ТЗ
подтверждено буквально, строка в строку;
- подтверждён механизм самовоспроизведения: `resolveDeviceAreaRelocations`
(`src/device-area-relocation.ts:181-188`) решает `relocate = true`
ровно когда `previous.area !== area`; после откатa снапшота на старую
area это условие снова истинно на следующем authoritative-проходе —
цикл, описанный в ТЗ, воспроизводится логикой резолвера, а не является
догадкой;
- подтверждена находка M1: `_devicePositionHistory.clear()`
(`src/houseplan-card.ts:5062-5065` — ТЗ называет `:5056-5058`,
расхождение в 6 строк, см. находку L1) стоит под условием «есть хоть
одно переезжающее устройство», без фильтра по `deviceId`;
- `CommandStack` (`src/command-stack.ts`) не имеет метода выборочного
удаления — подтверждено, что контракт AC5/AC6 («снимается история
переехавших, а не весь стек») требует нового метода, но это техническая
деталь реализации, а не пробел ТЗ (стек типизирован по
`NamedCommand<DevicePositionState>`, `deviceId` уже есть в каждой
записи — технически осуществимо без изменения формата данных);
- `toast.pos_save_failed`/`toast.cfg_save_failed`/`toast.conflict`/
`history.device_stale` — все четыре ключа существуют в `src/i18n/ru.json`
(и en/de/fr) — заявления ТЗ о «существующей метке» и «существующем
уведомлении» подтверждены, не придуманы;
- `registryFollowingBinding`/формат `binding` (`device-area-relocation.ts:95-107`)
— подтверждён формат `${bindingKind}:${bindingRef}`, ровно то, что автор
сам называет причиной трёх неудачных попыток воспроизведения C2 до
финального успешного прогона (комментарии аналитики) — воспроизведение
не голословно, ошибка автора зафиксирована и исправлена явно.
4. Проверено соответствие терминологии `docs/USER-GUIDE.ru.md`: раздел
«Устройства» (`:757-771`) документирует именно тот успешный путь, который
AC2 требует сохранить («старая позиция удаляется… появляется красная
отметка внимания»), и раздел «История редактора» (`:260`, `:808-809`)
подтверждает существующий контракт Undo (50 команд, best effort на touch) —
спецификация не вводит новых терминов и не противоречит гайду.
5. Проверено `docs/TOUCH-SUPPORT.md` и DoR-чек-лист §2.5 на обязательные
пункты «влияние на touch» и «влияние на производительность» — см. находку
H1.
6. Проверены обязательные разделы §7.1 — присутствуют все (сценарий · что
человек увидит до/после · проблема и контракт по каждому пункту · скоуп/
не-скоуп · UX · модель данных и миграция · i18n · AC1–AC7 с доказательством ·
план автотестов · риски · откат · release-артефакты).
7. Проверено существование инструментов, на которые ссылается план тестов:
`scripts/mutation-gate.mjs` есть, ни одного мутанта `area-relocation-*` в
нём пока нет (согласуется с ТЗ — это новые мутанты); `demo/smoke_area_relocation.mjs`
существует и уже умеет мокать отказ `houseplan/config/set`
(`rejectKettleRelocation`, строки 19/262/279) — план AC1/AC4 технически
реализуем на существующей инфраструктуре смоков, не является фантазией.
8. Гейты кода (`tsc`, `test`, `build`) не гонялись: на этапе ТЗ продуктового
диффа нет (класс C — только `docs/specs/**`), гонять их не над чем.
## Находки
### H1 (High, блокирует, в скоупе). ТЗ не называет два обязательных пункта DoR — влияние на touch/kiosk и на производительность
**Где**: весь файл `docs/specs/403-area-relocation-safety.md` — ни разу не
упоминает touch, kiosk, `TOUCH-SUPPORT.md`, производительность, перф или
бюджет (`grep -i "touch|kiosk|перф|производительн|performance"` по файлу —
ноль совпадений).
**Почему это находка, а не формальность**. PROCESS.md §2.5 перечисляет
обязательные пункты «Готово к разработке» и требует по каждому явную
запись, а не молчание: «влияние на производительность и бюджеты названо
(**или явно «нет»**)» и «влияние на touch по `docs/TOUCH-SUPPORT.md`
(**View и киоск — блокирующие**)». Оба пункта — из списка, помеченного
«Все пункты обязательны», и: «Если хоть один пункт не выполнен — статус не
«Готово к разработке», как бы ни хотелось начать». Ни один из двух пунктов
в ТЗ не назван — ни утвердительно, ни отрицательно.
Это не абстрактная бумажная претензия: у задачи есть настоящая View/kiosk
грань. AC2 фиксирует видимый на любом клиенте (включая киоск-планшет — J1
`docs/SCOPE.md`, «Show the whole home … device states») эффект успешного
переезда — маркер оказывается в новой комнате с отметкой внимания; это
именно то поведение, что описано в `docs/USER-GUIDE.ru.md:761-767`.
Симметрично, дефект C2 в необработанном виде **тоже виден на киоске** —
маркер молча возвращается в центр комнаты. То есть эта ветка кода реально
затрагивает View/kiosk-наблюдаемое поведение, а не только редакторский
слой, и именно поэтому DoR требует явного заявления, а не тишины.
AC5/AC6 (сужение очистки Undo) относятся к редактору устройств, для
которого touch уже документирован как best effort (`USER-GUIDE.ru.md:260`,
`:808-809`) — но это тоже должно быть **названо**, а не молчаливо
унаследовано: без явной строки нельзя отличить «автор сверился с
TOUCH-SUPPORT.md и решил, что контракт не меняется» от «автор не думал про
touch вовсе» (тот же аргумент, которым в SPEC-REVIEW-402-r1 было обосновано
идентичное H1-заключение для #402 — прецедент этого же ревьюера на этом же
проекте).
Фактическая оценка по существу (для экономии цикла): последствий, скорее
всего, нет ни для touch, ни для перфа. Обе правки — (1) порядок операций в
одном async-методе `_syncAreaRelocations` плюс восстановление/повторная
попытка записи при отказе, (2) точечный фильтр по `deviceId` в
уже существующей структуре истории на 50 записей. Ни жесты, ни рендер, ни
DOM, ни сетевые вызовы сверх уже выполняемых не меняются. Но это вывод
ревьюера, а не факт, зафиксированный автором в ТЗ, — фиксировать обязан
автор.
**Требуемая правка** (несколько строк текста, не кода): добавить в ТЗ,
например —
- `Touch: не затронут — правка меняет порядок серверной записи
(_syncAreaRelocations) и фильтр очистки Undo-стека по deviceId, не
касается drag/tap-жестов, рендера или DOM; наследует существующий
контракт Undo/Redo (best effort на touch, USER-GUIDE §10). View/kiosk:
наблюдаемый эффект (AC1/AC2) — позиционный, не входной, контракта View на
touch не меняет.`
- `Производительность: нет — правка не добавляет новых циклов, подписок или
сетевых вызовов сверх уже выполняемых `_syncAreaRelocations`/`_writeConfig`;
фильтр истории работает на существующем массиве максимум 50 записей.`
### L1 (Low, снимается с записью). Номер строк для сниппета M1 отстал от кода на SHA `1f9d9014`
**Где**: `docs/specs/403-area-relocation-safety.md`, раздел «(2) M1»:
«`src/houseplan-card.ts:5056-5058`».
**Проверено чтением**: на `1f9d9014` этот диапазон (`:5056-5058`) — три
строки середины вызова `resolveDeviceAreaRelocations` (`model:`, `layout:`,
`snapshot:` — параметры объекта опций), не имеющие отношения к M1. Сам
процитированный в ТЗ сниппет (`this._areaRelocationIds = new
Set(...); if (...) { this._cancelDeviceDrag(); this._devicePositionHistory.clear();`)
дословно совпадает с кодом, но находится на строках `:5062-5065`.
Содержание находки верно и не пострадало (сверено выше, в «Как
проверялось», п.3), только адрес неточен — вероятно, из-за смещения при
правках между тем, когда аналитика собирала цитату, и фиксацией ТЗ.
**Решение ревьюера**: не блокирует, не создаёт отдельного цикла. Снимаю с
записью — исправить номера строк можно попутно при правке по H1 (тот же
файл будет открыт на редактирование), отдельного возврата ради одной этой
находки не требуется.
## Что проверено и признано корректным
- **Диагноз C2 точен и воспроизводим**: ветка отказа удаления восстанавливает
layout (`:5184`), ветка отказа записи конфига — нет (`:5223-5248`); порядок
«delete-first» (`:5147` комментарий «Layout deletion is deliberately
completed before provenance advances») подтверждён и корректно процитирован.
Самовоспроизводящийся цикл (снапшот откатывается → резолвер снова решает
`relocate`) подтверждён логикой `resolveDeviceAreaRelocations`
(`device-area-relocation.ts:181-188`), а не выдан за факт без опоры на код.
- **Диагноз M1 точен**: `clear()` действительно безусловен по всему набору
переезжающих устройств, `history.device_stale` действительно существует как
образец уже принятого в проекте паттерна уведомления об очистке истории.
- **Два допустимых исхода C2 (запись первой / восстановление при отказе)
сформулированы как решаемая ревьюером/автором техническая развилка**, а не
как догадка, выданная за факт — с явным критерием выбора (AC3, свойство
delete-first) и явной эскалацией в §"Риски", если восстановление тоже
отказывает («потеря неизбежна» → AC1 формулируется как «позиция ИЛИ
метка», не «позиция всегда»). Это корректное использование блока
«принято предположительно» из §7.1 AGENTS.md для чисто технических решений.
- **AC1–AC7 однозначны и указывают способ доказательства** (браузерный смок,
для AC7 — уже существующий `demo/smoke_area_relocation.mjs`). Способ
реалистичен: существующий смок #126 уже умеет мокать отказ `config/set`
(`rejectKettleRelocation`), новый смок под #403 — органичное расширение
того же приёма, не изобретение с нуля.
- **Скоуп/не-скоуп корректен и не пересекается** с #126 (критерии переезда,
формат снапшота — не трогаются), #74/#397 (механика Undo как таковая — не
трогается, трогается только объём очистки), #406 «г» (гигиена снапшотов
исчезнувших устройств — не относится к этой находке).
- **Соответствие `docs/SCOPE.md`**: закрывает J6 (оптимистичная блокировка,
ручная расстановка маркеров) и защищает от нарушения стоящего правила
«никогда не удалять пользовательские данные по инференсу» — задача не
расширяет продукт, а чинит потерю уже введённых пользователем данных.
- **i18n-раздел корректно условен**: если решение обходится существующей
меткой внимания — новых строк нет; если потребуется отдельное уведомление —
явно предписано добавить ключ во все четыре словаря. Не оставляет открытого
вопроса без явного правила на оба исхода.
- **Откат и release-артефакты адекватны масштабу**: обе правки локальны
(один метод, один фильтр), changelog User-Visible: yes корректно назван
(пользователь увидит, что расстановка переживает отказ записи).
- **Метки issue** (`bug`, `P1`, `S4-spec-review`, без `small`/`trivial`)
согласуются с заявленным в ТЗ полным треком — задание корректно определило
входной трек.
- **Инфраструктурная переписка issue** (шесть провалившихся прогонов
конвейера, `is_error`/`modelUsage: {}`) не образует циклов ревью:
вердикта не было ни в одном из них, бюджет §4 остаётся 0 до этого раунда.
## Чего не проверял
- **Гейты кода** (`npx tsc --noEmit`, `npm test`, `npm run build`,
`check-docs`, смоки, инварианты модели) — не гонялись: на этапе ТЗ
продуктового кода нет, диффа для гейтов не существует (диапазон
`origin/dev..HEAD` состоит только из `docs/specs/403-area-relocation-safety.md`).
Это предмет код-ревью после реализации.
- **`scripts/mutation-gate.mjs` / `demo/smoke_area_relocation.mjs` —
запуск**: не запускал ни то, ни другое; только убедился построчным чтением,
что оба файла существуют, а механизм мока отказа WS (`rejectKettleRelocation`)
в существующем смоке технически совместим с планом автотестов ТЗ (AC1/AC4).
- **Реальный браузерный повтор репродукции C2/M1** — на этапе ТЗ кода
нет; воспроизведение, описанное автором аналитики (лог WS-вызовов,
состояние `layoutHasDevice`/`snapshotAreaNow`/`attention`/`undoAvailable`),
принято на основании сверки с логикой резолвера и обработчика отказа в
текущем дереве (см. «Как проверялось», п.3), а не путём собственного
запуска браузера — независимая браузерная перепроверка будет предметом
код-ревью, когда появится смок.
- **`CommandStack`-реализация выборочного удаления** — не проектировал и не
требовал конкретного API; отметил только, что текущий тип данных
(`NamedCommand<DevicePositionState>` с `deviceId` в каждой записи) делает
контракт AC5/AC6 технически осуществимым, выбор метода — за реализацией.
- **Таблицу `docs/specs/README.md`** — строка для #403 в неё не добавлена;
это известный, не относящийся к этой задаче долг (§7.3 п.1 PROCESS.md),
не поднимаю отдельной находкой.
## Вывод
Диагноз и контракт по обеим находкам аудита (C2, M1) точны, построчно
сверены с кодом на `1f9d9014` и не содержат догадок, выданных за факт; AC1–
AC7 однозначны, доказуемы и реалистичны на существующей тестовой
инфраструктуре. Единственная блокирующая находка — процедурная (H1):
ТЗ не называет обязательные по DoR §2.5 пункты про touch/kiosk и
производительность. По существу риска в обоих пунктах, скорее всего, нет,
и правка — несколько строк текста; возвращаю жёлтым, не красным.
+152
View File
@@ -0,0 +1,152 @@
# SPEC-REVIEW-403-r2
- Issue: https://github.com/Matysh/houseplan-card/issues/403
- Артефакт ТЗ: `docs/specs/403-area-relocation-safety.md` (полный трек, класс A;
без метки `small`/`trivial`)
- Материал: спец-файл на `HEAD = 83005c3c` (ветка `issue/403-area-relocation-safety`,
коммит «docs: revise area relocation safety spec», ревизия ТЗ 2)
- Заход: r2 · блокирующих циклов израсходовано **1/4** (зелёный вердикт цикл не
образует, §4/#227 — этот раунд бюджет не тронет)
- Ревьюер: Claude (роль «ревьюер ТЗ»), независимая сессия
- Разбор — **по дельте** (§2.10 PROCESS.md): дельта локальна (см. ниже),
ребейза не было, контракт поведения не менялся, новая подсистема не задета,
объём дельты (25 строк diff) многократно меньше исходной задачи
## 1. Вердикт r1 и SHA, на котором он получен
`docs/reviews/SPEC-REVIEW-403-r1.md` (коммит `bfe05bed`): **жёлтый**, заход r1,
блокирующих циклов 1/4, High: 1 (H1), Medium: 0, Low: 1 (L1, снят с записью).
Материал ревью r1 — SHA `1f9d9014` (ветка `issue/403-area-relocation-safety`,
тот же текст, что и коммит `bb47ec31` в этой ветке; расхождение SHA — только
факт истории ветки на момент r1, содержимое спецификации идентично тому, что
лежит в `bb47ec31`). SHA был назван в документе r1 явно — находки «SHA не
назван» здесь нет.
## 2. Объявление дельты
Код продукта (`src/**`) на этапе ТЗ не менялся и не должен был: спор — только
о тексте спецификации. Дельта:
```
git diff bb47ec31..83005c3c -- docs/specs/403-area-relocation-safety.md
```
```diff
- Ревизия: 1 (2026-08-31)
+ Ревизия: 2 (2026-09-01; H1/L1 из SPEC-REVIEW-403-r1)
...
- `src/houseplan-card.ts:5056-5058`:
+ `src/houseplan-card.ts:5062-5065`:
...
+ ## Touch, View и kiosk
+ (текст: touch-контракт не меняется, drag/tap-жесты и DOM/рендер не
+ затрагиваются, View/kiosk сохраняют идентичный desktop-результат)
+
+ ## Производительность
+ (текст: нет новых циклов/подписок/сетевых вызовов сверх уже выполняемых
+ `_syncAreaRelocations`/`_writeConfig`; очистка истории — не более 50 команд)
```
`git diff --stat bb47ec31..HEAD` подтверждает: изменены ровно два файла —
сам спек (+23/-2) и новый `docs/reviews/SPEC-REVIEW-403-r1.md` (публикация
документа r1, не предмет разбора). Продуктовый код, тесты, `docs/SCOPE.md`,
`docs/TOUCH-SUPPORT.md`, `docs/USER-GUIDE.ru.md` в дельте не участвуют —
разбор ограничен текстом спецификации, что и требуется §2.10 для дельты по ТЗ.
Дельта локальна: без ребейза (истории общий предок совпадает), без смены
контракта (AC1–AC7, сценарий, скоуп/не-скоуп не тронуты), без новой
подсистемы (правки — только два новых раздела DoR-чек-листа и один адрес
строки).
## 3. Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| **H1 (High).** ТЗ не называет влияние на touch/kiosk (`docs/TOUCH-SUPPORT.md`) и на производительность — ни утвердительно, ни явным «нет», как требует DoR §2.5 | Добавлены разделы «Touch, View и kiosk» (docs/specs/403-area-relocation-safety.md:133-144) и «Производительность» (:146-152) с явными положительными утверждениями «нет влияния» и обоснованием | `docs/specs/403-area-relocation-safety.md:133-152`; проверено построчно против `docs/TOUCH-SUPPORT.md:9-38` (View/kiosk — «fully supported», доступный AC1/AC2-результат туда попадает без ослабления) и против `docs/USER-GUIDE.ru.md:260,1325` (раздел 6 «Навигация, масштаб и жесты» — строка «История редактора»; лимит 50 команд подтверждён кодом, см. §5) |
| **L1 (Low).** Адрес сниппета M1 указывал `src/houseplan-card.ts:5056-5058`, актуальный код на `1f9d9014` — `:5062-5065` | Адрес в тексте ТЗ обновлён на `:5062-5065` | `docs/specs/403-area-relocation-safety.md:84`; сверено с текущим деревом — `src/houseplan-card.ts:5062-5065` содержит ровно процитированный фрагмент (см. §5) |
Обе находки закрыты правкой текста, не заявлением автора о правке — ниже
приведена самостоятельная проверка, а не пересказ хендоффа.
## 4. Унаследовано из r1 (без повторной проверки)
Источник: `docs/reviews/SPEC-REVIEW-403-r1.md` (коммит `bfe05bed`, материал —
SHA `1f9d9014`). Дельта r2 этих утверждений не касается, поэтому переносятся
без повторной проверки:
- диагноз C2 и M1 точен и построчно сверен с кодом (`_syncAreaRelocations`,
`resolveDeviceAreaRelocations`, `_devicePositionHistory.clear()`);
- AC1–AC7 однозначны, у каждого указан способ доказательства, реалистичны на
существующей инфраструктуре смоков (`demo/smoke_area_relocation.mjs` уже
умеет мокать отказ `houseplan/config/set`);
- все обязательные разделы §7.1 присутствуют (сценарий · что человек увидит ·
проблема/контракт по пунктам · скоуп/не-скоуп · UX · модель данных и
миграция · i18n · план автотестов · риски · откат · release-артефакты);
- терминология соответствует `docs/USER-GUIDE.ru.md` (раздел «Устройства»,
раздел «История редактора»);
- инструменты плана тестов существуют (`scripts/mutation-gate.mjs`,
`demo/smoke_area_relocation.mjs`);
- задача лежит в скоупе `docs/SCOPE.md` (J6 «Keep the plan true as the home
evolves» + правило «никогда не удалять данные пользователя по догадке»).
## 5. Проверка дельты (не унаследовано — перепроверено заново)
**Строка L1.** Прочитан `src/houseplan-card.ts:5060-5070` на текущем HEAD:
```
5062 this._areaRelocationIds = new Set(areaRelocations.relocateIds);
5063 if (this._areaRelocationIds.size) {
5064 this._cancelDeviceDrag();
5065 this._devicePositionHistory.clear();
```
Совпадает буквально с текстом ТЗ и с новым адресом `:5062-5065`. Находка L1
закрыта корректно, не только по номеру, но и по содержанию.
**Раздел «Производительность».** ТЗ утверждает лимит истории «не более чем из
50 команд». Проверено чтением `src/command-stack.ts:20-21`:
`public constructor(limit = 50)` — комментарий на той же строке ссылается на
UX-04 («useful 30–50-step history»). Число подтверждено, не выдумано.
**Раздел «Touch, View и kiosk».** Утверждение «View/kiosk сохраняют
одинаковый позиционный результат на desktop и touch» проверено против
`docs/TOUCH-SUPPORT.md:9-41` — View обязан быть «fully supported; must be
convenient and reliable» на touch, отдельного исключения для содержимого
маркера/отметки внимания там нет; AC1/AC2 результата (позиция или отметка)
не вводят нового жеста и не меняют DOM/рендер — согласуется с диагнозом,
унаследованным из r1 (правка ограничена `_syncAreaRelocations` и фильтром по
`deviceId` в существующей истории). Раздел не содержит утверждений о
поведении, которых нет в каноне: ссылки на TOUCH-SUPPORT.md и на раздел
«История редактора» USER-GUIDE.ru.md проверены и точны.
Ни один AC не задет дельтой текстуально (AC1–AC7 не менялись), поэтому
повторная проверка самих AC не требовалась по правилу §2.10 п.4 — они
наследуются из §4 этого документа.
## 6. Что проверено и корректно
- Оба замечания r1 (H1, L1) закрыты по существу, не косметически: новые
разделы отвечают на конкретные пункты DoR, а не констатируют «нет» без
обоснования; исправленный адрес строки указывает на верный код.
- Ревизия ТЗ поднята до 2 с корректной пометкой источника правки.
- Дельта не расширяет скоуп, не меняет AC, не вводит новых терминов и не
противоречит канону подсистемы (TOUCH-SUPPORT.md, USER-GUIDE.ru.md).
- Продуктовый код и тесты в дельте отсутствуют, что и ожидается на этапе ТЗ.
## 7. Чего не проверял
- Диагноз C2/M1 и построчную сверку с `src/houseplan-card.ts` /
`src/device-area-relocation.ts` — не перепроверял заново, дельта их не
касается; см. §4 «Унаследовано из r1».
- Автотесты, мутанты и код реализации — их не существует на этапе ТЗ, вне
скоупа спек-ревью.
- Гейты `typecheck`/`test`/`build`/`golden`/смоки — не прогонял: дельта этого
раунда состоит из двух docs-коммитов (`bfe05bed`, `83005c3c`), продуктовый
код не тронут ни разу с прошлого зелёного Validate на `83005c3c` (тот же
SHA — это и есть HEAD; прогон уже зафиксирован в контексте задачи). Полные
наборы гейтов — предмет код-ревью, а не ревью ТЗ.
## Итог
Обе находки r1 закрыты правкой текста, а не заявлением. Дельта локальна,
дополнительных находок не выявлено. **Вердикт: зелёный.**
+318
View File
@@ -0,0 +1,318 @@
# SPEC-REVIEW-403-r3
- Issue: https://github.com/Matysh/houseplan-card/issues/403
- Артефакт ТЗ: `docs/specs/403-area-relocation-safety.md` (полный трек, класс A;
без метки `small`/`trivial`)
- Материал: SHA `e9938029de4908cac10ca432f79f95039baef458` (ветка
`origin/issue/403-area-relocation-safety`, проверено `git rev-parse HEAD`
непосредственно перед выводом; спецификация на этом SHA — «Ревизия: 3
(2026-09-01; техническое уточнение AC7 при реализации)»)
- Заход: r3 · блокирующих циклов израсходовано **1/4** (жёлтый r1 потратил 1
цикл; зелёный r2 бюджет не тронул — §4/#227)
- Ревьюер: Claude (роль «ревьюер ТЗ»), независимая сессия, без устных пояснений
автора
- Разбор — **по дельте** (§2.10 PROCESS.md): дельта локальна (см. ниже),
ребейза текста спецификации между r2 и r3 не было, контракт поведения AC7 не
меняется (меняется только способ доказательства), новая подсистема не задета,
объём дельты (14 строк diff суммарно по спеку и смоку) многократно меньше
исходной задачи
## Скоуп ревью
Особенность этого захода: он идёт **после**, а не до реализации. Хронология по
issue и git-истории:
1. `SPEC-REVIEW-403-r1` (жёлтый, H1) → `docs: revise area relocation safety
spec` (`94502d3d`, ревизия 2) → `SPEC-REVIEW-403-r2` (зелёный).
2. Реализация (`71bbc953`, «fix: preserve area relocation state») — в этом же
коммите разработчик поднял ревизию ТЗ до 3, скорректировав формулировку
AC7 (см. «Проверка дельты» ниже), и явно назвал это в хендофф-комментарии
(«Явная техническая корректировка AC7»).
3. `CODE-REVIEW-403-r1` вынес **зелёный** вердикт на SHA `9c4d07ab`, уже
разобрав AC7 против текста ревизии 3.
4. Слияние не удалось: `dev` продвинулся за время ревью на 1 коммит, конвейер
предписал ребейз (§7.2) — ветка `issue/403-area-relocation-safety` перестала
быть вершиной ревью. Логическое содержимое коммитов `0d338b9c`/`94502d3d`/
`71bbc953` затем попало в `dev` под новыми SHA (`8e7c9272`/`98d6edb7`/
`355b0516` — те же сообщения коммитов, тот же диф) — подтверждено
`git merge-base --is-ancestor 355b0516 origin/dev` → `yes`. Issue сейчас
несёт метку `S8-merged` («код в dev, ждёт беты»), состояние подтверждено
`gh issue view` (`state: OPEN`, `labels: bug, P1, S8-merged`).
5. Материал **этого** захода — старая, до-ребейзная вершина ветки (`e9938029`,
ей же помечен `origin/issue/403-area-relocation-safety`); её текст спека
побайтово совпадает с версией, уже лежащей в `dev`
(`git diff origin/dev -- docs/specs/403-area-relocation-safety.md` → пусто).
Итог: код, реализующий ревизию 3 ТЗ, уже смёржен и прошёл код-ревью зелёным.
Предмет этого захода — не блокировка чего-либо «в полёте», а проверка того,
что сама правка ревизии 3 (изменение формулировки AC7) была обоснованным
уточнением, а не догадкой, выданной за факт задним числом. Практический эффект
жёлтого вердикта здесь — не «возврат в разработку» (код уже в `dev`), а
требование правки текста ТЗ и/или отдельного issue до закрытия задачи при
выпуске беты (§2.8).
## 1. Вердикт r2 и SHA, на котором он получен
`docs/reviews/SPEC-REVIEW-403-r2.md`: **зелёный**, заход r2, блокирующих циклов
1/4, High: 0, Medium: 0, Low: 0, новых находок нет.
Заявленный в документе и в комментарии автора (issue, 2026-09-01T14:26:53Z:
«Коммит дельты: `83005c3c`») материал — SHA `83005c3c`. **Этот SHA не
существует в репозитории**: `git cat-file -t 83005c3c` → `fatal: invalid
object name`; репозиторий не мелкий (`git rev-list --all | wc -l` → 2437,
`--is-shallow-repository` → `false`), перебор всех объектов
(`git rev-list --all`) не находит ни одного хеша с префиксом `83005c`. Это не
следствие последующего ребейза (см. «Скоуп ревью», п.4) — ребейз случился
позже, при код-ревью, и переименовал только `71bbc953`→`355b0516` и предков;
он не объясняет отсутствие `83005c3c` уже на момент r2.
Реальный коммит, содержимое которого дословно совпадает с тем, что
`SPEC-REVIEW-403-r2.md` цитирует как дельту (сообщение «docs: revise area
relocation safety spec», диф +23/-2 в `docs/specs/403-area-relocation-safety.md`,
добавление разделов «Touch, View и kiosk» и «Производительность», правка адреса
строки M1) — `94502d3d67cacf85bdb9f69cd511b342989891fd`. Сверено построчно:
`git show 94502d3d -- docs/specs/403-area-relocation-safety.md` даёt ровно тот
диф, что процитирован в `SPEC-REVIEW-403-r2.md` разделе 2. Дальше в этом
документе как материал r2 используется `94502d3d` — единственный коммит,
фактически соответствующий описанному содержимому.
Это самостоятельная находка данного раунда — см. «Находки», H-SHA.
## 2. Объявление дельты
Дельта — с `94502d3d` (фактическое содержимое r2) по `e9938029` (HEAD этого
раунда):
```
git diff 94502d3d..e9938029 -- docs/specs/403-area-relocation-safety.md
```
```diff
- Ревизия: 2 (2026-09-01; H1/L1 из SPEC-REVIEW-403-r1)
+ Ревизия: 3 (2026-09-01; техническое уточнение AC7 при реализации)
...
- **AC7**. Существующее поведение #126 не сломано: ручная перестановка после
- переезда переживает три rebuild-тика, delete-first echo не воскрешает точку.
- Доказательство: `demo/smoke_area_relocation.mjs` остаётся зелёным без
- правок его утверждений.
+ **AC7**. Существующее успешное поведение #126 не сломано: ручная перестановка
+ после переезда переживает три rebuild-тика, delete-first echo не воскрешает
+ точку. Доказательство: `demo/smoke_area_relocation.mjs` остаётся зелёным;
+ единственное прежнее утверждение `failedConfigRetryable`, которое прямо
+ требовало состояние исходного бага (`layout` уже удалён после отказа
+ `config/set`), заменяется witness нового AC1 — позиция восстановлена, а
+ повтор остаётся возможен. Остальные утверждения смока не меняются.
```
`git diff --stat 94502d3d..e9938029` подтверждает: изменения в диапазоне —
спек (эта правка, коммит `71bbc953`), продуктовый код и тесты правки C2/M1
(`71bbc953`, вне предмета *этого* захода — уже пройдены `CODE-REVIEW-403-r1`),
два новых документа ревью (`6b515800` = `SPEC-REVIEW-403-r2.md`,
публикация конвейера) и `9c4d07ab` (пересъёмка скриншотов). Предмет **этого**
захода — только текст `docs/specs/403-area-relocation-safety.md`, как и
требует §2.10 для этапа ТЗ; продуктовый код в этом раунде не разбирается
заново — он уже прошёл `CODE-REVIEW-403-r1` (зелёный, независимый гейт, §7.2).
Дельта локальна: ребейз текста спека между r2 и r3 не случился (текст
идентичен что до, что после последующего ребейза кода — `git diff
origin/dev -- docs/specs/403-area-relocation-safety.md` пуст), контракт
поведения AC7 не изменился (по-прежнему «успешный сценарий #126 не сломан»),
новая подсистема не задета, объём (14 строк diff по обоим файлам) существенно
меньше исходной задачи.
## 3. Закрытие раунда r2
Раунд r2 был зелёным без находок — закрывать нечего (жёлтых/блокирующих
пунктов не было; §4: зелёный вердикт цикла не образует и правок не
предписывает). Таблица находок r2 пуста по построению.
## 4. Унаследовано из r2 (без повторной проверки)
Источник: `docs/reviews/SPEC-REVIEW-403-r2.md` (фактический материал —
`94502d3d`, см. «Находки», H-SHA, о расхождении с указанным в документе SHA).
Дельта r3 этих утверждений не касается, переносятся без повторной проверки:
- диагноз C2 и M1 точен и построчно сверен с кодом на исходном SHA `1f9d9014`
(унаследовано ещё из r1 через r2);
- AC1–AC6 однозначны, у каждого указан способ доказательства, реалистичны на
существующей инфраструктуре смоков — текст этих пунктов дельтой не
затронут;
- разделы «Touch, View и kiosk» и «Производительность» отвечают DoR §2.5 и
проверены против `docs/TOUCH-SUPPORT.md`/`src/command-stack.ts` — текст не
менялся;
- все обязательные разделы §7.1 присутствуют (сценарий · что человек увидит ·
проблема/контракт по пунктам · скоуп/не-скоуп · UX · модель данных и
миграция · i18n · план автотестов · риски · откат · release-артефакты) —
ни один не тронут этой дельтой;
- терминология соответствует `docs/USER-GUIDE.ru.md`;
- задача лежит в скоупе `docs/SCOPE.md` (J6 + правило о недопустимости
удаления пользовательских данных по догадке).
## 5. Проверка дельты (не унаследовано — перепроверено заново)
Предмет дельты — единственное утверждение: старый AC7 требовал, чтобы
`demo/smoke_area_relocation.mjs` остался зелёным «без правок его утверждений»;
новый AC7 разрешает ровно одну именованную замену (`failedConfigRetryable` →
`failedConfigRestoredAndRetryable`) с обоснованием «старое утверждение прямо
требовало состояния исходного бага». Это утверждение проверено по коду, а не
принято на слово:
**Что проверено.** `git show 71bbc953 -- demo/smoke_area_relocation.mjs`:
```diff
- const failedConfigRetryable = !c._layout.d_kettle
+ const failedConfigRestoredAndRetryable = c._layout.d_kettle?.s === kettlePoint.s
+ && c._layout.d_kettle?.x === kettlePoint.x
+ && c._layout.d_kettle?.y === kettlePoint.y
&& c._serverCfg.settings.marker_area_snapshot?.d_kettle?.area === 'kitchen'
- && c._areaRelocationSyncKey === '';
+ && c._areaRelocationSyncKey === ''
+ && calls.some((message) => message.type === 'houseplan/layout/update'
+ && message.device_id === 'd_kettle');
```
Старое условие `!c._layout.d_kettle` требовало **отсутствия** записи layout у
устройства — то есть буквально требовало, чтобы позиция осталась потеряна
после отказа `config/set`. Заявление ТЗ («прямо требовало состояние исходного
бага») подтверждено буквально, не является пересказом с чужих слов.
Новое условие проверяет: позиция восстановлена (`s/x/y` совпадают с
`kettlePoint`, сохранённым до переезда), снапшот всё ещё на прежней area
(`kitchen`, не продвинут — это ожидаемо: конфиг не записан), синхронизация не
залипла (`_areaRelocationSyncKey === ''`), и восстановление реально ушло на
сервер (`layout/update` вызван) — то есть проверяет именно контракт AC1 (см.
`docs/specs/403-area-relocation-safety.md:156-159`), а не более слабое
условие.
«Повтор остаётся возможен» проверено смежной, не изменённой этим дифом
переменной чуть ниже по файлу (`sed -n '124,128p'`):
```js
const configRetrySucceeded = c._serverCfg.settings.marker_area_snapshot?.d_kettle?.area
=== 'living_room' && c._serverCfg.settings.new_device_ids?.includes('d_kettle');
```
— после восстановления сценарий повторяет `window.__setRegistryArea` без
мока отказа, и `configRetrySucceeded` действительно проверяет, что снапшот
продвинулся, а устройство попало в `new_device_ids`. Итоговое утверждение AC7
(«позиция восстановлена, а повтор остаётся возможен») доказано двумя
раздельными булевыми полями смока, оба из итогового `return`
(`demo/smoke_area_relocation.mjs:334-335`), а не одним удобным флагом.
**«Остальные утверждения смока не меняются»** — проверено по объёму дифа:
`git show 71bbc953 --stat -- demo/smoke_area_relocation.mjs` → `1 file changed,
9 insertions(+), 4 deletions(-)`, ровно два хунка (переопределение переменной
в сценарии kettle-retry и переименование поля в итоговом объекте). Других
утверждений смока диф не касается — заявление ТЗ точно.
**Вывод по дельте**: правка AC7 — не догадка и не косметическая уступка ради
зелёного теста: старая формулировка была логически противоречива сама с собой
(нельзя одновременно требовать «позиция цела после отказа» по AC1 и «после
отказа `layout` пуст» по старому AC7 — это взаимоисключающие состояния одного
и того же прогона), новая формулировка разрешает ровно то расхождение, которое
и создавало противоречие, ссылаясь на конкретный, проверяемый механизм (AC1),
а не ослабляет проверку в целом.
## 6. Что проверено и корректно
- Ревизия ТЗ поднята до 3 с корректной, содержательной пометкой источника
правки («техническое уточнение AC7 при реализации»), а не молчаливым
редактированием задним числом.
- Дельта не расширяет скоуп, не меняет поведенческий контракт AC7 (успешный
сценарий #126 по-прежнему обязан работать), не вводит новых терминов и не
противоречит канону подсистемы.
- Правка независимо подтверждена сторонним гейтом: `CODE-REVIEW-403-r1.md`
зафиксировал зелёный прогон `demo/smoke_area_relocation.mjs` (18/18) именно
с новым полем `failedConfigRestoredAndRetryable` на SHA `9c4d07ab` — то есть
утверждение ТЗ не только текстуально самосогласовано, но и подтверждено
исполнением реального кода (хотя код-ревью — отдельный гейт с отдельным
бюджетом, §7.2, и его вывод не заменяет собой эту проверку, а дополняет).
- Продуктовый код, затронутый этой дельтой (правка C2/M1), уже независимо
прошёл `CODE-REVIEW-403-r1` зелёным на отдельном гейте — повторно не
разбирается в этом документе.
## 7. Находки
### Наблюдение (не находка). Спек-ревью r3 идёт после того, как реализация уже смёржена в `dev`
ТЗ было доведено до ревизии 3 внутри коммита реализации (`71bbc953`,
статус issue на тот момент — «в разработке»), код-ревью прошло зелёным на
следующий день по этой же ревизии, и лишь затем запущен этот, посвящённый
именно тексту ТЗ, заход. Явного правила PROCESS.md, требующего провести
спек-ревью формулировки AC **до** завершения код-ревью в случае, когда правка
ТЗ — техническое уточнение формулировки уже одобренного AC (а не расширение
скоупа, §2.6), не найдено; §3 правило 2 требует зелёное ревью ТЗ **до входа**
в разработку, что было выполнено (ревизия 2 → r2 зелёный → взято в работу).
Итог по существу неотличим от штатного порядка (обе проверки прошли зелёными,
несогласованности между текстом и кодом нет — см. §5 выше), поэтому не
поднимаю блокирующей находкой. Называю явно, чтобы не выглядеть тихим
одобрением задним числом.
### H-SHA (Medium, вне скоупа задачи #403 → заведён отдельный issue). SHA материала SPEC-REVIEW-403-r2 не существует в репозитории
**Где**: `docs/reviews/SPEC-REVIEW-403-r2.md:6` («Материал: спец-файл на
`HEAD = 83005c3c`») и комментарий автора в issue #403 от 2026-09-01T14:26:53Z
(«Коммит дельты: `83005c3c`»).
**Почему это находка**. `83005c3c` не резолвится ни в один объект этого
репозитория (`git cat-file -t 83005c3c` → `fatal: invalid object name`,
подтверждено полным (не мелким) клоном — `git rev-list --all | wc -l` = 2437,
`--is-shallow-repository` = `false`, перебор всех хешей коммитов не находит
префикса `83005c`). §2.10 п.1 требует называть SHA предыдущего раунда именно
для того, чтобы дельта следующего раунда объявлялась воспроизводимой командой
(`git diff <SHA>..HEAD`); §7.2 требует сверять факты отчёта с
`git rev-parse HEAD`, а не с ранее записанным значением. Здесь оба источника —
и человек, и предыдущий ревьюер — независимо друг от друга не сверили
цитируемый SHA с деревом, и оба указали на объект, которого не существует.
Практический эффект уже проявился в этом раунде: п.1 этого документа не мог
быть выполнен командой `git diff 83005c3c..HEAD` буквально — пришлось
реконструировать реальный коммит (`94502d3d`) по содержимому диффа,
процитированному в тексте r2.
**Почему не блокирует и не входит в скоуп #403**: содержимое ревизии 2 ТЗ
верно и не пострадало — сверено построчно (см. «Унаследовано из r2»); дефект
не в тексте `docs/specs/403-area-relocation-safety.md`, который правит автор
ТЗ, а в артефакте ревью-конвейера (`SPEC-REVIEW-403-r2.md`, уже
опубликованный документ прошлого раунда) и в дисциплине подтверждения SHA
перед выводом отчёта — тот же класс проблемы, что уже собирал отдельные
process-issue в этом репозитории (#171, #207, #214, #227). Правка текста ТЗ
#403 эту находку не закрывает.
**Требуемое действие**: заведён отдельный issue (см. ниже), Medium/process,
со ссылкой на #403 и на этот документ.
## 8. Чего не проверял
- **Диагноз C2/M1 и построчную сверку с `src/houseplan-card.ts`/
`src/device-area-relocation.ts`** — не перепроверял, дельта их не касается;
см. §4 «Унаследовано из r2».
- **Гейты кода** (`tsc`, `test`, `build`, `check-docs`, смоки, мутанты,
инварианты) — не гонял в этом раунде: продуктовый код и тесты, затронутые
этой задачей, уже прошли независимый код-ревью (`CODE-REVIEW-403-r1.md`,
зелёный, полная таблица гейтов на SHA `9c4d07ab`) — повторный прогон того же
гейта на том же диапазоне без нового изменения кода не добавляет информации;
в этом раунде продуктовый код не менялся вовсе (диапазон дельты — только
`docs/specs/403-area-relocation-safety.md`). Прочитан, не исполнен, только
фрагмент `demo/smoke_area_relocation.mjs`, необходимый для проверки самой
формулировки AC7 (§5) — этого достаточно для вопроса, стоящего перед этим
раундом («формулировка ТЗ соответствует коду, а не является догадкой»);
исполнение смока уже задокументировано в `CODE-REVIEW-403-r1.md`.
- **Состояние `dev` после ребейза 355b0516 и далее** — не разбирал: это
предмет уже пройденного код-ревью и последующего слияния, не этого
спек-раунда.
- **Полную повторную сверку разделов «Touch, View и kiosk» и
«Производительность» с канонit** — не требовалась: текст этих разделов не
менялся между r2 и r3 (см. «Объявление дельты»).
## Итог
Единственная содержательная правка этого раунда — уточнение формулировки AC7,
вызванное реальным логическим противоречием старой формулировки, а не
удобством или догадкой: старое утверждение смока требовало состояния
исходного бага, что стало невозможно совместить с фиксом C2. Новая
формулировка проверена построчно против кода (диф `71bbc953`) и подтверждена
независимым зелёным код-ревью. Найдена одна Medium-находка вне скоупа задачи
#403 — несуществующий SHA, процитированный в `SPEC-REVIEW-403-r2.md` и в
комментарии автора; заведён отдельный issue, задачу #403 она не блокирует.
**Вердикт: зелёный.**
+224
View File
@@ -0,0 +1,224 @@
# ТЗ #403 — Переезд area не теряет ручную расстановку и не гасит чужую историю
- Issue: https://github.com/Matysh/houseplan-card/issues/403
- Приоритет: P1, bug; полный трек — класс A, две находки на одной поверхности
(свежий код #126)
- Ревизия: 3 (2026-09-01; техническое уточнение AC7 при реализации)
## Сценарий
Хозяин расставил маркеры по плану руками — это единственные данные, которые он
вводил сам. В Home Assistant он переименовывает area у лампы. Карточка честно
переносит маркер в новую комнату, но запись конфига в этот момент не проходит
(конфликт из второй вкладки, отказ бэкенда, обрыв связи). Позиция, которую он
ставил, исчезает — насовсем и молча. Ни маркера «посмотри сюда», ни сообщения:
лампа просто оказывается в центре новой комнаты, будто её никогда не двигали.
Второе, мельче: любой такой переезд — даже успешный и даже одного устройства —
гасит кнопки Undo/Redo для **всех** маркеров.
## Что человек увидит до и после
**До**: (1) при неудачной записи конфига ручная позиция переехавшего маркера
теряется без следа и без метки внимания; (2) переезд любого устройства
очищает весь стек Undo позиций, молча.
**После**: позиция либо сохраняется, либо устройство помечено как требующее
внимания; история остаётся у тех маркеров, которых переезд не касался.
## Проблема и контракты по пунктам
### (1) C2 — отказ записи конфига теряет позицию
Порядок в `src/houseplan-card.ts:5160-5250`: сначала для каждого переезжающего
устройства выполняется `_persistDevicePlacement(id, null)` (удаление
сохранённой точки), затем одной записью сохраняется новый снапшот и список
внимания.
Ветка отказа есть у **удаления** (`:5183`): `applyDevicePlacement(before)`
возвращает позицию локально. У отказа **записи конфига** (`:5223-5248`) её нет:
восстанавливаются `marker_area_snapshot` и `new_device_ids`, а layout остаётся
удалённым.
**Воспроизведено исполнением** (браузер, реальная карточка; устройство
`device:d_light1` с ручной позицией, снапшот с прежней area, первый `config/set`
отказывает):
```
houseplan/layout/delete → успех
houseplan/config/set → отказ
layoutHasDevice: false ← позиция удалена и не восстановлена
snapshotAreaNow: kitchen ← снапшот откачен
attention: false ← метки внимания нет
```
Дальше это самовоспроизводится: снапшот откачен на прежнюю area, поэтому
следующий authoritative-проход снова решает «relocate», снова удаляет (удалять
нечего) и снова пишет конфиг. Позиция не возвращается ни на одном витке.
Отдельная ветка того же корня — `conflict` (`:5245`): она перечитывает конфиг
с сервера, но про потерянный layout тоже ничего не знает.
**Контракт**: удаление сохранённой точки и продвижение провенанса — одна
операция с точки зрения пользователя, и её незавершённость не имеет права
стоить ему данных. Допустимы два исхода, выбрать при реализации:
1. **Запись первой**: конфиг сохраняется до удаления layout; точка удаляется
только после подтверждённой записи. Тогда отказ вообще ничего не меняет.
2. **Восстановление при отказе**: если запись не прошла, позиция
восстанавливается и локально, и на сервере (`_persistDevicePlacement(id,
before)`), а не только в памяти.
Если после всех попыток позиция всё же утрачена (например, восстановление тоже
отказало), устройство обязано попасть в `new_device_ids` — обещание §3.4/AC9
ТЗ #126. Молчаливая потеря запрещена в любом исходе.
Порядок «delete-first» был выбран в #126 сознательно (`:5142-5146`: «Layout
deletion is deliberately completed before provenance advances»), чтобы
устаревшая точка не выиграла между отрисовкой реестра и подтверждением
сервера. Реализация обязана сохранить это свойство: исход (1) допустим только
если гонка «старая точка побеждает» остаётся невозможной, иначе берётся (2).
### (2) M1 — переезд одного устройства гасит историю всех
`src/houseplan-card.ts:5062-5065`:
```ts
this._areaRelocationIds = new Set(areaRelocations.relocateIds);
if (this._areaRelocationIds.size) {
this._cancelDeviceDrag();
this._devicePositionHistory.clear();
```
Стек очищается целиком при непустом наборе переезжающих — независимо от того,
чьи записи в нём лежат, и молча, тогда как у соседнего класса событий
уведомление есть (`history.device_stale`). Повторяется на каждом
authoritative-rebuild, пока набор непуст: при read-only клиенте или после
неудачного удаления — на каждом тике.
**Воспроизведено** тем же прогоном: переезжало одно устройство, `canUndo`
после него `false`.
**Контракт**: из истории удаляются записи переехавших устройств; записи
остальных остаются. Если по какой-то причине приходится очищать больше,
пользователь получает то же уведомление, что и в остальных случаях устаревания
истории.
## Скоуп / не-скоуп
**В скоупе**: `_syncAreaRelocations` и обработка отказов в
`src/houseplan-card.ts`, сужение очистки истории позиций, смоки и мутанты.
**Не в скоупе**: сам резолвер `src/device-area-relocation.ts` (его решения
верны — проверено прогоном), критерии переезда и формат снапшота (#126),
механика истории Undo (#74/#397), гигиена снапшотов исчезнувших устройств
(#406 «г»).
## UX
Видимого оформления не меняем. Меняется поведение при отказе: маркер остаётся
там, где его поставил хозяин, либо получает уже существующую метку внимания.
## Модель данных и миграция
Формат `marker_area_snapshot`, `new_device_ids` и layout не меняется. Миграции
нет: правится только порядок операций и обработка отказа.
## i18n
Новых строк нет, если выбран путь с существующей меткой внимания. Если решение
потребует отдельного уведомления о потере позиции — ключ добавляется во все
четыре словаря.
## Touch, View и kiosk
Touch-контракт не меняется. Правка не затрагивает drag/tap-жесты, pointer-
обработчики, DOM или рендер; история редактора устройств сохраняет действующий
best-effort контракт на touch из `docs/TOUCH-SUPPORT.md` и раздела 6
`docs/USER-GUIDE.ru.md`.
В View и kiosk результат AC1/AC2 наблюдаем — маркер сохраняет корректную
позицию либо получает существующую отметку внимания, — но новых действий или
жестов не появляется. Гарантированный touch-first контракт View/kiosk не
ослабляется: на сенсорном клиенте итоговое положение и отметка должны быть теми
же, что на desktop.
## Производительность
Влияния на производительность и действующие бюджеты нет. Исправление не
добавляет циклов, подписок, постоянных вычислений или сетевых вызовов сверх уже
выполняемых `_syncAreaRelocations` / `_writeConfig`; выборочная очистка работает
по существующему стеку не более чем из 50 команд. Нового performance-профиля не
требуется.
## Критерии приёмки
- **AC1**. Отказ `config/set` во время переезда не оставляет устройство без
сохранённой позиции: либо позиция на месте (локально и на сервере), либо
устройство попало в `new_device_ids`. Доказательство: браузерный смок с
одним отказом `config/set`, проверяющий оба состояния явно.
- **AC2**. Успешный переезд по-прежнему работает: точка удалена, снапшот
продвинут, устройство помечено как требующее внимания. Доказательство: тот
же смок, ветка без отказа.
- **AC3**. Свойство delete-first сохранено: устаревшая сохранённая точка не
может выиграть между отрисовкой реестра и подтверждением сервера.
Доказательство: смок с задержанным ответом сервера — маркер не возвращается
в прежнюю комнату.
- **AC4**. Ветка `conflict` (правка из второй вкладки) подчиняется AC1.
Доказательство: смок с ошибкой `code: 'conflict'`.
- **AC5**. Переезд одного устройства не гасит историю другого: после
перетаскивания маркера A и переезда маркера B кнопка Undo для A активна.
Доказательство: смок.
- **AC6**. Записи переехавшего устройства из истории удалены — Undo не вернёт
его в комнату, которой у него больше нет. Доказательство: тот же смок.
- **AC7**. Существующее успешное поведение #126 не сломано: ручная перестановка
после переезда переживает три rebuild-тика, delete-first echo не воскрешает
точку. Доказательство: `demo/smoke_area_relocation.mjs` остаётся зелёным;
единственное прежнее утверждение `failedConfigRetryable`, которое прямо
требовало состояние исходного бага (`layout` уже удалён после отказа
`config/set`), заменяется witness нового AC1 — позиция восстановлена, а
повтор остаётся возможен. Остальные утверждения смока не меняются.
## План автотестов
**Browser smoke** (новый файл — существующие смоки #126 держат свои фикстуры):
1. Переезд с одним отказом `config/set` → позиция на месте ИЛИ устройство в
`new_device_ids`; молчаливая потеря краснеет (AC1).
2. Переезд без отказа → точка удалена, снапшот продвинут, метка внимания
поставлена (AC2).
3. Задержанный ответ сервера → старая точка не выигрывает (AC3).
4. Отказ с `code: 'conflict'` → поведение AC1 (AC4).
5. Драг маркера A, переезд маркера B → `canUndo` для A остаётся `true` (AC5);
записи B из истории удалены (AC6).
**Мутанты** (`scripts/mutation-gate.mjs`):
- `area-relocation-loses-position-on-refusal`: убрать восстановление/пометку →
смок AC1 красный.
- `area-relocation-clears-whole-history`: вернуть `clear()` на весь стек →
смок AC5 красный.
## Риски
- **Смена порядка операций ломает delete-first.** Исход (1) переставляет
запись перед удалением, а именно этого #126 избегал. Смягчение: AC3
проверяет исходное свойство напрямую; если оно не удерживается — берётся
исход (2), где порядок не меняется вовсе.
- **Восстановление на сервере тоже может отказать.** Тогда потеря неизбежна.
Смягчение: этот случай обязан оставлять метку внимания — то есть AC1
формулируется как «позиция ИЛИ метка», а не «позиция всегда».
- **Сужение очистки истории оставит записи, ссылающиеся на исчезнувшую
комнату.** Смягчение: AC6 требует удалять записи именно переехавших, а не
просто «очищать меньше».
## Откат
Обе правки локальны: обработка отказа в одном методе и фильтр в одной строке
очистки истории. Формат данных не затрагивается.
## Release-артефакты
- `docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md`: пункт о том, что расстановка
маркеров переживает неудачную запись при смене area (User-Visible: yes).
- Скриншоты не меняются.
+24
View File
@@ -4306,6 +4306,30 @@ const MUTANT_DEFINITIONS = [
replace: ' const rolledBack = false;',
}],
},
{
id: 'area-relocation-loses-position-on-refusal',
guard: 'npm run bundle:sync && node demo/smoke_area_relocation_safety.mjs',
because: 'a rejected provenance write must restore every manual layout point deleted '
+ 'before config/set, or leave explicit attention when restoration also fails (#403)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' await this._persistDevicePlacement(id, placement);',
replace: ' void id;\n void placement;',
}],
},
{
id: 'area-relocation-clears-whole-history',
guard: 'npm run bundle:sync && node demo/smoke_area_relocation_safety.mjs',
because: 'moving one marker to a new HA Area must invalidate only that marker\'s commands '
+ 'instead of silently erasing Undo and Redo for every other marker (#403)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' const relocating = this._areaRelocationIds;\n'
+ ' this._devicePositionHistory.removeWhere(({ before, after }) =>\n'
+ ' relocating.has(before.deviceId) || relocating.has(after.deviceId));',
replace: ' this._devicePositionHistory.clear();',
}],
},
];
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
+9
View File
@@ -48,6 +48,15 @@ export class CommandStack<T> {
return command;
}
/** Remove commands invalidated by an external change while preserving unrelated history. */
public removeWhere(predicate: (command: NamedCommand<T>) => boolean): number {
const undoSize = this._undo.length;
const redoSize = this._redo.length;
this._undo = this._undo.filter((command) => !predicate(command));
this._redo = this._redo.filter((command) => !predicate(command));
return undoSize + redoSize - this._undo.length - this._redo.length;
}
public clear(): void {
this._undo = [];
this._redo = [];
+43 -2
View File
@@ -5062,7 +5062,9 @@ export class HouseplanCard extends LitElement {
this._areaRelocationIds = new Set(areaRelocations.relocateIds);
if (this._areaRelocationIds.size) {
this._cancelDeviceDrag();
this._devicePositionHistory.clear();
const relocating = this._areaRelocationIds;
this._devicePositionHistory.removeWhere(({ before, after }) =>
relocating.has(before.deviceId) || relocating.has(after.deviceId));
const dialog = this._markerDialog;
if (dialog?.devId && !dialog.roomTouched && this._areaRelocationIds.has(dialog.devId)) {
const current = this._devices.find((device) => device.id === dialog.devId);
@@ -5171,6 +5173,7 @@ export class HouseplanCard extends LitElement {
});
this._areaRelocationIds = new Set(current.relocateIds);
const committed = new Set<string>();
const deletedPlacements = new Map<string, DevicePlacement>();
let deleteFailed = false;
for (const decision of current.decisions) {
if (!decision.relocate) continue;
@@ -5178,6 +5181,7 @@ export class HouseplanCard extends LitElement {
try {
await this._persistDevicePlacement(decision.id, null);
committed.add(decision.id);
if (before) deletedPlacements.set(decision.id, before);
this._areaRelocationIds.delete(decision.id);
} catch (error: unknown) {
deleteFailed = true;
@@ -5236,16 +5240,53 @@ export class HouseplanCard extends LitElement {
this._serverCfg = { ...this._serverCfg!, settings: restored };
this._cfgContentFingerprint = contentFingerprint(this._serverCfg);
}
// Config and layout are separate stores, but this lifecycle change is
// one user transaction. A rejected provenance write must put every
// successfully deleted manual point back before the relocation may
// retry. Keep the ids pending while restoring so the stale point
// cannot win the render against the authoritative registry Area.
for (const id of committed) this._areaRelocationIds.add(id);
const restoreFailed = new Set<string>();
for (const [id, placement] of deletedPlacements) {
try {
await this._persistDevicePlacement(id, placement);
} catch (restoreError: unknown) {
restoreFailed.add(id);
this._showToast(this._t('toast.pos_save_failed', {
err: this._errText(restoreError),
}));
}
}
this._areaRelocationSyncKey = '';
this._regSignature = '';
const code = error && typeof error === 'object' && 'code' in error
? (error as { code?: unknown }).code : undefined;
if (code === 'conflict') {
this._showToast(this._t('toast.conflict'));
void this._reloadConfigOnly(true);
await this._reloadConfigOnly(true);
} else {
this._showToast(this._t('toast.cfg_save_failed', { err: this._errText(error) }));
}
if (restoreFailed.size && this._serverCfg) {
const attentionSettings = this._settings;
const attention = [...new Set([
...(Array.isArray(attentionSettings.new_device_ids)
? attentionSettings.new_device_ids : []),
...restoreFailed,
])];
this._serverCfg = {
...this._serverCfg,
settings: { ...attentionSettings, new_device_ids: attention },
};
this._cfgEpoch++;
try {
await this._writeConfig();
} catch (attentionError: unknown) {
this._showToast(this._t('toast.cfg_save_failed', {
err: this._errText(attentionError),
}));
}
}
}
}
if (deleteFailed) this._areaRelocationSyncKey = '';
+21
View File
@@ -29,3 +29,24 @@ test('the configured history cannot be weakened below thirty commands', () => {
for (let i = 0; i < 35; i++) stack.push({ name: String(i), before: i, after: i + 1 });
assert.equal(stack.size, 30);
});
test('selective invalidation removes matching undo and redo without clearing neighbours', () => {
const stack = new CommandStack(50);
stack.push({ name: 'lamp', before: { deviceId: 'lamp' }, after: { deviceId: 'lamp' } });
stack.push({ name: 'sensor', before: { deviceId: 'sensor' }, after: { deviceId: 'sensor' } });
stack.push({ name: 'lock', before: { deviceId: 'lock' }, after: { deviceId: 'lock' } });
assert.equal(stack.undo()?.name, 'lock');
assert.equal(
stack.removeWhere(({ before }) => before.deviceId === 'sensor'),
1,
);
assert.equal(stack.undoName, 'lamp');
assert.equal(stack.redoName, 'lock');
assert.equal(stack.canUndo, true);
assert.equal(stack.canRedo, true);
assert.equal(stack.removeWhere(({ after }) => after.deviceId === 'lock'), 1);
assert.equal(stack.canRedo, false);
assert.equal(stack.undo()?.name, 'lamp');
});