Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bca32076b | ||
|
|
e9938029de | ||
|
|
9c4d07abe2 | ||
|
|
71bbc95309 | ||
|
|
6b5158003a | ||
|
|
94502d3d67 | ||
|
|
caddeb08c1 | ||
|
|
0d338b9c82 |
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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)}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)).
|
||||
|
||||
@@ -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)).
|
||||
|
||||
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 344 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 313 KiB After Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 298 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 116 KiB |
@@ -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 пар визуально проверены, продуктовых изменений нет"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 на месте.
|
||||
@@ -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 и
|
||||
производительность. По существу риска в обоих пунктах, скорее всего, нет,
|
||||
и правка — несколько строк текста; возвращаю жёлтым, не красным.
|
||||
@@ -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 закрыты правкой текста, а не заявлением. Дельта локальна,
|
||||
дополнительных находок не выявлено. **Вердикт: зелёный.**
|
||||
@@ -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 она не блокирует.
|
||||
|
||||
**Вердикт: зелёный.**
|
||||
@@ -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).
|
||||
- Скриншоты не меняются.
|
||||
@@ -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');
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||