mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
feat(adoption): one owner for config/layout identity and the adoption sequence
`src/config-adoption.ts` owns config/layout with revision and fingerprint; the host keeps `_serverCfg`/`_cfgRev`/`_layout`/`_layoutRev` as delegates. All seven authoritative adoptions go through `adoptAuthoritativeGated` (backdrop readiness → continuity → adopt → profile tail); the post-write paths (space/delete ×2, optimize_undo, import/apply) gain the gate and take revisions from the re-read bodies. `rollbackOptimistic` moves to the owner; plan-optimize, space copy and the vacuum writers stop assigning identity. Issue: #500 User-Visible: no
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0a76a76ae3d778006d8d7f2d7296a95336a306c743d15efd5166a8249caedeee";import{m as e,l as o,t,e3 as s,A as a,e4 as i,e5 as l,e6 as n,dU as r,b as c}from"./houseplan-card-CfF5g1xu.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=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${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(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
File diff suppressed because one or more lines are too long
+4
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0a76a76ae3d778006d8d7f2d7296a95336a306c743d15efd5166a8249caedeee";import{dS as r,dT as e,dU as t}from"./houseplan-card-CfF5g1xu.js";const s={},a=r(class extends e{constructor(){super(...arguments),this.ot=s}render(r,e){return e()}update(r,[e,s]){if(Array.isArray(e)){if(Array.isArray(this.ot)&&this.ot.length===e.length&&e.every((r,e)=>r===this.ot[e]))return t}else if(this.ot===e)return t;return this.ot=Array.isArray(e)?Array.from(e):e,this.render(e,s)}});export{a as i};
|
||||
File diff suppressed because one or more lines are too long
+252
-236
File diff suppressed because one or more lines are too long
+31
-25
File diff suppressed because one or more lines are too long
+10
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,8 @@
|
||||
<<<<<<<< HEAD:custom_components/houseplan/frontend/houseplan-assets/summary-panel-editor-DoCYqzD1.js
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="5c1b1c61b738b69334f9555c60ee17aa874c642234b0dcca6c358552357c0daf";import{b as a,A as e}from"./houseplan-card-yh8-YihB.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-CPSfTezy.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
|
||||
========
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0a76a76ae3d778006d8d7f2d7296a95336a306c743d15efd5166a8249caedeee";import{b as a,A as e}from"./houseplan-card-CfF5g1xu.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-DZOniG9e.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
|
||||
>>>>>>>> 1ff91b54 (feat(adoption): one owner for config/layout identity and the adoption sequence):custom_components/houseplan/frontend/houseplan-assets/summary-panel-editor-C77DC5Fh.js
|
||||
<span class="summary-switch-caption"><strong>${b("summary.show_local")}</strong>
|
||||
<small id="summary-local-show-hint">${b("summary.show_local_hint")}</small></span>
|
||||
<input id="summary-local-show" data-summary-local-show type="checkbox"
|
||||
|
||||
+19
-7
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4
@@ -1 +1,5 @@
|
||||
<<<<<<<< HEAD:custom_components/houseplan/frontend/houseplan-assets/zigbee-topology-runtime-CrPRm7sL.js
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="5c1b1c61b738b69334f9555c60ee17aa874c642234b0dcca6c358552357c0daf";import{n as t,b as n}from"./zigbee-topology-IW8V-F7u.js";import{F as e}from"./houseplan-card-yh8-YihB.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function g(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function m(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,m=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,m=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&m?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await g(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await g(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,m as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
========
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0a76a76ae3d778006d8d7f2d7296a95336a306c743d15efd5166a8249caedeee";import{n as t,b as n}from"./zigbee-topology-CP61k3bH.js";import{B as e}from"./houseplan-card-CfF5g1xu.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function g(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function m(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,m=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,m=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&m?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await g(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await g(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,m as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
>>>>>>>> 1ff91b54 (feat(adoption): one owner for config/layout identity and the adoption sequence):custom_components/houseplan/frontend/houseplan-assets/zigbee-topology-runtime-9uRhJ6b0.js
|
||||
|
||||
+12
File diff suppressed because one or more lines are too long
Vendored
+4
File diff suppressed because one or more lines are too long
+14
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0a76a76ae3d778006d8d7f2d7296a95336a306c743d15efd5166a8249caedeee";import{m as e,l as o,t,e3 as s,A as a,e4 as i,e5 as l,e6 as n,dU as r,b as c}from"./houseplan-card-CfF5g1xu.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=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${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(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);
|
||||
Vendored
+4
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0a76a76ae3d778006d8d7f2d7296a95336a306c743d15efd5166a8249caedeee";import{dS as r,dT as e,dU as t}from"./houseplan-card-CfF5g1xu.js";const s={},a=r(class extends e{constructor(){super(...arguments),this.ot=s}render(r,e){return e()}update(r,[e,s]){if(Array.isArray(e)){if(Array.isArray(this.ot)&&this.ot.length===e.length&&e.every((r,e)=>r===this.ot[e]))return t}else if(this.ot===e)return t;return this.ot=Array.isArray(e)?Array.from(e):e,this.render(e,s)}});export{a as i};
|
||||
+94
-66
File diff suppressed because one or more lines are too long
+252
-236
File diff suppressed because one or more lines are too long
+31
-25
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,8 @@
|
||||
<<<<<<<< HEAD:dist/houseplan-assets/summary-panel-editor-DoCYqzD1.js
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="5c1b1c61b738b69334f9555c60ee17aa874c642234b0dcca6c358552357c0daf";import{b as a,A as e}from"./houseplan-card-yh8-YihB.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-CPSfTezy.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
|
||||
========
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0a76a76ae3d778006d8d7f2d7296a95336a306c743d15efd5166a8249caedeee";import{b as a,A as e}from"./houseplan-card-CfF5g1xu.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-DZOniG9e.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
|
||||
>>>>>>>> 1ff91b54 (feat(adoption): one owner for config/layout identity and the adoption sequence):dist/houseplan-assets/summary-panel-editor-C77DC5Fh.js
|
||||
<span class="summary-switch-caption"><strong>${b("summary.show_local")}</strong>
|
||||
<small id="summary-local-show-hint">${b("summary.show_local_hint")}</small></span>
|
||||
<input id="summary-local-show" data-summary-local-show type="checkbox"
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1,5 @@
|
||||
<<<<<<<< HEAD:dist/houseplan-assets/zigbee-topology-runtime-CrPRm7sL.js
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="5c1b1c61b738b69334f9555c60ee17aa874c642234b0dcca6c358552357c0daf";import{n as t,b as n}from"./zigbee-topology-IW8V-F7u.js";import{F as e}from"./houseplan-card-yh8-YihB.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function g(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function m(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,m=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,m=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&m?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await g(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await g(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,m as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
========
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0a76a76ae3d778006d8d7f2d7296a95336a306c743d15efd5166a8249caedeee";import{n as t,b as n}from"./zigbee-topology-CP61k3bH.js";import{B as e}from"./houseplan-card-CfF5g1xu.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function g(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function m(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,m=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,m=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&m?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await g(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await g(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,m as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
>>>>>>>> 1ff91b54 (feat(adoption): one owner for config/layout identity and the adoption sequence):dist/houseplan-assets/zigbee-topology-runtime-9uRhJ6b0.js
|
||||
|
||||
@@ -290,7 +290,7 @@ fingerprint восстанавливается с пересчётом, как
|
||||
| AC4 | Post-write пути: для каждого из четырёх (`space/delete` ×2 входа, `optimize_undo`, `import/apply`) при параллельной смене `plan_url` другим клиентом `prepareImage` вызывается **до** adoption; после обоих `space/delete` `configRev`/`layoutRev` равны ревизиям адоптированных `config/get`/`layout/get`, а не ответа `delete` | `demo/smoke_post_write_adoption.mjs` на production bundle: четыре сценария с synthetic HA, моки `prepareImage` и порядок вызовов (по образцу `recoveryPreparesBackdropBeforeAdoption`), для `space/delete` — оба входа (onboarding и editor runtime) и проверка ревизий; unit модуля «ревизия не берётся из чужого ответа» (I2) | вернуть `host._cfgRev = response.config_rev` в любой копии `delete` — смок и unit красные; убрать гейт в любом из четырёх — смок красный |
|
||||
| AC5 | Тёплый старт: `snapshot()` → `restoreCached()` восстанавливает идентичность ровно (rev, оба fingerprint, layout, virtual lights); ключи `LS_CFG` неизменны; кэш без fingerprint восстанавливается с пересчётом | unit `test/config-adoption.test.mjs` (round-trip и фикстура старого кэша) | переименовать ключ или потерять `layout_rev` — красный |
|
||||
| AC6 | Optimistic rollback (#314) через модуль: откат только при совпадении rev **и** fingerprint попытки; откат не меняет rev; конфликтный reload побеждает | `test/serialized-write-queue.test.mjs` перенесён/адаптирован + мутант | мутант «откат без проверки rev» — красный |
|
||||
| AC7 | Бюджеты: `core-file-budget` для `houseplan-card.ts` опущен до нового размера (храповик вниз), `bundle:budget` не поднят; новых WS-запросов на путях adoption нет (те же `config/get`/`layout/get`, что сегодня) | `test/core-file-budget.test.mjs`, `npm run bundle:budget`; отсутствие новых `callWS` — ревью диффа | поднять потолок — красный (по правилу теста) |
|
||||
| AC7 | Бюджеты: `core-file-budget` для `houseplan-card.ts` опущен до нового размера (храповик вниз); общий бюджет initial View (`INITIAL_VIEW_GZIP_BUDGET`, 301 066 Б) **не поднят**; если факт выходит за полосу потолка, потолок перецентрируется по практике #485/#506 — с датированной записью причины и измеренного факта в `scripts/bundle-budget.mjs`, а рост называется в хендоффе; новых WS-запросов на путях adoption нет (те же `config/get`/`layout/get`, что сегодня) | `test/core-file-budget.test.mjs`, `npm run bundle:budget`, `test/bundle-assets.test.mjs` (полоса ±2000, запас ≥500 Б с обеих сторон); отсутствие новых `callWS` — ревью диффа | поднять `core-file-budget` или общий бюджет — красный (по правилу тестов); перецентровка без записи — находка ревью |
|
||||
| AC8 | Документация: `docs/ARCHITECTURE.md` описывает границу (владелец, три способа смены ревизии, единая последовательность); changelog не трогается (`User-Visible: no`) | ревью кода | — |
|
||||
|
||||
## 11. План автотестов
|
||||
@@ -338,7 +338,8 @@ fingerprint восстанавливается с пересчётом, как
|
||||
- Пользовательская документация: нет.
|
||||
- Golden/скриншоты: нет (визуал не меняется).
|
||||
- `docs/ARCHITECTURE.md`: абзац о границе (AC8).
|
||||
- Performance: бюджеты по AC7, без новых замеров.
|
||||
- Performance: бюджеты по AC7 (`core-file-budget` вниз; потолок initial View
|
||||
перецентрирован с записью, общий бюджет не тронут), без новых замеров.
|
||||
- Security: нет.
|
||||
|
||||
## 15. Принято предположительно (техническое, менять свободно)
|
||||
|
||||
@@ -206,8 +206,16 @@ export const LOW_HEADROOM_WARNING_BYTES = 15_000;
|
||||
* подключения на экземпляр (+299 Б gzip); сам runtime панели остаётся
|
||||
* ленивым чанком. Измеренный факт 298 891 Б оставляет 709 Б сверху и 1 291 Б
|
||||
* до нижней границы полосы; общий бюджет 301 066 Б и долг #367 не меняются.
|
||||
*
|
||||
* 2026-09-10, #500: потолок перецентрирован 299 600 → 300 200. Идентичность
|
||||
* config/layout и единая гейт-последовательность adoption вынесены в
|
||||
* `config-adoption.ts` (+~600 Б gzip: владелец идентичности, делегаты хоста,
|
||||
* post-write профиль для четырёх путей, которые раньше гейт пропускали);
|
||||
* дубли в `_loadFromServer`/`_reloadConfigOnly`/summary recovery сняты.
|
||||
* Измеренный факт 299 577 Б оставляет 623 Б сверху и 1 377 Б до нижней
|
||||
* границы полосы; общий бюджет 301 066 Б и долг #367 не меняются.
|
||||
*/
|
||||
export const INITIAL_VIEW_GZIP_CEILING = 299_600;
|
||||
export const INITIAL_VIEW_GZIP_CEILING = 300_200;
|
||||
export const INITIAL_VIEW_CEILING_BAND = 2_000;
|
||||
|
||||
/**
|
||||
|
||||
+67
-12
@@ -1055,7 +1055,7 @@ const MUTANT_DEFINITIONS = [
|
||||
because: 'a rejected second write must restore server truth without rolling back the accepted Optimize (#456 AC11)',
|
||||
patches: [{
|
||||
file: 'src/space-copy-runtime.ts',
|
||||
find: ' if (rollbackOptimistic(host, attempt, contentFingerprint)) invalidateConfig(host);',
|
||||
find: ' if (host._rollbackOptimistic(attempt)) invalidateConfig(host);',
|
||||
replace: '',
|
||||
}, {
|
||||
file: 'src/space-copy-runtime.ts',
|
||||
@@ -6896,7 +6896,7 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'leaving the independent Device editor draft available for Retry (#442 AC1)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-editor-runtime.ts',
|
||||
find: ' rollbackOptimistic(this.host, attempt, contentFingerprint);\n'
|
||||
find: ' this.host._rollbackOptimistic(attempt);\n'
|
||||
+ " this.host._regSignature = '';",
|
||||
replace: " this.host._regSignature = '';",
|
||||
}],
|
||||
@@ -6920,7 +6920,7 @@ const MUTANT_DEFINITIONS = [
|
||||
patches: [{
|
||||
file: 'src/houseplan-editor-runtime.ts',
|
||||
find: ' if (!configAccepted && attempt) {\n'
|
||||
+ ' rollbackOptimistic(this.host, attempt, contentFingerprint);',
|
||||
+ ' this.host._rollbackOptimistic(attempt);',
|
||||
replace: ' if (attempt) {\n'
|
||||
+ ' this.host._serverCfg = attempt.previous;',
|
||||
}],
|
||||
@@ -6932,7 +6932,7 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'marker in the local accepted config (#442 AC5–AC7)',
|
||||
patches: [{
|
||||
file: 'src/vacuum-calibration-write.ts',
|
||||
find: ' rollbackOptimistic(host, attempt, contentFingerprint);\n rebuild(host);',
|
||||
find: ' host._rollbackOptimistic(attempt);\n rebuild(host);',
|
||||
replace: ' rebuild(host);',
|
||||
}],
|
||||
},
|
||||
@@ -6954,16 +6954,16 @@ const MUTANT_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
id: 'optimistic-rollback-skips-same-root-fingerprint',
|
||||
guard: 'node --test test/serialized-write-queue.test.mjs',
|
||||
guard: 'node --test test/config-adoption.test.mjs',
|
||||
because: 'newer in-place content can retain the attempted object identity; rollback must still '
|
||||
+ 'compare content and must never erase that newer owner (#442 AC2)',
|
||||
+ 'compare content and must never erase that newer owner (#442 AC2; owner moved by #500)',
|
||||
patches: [{
|
||||
file: 'src/serialized-write-queue.ts',
|
||||
find: ' if (!current || host._cfgRev !== attempt.revision\n'
|
||||
+ ' || fingerprint(current) !== attempt.attemptedFingerprint) return false;',
|
||||
replace: ' if (!current || host._cfgRev !== attempt.revision\n'
|
||||
+ ' || (current !== attempt.attempted\n'
|
||||
+ ' && fingerprint(current) !== attempt.attemptedFingerprint)) return false;',
|
||||
file: 'src/config-adoption.ts',
|
||||
find: ' if (!current || this.configRev !== attempt.revision\n'
|
||||
+ ' || contentFingerprint(current) !== attempt.attemptedFingerprint) return false;',
|
||||
replace: ' if (!current || this.configRev !== attempt.revision\n'
|
||||
+ ' || (current !== attempt.attempted\n'
|
||||
+ ' && contentFingerprint(current) !== attempt.attemptedFingerprint)) return false;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
@@ -8513,6 +8513,61 @@ const MUTANT_DEFINITIONS = [
|
||||
+ ' _visit(node_id)\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'config-adoption-echo-clears-history',
|
||||
guard: 'node --test test/config-adoption.test.mjs',
|
||||
because: 'an authoritative echo with identical content must keep local geometry undo and the '
|
||||
+ 'reactive root; only a genuinely different baseline retires them (#500 AC3, I3)',
|
||||
patches: [{
|
||||
file: 'src/config-adoption.ts',
|
||||
find: ' const configChanged = nextFingerprint !== this.currentConfigFingerprint();\n'
|
||||
+ ' if (configChanged) {\n'
|
||||
+ ' onConfigReplace();',
|
||||
replace: ' const configChanged = nextFingerprint !== this.currentConfigFingerprint();\n'
|
||||
+ ' onConfigReplace();\n'
|
||||
+ ' if (configChanged) {',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'config-adoption-rev-from-foreign-response',
|
||||
guard: 'node --test test/config-adoption.test.mjs',
|
||||
because: 'a revision is accepted only together with the body it describes; taking the reply '
|
||||
+ 'revision without its body leaves a stale candidate with a newer rev (#500 I2, #490 F1)',
|
||||
patches: [{
|
||||
file: 'src/config-adoption.ts',
|
||||
find: ' if (!assetReady) {\n'
|
||||
+ " host._continuity.note('asset-failed');",
|
||||
replace: ' if (!assetReady) {\n'
|
||||
+ ' adoption.acceptConfigWrite(candidateConfig ?? adoption.config!, cfgResp ?? {});\n'
|
||||
+ " host._continuity.note('asset-failed');",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'config-adoption-rollback-ignores-rev',
|
||||
guard: 'node --test test/config-adoption.test.mjs',
|
||||
because: 'a rollback must yield to a newer revision adopted while the candidate was in flight; '
|
||||
+ 'comparing content alone would erase a conflict reload (#500 AC6, #439)',
|
||||
patches: [{
|
||||
file: 'src/config-adoption.ts',
|
||||
find: ' if (!current || this.configRev !== attempt.revision\n'
|
||||
+ ' || contentFingerprint(current) !== attempt.attemptedFingerprint) return false;',
|
||||
replace: ' if (!current\n'
|
||||
+ ' || contentFingerprint(current) !== attempt.attemptedFingerprint) return false;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'post-write-skips-asset-gate',
|
||||
guard: 'node --test test/config-adoption.test.mjs',
|
||||
because: 'post-write adoptions (space/delete, optimize_undo, import/apply) pass the same '
|
||||
+ 'backdrop readiness gate as reloads; skipping it re-creates the transient blank plan of #490 M1 (#500 AC4)',
|
||||
patches: [{
|
||||
file: 'src/config-adoption.ts',
|
||||
find: ' if (structuralChanged) {\n'
|
||||
+ ' const assetReady = await host._signer.prepareImage(host.hass, host._candidateBackdrop(candidateConfig));',
|
||||
replace: " if (structuralChanged && input.profile === 'reload') {\n"
|
||||
+ ' const assetReady = await host._signer.prepareImage(host.hass, host._candidateBackdrop(candidateConfig));',
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* Config/layout adoption boundary (#500).
|
||||
*
|
||||
* One owner for the structural identity of the card: the server config with
|
||||
* its revision and content fingerprint, and the device layout with its own
|
||||
* pair. Before #500 these six fields lived on the host and were assigned from
|
||||
* eight modules; every new path that received an authoritative response
|
||||
* re-implemented the pre-adoption gate (backdrop readiness → continuity
|
||||
* candidate) by hand, and one of them forgot it (#490 M1). Here the identity
|
||||
* changes in exactly three ways — adopting an authoritative response,
|
||||
* accepting the response to our own write, restoring the warm cache — and the
|
||||
* gated sequence exists once, in `adoptAuthoritativeGated`.
|
||||
*
|
||||
* The module owns identity and order, not the host subsystems touched when a
|
||||
* structure changes (geometry history, camera, gestures, virtual lights):
|
||||
* those stay on the host and are called through `ConfigAdoptionHostPort` in
|
||||
* the order the host used before the extraction.
|
||||
*/
|
||||
import type { DeviceLayout } from './device-position-history';
|
||||
import type { ServerConfig } from './types';
|
||||
import type { AuthoritativeConfigResponse } from './version-recovery-card';
|
||||
import { contentFingerprint } from './visual-continuity';
|
||||
import {
|
||||
adoptVirtualLightServerSnapshot,
|
||||
virtualLightFingerprint,
|
||||
type VirtualLightSnapshot,
|
||||
type VirtualLightWireSnapshot,
|
||||
} from './virtual-light-state';
|
||||
import { optimisticAttempt, type OptimisticAttempt } from './serialized-write-queue';
|
||||
|
||||
export interface LayoutResponse {
|
||||
readonly layout?: DeviceLayout | null;
|
||||
readonly rev?: number;
|
||||
readonly can_optimize_undo?: boolean;
|
||||
readonly undo_kind?: string | null;
|
||||
}
|
||||
|
||||
/** Wire shape of the `LS_CFG` warm-start cache. Keys are a persisted contract. */
|
||||
export interface CachedStructuralSnapshot {
|
||||
config: ServerConfig;
|
||||
rev: number;
|
||||
config_fingerprint: string;
|
||||
layout: DeviceLayout;
|
||||
layout_rev: number;
|
||||
layout_fingerprint: string;
|
||||
virtual_lights?: unknown;
|
||||
}
|
||||
|
||||
const configOf = (cfgResp: AuthoritativeConfigResponse | undefined | null): ServerConfig | null => {
|
||||
const raw = cfgResp?.config;
|
||||
return raw && Array.isArray((raw as ServerConfig).spaces) ? raw as ServerConfig : null;
|
||||
};
|
||||
|
||||
const finiteRevision = (value: unknown, fallback: number): number =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
|
||||
/**
|
||||
* Identity owner. Bodies are stored by reference: the editor mutates the config
|
||||
* in place and compares `host._serverCfg === cfg`, so cloning here would break
|
||||
* both (#500 I4). Every other module sees the `ConfigAdoption` type below, where
|
||||
* the six fields are `readonly`; only the methods of this class write them.
|
||||
*/
|
||||
export class MutableConfigAdoption {
|
||||
config: ServerConfig | null = null;
|
||||
configRev = 0;
|
||||
/** Fingerprint of the accepted/sent body; '' until anything was accepted. */
|
||||
configFingerprint = '';
|
||||
layout: DeviceLayout = {};
|
||||
layoutRev = 0;
|
||||
layoutFingerprint = '';
|
||||
|
||||
/** Accepted fingerprint, or the live body's when nothing was accepted yet. */
|
||||
currentConfigFingerprint(): string {
|
||||
return this.configFingerprint || contentFingerprint(this.config);
|
||||
}
|
||||
currentLayoutFingerprint(): string {
|
||||
return this.layoutFingerprint || contentFingerprint(this.layout);
|
||||
}
|
||||
|
||||
// --- authoritative responses -------------------------------------------
|
||||
|
||||
/**
|
||||
* Adopt an authoritative response pair. Equal content is an echo: the body
|
||||
* reference stays, only the revision moves (I3). Different content replaces
|
||||
* the body; the matching `onReplace` hook runs between the decision and the
|
||||
* replacement so the host can retire state bound to the old baseline. A
|
||||
* `layResp` of `undefined` leaves layout identity alone.
|
||||
*/
|
||||
adoptResponses(
|
||||
cfgResp: AuthoritativeConfigResponse,
|
||||
layResp: LayoutResponse | null | undefined,
|
||||
onConfigReplace: () => void,
|
||||
onLayoutReplace: () => void,
|
||||
): { configChanged: boolean; layoutChanged: boolean } {
|
||||
const next = configOf(cfgResp);
|
||||
const nextFingerprint = contentFingerprint(next);
|
||||
const configChanged = nextFingerprint !== this.currentConfigFingerprint();
|
||||
if (configChanged) {
|
||||
onConfigReplace();
|
||||
this.config = next;
|
||||
this.configFingerprint = nextFingerprint;
|
||||
}
|
||||
this.configRev = cfgResp?.rev ?? this.configRev;
|
||||
let layoutChanged = false;
|
||||
if (layResp !== undefined) {
|
||||
const nextLayout = layResp?.layout ?? {};
|
||||
const nextLayoutFingerprint = contentFingerprint(nextLayout);
|
||||
layoutChanged = nextLayoutFingerprint !== this.currentLayoutFingerprint();
|
||||
if (layoutChanged) {
|
||||
onLayoutReplace();
|
||||
this.layout = nextLayout;
|
||||
this.layoutFingerprint = nextLayoutFingerprint;
|
||||
}
|
||||
this.layoutRev = layResp?.rev ?? this.layoutRev;
|
||||
}
|
||||
return { configChanged, layoutChanged };
|
||||
}
|
||||
|
||||
/**
|
||||
* `layout/get` merged with positions this card still owns (`_reloadLayoutOnly`):
|
||||
* the merged body is what the card shows, the response revision is what the
|
||||
* server holds. Reference is kept when content is equal.
|
||||
*/
|
||||
adoptMergedLayout(merged: DeviceLayout, layResp: LayoutResponse, onReplace?: () => void): boolean {
|
||||
const fingerprint = contentFingerprint(merged);
|
||||
const changed = fingerprint !== contentFingerprint(this.layout);
|
||||
if (changed) {
|
||||
onReplace?.();
|
||||
this.layout = merged;
|
||||
}
|
||||
this.layoutFingerprint = fingerprint;
|
||||
this.layoutRev = layResp?.rev ?? this.layoutRev;
|
||||
return changed;
|
||||
}
|
||||
|
||||
// --- our own writes -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Expose the canonical candidate the writer is about to send. The reactive
|
||||
* root is replaced only when canonicalization actually changed content
|
||||
* (#224 review H1); the accepted fingerprint becomes the candidate's.
|
||||
*/
|
||||
stageConfigCandidate(candidate: ServerConfig): void {
|
||||
const fingerprint = contentFingerprint(candidate);
|
||||
if (fingerprint !== contentFingerprint(this.config)) this.config = candidate;
|
||||
this.configFingerprint = fingerprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server answered our `config/set` for `candidate`. The revision is taken
|
||||
* together with the body it belongs to (I2). A reply without `rev` keeps the
|
||||
* historical `+1` guess — centralized here, see #500 §15 п.3.
|
||||
*/
|
||||
acceptConfigWrite(candidate: ServerConfig, response: { rev?: unknown } | null | undefined): void {
|
||||
this.configFingerprint = contentFingerprint(candidate);
|
||||
this.configRev = finiteRevision(response?.rev, this.configRev + 1);
|
||||
}
|
||||
|
||||
/** Paired write (`plan/optimize`, copy, import): both bodies with both revisions. */
|
||||
acceptPairWrite(
|
||||
config: ServerConfig,
|
||||
layout: DeviceLayout,
|
||||
response: { config_rev?: unknown; layout_rev?: unknown } | null | undefined,
|
||||
): void {
|
||||
this.config = config;
|
||||
this.configFingerprint = contentFingerprint(config);
|
||||
this.layout = layout;
|
||||
this.layoutFingerprint = contentFingerprint(layout);
|
||||
this.configRev = finiteRevision(response?.config_rev, this.configRev + 1);
|
||||
this.layoutRev = finiteRevision(response?.layout_rev, this.layoutRev + 1);
|
||||
}
|
||||
|
||||
/** Our own `layout/update` reply, or its event: revisions are monotonic. */
|
||||
noteLayoutRevision(rev: unknown): void {
|
||||
if (typeof rev === 'number' && rev > this.layoutRev) this.layoutRev = rev;
|
||||
}
|
||||
|
||||
// --- local staging (bodies only; identity untouched) --------------------
|
||||
|
||||
/** Editor replaced the working config before a write; revision/fingerprint stay. */
|
||||
stageLocalConfig(config: ServerConfig | null): void { this.config = config; }
|
||||
stageLocalLayout(layout: DeviceLayout): void { this.layout = layout; }
|
||||
|
||||
/**
|
||||
* Re-pair fingerprints with the live bodies. Local edits mutate nested
|
||||
* config/layout before the debounced write; the cache and the rollback of a
|
||||
* rejected physical write need the identity to describe what is actually
|
||||
* held (`_cacheSnapshot`, `_rollbackRejectedPhysicalWrites`).
|
||||
*/
|
||||
refreshConfigFingerprint(): void {
|
||||
this.configFingerprint = contentFingerprint(this.config);
|
||||
}
|
||||
refreshLayoutFingerprint(): void {
|
||||
this.layoutFingerprint = contentFingerprint(this.layout);
|
||||
}
|
||||
|
||||
// --- optimistic writes (#314) -------------------------------------------
|
||||
|
||||
beginOptimistic(previous: ServerConfig, attempted: ServerConfig): OptimisticAttempt<ServerConfig> {
|
||||
return optimisticAttempt(previous, attempted, this.configFingerprint, this.configRev, contentFingerprint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll back only the failed candidate this attempt describes. A conflict
|
||||
* reload or a newer edit owns a different revision/content and must win.
|
||||
*/
|
||||
rollbackOptimistic(attempt: OptimisticAttempt<ServerConfig>): boolean {
|
||||
const current = this.config;
|
||||
if (!current || this.configRev !== attempt.revision
|
||||
|| contentFingerprint(current) !== attempt.attemptedFingerprint) return false;
|
||||
this.config = attempt.previous;
|
||||
this.configFingerprint = attempt.previousFingerprint;
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- warm cache -----------------------------------------------------------
|
||||
|
||||
/** Restore from `LS_CFG`; a cache written before fingerprints recomputes them. */
|
||||
restoreCached(cached: Partial<CachedStructuralSnapshot> | null | undefined): boolean {
|
||||
const config = cached?.config;
|
||||
if (!config || !Array.isArray(config.spaces)) return false;
|
||||
this.config = config;
|
||||
this.configRev = cached?.rev || 0;
|
||||
this.configFingerprint = cached?.config_fingerprint || contentFingerprint(config);
|
||||
this.layout = cached?.layout || {};
|
||||
this.layoutRev = cached?.layout_rev || 0;
|
||||
this.layoutFingerprint = cached?.layout_fingerprint || contentFingerprint(this.layout);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Snapshot for `LS_CFG`. Re-pairs fingerprints with the cached bodies first. */
|
||||
snapshot(virtualLights: unknown): CachedStructuralSnapshot | null {
|
||||
if (!this.config) return null;
|
||||
this.refreshConfigFingerprint();
|
||||
this.refreshLayoutFingerprint();
|
||||
return {
|
||||
config: this.config,
|
||||
rev: this.configRev,
|
||||
config_fingerprint: this.configFingerprint,
|
||||
layout: this.layout,
|
||||
layout_rev: this.layoutRev,
|
||||
layout_fingerprint: this.layoutFingerprint,
|
||||
virtual_lights: virtualLights,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** What every other module holds: the owner with its identity fields read-only. */
|
||||
export type ConfigAdoption = Readonly<MutableConfigAdoption>;
|
||||
export const createConfigAdoption = (): ConfigAdoption => new MutableConfigAdoption();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host side effects and the single gated sequence.
|
||||
|
||||
interface Clearable { clear(): void }
|
||||
|
||||
export interface ConfigAdoptionHostPort {
|
||||
hass: unknown;
|
||||
_adoption: ConfigAdoption;
|
||||
_space: string;
|
||||
_model: unknown[];
|
||||
_virtualLights: VirtualLightSnapshot;
|
||||
_capturedSnapshotVirtual: string;
|
||||
_geometryHistory: Clearable;
|
||||
_devicePositionHistory: Clearable;
|
||||
_pendingPhysicalWrites: Clearable;
|
||||
_canOptimizeUndo: boolean;
|
||||
_undoKind: 'optimize' | 'import' | null;
|
||||
_serverCanWrite: boolean | null;
|
||||
_regSignature: string;
|
||||
_continuity: {
|
||||
readonly hasCompleteFrame: boolean;
|
||||
readonly state: string;
|
||||
note(event: string, detail?: Record<string, unknown>): void;
|
||||
};
|
||||
_signer: { prepareImage(hass: unknown, url: string): Promise<boolean> };
|
||||
_cancelDeviceDrag(): unknown;
|
||||
_clearRoomFocus(pointer?: boolean): void;
|
||||
_cancelCameraTransition(commitTarget?: boolean, keepPresented?: boolean): void;
|
||||
_clearGeometryGesture(): void;
|
||||
_seedDecorStyle(cfg: ServerConfig | null): void;
|
||||
_adoptConfigCapabilities(response: unknown): void;
|
||||
_candidateBackdrop(config: ServerConfig | null): string;
|
||||
_scheduleLoadRetry(force?: boolean): void;
|
||||
_beginContinuityCandidate(reason: string, dataReady: boolean): number;
|
||||
_syncDecorAssets(config: ServerConfig | null): Promise<void>;
|
||||
_adoptInitialSpace(models: unknown[], authoritative?: boolean): unknown;
|
||||
_resumePendingNavMode(): boolean;
|
||||
_cacheSnapshot(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn authoritative responses into host state — the former
|
||||
* `_adoptStructuralResponses`, side effects in the same order. Equal payloads
|
||||
* keep their object references and geometry epoch; equal revisions never hide
|
||||
* changed content (#73 §9.4).
|
||||
*/
|
||||
export function adoptStructuralResponses(
|
||||
host: ConfigAdoptionHostPort,
|
||||
cfgResp: AuthoritativeConfigResponse | null | undefined,
|
||||
layResp?: LayoutResponse | null,
|
||||
): { configChanged: boolean; layoutChanged: boolean } {
|
||||
const adoption = host._adoption;
|
||||
const hadConfig = !!adoption.config;
|
||||
const { configChanged, layoutChanged } = adoption.adoptResponses(cfgResp ?? {}, layResp,
|
||||
() => {
|
||||
// A genuinely different baseline invalidates local geometry undo. A
|
||||
// reconnect echo with identical content deliberately does not.
|
||||
host._geometryHistory.clear();
|
||||
host._devicePositionHistory.clear();
|
||||
host._cancelDeviceDrag();
|
||||
host._pendingPhysicalWrites.clear();
|
||||
host._clearRoomFocus(true);
|
||||
// #82: a new structural baseline owns the viewport. Freeze the last
|
||||
// painted camera frame before replacing geometry so an obsolete target
|
||||
// cannot settle against the new content frame.
|
||||
host._cancelCameraTransition(false);
|
||||
if (hadConfig) host._clearGeometryGesture();
|
||||
},
|
||||
() => {
|
||||
host._cancelCameraTransition(false);
|
||||
host._devicePositionHistory.clear();
|
||||
host._cancelDeviceDrag();
|
||||
});
|
||||
if (configChanged) host._seedDecorStyle(adoption.config);
|
||||
if (cfgResp && ('virtual_lights' in cfgResp || 'config' in cfgResp)) {
|
||||
const nextVirtualLights = adoptVirtualLightServerSnapshot(
|
||||
host._virtualLights,
|
||||
cfgResp.virtual_lights as VirtualLightWireSnapshot | null | undefined,
|
||||
adoption.configRev,
|
||||
'virtual_lights' in cfgResp,
|
||||
);
|
||||
if (virtualLightFingerprint(nextVirtualLights) !== virtualLightFingerprint(host._virtualLights)) {
|
||||
host._virtualLights = nextVirtualLights;
|
||||
host._capturedSnapshotVirtual = '';
|
||||
}
|
||||
}
|
||||
|
||||
host._canOptimizeUndo = !!(cfgResp?.can_optimize_undo || layResp?.can_optimize_undo);
|
||||
host._adoptConfigCapabilities(cfgResp);
|
||||
host._undoKind = (cfgResp?.undo_kind || layResp?.undo_kind || null) as 'optimize' | 'import' | null;
|
||||
if (typeof cfgResp?.can_write === 'boolean') host._serverCanWrite = cfgResp.can_write;
|
||||
if (configChanged) host._continuity.note('config-candidate', { configRev: adoption.configRev });
|
||||
if (layoutChanged) host._continuity.note('layout-candidate', { layoutRev: adoption.layoutRev });
|
||||
return { configChanged, layoutChanged };
|
||||
}
|
||||
|
||||
export type AdoptionReason =
|
||||
| 'structural-response' | 'config-reload' | 'summary-recovery'
|
||||
| 'space-delete' | 'optimize-undo' | 'import-apply';
|
||||
|
||||
export interface GatedAdoptionInput {
|
||||
cfgResp?: AuthoritativeConfigResponse | null;
|
||||
layResp?: LayoutResponse | null;
|
||||
reason: AdoptionReason;
|
||||
/**
|
||||
* `reload` — the three re-read paths; after adoption the shared tail runs
|
||||
* (decor assets, initial space, pending nav mode, cache snapshot).
|
||||
* `post-write` — a path that just completed its own paired write and
|
||||
* re-reads; the sequence ends at adoption, the caller keeps its own tail.
|
||||
*/
|
||||
profile: 'reload' | 'post-write';
|
||||
/** Runs once the gate passed, before adoption (connection flags of the initial load). */
|
||||
beforeAdopt?: () => void;
|
||||
}
|
||||
|
||||
export type GatedAdoptionResult =
|
||||
| { status: 'asset-wait' }
|
||||
| { status: 'adopted'; spaceChanged: boolean };
|
||||
|
||||
/**
|
||||
* The one adoption sequence (#500 §6.3): compare → backdrop readiness gate →
|
||||
* continuity candidate → adopt → profile tail. On `asset-wait` nothing was
|
||||
* adopted; a load retry is already scheduled.
|
||||
*/
|
||||
export async function adoptAuthoritativeGated(
|
||||
host: ConfigAdoptionHostPort,
|
||||
input: GatedAdoptionInput,
|
||||
): Promise<GatedAdoptionResult> {
|
||||
const { cfgResp, layResp } = input;
|
||||
const adoption = host._adoption;
|
||||
const candidateConfig = configOf(cfgResp);
|
||||
const structuralChanged = (cfgResp != null
|
||||
&& contentFingerprint(candidateConfig) !== adoption.currentConfigFingerprint())
|
||||
|| (layResp != null && contentFingerprint(layResp.layout ?? {}) !== adoption.currentLayoutFingerprint());
|
||||
if (structuralChanged) {
|
||||
const assetReady = await host._signer.prepareImage(host.hass, host._candidateBackdrop(candidateConfig));
|
||||
if (!assetReady) {
|
||||
host._continuity.note('asset-failed');
|
||||
host._scheduleLoadRetry(true);
|
||||
return { status: 'asset-wait' };
|
||||
}
|
||||
}
|
||||
if (structuralChanged && host._continuity.hasCompleteFrame && host._continuity.state === 'steady') {
|
||||
host._beginContinuityCandidate(input.reason, true);
|
||||
}
|
||||
input.beforeAdopt?.();
|
||||
const visibleSpace = host._space;
|
||||
adoptStructuralResponses(host, cfgResp, layResp);
|
||||
if (input.profile === 'reload') {
|
||||
void host._syncDecorAssets(candidateConfig).catch(() => undefined);
|
||||
host._adoptInitialSpace(host._model, true);
|
||||
host._resumePendingNavMode();
|
||||
host._cacheSnapshot();
|
||||
}
|
||||
return { status: 'adopted', spaceChanged: host._space !== visibleSpace };
|
||||
}
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
commitVacuumRouteDraft, convertLegacyRoutes, newRouteId, removeRoute,
|
||||
type VacuumRouteDraft,
|
||||
} from '../vacuum-route-edit';
|
||||
import { optimisticAttempt, rollbackOptimistic } from '../serialized-write-queue';
|
||||
import { contentFingerprint } from '../visual-continuity';
|
||||
import type { ConfigAdoption } from '../config-adoption';
|
||||
import type { OptimisticAttempt } from '../serialized-write-queue';
|
||||
|
||||
interface SpaceRow { id?: unknown; name?: unknown }
|
||||
|
||||
@@ -27,9 +27,10 @@ interface SpaceRow { id?: unknown; name?: unknown }
|
||||
export interface VacuumMapsCardHost {
|
||||
hass?: { states?: Record<string, unknown> } | null;
|
||||
_config?: { language?: string | null } | null;
|
||||
_serverCfg: (ServerConfig & { spaces: SpaceRow[] }) | null;
|
||||
_cfgContentFingerprint: string;
|
||||
_cfgRev: number;
|
||||
readonly _serverCfg: (ServerConfig & { spaces: SpaceRow[] }) | null;
|
||||
readonly _cfgRev: number;
|
||||
readonly _adoption: ConfigAdoption;
|
||||
_rollbackOptimistic: (attempt: OptimisticAttempt<ServerConfig>) => boolean;
|
||||
_saveConfigDebounced: { pending: () => boolean; cancel: () => void };
|
||||
_regSignature: string;
|
||||
_t: (key: I18nKey, vars?: Record<string, string | number>) => string;
|
||||
@@ -102,9 +103,8 @@ export function renderVacuumMapsSection(
|
||||
}
|
||||
marker.vacuum = { ...(marker.vacuum || {}), map_routes: nextRoutes };
|
||||
delete marker.vacuum.calibration;
|
||||
const attempt = optimisticAttempt(previous, nextConfig, host._cfgContentFingerprint,
|
||||
host._cfgRev, contentFingerprint);
|
||||
host._serverCfg = nextConfig;
|
||||
const attempt = host._adoption.beginOptimistic(previous, nextConfig);
|
||||
host._adoption.stageLocalConfig(nextConfig);
|
||||
host._regSignature = '';
|
||||
host._maybeRebuildDevices();
|
||||
host.requestUpdate();
|
||||
@@ -113,7 +113,7 @@ export function renderVacuumMapsSection(
|
||||
await runtime._saveConfigNow();
|
||||
return true;
|
||||
} catch (error) {
|
||||
rollbackOptimistic(host, attempt, contentFingerprint);
|
||||
host._rollbackOptimistic(attempt);
|
||||
host._regSignature = '';
|
||||
host._maybeRebuildDevices();
|
||||
host.requestUpdate();
|
||||
|
||||
+58
-163
@@ -168,6 +168,11 @@ import type {
|
||||
MarkerValueBadge, ValueBadgePosition, ValueBadgeSource, ZeroWallStyle,
|
||||
} from './types';
|
||||
import type { RadarEditorDraft } from './radar-editor';
|
||||
import {
|
||||
adoptAuthoritativeGated, createConfigAdoption,
|
||||
type ConfigAdoption, type ConfigAdoptionHostPort, type GatedAdoptionInput, type GatedAdoptionResult,
|
||||
} from './config-adoption';
|
||||
import type { OptimisticAttempt } from './serialized-write-queue';
|
||||
import {
|
||||
COLUMN_MAX_CM, canonicalColumnAngle, clampColumnCm, columnBody,
|
||||
directionalOccluders, floorMinusBodies, geometryArea, geometryOuterRings,
|
||||
@@ -904,24 +909,32 @@ export class HouseplanCard extends LitElement {
|
||||
private _config?: CardConfig;
|
||||
|
||||
private _space = 'f1';
|
||||
private _layout: DeviceLayout = {};
|
||||
/**
|
||||
* #500: structural identity (config/layout with revision and fingerprint)
|
||||
* has one owner. The accessors below are read delegates; bodies may be
|
||||
* staged locally before a write (allowlisted in
|
||||
* test/config-adoption-ownership.test.mjs), revisions and fingerprints
|
||||
* change only through `_adoption`.
|
||||
*/
|
||||
public readonly _adoption: ConfigAdoption = createConfigAdoption();
|
||||
private get _layout(): DeviceLayout { return this._adoption.layout; }
|
||||
private set _layout(layout: DeviceLayout) { this._adoption.stageLocalLayout(layout); }
|
||||
private _serverStorage = false;
|
||||
private _loadOk = false;
|
||||
/** null until config/get answers; then mirrors auth.may_write for this user. */
|
||||
private _serverCanWrite: boolean | null = null;
|
||||
private _loading = false;
|
||||
private _loadTries = 0;
|
||||
private _serverCfg: ServerConfig | null = null;
|
||||
private _cfgRev = 0;
|
||||
private _cfgContentFingerprint = '';
|
||||
private get _serverCfg(): ServerConfig | null { return this._adoption.config; }
|
||||
private set _serverCfg(config: ServerConfig | null) { this._adoption.stageLocalConfig(config); }
|
||||
private get _cfgRev(): number { return this._adoption.configRev; }
|
||||
private _unsubCfg: (() => void) | null = null;
|
||||
private _unsubLayout: (() => void) | null = null;
|
||||
private _unsubVirtual: (() => void) | null = null;
|
||||
private _liveSyncAttempt: Promise<void> | null = null;
|
||||
private _liveSyncGeneration = 0;
|
||||
private _liveSyncConnection: any = null;
|
||||
private _layoutRev = 0;
|
||||
private _layoutContentFingerprint = '';
|
||||
private get _layoutRev(): number { return this._adoption.layoutRev; }
|
||||
private _virtualLights: VirtualLightSnapshot = virtualLightSnapshot(null);
|
||||
/** One-deep server snapshot; invalidated by the first later plan edit. */
|
||||
private _canOptimizeUndo = false;
|
||||
@@ -3204,15 +3217,9 @@ export class HouseplanCard extends LitElement {
|
||||
// right away, without waiting for the server response — fresh data will load in the background.
|
||||
try {
|
||||
const c = JSON.parse(localStorage.getItem(LS_CFG) || 'null');
|
||||
if (c && c.config && Array.isArray(c.config.spaces)) {
|
||||
this._serverCfg = c.config;
|
||||
if (this._adoption.restoreCached(c)) {
|
||||
this._seedDecorStyle(this._serverCfg);
|
||||
this._cfgEpoch++;
|
||||
this._cfgRev = c.rev || 0;
|
||||
this._cfgContentFingerprint = c.config_fingerprint || contentFingerprint(c.config);
|
||||
this._layout = c.layout || {};
|
||||
this._layoutRev = c.layout_rev || 0;
|
||||
this._layoutContentFingerprint = c.layout_fingerprint || contentFingerprint(this._layout);
|
||||
this._virtualLights = virtualLightSnapshot(c.virtual_lights, this._cfgRev);
|
||||
this._serverStorage = true;
|
||||
}
|
||||
@@ -3633,22 +3640,13 @@ export class HouseplanCard extends LitElement {
|
||||
private _cacheSnapshot(): void {
|
||||
if (!this._serverCfg) return;
|
||||
try {
|
||||
// Local edits may mutate nested config/layout before the debounced write;
|
||||
// keep the accepted identity paired with exactly what is cached.
|
||||
this._cfgContentFingerprint = contentFingerprint(this._serverCfg);
|
||||
this._layoutContentFingerprint = contentFingerprint(this._layout);
|
||||
this._virtualLights = reconcileVirtualLightSnapshot(
|
||||
this._virtualLights, this._serverCfg, this._cfgRev,
|
||||
);
|
||||
localStorage.setItem(LS_CFG, JSON.stringify({
|
||||
config: this._serverCfg,
|
||||
rev: this._cfgRev,
|
||||
config_fingerprint: this._cfgContentFingerprint,
|
||||
layout: this._layout,
|
||||
layout_rev: this._layoutRev,
|
||||
layout_fingerprint: this._layoutContentFingerprint,
|
||||
virtual_lights: virtualLightWire(this._virtualLights),
|
||||
}));
|
||||
// Local edits may mutate nested config/layout before the debounced write;
|
||||
// `snapshot()` re-pairs the accepted identity with exactly what is cached.
|
||||
const snapshot = this._adoption.snapshot(virtualLightWire(this._virtualLights));
|
||||
if (snapshot) localStorage.setItem(LS_CFG, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -3730,9 +3728,9 @@ export class HouseplanCard extends LitElement {
|
||||
const size = stage ? [stage.clientWidth, stage.clientHeight] : [0, 0];
|
||||
return visualFrameFingerprint([
|
||||
this._cfgRev,
|
||||
this._cfgContentFingerprint || contentFingerprint(this._serverCfg),
|
||||
this._adoption.currentConfigFingerprint(),
|
||||
this._layoutRev,
|
||||
this._layoutContentFingerprint || contentFingerprint(this._layout),
|
||||
this._adoption.currentLayoutFingerprint(),
|
||||
this._space,
|
||||
this._mode,
|
||||
this._view,
|
||||
@@ -4196,11 +4194,6 @@ export class HouseplanCard extends LitElement {
|
||||
|
||||
// ================= server: config + layout =================
|
||||
|
||||
/**
|
||||
* Adopt one structural WS candidate by revision + actual content identity.
|
||||
* Equal payloads keep their authoritative object references and geometry
|
||||
* epoch; equal revisions never hide changed content (#73 §9.4).
|
||||
*/
|
||||
private _adoptConfigCapabilities(response: unknown): void {
|
||||
adoptCardConfigCapabilities(this as unknown as ConfigCapabilitiesCardPort, response);
|
||||
}
|
||||
@@ -4228,72 +4221,19 @@ export class HouseplanCard extends LitElement {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _adoptStructuralResponses(
|
||||
cfgResp: any,
|
||||
layResp?: any,
|
||||
layoutOverride?: Record<string, any>,
|
||||
): { configChanged: boolean; layoutChanged: boolean } {
|
||||
const rawConfig = cfgResp?.config;
|
||||
const nextConfig = rawConfig && Array.isArray(rawConfig.spaces)
|
||||
? rawConfig as ServerConfig : null;
|
||||
const nextCfgFingerprint = contentFingerprint(nextConfig);
|
||||
const configChanged = nextCfgFingerprint !== (this._cfgContentFingerprint
|
||||
|| contentFingerprint(this._serverCfg));
|
||||
if (configChanged) {
|
||||
// A genuinely different baseline invalidates local geometry undo. A
|
||||
// reconnect echo with identical content deliberately does not.
|
||||
this._geometryHistory.clear();
|
||||
this._devicePositionHistory.clear();
|
||||
this._cancelDeviceDrag();
|
||||
this._pendingPhysicalWrites.clear();
|
||||
this._clearRoomFocus(true);
|
||||
// #82: a new structural baseline owns the viewport. Freeze the last
|
||||
// painted camera frame before replacing geometry so an obsolete target
|
||||
// cannot settle against the new content frame.
|
||||
this._cancelCameraTransition(false);
|
||||
if (this._serverCfg) this._clearGeometryGesture();
|
||||
this._serverCfg = nextConfig;
|
||||
this._seedDecorStyle(this._serverCfg);
|
||||
this._cfgContentFingerprint = nextCfgFingerprint;
|
||||
}
|
||||
this._cfgRev = cfgResp?.rev ?? this._cfgRev;
|
||||
if (cfgResp && ('virtual_lights' in cfgResp || 'config' in cfgResp)) {
|
||||
const nextVirtualLights = adoptVirtualLightServerSnapshot(
|
||||
this._virtualLights,
|
||||
cfgResp.virtual_lights,
|
||||
this._cfgRev,
|
||||
'virtual_lights' in cfgResp,
|
||||
);
|
||||
if (virtualLightFingerprint(nextVirtualLights)
|
||||
!== virtualLightFingerprint(this._virtualLights)) {
|
||||
this._virtualLights = nextVirtualLights;
|
||||
this._capturedSnapshotVirtual = '';
|
||||
}
|
||||
}
|
||||
/**
|
||||
* #500: the one adoption entry. Compare → backdrop readiness gate →
|
||||
* continuity candidate → adopt → profile tail; see `adoptAuthoritativeGated`.
|
||||
*/
|
||||
public _adoptAuthoritative(input: GatedAdoptionInput): Promise<GatedAdoptionResult> {
|
||||
return adoptAuthoritativeGated(this as unknown as ConfigAdoptionHostPort, input);
|
||||
}
|
||||
|
||||
let layoutChanged = false;
|
||||
if (layResp !== undefined || layoutOverride !== undefined) {
|
||||
const nextLayout = layoutOverride ?? layResp?.layout ?? {};
|
||||
const nextLayoutFingerprint = contentFingerprint(nextLayout);
|
||||
layoutChanged = nextLayoutFingerprint !== (this._layoutContentFingerprint
|
||||
|| contentFingerprint(this._layout));
|
||||
if (layoutChanged) {
|
||||
this._cancelCameraTransition(false);
|
||||
this._devicePositionHistory.clear();
|
||||
this._cancelDeviceDrag();
|
||||
this._layout = nextLayout;
|
||||
this._layoutContentFingerprint = nextLayoutFingerprint;
|
||||
}
|
||||
this._layoutRev = layResp?.rev ?? this._layoutRev;
|
||||
}
|
||||
|
||||
this._canOptimizeUndo = !!(cfgResp?.can_optimize_undo || layResp?.can_optimize_undo);
|
||||
this._adoptConfigCapabilities(cfgResp);
|
||||
this._undoKind = (cfgResp?.undo_kind || layResp?.undo_kind || null) as any;
|
||||
if (typeof cfgResp?.can_write === 'boolean') this._serverCanWrite = cfgResp.can_write;
|
||||
if (configChanged) this._continuity.note('config-candidate', { configRev: this._cfgRev });
|
||||
if (layoutChanged) this._continuity.note('layout-candidate', { layoutRev: this._layoutRev });
|
||||
return { configChanged, layoutChanged };
|
||||
/** #314 via #500: undo only the exact failed candidate, then repaint. */
|
||||
public _rollbackOptimistic(attempt: OptimisticAttempt<ServerConfig>): boolean {
|
||||
const rolledBack = this._adoption.rollbackOptimistic(attempt);
|
||||
if (rolledBack) this.requestUpdate();
|
||||
return rolledBack;
|
||||
}
|
||||
|
||||
/** Resume only a same-route warm editor intent after permissions arrive.
|
||||
@@ -4322,36 +4262,14 @@ export class HouseplanCard extends LitElement {
|
||||
this._getAuthoritativeConfig(),
|
||||
this.hass.callWS({ type: 'houseplan/layout/get' }),
|
||||
]);
|
||||
const candidateConfig = cfgResp?.config && Array.isArray(cfgResp.config.spaces)
|
||||
? cfgResp.config : null;
|
||||
const structuralChanged = contentFingerprint(candidateConfig)
|
||||
!== (this._cfgContentFingerprint || contentFingerprint(this._serverCfg))
|
||||
|| contentFingerprint(layResp?.layout ?? {})
|
||||
!== (this._layoutContentFingerprint || contentFingerprint(this._layout));
|
||||
if (structuralChanged) {
|
||||
const assetReady = await this._signer.prepareImage(
|
||||
this.hass, this._candidateBackdrop(candidateConfig),
|
||||
);
|
||||
if (!assetReady) {
|
||||
this._continuity.note('asset-failed');
|
||||
this._scheduleLoadRetry(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (structuralChanged && this._continuity.hasCompleteFrame
|
||||
&& this._continuity.state === 'steady') {
|
||||
this._beginContinuityCandidate('structural-response', true);
|
||||
}
|
||||
this._connectionWasLost = false;
|
||||
this._serverStorage = true;
|
||||
// absent can_write = older backend / demo stub → keep null (legacy admin fallback)
|
||||
if (typeof cfgResp?.can_write === 'boolean') this._serverCanWrite = cfgResp.can_write;
|
||||
this._canOptimizeUndo = !!(cfgResp?.can_optimize_undo || layResp?.can_optimize_undo);
|
||||
this._adoptStructuralResponses(cfgResp, layResp);
|
||||
void this._syncDecorAssets(candidateConfig).catch(() => undefined);
|
||||
this._adoptInitialSpace(this._model, true);
|
||||
this._resumePendingNavMode();
|
||||
this._cacheSnapshot();
|
||||
const adopted = await this._adoptAuthoritative({
|
||||
cfgResp, layResp, reason: 'structural-response', profile: 'reload',
|
||||
beforeAdopt: () => {
|
||||
this._connectionWasLost = false;
|
||||
this._serverStorage = true;
|
||||
},
|
||||
});
|
||||
if (adopted.status !== 'adopted') return;
|
||||
// DEV-B703-03: a warm re-mount already holds the exact viewport of the
|
||||
// instance that was thrown away; the centred restore here IS the
|
||||
// reported jerk. Only a genuine navigation (the hash/nav landed us on
|
||||
@@ -4512,24 +4430,11 @@ export class HouseplanCard extends LitElement {
|
||||
this._beginContinuityCandidate('config-reload', false);
|
||||
try {
|
||||
const resp = await this._getAuthoritativeConfig();
|
||||
const candidateConfig = resp?.config && Array.isArray(resp.config.spaces)
|
||||
? resp.config as ServerConfig : null;
|
||||
const configChanged = contentFingerprint(candidateConfig)
|
||||
!== (this._cfgContentFingerprint || contentFingerprint(this._serverCfg));
|
||||
if (configChanged && !await this._signer.prepareImage(
|
||||
this.hass, this._candidateBackdrop(candidateConfig),
|
||||
)) {
|
||||
this._continuity.note('asset-failed');
|
||||
this._scheduleLoadRetry(true);
|
||||
return;
|
||||
}
|
||||
const visibleSpace = this._space;
|
||||
this._adoptStructuralResponses(resp);
|
||||
void this._syncDecorAssets(candidateConfig).catch(() => undefined);
|
||||
this._adoptInitialSpace(this._model, true);
|
||||
this._resumePendingNavMode();
|
||||
this._cacheSnapshot();
|
||||
if (this._space !== visibleSpace) this._restoreZoom();
|
||||
const adopted = await this._adoptAuthoritative({
|
||||
cfgResp: resp, reason: 'config-reload', profile: 'reload',
|
||||
});
|
||||
if (adopted.status !== 'adopted') return;
|
||||
if (adopted.spaceChanged) this._restoreZoom();
|
||||
this._regSignature = '';
|
||||
this._maybeRebuildDevices();
|
||||
this.requestUpdate();
|
||||
@@ -4951,8 +4856,7 @@ export class HouseplanCard extends LitElement {
|
||||
* re-read of what we just wrote.
|
||||
*/
|
||||
private _noteLayoutRev(r: any): void {
|
||||
const rev = r?.rev;
|
||||
if (typeof rev === 'number' && rev > this._layoutRev) this._layoutRev = rev;
|
||||
this._adoption.noteLayoutRevision(r?.rev);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4986,15 +4890,10 @@ export class HouseplanCard extends LitElement {
|
||||
if (pos === null) delete merged[id];
|
||||
else merged[id] = pos;
|
||||
}
|
||||
const fingerprint = contentFingerprint(merged);
|
||||
const differsFromCurrent = fingerprint !== contentFingerprint(this._layout);
|
||||
if (differsFromCurrent) {
|
||||
this._adoption.adoptMergedLayout(merged, resp, () => {
|
||||
this._cancelDeviceDrag();
|
||||
this._devicePositionHistory.clear();
|
||||
this._layout = merged;
|
||||
}
|
||||
this._layoutContentFingerprint = fingerprint;
|
||||
this._layoutRev = resp?.rev ?? this._layoutRev;
|
||||
});
|
||||
this._canOptimizeUndo = !!resp?.can_optimize_undo;
|
||||
this._undoKind = (resp?.undo_kind || null) as any;
|
||||
this._cacheSnapshot();
|
||||
@@ -5288,7 +5187,7 @@ export class HouseplanCard extends LitElement {
|
||||
else restored.new_device_ids = previousAttention;
|
||||
const restoredConfig: ServerConfig = { ...this._serverCfg!, settings: restored };
|
||||
this._setAreaLifecycleConfig(restoredConfig);
|
||||
this._cfgContentFingerprint = contentFingerprint(this._serverCfg);
|
||||
this._adoption.refreshConfigFingerprint();
|
||||
}
|
||||
// Config and layout are separate stores, but this lifecycle change is
|
||||
// one user transaction. A rejected provenance write must put every
|
||||
@@ -5519,7 +5418,7 @@ export class HouseplanCard extends LitElement {
|
||||
});
|
||||
}
|
||||
this._noteLayoutRev(response);
|
||||
this._layoutContentFingerprint = contentFingerprint(this._layout);
|
||||
this._adoption.refreshLayoutFingerprint();
|
||||
this._cacheSnapshot();
|
||||
} finally {
|
||||
if (registered && this._sentPos.get(deviceId) === pending) this._sentPos.delete(deviceId);
|
||||
@@ -7789,7 +7688,7 @@ export class HouseplanCard extends LitElement {
|
||||
const response = await this.hass.callWS({
|
||||
type: 'houseplan/config/set', config: canonicalCandidate, expected_rev: this._cfgRev,
|
||||
});
|
||||
this._cfgRev = response?.rev ?? this._cfgRev + 1;
|
||||
this._adoption.acceptConfigWrite(canonicalCandidate, response);
|
||||
}
|
||||
|
||||
private _writeConfig(): Promise<void> {
|
||||
@@ -7802,11 +7701,7 @@ export class HouseplanCard extends LitElement {
|
||||
this._writeChain = enqueueSerializedWrite(this._writeChain, async () => {
|
||||
if (!this._serverCfg) return;
|
||||
const candidate = canonicalizeConfigGeometry(this._serverCfg);
|
||||
const candidateFingerprint = contentFingerprint(candidate);
|
||||
if (candidateFingerprint !== contentFingerprint(this._serverCfg)) {
|
||||
this._serverCfg = candidate;
|
||||
}
|
||||
this._cfgContentFingerprint = candidateFingerprint;
|
||||
this._adoption.stageConfigCandidate(candidate);
|
||||
await this._sendConfigCandidate(candidate);
|
||||
});
|
||||
const mine = this._writeChain.finally(() => { this._writesPending--; });
|
||||
|
||||
@@ -26,6 +26,7 @@ import './hp-color-opacity';
|
||||
import type { ColorPickerLabels } from './hp-color-opacity';
|
||||
import './hp-help';
|
||||
import type { AuthoritativeConfigResponse } from './version-recovery-card';
|
||||
import type { ConfigAdoption, GatedAdoptionInput, GatedAdoptionResult } from './config-adoption';
|
||||
import './hp-device-preview'; import './hp-zigbee-topology-settings';
|
||||
import {
|
||||
EXCLUDED_DOMAINS, DEFAULT_ICON_RULES, compileIconRules, isValidPattern, iconFor,
|
||||
@@ -274,7 +275,7 @@ import {
|
||||
canonicalizePosition,
|
||||
formatLatticeShiftCm,
|
||||
} from './coordinate-canonicalization';
|
||||
import { enqueueSerializedWrite, optimisticAttempt, rollbackOptimistic, type OptimisticAttempt } from './serialized-write-queue';
|
||||
import { enqueueSerializedWrite, type OptimisticAttempt } from './serialized-write-queue';
|
||||
import { applyCalibrationProposal, saveAutomaticCalibration, saveManualCalibration, saveVacuumMatrix,
|
||||
type CalibrationProposal, type VacuumFit } from './vacuum-calibration-write';
|
||||
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
|
||||
@@ -839,7 +840,9 @@ export interface HouseplanEditorHostPort {
|
||||
_activePlanSnapConflicts: PlanSnapEndpoint[];
|
||||
_activityRt: Map<string, FiniteActivityRuntime>;
|
||||
_adoptInitialSpace: (models: SpaceModel[], authoritative?: boolean) => InitialSpaceSelection;
|
||||
_adoptStructuralResponses: (cfgResp: any, layResp?: any, layoutOverride?: Record<string, any>) => { configChanged: boolean; layoutChanged: boolean; };
|
||||
_adoptAuthoritative: (input: GatedAdoptionInput) => Promise<GatedAdoptionResult>; // #500: the one adoption entry
|
||||
_rollbackOptimistic: (attempt: OptimisticAttempt<ServerConfig>) => boolean;
|
||||
readonly _adoption: ConfigAdoption; // #500: identity owner; _serverCfg/_cfgRev/_layout/_layoutRev delegate to it
|
||||
_alignDialog: { report: OptimizeReport; config: any; layout: Record<string, any>; preflight: OptimizeGeometryPreflightResult | null; cm: number; where: string; changed: boolean; busy: boolean; removeLiveMissingPositions: boolean; } | null;
|
||||
_alignPoint: number[] | null;
|
||||
_allRoomsFlat: () => { value: string; label: string; }[];
|
||||
@@ -877,9 +880,8 @@ export interface HouseplanEditorHostPort {
|
||||
_candidateDeviceSnapshot: RenderDeviceSnapshot | null;
|
||||
_capturedSnapshotConfigEpoch: number;
|
||||
_cellCm: number;
|
||||
_cfgContentFingerprint: string;
|
||||
_cfgEpoch: number;
|
||||
_cfgRev: number;
|
||||
readonly _cfgRev: number;
|
||||
_clearTransientHover: (suspend?: boolean) => void;
|
||||
_closeInfoCard: () => void;
|
||||
_closingWallCm: number | null;
|
||||
@@ -996,7 +998,7 @@ export interface HouseplanEditorHostPort {
|
||||
_labsIso: boolean;
|
||||
_lastValidStageSize: [number, number] | null;
|
||||
_layout: DeviceLayout;
|
||||
_layoutRev: number;
|
||||
readonly _layoutRev: number;
|
||||
_logicalViewCenter: (projection: "flat" | "iso") => { x: number; y: number; } | null;
|
||||
_markerDialog: { devId?: string; uploadId?: string; name: string; binding: string; bindingMode: "virtual" | "ha"; bindingOpen: boolean; showEntities: boolean; bindingFilter: string; icon: string; autoIcon: string; display: DeviceDisplayMode; rippleColor: string; rippleSize: number; size: number; angle: number; tapAction: string; tapActionTouched: boolean; originalHasTapAction: boolean; originalTapAction: string | null | undefined; tapHintAnnouncement: string; toggleEntity: string; toggleEntityTouched: boolean; originalHasToggleEntity: boolean; originalToggleEntity: string | null | undefined; tapTarget: string; tapConfirm: boolean; runFilter: string; controls: string[]; controlsFilter: string; glowRadius: string; lightRole: "auto" | "always" | "never"; lightRoleTouched: boolean; originalHasIsLight: boolean; originalIsLight: boolean | null | undefined; lightEntity: string; lightEntityTouched: boolean; originalHasLightEntity: boolean; originalLightEntity: string | null | undefined; glowMode: "auto" | "color" | "fixed"; glowColor: string; glowBrightness: number; glowColorDrafted: boolean; glowBrightnessDrafted: boolean; glowTouched: boolean; originalHasGlowColor: boolean; originalGlowColor: { c: string; bri?: number | null; } | null | undefined; valueBadgeEnabled: boolean; valueBadgeSource: ValueBadgeSource | null; valueBadgePosition: ValueBadgePosition; valueBadgeTouched: boolean; originalHasValueBadge: boolean; originalValueBadge: MarkerValueBadge | null | undefined; valueSource: ValueBadgeSource | null; valueSourceTouched: boolean; originalHasValueSource: boolean; originalValueSource: ValueBadgeSource | null | undefined; useClimateTemp: boolean; model: string; link: string; description: string; pdfs: PdfRef[]; room: string; roomTouched: boolean; radar: RadarEditorDraft | null; radarEligible: boolean; radarTouched: boolean; radarRemove: boolean; hideFromPlan: boolean; busy: boolean; } | null;
|
||||
_markerPreviewDevicesMemo: { base: readonly DevItem[]; preview: DevItem; devices: readonly DevItem[]; } | null;
|
||||
@@ -1828,7 +1830,7 @@ public _rollbackRejectedPhysicalWrites(
|
||||
this.host._physicalBodiesCache = null;
|
||||
this.host._frame = null;
|
||||
this.host._regSignature = '';
|
||||
this.host._cfgContentFingerprint = contentFingerprint(this.host._serverCfg);
|
||||
this.host._adoption.refreshConfigFingerprint();
|
||||
this.host._maybeRebuildDevices();
|
||||
this.host.requestUpdate();
|
||||
return true;
|
||||
@@ -1871,10 +1873,7 @@ public _writeConfig(attempt: OptimisticAttempt<ServerConfig> | null = null): Pro
|
||||
// unrelated write-time cleanup as a visual change (#224 review H1).
|
||||
// A real coordinate change still adopts the exact object sent below;
|
||||
// willUpdate owns the corresponding geometry-epoch bump.
|
||||
if (candidateFingerprint !== contentFingerprint(this.host._serverCfg)) {
|
||||
this.host._serverCfg = candidate;
|
||||
}
|
||||
this.host._cfgContentFingerprint = candidateFingerprint;
|
||||
this.host._adoption.stageConfigCandidate(candidate);
|
||||
try {
|
||||
await this.host._sendConfigCandidate(candidate);
|
||||
} catch (error) {
|
||||
@@ -8102,7 +8101,7 @@ public async _saveMarker(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
candidate = this._prepareConfigCandidate(candidate);
|
||||
attempt = optimisticAttempt(cfg, candidate, this.host._cfgContentFingerprint, this.host._cfgRev, contentFingerprint);
|
||||
attempt = this.host._adoption.beginOptimistic(cfg, candidate);
|
||||
this.host._serverCfg = candidate;
|
||||
this.host._regSignature = '';
|
||||
this.host._maybeRebuildDevices();
|
||||
@@ -8144,7 +8143,7 @@ public async _saveMarker(): Promise<void> {
|
||||
// then crashes, blanking the whole card. The toast below is the
|
||||
// only remaining signal, so it must still fire.
|
||||
if (!configAccepted && attempt) {
|
||||
rollbackOptimistic(this.host, attempt, contentFingerprint);
|
||||
this.host._rollbackOptimistic(attempt);
|
||||
this.host._regSignature = '';
|
||||
this.host._maybeRebuildDevices();
|
||||
this.host.requestUpdate();
|
||||
@@ -8680,7 +8679,7 @@ public async _deleteSpace(): Promise<void> {
|
||||
if (this.host._saveConfigDebounced.pending()) this.host._saveConfigDebounced.flush();
|
||||
if (this.host._persistLayout.pending()) this.host._persistLayout.flush();
|
||||
await this.host._writeChain;
|
||||
const response: any = await this.host.hass.callWS({
|
||||
await this.host.hass.callWS({
|
||||
type: 'houseplan/space/delete',
|
||||
space_id: spaceId,
|
||||
expected_config_rev: this.host._cfgRev,
|
||||
@@ -8690,11 +8689,10 @@ public async _deleteSpace(): Promise<void> {
|
||||
this.host._getAuthoritativeConfig(),
|
||||
this.host.hass.callWS({ type: 'houseplan/layout/get' }),
|
||||
]);
|
||||
this.host._adoptStructuralResponses(configResponse, layoutResponse);
|
||||
this.host._cfgRev = response?.config_rev ?? this.host._cfgRev;
|
||||
this.host._layoutRev = response?.layout_rev ?? this.host._layoutRev;
|
||||
// #500: revisions come with the re-read bodies, never from the delete reply.
|
||||
const adopted = await this.host._adoptAuthoritative({ cfgResp: configResponse, layResp: layoutResponse, reason: 'space-delete', profile: 'post-write' });
|
||||
this.host._spaceDialog = null;
|
||||
if (this.host._space === spaceId) this.host._commitSpace(this.host._serverCfg!.spaces[0]?.id || '');
|
||||
if (adopted.status === 'adopted' && this.host._space === spaceId) this.host._commitSpace(this.host._serverCfg!.spaces[0]?.id || '');
|
||||
this.host._regSignature = '';
|
||||
this.host._maybeRebuildDevices();
|
||||
this.host._showToast(this.host._t('toast.space_deleted'));
|
||||
@@ -9574,7 +9572,7 @@ public async _undoPlanOptimization(): Promise<void> {
|
||||
// config/get is the sole authority for runtime capabilities, including
|
||||
// integration_version. Reuse the full-card adopter instead of leaving
|
||||
// this direct optimization-undo path with stale version state (#462).
|
||||
this.host._adoptStructuralResponses(cfgResp, layResp);
|
||||
await this.host._adoptAuthoritative({ cfgResp, layResp, reason: 'optimize-undo', profile: 'post-write' });
|
||||
this.host._geometryHistory.clear();
|
||||
this.host._cancelDeviceDrag();
|
||||
this.host._devicePositionHistory.clear();
|
||||
@@ -9733,7 +9731,7 @@ public async _applyBackupImport(): Promise<void> {
|
||||
this.host._getAuthoritativeConfig(),
|
||||
this.host.hass.callWS({ type: 'houseplan/layout/get' }),
|
||||
]);
|
||||
this.host._adoptStructuralResponses(configResponse, layoutResponse);
|
||||
const adopted = await this.host._adoptAuthoritative({ cfgResp: configResponse, layResp: layoutResponse, reason: 'import-apply', profile: 'post-write' });
|
||||
this.host._geometryHistory.clear();
|
||||
this.host._dirtyPos.clear();
|
||||
this.host._sentPos.clear();
|
||||
@@ -9754,7 +9752,9 @@ public async _applyBackupImport(): Promise<void> {
|
||||
? result.space_id
|
||||
: spaces.some((space) => space.id === previousSpace)
|
||||
? previousSpace : spaces[0]?.id || this.host._space;
|
||||
if (this.host._hasFixedFloor) this.host._adoptInitialSpace(this.host._model, true);
|
||||
// Not adopted (bounded asset wait): the scheduled reload owns the space selection.
|
||||
if (adopted.status !== 'adopted') { /* keep the current space */ }
|
||||
else if (this.host._hasFixedFloor) this.host._adoptInitialSpace(this.host._model, true);
|
||||
else this.host._commitSpace(nextSpace);
|
||||
this.host._backupImportDialog = null;
|
||||
this.host._cacheSnapshot();
|
||||
@@ -10043,8 +10043,7 @@ public _updateDecorStyle(next: DecorStyle): void {
|
||||
// longer affects sunlight; saving general settings cleans it up.
|
||||
delete settings.weather_entity;
|
||||
const nextConfig = { ...cfg, settings };
|
||||
attempt = optimisticAttempt(cfg, nextConfig, this.host._cfgContentFingerprint,
|
||||
this.host._cfgRev, contentFingerprint);
|
||||
attempt = this.host._adoption.beginOptimistic(cfg, nextConfig);
|
||||
this.host._serverCfg = nextConfig;
|
||||
await this._saveConfigNow(attempt);
|
||||
if (!d.showRoomTooltip && this.host._tip?.room) this.host._tip = null;
|
||||
@@ -10054,7 +10053,7 @@ public _updateDecorStyle(next: DecorStyle): void {
|
||||
} catch (e: any) {
|
||||
// Esc may close the dialog in flight; the toast must still fire (audit L3).
|
||||
// Never overwrite an authoritative conflict reload or newer edit (#439).
|
||||
if (attempt) rollbackOptimistic(this.host, attempt, contentFingerprint);
|
||||
if (attempt) this.host._rollbackOptimistic(attempt);
|
||||
if (this.host._settingsDialog) this.host._settingsDialog = { ...this.host._settingsDialog, busy: false };
|
||||
this.host._showToast(this.host._t('toast.error', { err: this.host._errText(e) }));
|
||||
}
|
||||
|
||||
@@ -463,7 +463,7 @@ export class HouseplanOnboardingRuntime {
|
||||
if (this.host._saveConfigDebounced.pending()) this.host._saveConfigDebounced.flush();
|
||||
if (this.host._persistLayout.pending()) this.host._persistLayout.flush();
|
||||
await this.host._writeChain;
|
||||
const response: { config_rev?: number; layout_rev?: number } = await this.host.hass.callWS({
|
||||
await this.host.hass.callWS({
|
||||
type: 'houseplan/space/delete',
|
||||
space_id: spaceId,
|
||||
expected_config_rev: this.host._cfgRev,
|
||||
@@ -473,11 +473,12 @@ export class HouseplanOnboardingRuntime {
|
||||
this.host._getAuthoritativeConfig(),
|
||||
this.host.hass.callWS({ type: 'houseplan/layout/get' }),
|
||||
]);
|
||||
this.host._adoptStructuralResponses(configResponse, layoutResponse);
|
||||
this.host._cfgRev = response?.config_rev ?? this.host._cfgRev;
|
||||
this.host._layoutRev = response?.layout_rev ?? this.host._layoutRev;
|
||||
// #500: revisions come with the re-read bodies, never from the delete reply.
|
||||
const adopted = await this.host._adoptAuthoritative({
|
||||
cfgResp: configResponse, layResp: layoutResponse, reason: 'space-delete', profile: 'post-write',
|
||||
});
|
||||
this.host._spaceDialog = null;
|
||||
if (this.host._space === spaceId) {
|
||||
if (adopted.status === 'adopted' && this.host._space === spaceId) {
|
||||
this.host._commitSpace(this.host._serverCfg!.spaces[0]?.id || '');
|
||||
}
|
||||
this.host._regSignature = '';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ConfigAdoption } from './config-adoption';
|
||||
import type { DeviceLayout } from './device-position-history';
|
||||
import type { ServerConfig } from './types';
|
||||
import { contentFingerprint } from './visual-continuity';
|
||||
|
||||
interface Clearable { clear(): void }
|
||||
interface Flushable { pending(): boolean; flush(): void }
|
||||
@@ -9,11 +9,10 @@ export interface PlanOptimizeWriteHost {
|
||||
hass: { callWS(message: Record<string, unknown>): Promise<Record<string, unknown>> };
|
||||
_saveConfigDebounced: Flushable;
|
||||
_writeChain: Promise<void>;
|
||||
_cfgRev: number;
|
||||
_cfgContentFingerprint: string;
|
||||
_layoutRev: number;
|
||||
_serverCfg: ServerConfig | null;
|
||||
_layout: DeviceLayout;
|
||||
readonly _cfgRev: number;
|
||||
readonly _layoutRev: number;
|
||||
/** #500: identity owner — bodies and revisions of the pair land through it. */
|
||||
readonly _adoption: ConfigAdoption;
|
||||
_geometryHistory: Clearable;
|
||||
_devicePositionHistory: Clearable;
|
||||
_cancelDeviceDrag(): boolean;
|
||||
@@ -30,9 +29,6 @@ export interface PlanOptimizeWriteHost {
|
||||
requestUpdate(): void;
|
||||
}
|
||||
|
||||
const finiteRevision = (value: unknown, fallback: number): number =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
|
||||
/** The one adoption path shared by the Optimize dialog and Copy space. */
|
||||
export async function commitPlanOptimization(
|
||||
host: PlanOptimizeWriteHost,
|
||||
@@ -48,14 +44,11 @@ export async function commitPlanOptimization(
|
||||
expected_config_rev: host._cfgRev,
|
||||
expected_layout_rev: host._layoutRev,
|
||||
});
|
||||
host._serverCfg = config;
|
||||
host._cfgContentFingerprint = contentFingerprint(config);
|
||||
host._layout = layout;
|
||||
// Both bodies with both revisions from the reply that accepted them (#500 I2).
|
||||
host._adoption.acceptPairWrite(config, layout, response);
|
||||
host._geometryHistory.clear();
|
||||
host._cancelDeviceDrag();
|
||||
host._devicePositionHistory.clear();
|
||||
host._cfgRev = finiteRevision(response.config_rev, host._cfgRev + 1);
|
||||
host._layoutRev = finiteRevision(response.layout_rev, host._layoutRev + 1);
|
||||
host._canOptimizeUndo = response.can_undo === true;
|
||||
host._undoKind = response.can_undo === true ? 'optimize' : null;
|
||||
host._dirtyPos.clear();
|
||||
|
||||
@@ -35,25 +35,5 @@ export function optimisticAttempt<T>(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll back only the failed candidate represented by this attempt. A conflict
|
||||
* reload or a newer edit owns a different revision/content and must win.
|
||||
*/
|
||||
export function rollbackOptimistic<T>(
|
||||
host: {
|
||||
_serverCfg: T | null;
|
||||
_cfgRev: number;
|
||||
_cfgContentFingerprint: string;
|
||||
requestUpdate: () => unknown;
|
||||
},
|
||||
attempt: OptimisticAttempt<T>,
|
||||
fingerprint: (value: T) => string,
|
||||
): boolean {
|
||||
const current = host._serverCfg;
|
||||
if (!current || host._cfgRev !== attempt.revision
|
||||
|| fingerprint(current) !== attempt.attemptedFingerprint) return false;
|
||||
host._serverCfg = attempt.previous;
|
||||
host._cfgContentFingerprint = attempt.previousFingerprint;
|
||||
host.requestUpdate();
|
||||
return true;
|
||||
}
|
||||
// The rollback of an attempt lives with the identity owner:
|
||||
// `ConfigAdoption.rollbackOptimistic` (#500).
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from './space-copy';
|
||||
import type { OptimizeGeometryPreflightResult } from './plan-geometry-preflight';
|
||||
import type { SpaceReferenceRepairContext } from './space-reference-repair';
|
||||
import { optimisticAttempt, rollbackOptimistic, type OptimisticAttempt } from './serialized-write-queue';
|
||||
import type { OptimisticAttempt } from './serialized-write-queue';
|
||||
import type { ServerConfig } from './types';
|
||||
import { contentFingerprint } from './visual-continuity';
|
||||
|
||||
@@ -213,15 +213,13 @@ export async function saveSpaceCopy(
|
||||
if (!safe || host._junctionLimitViolations(result.config, result.space.id).length) {
|
||||
throw new SpaceCopyError('geometry_unsafe');
|
||||
}
|
||||
const attempt = optimisticAttempt(
|
||||
previous, result.config, host._cfgContentFingerprint, host._cfgRev, contentFingerprint,
|
||||
);
|
||||
host._serverCfg = result.config;
|
||||
const attempt = host._adoption.beginOptimistic(previous, result.config);
|
||||
host._adoption.stageLocalConfig(result.config);
|
||||
invalidateConfig(host);
|
||||
try {
|
||||
await services.saveConfigNow(attempt);
|
||||
} catch (error) {
|
||||
if (rollbackOptimistic(host, attempt, contentFingerprint)) invalidateConfig(host);
|
||||
if (host._rollbackOptimistic(attempt)) invalidateConfig(host);
|
||||
if ((error as { code?: unknown } | null)?.code !== 'conflict') {
|
||||
await host._reloadConfigOnly(true);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { HaRegistrySnapshot } from './ha-binding-status';
|
||||
import type { I18nKey } from './i18n';
|
||||
import type { CardConfig, Marker, ServerConfig, SpaceModel } from './types';
|
||||
import type { AuthoritativeConfigResponse } from './version-recovery-card';
|
||||
import type { ConfigAdoption, GatedAdoptionInput, GatedAdoptionResult } from './config-adoption';
|
||||
|
||||
export interface SummaryHassState {
|
||||
state?: string;
|
||||
@@ -43,20 +44,13 @@ export interface SummaryPanelHost extends Node {
|
||||
_config?: CardConfig;
|
||||
_model: SpaceModel[];
|
||||
_markers: Marker[];
|
||||
_serverCfg: ServerConfig | null;
|
||||
_cfgRev: number;
|
||||
readonly _serverCfg: ServerConfig | null;
|
||||
readonly _cfgRev: number;
|
||||
_cfgEpoch: number;
|
||||
_layoutRev: number;
|
||||
_cfgContentFingerprint: string;
|
||||
readonly _layoutRev: number;
|
||||
/** #500: identity owner; the fields above are its read delegates. */
|
||||
readonly _adoption: ConfigAdoption;
|
||||
_regSignature: string;
|
||||
_signer: {
|
||||
prepareImage(hass: SummaryHass | undefined, href: string): Promise<boolean>;
|
||||
};
|
||||
_continuity: {
|
||||
readonly hasCompleteFrame: boolean;
|
||||
readonly state: string;
|
||||
note(event: string): void;
|
||||
};
|
||||
_writesPending: number;
|
||||
_writeChain: Promise<void>;
|
||||
_kiosk: boolean;
|
||||
@@ -81,14 +75,9 @@ export interface SummaryPanelHost extends Node {
|
||||
}): Promise<boolean>;
|
||||
_sendConfigCandidate(candidate: ServerConfig): Promise<void>;
|
||||
_getAuthoritativeConfig(): Promise<AuthoritativeConfigResponse>;
|
||||
_candidateBackdrop(config: ServerConfig | null): string;
|
||||
_scheduleLoadRetry(force?: boolean): void;
|
||||
_beginContinuityCandidate(reason: string, dataReady: boolean): number;
|
||||
_adoptStructuralResponses(response: AuthoritativeConfigResponse): { configChanged: boolean };
|
||||
_syncDecorAssets(config: ServerConfig | null): Promise<void>;
|
||||
_adoptInitialSpace(models: SpaceModel[], authoritative?: boolean): unknown;
|
||||
/** #500: the one adoption entry — gate, continuity, adopt, reload tail. */
|
||||
_adoptAuthoritative(input: GatedAdoptionInput): Promise<GatedAdoptionResult>;
|
||||
_maybeRebuildDevices(): void;
|
||||
_resumePendingNavMode(): boolean;
|
||||
_restoreZoom(): void;
|
||||
_reloadConfigOnly(force?: boolean): Promise<void>;
|
||||
_cacheSnapshot(): void;
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
summaryLocalKey, summaryPanelEntityIds, summaryPanelOf, validateSummaryDraft, visibleSummaryBlocks,
|
||||
} from './summary-panel';
|
||||
import { SUMMARY_PANEL_API_VERSION } from './summary-panel-api';
|
||||
import { contentFingerprint } from './visual-continuity';
|
||||
import { enqueueSerializedWrite } from './serialized-write-queue';
|
||||
import { canonicalizeConfigGeometry } from './coordinate-canonicalization';
|
||||
import type { SummaryPanelEditorRenderer } from './summary-panel-editor';
|
||||
@@ -625,26 +624,14 @@ export class LoadedSummaryPanelRuntime {
|
||||
if (!this.current(generation)) throw writeError;
|
||||
const confirmed = confirmedSummaryPanelWriteRecovery(authoritative, draft);
|
||||
if (!confirmed) throw writeError;
|
||||
const configChanged = contentFingerprint(confirmed.config)
|
||||
!== (this.host._cfgContentFingerprint || contentFingerprint(this.host._serverCfg));
|
||||
if (configChanged && !await this.host._signer.prepareImage(
|
||||
this.host.hass, this.host._candidateBackdrop(confirmed.config),
|
||||
)) {
|
||||
this.host._continuity.note('asset-failed');
|
||||
this.host._scheduleLoadRetry(true);
|
||||
throw writeError;
|
||||
}
|
||||
if (configChanged && this.host._continuity.hasCompleteFrame
|
||||
&& this.host._continuity.state === 'steady') {
|
||||
this.host._beginContinuityCandidate('summary-recovery', true);
|
||||
}
|
||||
const visibleSpace = this.host._space;
|
||||
this.host._adoptStructuralResponses(authoritative);
|
||||
void this.host._syncDecorAssets(confirmed.config).catch(() => undefined);
|
||||
this.host._adoptInitialSpace(this.host._model, true);
|
||||
this.host._resumePendingNavMode();
|
||||
this.host._cacheSnapshot();
|
||||
if (this.host._space !== visibleSpace) this.host._restoreZoom();
|
||||
// #500: the same gated sequence as every other authoritative
|
||||
// adoption — backdrop readiness, continuity candidate, adopt,
|
||||
// reload tail. A bounded asset failure keeps the write error.
|
||||
const adopted = await this.host._adoptAuthoritative({
|
||||
cfgResp: authoritative, reason: 'summary-recovery', profile: 'reload',
|
||||
});
|
||||
if (adopted.status !== 'adopted') throw writeError;
|
||||
if (adopted.spaceChanged) this.host._restoreZoom();
|
||||
this.host._regSignature = '';
|
||||
this.host._maybeRebuildDevices();
|
||||
recovered = true;
|
||||
@@ -652,8 +639,7 @@ export class LoadedSummaryPanelRuntime {
|
||||
}
|
||||
if (!recovered) {
|
||||
if (!this.current(generation)) return;
|
||||
this.host._serverCfg = candidate;
|
||||
this.host._cfgContentFingerprint = contentFingerprint(candidate);
|
||||
this.host._adoption.stageConfigCandidate(candidate);
|
||||
this.host._cacheSnapshot();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,10 +3,8 @@ import type { I18nKey } from './i18n';
|
||||
import type { DevItem, Marker, ServerConfig } from './types';
|
||||
import { fitFromMatrix, type Affine, type FitParams } from './vacuum';
|
||||
import { writeVacuumMatrix } from './vacuum-route-edit';
|
||||
import {
|
||||
optimisticAttempt, rollbackOptimistic, type OptimisticAttempt,
|
||||
} from './serialized-write-queue';
|
||||
import { contentFingerprint } from './visual-continuity';
|
||||
import type { OptimisticAttempt } from './serialized-write-queue';
|
||||
import type { ConfigAdoption } from './config-adoption';
|
||||
|
||||
export type CalibrationProposal = {
|
||||
markerId: string;
|
||||
@@ -38,10 +36,11 @@ export type VacuumFit = {
|
||||
};
|
||||
|
||||
export interface VacuumCalibrationWriteHost {
|
||||
_serverCfg: ServerConfig | null;
|
||||
readonly _serverCfg: ServerConfig | null;
|
||||
_devices: DevItem[];
|
||||
_cfgContentFingerprint: string;
|
||||
_cfgRev: number;
|
||||
readonly _cfgRev: number;
|
||||
readonly _adoption: ConfigAdoption;
|
||||
_rollbackOptimistic: (attempt: OptimisticAttempt<ServerConfig>) => boolean;
|
||||
_saveConfigDebounced: { pending: () => boolean; cancel: () => void };
|
||||
_regSignature: string;
|
||||
_markerDialog: { busy: boolean } | null;
|
||||
@@ -102,17 +101,15 @@ export async function saveVacuumMatrix(
|
||||
source, mapId, routeId, matrix,
|
||||
});
|
||||
candidate = runtime._prepareConfigCandidate(candidate);
|
||||
const attempt = optimisticAttempt(
|
||||
previous, candidate, host._cfgContentFingerprint, host._cfgRev, contentFingerprint,
|
||||
);
|
||||
host._serverCfg = candidate;
|
||||
const attempt = host._adoption.beginOptimistic(previous, candidate);
|
||||
host._adoption.stageLocalConfig(candidate);
|
||||
rebuild(host);
|
||||
if (host._saveConfigDebounced.pending()) host._saveConfigDebounced.cancel();
|
||||
try {
|
||||
await runtime._saveConfigNow(attempt);
|
||||
return true;
|
||||
} catch (error) {
|
||||
rollbackOptimistic(host, attempt, contentFingerprint);
|
||||
host._rollbackOptimistic(attempt);
|
||||
rebuild(host);
|
||||
host._showToast(host._t('toast.cfg_save_failed', { err: host._errText(error) }));
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createConfigAdoption,
|
||||
adoptAuthoritativeGated,
|
||||
adoptStructuralResponses,
|
||||
} from '../test-build/config-adoption.js';
|
||||
import { contentFingerprint } from '../test-build/visual-continuity.js';
|
||||
import { virtualLightSnapshot } from '../test-build/virtual-light-state.js';
|
||||
|
||||
// #500 — one owner for config/layout identity. These tests pin the transition
|
||||
// table the host used to implement inline (`_adoptStructuralResponses`,
|
||||
// `_cacheSnapshot`, `_sendConfigCandidate`, `rollbackOptimistic`) so the
|
||||
// extraction is provably behaviour-neutral (AC3, AC5, AC6) and the invariants
|
||||
// I2/I4 have a red witness.
|
||||
|
||||
const cfg = (title, extra = {}) => ({ spaces: [{ id: 'f1', title }], settings: {}, ...extra });
|
||||
const cloneOf = (value) => JSON.parse(JSON.stringify(value));
|
||||
|
||||
/** A host stub recording every side effect the adoption may trigger, in order. */
|
||||
function hostStub(adoption, overrides = {}) {
|
||||
const calls = [];
|
||||
const rec = (name) => (...args) => { calls.push(name); return overrides[name]?.(...args); };
|
||||
const host = {
|
||||
hass: { id: 'hass' },
|
||||
_adoption: adoption,
|
||||
_space: 'f1',
|
||||
_model: [],
|
||||
_virtualLights: virtualLightSnapshot(null),
|
||||
_capturedSnapshotVirtual: 'kept',
|
||||
_geometryHistory: { clear: rec('geometryHistory.clear') },
|
||||
_devicePositionHistory: { clear: rec('devicePositionHistory.clear') },
|
||||
_pendingPhysicalWrites: { clear: rec('pendingPhysicalWrites.clear') },
|
||||
_canOptimizeUndo: false,
|
||||
_undoKind: null,
|
||||
_serverCanWrite: null,
|
||||
_regSignature: 'sig',
|
||||
_continuity: {
|
||||
hasCompleteFrame: true,
|
||||
state: 'steady',
|
||||
note: (event, detail) => { calls.push(`note:${event}`); host.notes.push([event, detail]); },
|
||||
},
|
||||
notes: [],
|
||||
_signer: { prepareImage: async (_hass, url) => { calls.push(`prepareImage:${url}`); return overrides.assetReady ?? true; } },
|
||||
_cancelDeviceDrag: rec('cancelDeviceDrag'),
|
||||
_clearRoomFocus: rec('clearRoomFocus'),
|
||||
_cancelCameraTransition: rec('cancelCameraTransition'),
|
||||
_clearGeometryGesture: rec('clearGeometryGesture'),
|
||||
_seedDecorStyle: rec('seedDecorStyle'),
|
||||
_adoptConfigCapabilities: rec('adoptConfigCapabilities'),
|
||||
_candidateBackdrop: (config) => config?.spaces?.[0]?.bg?.href || 'no-backdrop',
|
||||
_scheduleLoadRetry: rec('scheduleLoadRetry'),
|
||||
_beginContinuityCandidate: (reason) => { calls.push(`continuity:${reason}`); return 1; },
|
||||
_syncDecorAssets: async () => { calls.push('syncDecorAssets'); },
|
||||
_adoptInitialSpace: rec('adoptInitialSpace'),
|
||||
_resumePendingNavMode: rec('resumePendingNavMode'),
|
||||
_cacheSnapshot: rec('cacheSnapshot'),
|
||||
calls,
|
||||
};
|
||||
return host;
|
||||
}
|
||||
|
||||
const adoptedWith = (config, rev = 1) => {
|
||||
const adoption = createConfigAdoption();
|
||||
adoptStructuralResponses(hostStub(adoption), { config, rev }, { layout: {}, rev: 0 });
|
||||
return adoption;
|
||||
};
|
||||
|
||||
// --- AC3: transition table of the former _adoptStructuralResponses ---------
|
||||
|
||||
test('AC3: an identical payload is an echo — body reference, histories and epoch state stay, only the revision moves', () => {
|
||||
const body = cfg('Ground floor');
|
||||
const adoption = adoptedWith(body, 3);
|
||||
const host = hostStub(adoption);
|
||||
const echo = cloneOf(body);
|
||||
const result = adoptStructuralResponses(host, { config: echo, rev: 4 }, { layout: {}, rev: 2 });
|
||||
assert.deepEqual(result, { configChanged: false, layoutChanged: false });
|
||||
assert.equal(adoption.config, body, 'the echo must not replace the reactive root');
|
||||
assert.equal(adoption.configRev, 4);
|
||||
assert.equal(adoption.layoutRev, 2);
|
||||
assert.ok(!host.calls.includes('geometryHistory.clear'), 'echo keeps local undo');
|
||||
assert.ok(!host.calls.includes('cancelCameraTransition'));
|
||||
assert.ok(!host.notes.some(([event]) => event === 'config-candidate'));
|
||||
});
|
||||
|
||||
test('AC3: a different config replaces the body, retires baseline-bound state in the host order, and notes the candidate', () => {
|
||||
const adoption = adoptedWith(cfg('Ground floor'), 3);
|
||||
const host = hostStub(adoption);
|
||||
const next = cfg('Renamed');
|
||||
const result = adoptStructuralResponses(host, { config: next, rev: 5, can_write: true, can_optimize_undo: true, undo_kind: 'optimize' });
|
||||
assert.deepEqual(result, { configChanged: true, layoutChanged: false });
|
||||
assert.equal(adoption.config, next, 'the adopted body is the response object itself (I4)');
|
||||
assert.equal(adoption.configRev, 5);
|
||||
assert.equal(adoption.configFingerprint, contentFingerprint(next));
|
||||
assert.deepEqual(host.calls.slice(0, 7), [
|
||||
'geometryHistory.clear', 'devicePositionHistory.clear', 'cancelDeviceDrag',
|
||||
'pendingPhysicalWrites.clear', 'clearRoomFocus', 'cancelCameraTransition', 'clearGeometryGesture',
|
||||
]);
|
||||
assert.ok(host.calls.indexOf('seedDecorStyle') > host.calls.indexOf('clearGeometryGesture'));
|
||||
assert.equal(host._serverCanWrite, true);
|
||||
assert.equal(host._canOptimizeUndo, true);
|
||||
assert.equal(host._undoKind, 'optimize');
|
||||
assert.deepEqual(host.notes.at(-1), ['config-candidate', { configRev: 5 }]);
|
||||
});
|
||||
|
||||
test('AC3: the very first adoption does not clear a geometry gesture that never existed', () => {
|
||||
const adoption = createConfigAdoption();
|
||||
const host = hostStub(adoption);
|
||||
adoptStructuralResponses(host, { config: cfg('First'), rev: 1 });
|
||||
assert.ok(!host.calls.includes('clearGeometryGesture'));
|
||||
assert.ok(host.calls.includes('geometryHistory.clear'));
|
||||
});
|
||||
|
||||
test('AC3: layout-only change retires drag/camera state and notes the layout candidate; config side is untouched', () => {
|
||||
const adoption = adoptedWith(cfg('Ground floor'), 3);
|
||||
const host = hostStub(adoption);
|
||||
const layout = { dev1: { s: 'f1', x: 0.5, y: 0.5 } };
|
||||
const result = adoptStructuralResponses(host, { config: cloneOf(adoption.config), rev: 3 }, { layout, rev: 9 });
|
||||
assert.deepEqual(result, { configChanged: false, layoutChanged: true });
|
||||
assert.equal(adoption.layout, layout);
|
||||
assert.equal(adoption.layoutRev, 9);
|
||||
assert.equal(adoption.layoutFingerprint, contentFingerprint(layout));
|
||||
assert.deepEqual(host.calls.filter((c) => !c.startsWith('note') && c !== 'adoptConfigCapabilities'),
|
||||
['cancelCameraTransition', 'devicePositionHistory.clear', 'cancelDeviceDrag']);
|
||||
assert.deepEqual(host.notes, [['layout-candidate', { layoutRev: 9 }]]);
|
||||
});
|
||||
|
||||
test('AC3: a response without rev keeps the previous revision; an absent layout response leaves layout identity alone', () => {
|
||||
const adoption = adoptedWith(cfg('Ground floor'), 3);
|
||||
adoptStructuralResponses(hostStub(adoption), { config: cfg('Other') }, { layout: { a: {} }, rev: 4 });
|
||||
assert.equal(adoption.configRev, 3);
|
||||
assert.equal(adoption.layoutRev, 4);
|
||||
adoptStructuralResponses(hostStub(adoption), { config: cfg('Third'), rev: 8 });
|
||||
assert.equal(adoption.configRev, 8);
|
||||
assert.equal(adoption.layoutRev, 4, 'no layout response → layout identity untouched');
|
||||
assert.deepEqual(adoption.layout, { a: {} });
|
||||
});
|
||||
|
||||
test('AC3: virtual lights are adopted with the response revision and only replaced when they differ', () => {
|
||||
const adoption = adoptedWith(cfg('Ground floor'), 3);
|
||||
const host = hostStub(adoption);
|
||||
adoptStructuralResponses(host, {
|
||||
config: cloneOf(adoption.config), rev: 4,
|
||||
virtual_lights: { rev: 1, config_rev: 4, off: ['vl.one'] },
|
||||
});
|
||||
assert.deepEqual([...host._virtualLights.off], ['vl.one']);
|
||||
assert.equal(host._capturedSnapshotVirtual, '', 'a changed set invalidates the captured render snapshot');
|
||||
const before = host._virtualLights;
|
||||
host._capturedSnapshotVirtual = 'kept';
|
||||
adoptStructuralResponses(host, {
|
||||
config: cloneOf(adoption.config), rev: 4,
|
||||
virtual_lights: { rev: 1, config_rev: 4, off: ['vl.one'] },
|
||||
});
|
||||
assert.equal(host._capturedSnapshotVirtual, 'kept', 'an identical set is not a change');
|
||||
assert.equal(host._virtualLights, before, 'identical wire state keeps the snapshot object');
|
||||
});
|
||||
|
||||
// --- §6.3: the gated sequence -----------------------------------------------
|
||||
|
||||
test('gated adoption: unchanged structure skips the backdrop gate and continuity, adopts, and runs the reload tail', async () => {
|
||||
const adoption = adoptedWith(cfg('Ground floor'), 3);
|
||||
const host = hostStub(adoption);
|
||||
const result = await adoptAuthoritativeGated(host, {
|
||||
cfgResp: { config: cloneOf(adoption.config), rev: 4 }, reason: 'config-reload', profile: 'reload',
|
||||
});
|
||||
assert.deepEqual(result, { status: 'adopted', spaceChanged: false });
|
||||
assert.ok(!host.calls.some((c) => c.startsWith('prepareImage')));
|
||||
assert.ok(!host.calls.some((c) => c.startsWith('continuity:')));
|
||||
assert.deepEqual(host.calls.slice(-4), ['syncDecorAssets', 'adoptInitialSpace', 'resumePendingNavMode', 'cacheSnapshot']);
|
||||
});
|
||||
|
||||
test('gated adoption: a changed structure prepares the candidate backdrop, starts the continuity candidate, then adopts (#490 M1 order)', async () => {
|
||||
const adoption = adoptedWith(cfg('Ground floor'), 3);
|
||||
const host = hostStub(adoption);
|
||||
let beforeAdoptSeen = null;
|
||||
const next = cfg('New', {});
|
||||
next.spaces[0].bg = { href: '/local/new-plan.svg' };
|
||||
const result = await adoptAuthoritativeGated(host, {
|
||||
cfgResp: { config: next, rev: 4 }, reason: 'summary-recovery', profile: 'reload',
|
||||
beforeAdopt: () => { beforeAdoptSeen = adoption.config; },
|
||||
});
|
||||
assert.equal(result.status, 'adopted');
|
||||
const order = host.calls;
|
||||
assert.ok(order.indexOf('prepareImage:/local/new-plan.svg') < order.indexOf('continuity:summary-recovery'));
|
||||
assert.ok(order.indexOf('continuity:summary-recovery') < order.indexOf('geometryHistory.clear'));
|
||||
assert.notEqual(beforeAdoptSeen, next, 'beforeAdopt runs after the gate, before adoption');
|
||||
assert.equal(adoption.config, next);
|
||||
});
|
||||
|
||||
test('gated adoption: a bounded asset failure adopts nothing, notes the failure and schedules a retry', async () => {
|
||||
const adoption = adoptedWith(cfg('Ground floor'), 3);
|
||||
const host = hostStub(adoption, { assetReady: false });
|
||||
const before = adoption.config;
|
||||
const result = await adoptAuthoritativeGated(host, {
|
||||
cfgResp: { config: cfg('New'), rev: 4 }, reason: 'structural-response', profile: 'reload',
|
||||
beforeAdopt: () => assert.fail('beforeAdopt must not run when the gate refuses'),
|
||||
});
|
||||
assert.deepEqual(result, { status: 'asset-wait' });
|
||||
assert.equal(adoption.config, before);
|
||||
assert.equal(adoption.configRev, 3, 'the revision is not taken without its body');
|
||||
assert.ok(host.calls.includes('note:asset-failed'));
|
||||
assert.ok(host.calls.includes('scheduleLoadRetry'));
|
||||
assert.ok(!host.calls.includes('cacheSnapshot'));
|
||||
});
|
||||
|
||||
test('gated adoption: the continuity candidate needs a complete steady frame', async () => {
|
||||
const adoption = adoptedWith(cfg('Ground floor'), 3);
|
||||
const host = hostStub(adoption);
|
||||
host._continuity.state = 'candidate';
|
||||
await adoptAuthoritativeGated(host, { cfgResp: { config: cfg('New'), rev: 4 }, reason: 'config-reload', profile: 'reload' });
|
||||
assert.ok(!host.calls.some((c) => c.startsWith('continuity:')));
|
||||
});
|
||||
|
||||
test('post-write profile ends at adoption: no reload tail, the caller keeps its own (§6.3, r3)', async () => {
|
||||
const adoption = adoptedWith(cfg('Ground floor'), 3);
|
||||
const host = hostStub(adoption);
|
||||
const result = await adoptAuthoritativeGated(host, {
|
||||
cfgResp: { config: cfg('After import'), rev: 7 }, layResp: { layout: { d: {} }, rev: 2 },
|
||||
reason: 'import-apply', profile: 'post-write',
|
||||
});
|
||||
assert.equal(result.status, 'adopted');
|
||||
for (const tail of ['syncDecorAssets', 'adoptInitialSpace', 'resumePendingNavMode', 'cacheSnapshot']) {
|
||||
assert.ok(!host.calls.includes(tail), `${tail} belongs to the caller on post-write paths`);
|
||||
}
|
||||
assert.ok(host.calls.some((c) => c.startsWith('prepareImage')), 'the gate itself applies to post-write paths (AC4)');
|
||||
assert.equal(adoption.configRev, 7);
|
||||
assert.equal(adoption.layoutRev, 2);
|
||||
});
|
||||
|
||||
test('I2: a revision is never taken from a response other than the one carrying the adopted body', async () => {
|
||||
const adoption = adoptedWith(cfg('Ground floor'), 3);
|
||||
const host = hostStub(adoption);
|
||||
// A delete reply says config_rev 5, but a concurrent client already moved
|
||||
// the config to rev 6 before our re-read: the re-read wins, whole.
|
||||
const deleteReply = { config_rev: 5, layout_rev: 1 };
|
||||
await adoptAuthoritativeGated(host, {
|
||||
cfgResp: { config: cfg('Concurrently renamed'), rev: 6 }, layResp: { layout: {}, rev: 2 },
|
||||
reason: 'space-delete', profile: 'post-write',
|
||||
});
|
||||
assert.equal(adoption.configRev, 6);
|
||||
assert.equal(adoption.layoutRev, 2);
|
||||
assert.notEqual(adoption.configRev, deleteReply.config_rev);
|
||||
assert.equal(typeof adoption.acceptPairWrite, 'function');
|
||||
});
|
||||
|
||||
// --- our own writes -----------------------------------------------------------
|
||||
|
||||
test('stageConfigCandidate replaces the root only when canonicalization changed content (#224 H1) and pins the accepted fingerprint', () => {
|
||||
const adoption = adoptedWith(cfg('Ground floor'), 3);
|
||||
const live = adoption.config;
|
||||
const same = cloneOf(live);
|
||||
adoption.stageConfigCandidate(same);
|
||||
assert.equal(adoption.config, live, 'equal content keeps the reactive root');
|
||||
const changed = cfg('Canonical');
|
||||
adoption.stageConfigCandidate(changed);
|
||||
assert.equal(adoption.config, changed);
|
||||
assert.equal(adoption.configFingerprint, contentFingerprint(changed));
|
||||
});
|
||||
|
||||
test('acceptConfigWrite takes the reply revision for the sent candidate; without rev it keeps the historical +1 guess (§15 п.3)', () => {
|
||||
const adoption = adoptedWith(cfg('Ground floor'), 3);
|
||||
const candidate = cfg('Sent');
|
||||
adoption.acceptConfigWrite(candidate, { rev: 10 });
|
||||
assert.equal(adoption.configRev, 10);
|
||||
assert.equal(adoption.configFingerprint, contentFingerprint(candidate));
|
||||
adoption.acceptConfigWrite(candidate, {});
|
||||
assert.equal(adoption.configRev, 11);
|
||||
adoption.acceptConfigWrite(candidate, undefined);
|
||||
assert.equal(adoption.configRev, 12);
|
||||
});
|
||||
|
||||
test('acceptPairWrite lands both bodies with both revisions; missing revisions fall back to +1 each', () => {
|
||||
const adoption = adoptedWith(cfg('Ground floor'), 3);
|
||||
const config = cfg('Optimized');
|
||||
const layout = { d: { s: 'f1', x: 0.1, y: 0.2 } };
|
||||
adoption.acceptPairWrite(config, layout, { config_rev: 4, layout_rev: 9 });
|
||||
assert.equal(adoption.config, config);
|
||||
assert.equal(adoption.layout, layout);
|
||||
assert.equal(adoption.configRev, 4);
|
||||
assert.equal(adoption.layoutRev, 9);
|
||||
assert.equal(adoption.configFingerprint, contentFingerprint(config));
|
||||
assert.equal(adoption.layoutFingerprint, contentFingerprint(layout));
|
||||
adoption.acceptPairWrite(config, layout, { config_rev: 'x' });
|
||||
assert.equal(adoption.configRev, 5);
|
||||
assert.equal(adoption.layoutRev, 10);
|
||||
});
|
||||
|
||||
test('layout: merged re-read keeps the reference on equal content; own replies move the revision monotonically', () => {
|
||||
const adoption = adoptedWith(cfg('Ground floor'), 3);
|
||||
const layout = { d: { s: 'f1', x: 0.1, y: 0.2 } };
|
||||
adoption.stageLocalLayout(layout);
|
||||
let replaced = 0;
|
||||
assert.equal(adoption.adoptMergedLayout(cloneOf(layout), { rev: 5 }, () => { replaced += 1; }), false);
|
||||
assert.equal(adoption.layout, layout);
|
||||
assert.equal(adoption.layoutRev, 5);
|
||||
assert.equal(replaced, 0);
|
||||
const other = { d: { s: 'f1', x: 0.3, y: 0.2 } };
|
||||
assert.equal(adoption.adoptMergedLayout(other, { rev: 6 }, () => { replaced += 1; }), true);
|
||||
assert.equal(adoption.layout, other);
|
||||
assert.equal(replaced, 1);
|
||||
adoption.noteLayoutRevision(4);
|
||||
assert.equal(adoption.layoutRev, 6, 'an older own reply is not a step back');
|
||||
adoption.noteLayoutRevision(8);
|
||||
assert.equal(adoption.layoutRev, 8);
|
||||
adoption.noteLayoutRevision('9');
|
||||
assert.equal(adoption.layoutRev, 8);
|
||||
});
|
||||
|
||||
// --- AC5: warm cache round-trip ----------------------------------------------
|
||||
|
||||
test('AC5: snapshot → restoreCached reproduces the identity exactly with the persisted LS_CFG keys', () => {
|
||||
const adoption = adoptedWith(cfg('Ground floor'), 3);
|
||||
adoption.adoptMergedLayout({ d: { s: 'f1', x: 0.1, y: 0.2 } }, { rev: 7 });
|
||||
const snapshot = adoption.snapshot({ rev: 0, config_rev: 3, off: [] });
|
||||
assert.deepEqual(Object.keys(snapshot).sort(), [
|
||||
'config', 'config_fingerprint', 'layout', 'layout_fingerprint', 'layout_rev', 'rev', 'virtual_lights',
|
||||
]);
|
||||
const restored = createConfigAdoption();
|
||||
assert.equal(restored.restoreCached(JSON.parse(JSON.stringify(snapshot))), true);
|
||||
assert.deepEqual(restored.config, adoption.config);
|
||||
assert.equal(restored.configRev, 3);
|
||||
assert.equal(restored.configFingerprint, adoption.configFingerprint);
|
||||
assert.deepEqual(restored.layout, adoption.layout);
|
||||
assert.equal(restored.layoutRev, 7);
|
||||
assert.equal(restored.layoutFingerprint, adoption.layoutFingerprint);
|
||||
});
|
||||
|
||||
test('AC5: a cache written before fingerprints existed restores with recomputed fingerprints; garbage is refused', () => {
|
||||
const adoption = createConfigAdoption();
|
||||
const config = cfg('Old cache');
|
||||
assert.equal(adoption.restoreCached({ config, rev: 2, layout: { d: {} }, layout_rev: 1 }), true);
|
||||
assert.equal(adoption.configFingerprint, contentFingerprint(config));
|
||||
assert.equal(adoption.layoutFingerprint, contentFingerprint({ d: {} }));
|
||||
const empty = createConfigAdoption();
|
||||
assert.equal(empty.restoreCached(null), false);
|
||||
assert.equal(empty.restoreCached({ config: { spaces: 'nope' } }), false);
|
||||
assert.equal(empty.config, null);
|
||||
});
|
||||
|
||||
test('snapshot re-pairs fingerprints with bodies mutated in place before the debounced write', () => {
|
||||
const adoption = adoptedWith(cfg('Ground floor'), 3);
|
||||
adoption.config.spaces[0].title = 'Mutated in place';
|
||||
assert.notEqual(adoption.configFingerprint, contentFingerprint(adoption.config));
|
||||
const snapshot = adoption.snapshot(null);
|
||||
assert.equal(snapshot.config_fingerprint, contentFingerprint(adoption.config));
|
||||
assert.equal(adoption.configFingerprint, snapshot.config_fingerprint);
|
||||
assert.equal(createConfigAdoption().snapshot(null), null);
|
||||
});
|
||||
|
||||
// --- AC6: optimistic writes (#314/#439/#442) --------------------------------
|
||||
|
||||
test('AC6: a rejected optimistic write restores only its own candidate (#439)', () => {
|
||||
const adoption = adoptedWith(cfg('Ground floor'), 7);
|
||||
const previous = adoption.config;
|
||||
const attempted = cfg('Draft');
|
||||
const attempt = adoption.beginOptimistic(previous, attempted);
|
||||
assert.equal(attempt.revision, 7);
|
||||
assert.equal(attempt.previousFingerprint, adoption.configFingerprint);
|
||||
adoption.stageLocalConfig(attempted);
|
||||
assert.equal(adoption.rollbackOptimistic(attempt), true);
|
||||
assert.deepEqual(adoption.config, previous);
|
||||
assert.notEqual(adoption.config, previous, 'the rollback snapshot is isolated from later mutations');
|
||||
assert.equal(adoption.configFingerprint, attempt.previousFingerprint);
|
||||
assert.equal(adoption.configRev, 7, 'a rollback never touches the revision');
|
||||
});
|
||||
|
||||
test('AC6: a conflict reload or a newer mutation wins over a rejected candidate (#439)', () => {
|
||||
const adoption = adoptedWith(cfg('Server before'), 3);
|
||||
const attempt = adoption.beginOptimistic(adoption.config, cfg('Draft'));
|
||||
adoptStructuralResponses(hostStub(adoption), { config: cfg('Server after'), rev: 4 });
|
||||
const authoritative = adoption.config;
|
||||
assert.equal(adoption.rollbackOptimistic(attempt), false, 'a newer revision wins');
|
||||
assert.equal(adoption.config, authoritative);
|
||||
const same = createConfigAdoption();
|
||||
same.restoreCached({ config: cfg('Server before'), rev: 3 });
|
||||
const attempt2 = same.beginOptimistic(same.config, cfg('Draft'));
|
||||
same.stageLocalConfig(cfg('Newer local edit'));
|
||||
assert.equal(same.rollbackOptimistic(attempt2), false, 'different content on the same revision wins');
|
||||
assert.equal(same.config.spaces[0].title, 'Newer local edit');
|
||||
});
|
||||
|
||||
test('AC6: newer in-place content on the attempted root also wins (#442)', () => {
|
||||
const adoption = adoptedWith(cfg('Server before'), 3);
|
||||
const attempted = cfg('Draft');
|
||||
const attempt = adoption.beginOptimistic(adoption.config, attempted);
|
||||
adoption.stageLocalConfig(attempted);
|
||||
attempted.settings.newer = true;
|
||||
assert.equal(adoption.rollbackOptimistic(attempt), false);
|
||||
assert.equal(adoption.config, attempted);
|
||||
});
|
||||
@@ -194,9 +194,12 @@ test('frontend write paths adopt canonical candidates before persistence (#224)'
|
||||
source,
|
||||
/const canonicalCandidate = canonicalizeConfigGeometry\(candidate\);[\s\S]*config: canonicalCandidate/,
|
||||
);
|
||||
// #500: the reactive root is replaced only when canonicalization changed
|
||||
// content — the rule moved into the identity owner with the writers.
|
||||
assert.match(source, /this\._adoption\.stageConfigCandidate\(candidate\);\s*(?:try \{\s*)?await this\._sendConfigCandidate\(candidate\);/);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(candidateFingerprint !== contentFingerprint\(this\._serverCfg\)\) \{\s*this\._serverCfg = candidate;/,
|
||||
readFileSync(new URL('../src/config-adoption.ts', import.meta.url), 'utf8'),
|
||||
/stageConfigCandidate\(candidate: ServerConfig\): void \{\s*const fingerprint = contentFingerprint\(candidate\);\s*if \(fingerprint !== contentFingerprint\(this\.config\)\) this\.config = candidate;/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
|
||||
@@ -21,7 +21,9 @@ const SLACK = 250;
|
||||
const CAPS = {
|
||||
// #485 adds the View-side subscription/render integration seams; the live
|
||||
// model and rendering themselves remain in dedicated modules.
|
||||
'src/houseplan-card.ts': 13700,
|
||||
// #500 moved config/layout identity and the adoption sequence into
|
||||
// src/config-adoption.ts; the ratchet keeps that win.
|
||||
'src/houseplan-card.ts': 13600,
|
||||
// #478 removed the persisted room-draft editor branch. Keep that reduction.
|
||||
// #485 keeps its large setup surface in editors/radar-section.ts; these are
|
||||
// only the dialog state/save seam and the thin lazy-render adapter.
|
||||
|
||||
@@ -29,7 +29,8 @@ test('#397 AC1: the update branch stores what it sends, before sending it', () =
|
||||
assert.ok(branch, 'the persist method must be found');
|
||||
const write = branch.indexOf('this._layout = { ...this._layout, [deviceId]: pos }');
|
||||
const send = branch.indexOf("type: 'houseplan/layout/update'");
|
||||
const fingerprint = branch.indexOf('this._layoutContentFingerprint = contentFingerprint(this._layout)');
|
||||
// #500: the fingerprint is re-paired by the identity owner, not assigned by hand.
|
||||
const fingerprint = branch.indexOf('this._adoption.refreshLayoutFingerprint()');
|
||||
assert.ok(write > 0, 'the canonical position must be written back into _layout');
|
||||
assert.ok(write < send,
|
||||
'the local copy is updated BEFORE the wire, so a reload racing the answer '
|
||||
@@ -41,7 +42,7 @@ test('#397 AC1: the update branch stores what it sends, before sending it', () =
|
||||
test('#397 AC5a: the delete branch removes the key before the fingerprint', () => {
|
||||
const method = CARD.slice(CARD.indexOf('private async _persistDevicePlacement'));
|
||||
const apply = method.indexOf('applyDevicePlacement(this._layout, deviceId, placement)');
|
||||
const fingerprint = method.indexOf('this._layoutContentFingerprint = contentFingerprint(this._layout)');
|
||||
const fingerprint = method.indexOf('this._adoption.refreshLayoutFingerprint()');
|
||||
assert.ok(apply > 0 && apply < fingerprint,
|
||||
'both branches mutate _layout through applyDevicePlacement before the '
|
||||
+ 'fingerprint is recorded — deletion removes the key, not replaces a value');
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// #500: test hosts mirror the card's delegation — identity lives in a real
|
||||
// `ConfigAdoption`, the six host fields are accessors over it, bodies may be
|
||||
// staged through the setters exactly as production code does.
|
||||
import { createConfigAdoption } from '../../test-build/config-adoption.js';
|
||||
|
||||
/**
|
||||
* Install `_adoption` and the delegating accessors on a plain host stub.
|
||||
* `identity` seeds config/layout with revisions; fingerprints are computed.
|
||||
*/
|
||||
export function installAdoption(host, { config = null, layout = {}, configRev = 0, layoutRev = 0 } = {}) {
|
||||
const adoption = createConfigAdoption();
|
||||
if (config) {
|
||||
adoption.restoreCached({ config, rev: configRev, layout, layout_rev: layoutRev });
|
||||
} else {
|
||||
adoption.stageLocalLayout(layout);
|
||||
adoption.noteLayoutRevision(layoutRev);
|
||||
}
|
||||
Object.defineProperties(host, {
|
||||
_adoption: { value: adoption, enumerable: true },
|
||||
_serverCfg: {
|
||||
get: () => adoption.config,
|
||||
set: (next) => adoption.stageLocalConfig(next),
|
||||
enumerable: true,
|
||||
},
|
||||
_layout: {
|
||||
get: () => adoption.layout,
|
||||
set: (next) => adoption.stageLocalLayout(next),
|
||||
enumerable: true,
|
||||
},
|
||||
_cfgRev: { get: () => adoption.configRev, enumerable: true },
|
||||
_layoutRev: { get: () => adoption.layoutRev, enumerable: true },
|
||||
_cfgContentFingerprint: { get: () => adoption.configFingerprint, enumerable: true },
|
||||
_layoutContentFingerprint: { get: () => adoption.layoutFingerprint, enumerable: true },
|
||||
_rollbackOptimistic: {
|
||||
value: (attempt) => {
|
||||
const rolledBack = adoption.rollbackOptimistic(attempt);
|
||||
if (rolledBack) host.requestUpdate?.();
|
||||
return rolledBack;
|
||||
},
|
||||
enumerable: true,
|
||||
},
|
||||
});
|
||||
return adoption;
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import test from 'node:test';
|
||||
import {
|
||||
enqueueSerializedWrite,
|
||||
optimisticAttempt,
|
||||
rollbackOptimistic,
|
||||
} from '../test-build/serialized-write-queue.js';
|
||||
|
||||
function deferred() {
|
||||
@@ -56,64 +55,17 @@ test('a failed write does not poison the next queued edit (#224)', async () => {
|
||||
assert.deepEqual(seen, ['failed', 'recovered']);
|
||||
});
|
||||
|
||||
test('a rejected optimistic write restores only its own candidate (#439)', () => {
|
||||
// The rollback of an optimistic attempt moved to the identity owner (#500):
|
||||
// see test/config-adoption.test.mjs (#439/#442 cases live there now).
|
||||
test('optimisticAttempt snapshots the previous body by value (#439)', () => {
|
||||
const fingerprint = (value) => JSON.stringify(value);
|
||||
const previous = { settings: { enabled: true } };
|
||||
const attempted = { settings: { enabled: false } };
|
||||
const attempt = optimisticAttempt(previous, attempted, 'server-fingerprint', 7, fingerprint);
|
||||
let updates = 0;
|
||||
const host = {
|
||||
_serverCfg: attempted,
|
||||
_cfgRev: 7,
|
||||
_cfgContentFingerprint: fingerprint(attempted),
|
||||
requestUpdate: () => { updates += 1; },
|
||||
};
|
||||
|
||||
assert.equal(rollbackOptimistic(host, attempt, fingerprint), true);
|
||||
assert.deepEqual(host._serverCfg, previous);
|
||||
assert.notEqual(host._serverCfg, previous, 'the rollback snapshot is isolated from later mutations');
|
||||
assert.equal(host._cfgContentFingerprint, 'server-fingerprint');
|
||||
assert.equal(updates, 1);
|
||||
});
|
||||
|
||||
test('a conflict reload or newer mutation wins over a rejected candidate (#439)', () => {
|
||||
const fingerprint = (value) => JSON.stringify(value);
|
||||
const previous = { settings: { value: 'server-before' } };
|
||||
const attempted = { settings: { value: 'draft' } };
|
||||
const attempt = optimisticAttempt(previous, attempted, 'before', 3, fingerprint);
|
||||
const authoritative = { settings: { value: 'server-after' } };
|
||||
let updates = 0;
|
||||
const host = {
|
||||
_serverCfg: authoritative,
|
||||
_cfgRev: 4,
|
||||
_cfgContentFingerprint: fingerprint(authoritative),
|
||||
requestUpdate: () => { updates += 1; },
|
||||
};
|
||||
|
||||
assert.equal(rollbackOptimistic(host, attempt, fingerprint), false);
|
||||
assert.equal(host._serverCfg, authoritative);
|
||||
assert.equal(updates, 0);
|
||||
|
||||
host._cfgRev = 3;
|
||||
host._serverCfg = { settings: { value: 'newer-local-edit' } };
|
||||
assert.equal(rollbackOptimistic(host, attempt, fingerprint), false);
|
||||
assert.equal(host._serverCfg.settings.value, 'newer-local-edit');
|
||||
assert.equal(updates, 0);
|
||||
});
|
||||
|
||||
test('newer in-place content on the attempted root also wins (#442)', () => {
|
||||
const fingerprint = (value) => JSON.stringify(value);
|
||||
const previous = { settings: { value: 'server-before' } };
|
||||
const attempted = { settings: { value: 'draft' } };
|
||||
const attempt = optimisticAttempt(previous, attempted, 'before', 3, fingerprint);
|
||||
const host = {
|
||||
_serverCfg: attempted,
|
||||
_cfgRev: 3,
|
||||
_cfgContentFingerprint: fingerprint(attempted),
|
||||
requestUpdate: () => assert.fail('newer content must not be rolled back'),
|
||||
};
|
||||
|
||||
attempted.settings.newer = true;
|
||||
assert.equal(rollbackOptimistic(host, attempt, fingerprint), false);
|
||||
assert.equal(host._serverCfg, attempted);
|
||||
assert.deepEqual(attempt.previous, previous);
|
||||
assert.notEqual(attempt.previous, previous, 'the rollback snapshot is isolated from later mutations');
|
||||
assert.equal(attempt.previousFingerprint, 'server-fingerprint');
|
||||
assert.equal(attempt.revision, 7);
|
||||
assert.equal(attempt.attempted, attempted);
|
||||
assert.equal(attempt.attemptedFingerprint, fingerprint(attempted));
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import test from 'node:test';
|
||||
import {
|
||||
closeSpaceCopyDialog, openSpaceCopyDialog, saveSpaceCopy,
|
||||
} from '../test-build/space-copy-runtime.js';
|
||||
import { contentFingerprint } from '../test-build/visual-continuity.js';
|
||||
import { installAdoption } from './helpers/adoption-host.mjs';
|
||||
|
||||
const configFixture = () => ({
|
||||
model_version: 9,
|
||||
@@ -24,11 +24,6 @@ function runtimeHarness(overrides = {}) {
|
||||
mode: 'edit', spaceId: 'source', title: 'unsaved settings title', busy: false,
|
||||
copy: { title: 'Floor (2)', busy: false, token: 0 },
|
||||
},
|
||||
_serverCfg: config,
|
||||
_layout: {},
|
||||
_cfgRev: 5,
|
||||
_layoutRev: 8,
|
||||
_cfgContentFingerprint: contentFingerprint(config),
|
||||
_saveConfigDebounced: { pending: () => false, flush: () => events.push('flush-config') },
|
||||
_writeChain: Promise.resolve(),
|
||||
_geometryHistory: { clear: () => events.push('clear-geometry-history') },
|
||||
@@ -72,8 +67,9 @@ function runtimeHarness(overrides = {}) {
|
||||
_activeWallChainPartitionIds: ['partition'],
|
||||
_primeDrawWallField: () => events.push('prime-draw'),
|
||||
_saveNav: () => events.push('save-nav'),
|
||||
...overrides,
|
||||
};
|
||||
installAdoption(host, { config, layout: {}, configRev: 5, layoutRev: 8 });
|
||||
Object.assign(host, overrides);
|
||||
const services = {
|
||||
clearGeometryGesture: () => {
|
||||
events.push('clear-gesture');
|
||||
@@ -85,8 +81,7 @@ function runtimeHarness(overrides = {}) {
|
||||
reportPreflightFailure: () => events.push('report-preflight'),
|
||||
saveConfigNow: async () => {
|
||||
events.push('config-write');
|
||||
host._cfgRev++;
|
||||
host._cfgContentFingerprint = contentFingerprint(host._serverCfg);
|
||||
host._adoption.acceptConfigWrite(host._serverCfg, { rev: host._cfgRev + 1 });
|
||||
},
|
||||
setMode: () => events.push('mode:plan'),
|
||||
showWallModelMigrationBlocked: () => events.push('migration-blocked'),
|
||||
@@ -168,7 +163,8 @@ test('#456 a concurrent plan change invalidates an open Optimize confirmation',
|
||||
});
|
||||
harness.host._confirmDanger = async () => {
|
||||
harness.events.push('confirm');
|
||||
harness.host._cfgRev++;
|
||||
// another client's write landed while the confirmation was open
|
||||
harness.host._adoption.acceptConfigWrite(harness.host._serverCfg, { rev: harness.host._cfgRev + 1 });
|
||||
return true;
|
||||
};
|
||||
await saveSpaceCopy(harness.host, harness.services);
|
||||
@@ -220,8 +216,7 @@ test('#456 a rejected copy rolls back only the copy and keeps accepted Optimize'
|
||||
const { host, services, events } = runtimeHarness({
|
||||
_reloadConfigOnly: async () => {
|
||||
events.push('reload-config');
|
||||
host._serverCfg = structuredClone(optimized);
|
||||
host._cfgContentFingerprint = contentFingerprint(host._serverCfg);
|
||||
host._adoption.stageConfigCandidate(structuredClone(optimized));
|
||||
},
|
||||
});
|
||||
services.optimize = (_currentConfig, currentLayout) => ({
|
||||
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
saveManualCalibration,
|
||||
saveVacuumMatrix,
|
||||
} from '../test-build/vacuum-calibration-write.js';
|
||||
|
||||
const fingerprint = (value) => JSON.stringify(value);
|
||||
import { installAdoption } from './helpers/adoption-host.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
@@ -34,13 +33,10 @@ function fixture({ marker = true, save } = {}) {
|
||||
const toasts = [];
|
||||
let rebuilds = 0;
|
||||
const host = {
|
||||
_serverCfg: accepted,
|
||||
_devices: [{
|
||||
id: 'vac', bindingKind: 'entity', bindingRef: 'vacuum.demo',
|
||||
space: 'ground', area: null, hidden: false,
|
||||
}],
|
||||
_cfgContentFingerprint: fingerprint(accepted),
|
||||
_cfgRev: 7,
|
||||
_saveConfigDebounced: { pending: () => true, cancel: () => {} },
|
||||
_regSignature: 'accepted',
|
||||
_markerDialog: { busy: false, name: 'Vacuum draft' },
|
||||
@@ -54,11 +50,12 @@ function fixture({ marker = true, save } = {}) {
|
||||
_errText: (error) => String(error?.message || error),
|
||||
requestUpdate: () => {},
|
||||
};
|
||||
installAdoption(host, { config: accepted, configRev: 7 });
|
||||
const runtime = {
|
||||
host,
|
||||
_prepareConfigCandidate: (config) => config,
|
||||
_saveConfigNow: save || (async () => {
|
||||
host._cfgContentFingerprint = fingerprint(host._serverCfg);
|
||||
host._adoption.refreshConfigFingerprint();
|
||||
}),
|
||||
};
|
||||
return { accepted, host, runtime, toasts, rebuilds: () => rebuilds };
|
||||
@@ -68,7 +65,7 @@ test('rejected matrix restores the accepted marker and a retry can persist (#442
|
||||
let reject = true;
|
||||
const f = fixture({ save: async () => {
|
||||
if (reject) throw new Error('semantic reject');
|
||||
f.host._cfgContentFingerprint = fingerprint(f.host._serverCfg);
|
||||
f.host._adoption.refreshConfigFingerprint();
|
||||
} });
|
||||
|
||||
assert.equal(await saveVacuumMatrix(
|
||||
|
||||
@@ -131,6 +131,9 @@ test('#278 production source routes physical writers through one barrier and dec
|
||||
assert.match(source,
|
||||
/if \(physicalChanged\)[\s\S]{0,2500}_pendingPhysicalWrites\.set\((?:state|target)\.spaceId/,
|
||||
'physical Undo/Redo must retain the deferred-write barrier');
|
||||
assert.match(source, /if \(configChanged\)[\s\S]{0,300}_pendingPhysicalWrites\.clear\(\)/,
|
||||
// #500: the baseline replacement hook lives with the identity owner; the
|
||||
// clear runs inside the config-replace hook passed to adoptResponses.
|
||||
const adoption = readFileSync(new URL('../src/config-adoption.ts', import.meta.url), 'utf8');
|
||||
assert.match(adoption, /adoptResponses\(cfgResp \?\? \{\}, layResp,\s*\(\) => \{[\s\S]{0,400}_pendingPhysicalWrites\.clear\(\)/,
|
||||
'an external baseline must invalidate pending local approvals');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user