Compare commits

...
Author SHA1 Message Date
claude[bot] 96275b8019 docs: review document for #442
Issue: #442
User-Visible: no
2026-09-03 21:42:53 +00:00
Sergey Matyunin ffbf8242ab docs: accept marker rollback screenshot provenance
Issue: #442
User-Visible: no
2026-09-04 00:24:20 +03:00
Sergey Matyunin f9ac1eab35 test: harden marker rollback review gates
Issue: #442
User-Visible: no
2026-09-04 00:21:06 +03:00
Sergey Matyunin fcd4e3ddfe fix: roll back rejected marker writes atomically
Issue: #442
User-Visible: yes
2026-09-04 00:15:44 +03:00
claude[bot] 77ac72d15d docs: review document for #442
Issue: #442
User-Visible: no
2026-09-03 20:54:38 +00:00
Sergey Matyunin a8426268db docs: specify atomic marker write rollback
Define guarded immutable candidates for semantic marker saves and asynchronous retry-safe vacuum calibration UX.

Issue: #442
User-Visible: no
2026-09-03 23:48:18 +03:00
41 changed files with 2001 additions and 457 deletions
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5",
"fingerprint": "56c4f193c6899f80181491944c9d6679698be4658a0ed5ba54dc08d1409538e3",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-CfQ49v8a.js",
"houseplan-assets/houseplan-card-BYuSDJH2.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 293046,
"initialViewGzipBytes": 293163,
"lazyFiles": [
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
"houseplan-assets/de-D6tfUEEi.js",
"houseplan-assets/editor-veGv06fc.js",
"houseplan-assets/fr-B9jqd_9T.js",
"houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js",
"houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js"
"houseplan-assets/backdrop-pick-oWJolMYh.js",
"houseplan-assets/de-CF711iTL.js",
"houseplan-assets/editor-DBDu1fmS.js",
"houseplan-assets/fr-A4-MjeBb.js",
"houseplan-assets/houseplan-editor-runtime-B9vxxIYH.js",
"houseplan-assets/houseplan-onboarding-runtime-2O6rGYyJ.js"
],
"lazyGzipBytes": 221355,
"lazyGzipBytes": 222109,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
"houseplan-assets/editor-veGv06fc.js",
"houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js"
"houseplan-assets/backdrop-pick-oWJolMYh.js",
"houseplan-assets/editor-DBDu1fmS.js",
"houseplan-assets/houseplan-editor-runtime-B9vxxIYH.js"
],
"lazyEditorGzipBytes": 165335,
"lazyEditorGzipBytes": 166061,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
"houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js"
"houseplan-assets/backdrop-pick-oWJolMYh.js",
"houseplan-assets/houseplan-onboarding-runtime-2O6rGYyJ.js"
],
"lazyOnboardingGzipBytes": 14233,
"lazyOnboardingGzipBytes": 14241,
"lazyLocaleFiles": [
"houseplan-assets/de-D6tfUEEi.js",
"houseplan-assets/fr-B9jqd_9T.js"
"houseplan-assets/de-CF711iTL.js",
"houseplan-assets/fr-A4-MjeBb.js"
],
"lazyLocaleGzipBytes": 49066,
"lazyLocaleGzipBytes": 49090,
"files": [
{
"path": "houseplan-assets/backdrop-pick-DsJZC1kQ.js",
"sha256": "f163ec3c045e02b53c31d00b7b18b87394b1d485d01963878d390db5280e9d3c",
"path": "houseplan-assets/backdrop-pick-oWJolMYh.js",
"sha256": "77bedfa35c239b51d4395faff3decd94de86ab24d16e35746ea592696b6eeba2",
"rawBytes": 21418,
"gzipBytes": 7279,
"gzipBytes": 7283,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CfQ49v8a.js"
"houseplan-assets/houseplan-card-BYuSDJH2.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-D6tfUEEi.js",
"sha256": "8bbf9c291b9973c77de053a1677d19846cc61e44232988739af1e31412812a1e",
"path": "houseplan-assets/de-CF711iTL.js",
"sha256": "fc710917dd87b57c5d2bba9b636a28473373540d02348a4175fee903771fb6ec",
"rawBytes": 82140,
"gzipBytes": 24802,
"gzipBytes": 24814,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-veGv06fc.js",
"sha256": "40fcfabb1c6b1df59264c1a0da9d74cb67cfd2230e731f0e2c20e9fdaa21c01e",
"path": "houseplan-assets/editor-DBDu1fmS.js",
"sha256": "5e321ab61ddb1ac20c3911c9e82e8ddc6a62404d5d8dbca6780db37cc6ff0c98",
"rawBytes": 3826,
"gzipBytes": 1580,
"gzipBytes": 1585,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CfQ49v8a.js"
"houseplan-assets/houseplan-card-BYuSDJH2.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-B9jqd_9T.js",
"sha256": "4ec01fd1b61ce5f0a796edb86bcbe4f87dcfba1b37c6f12afca535590ce36b9d",
"path": "houseplan-assets/fr-A4-MjeBb.js",
"sha256": "d247900cdb0bdd2988b3f601bb08e7182c54c2ea05918a8d8edbd6cb5fdc8a70",
"rawBytes": 84267,
"gzipBytes": 24264,
"gzipBytes": 24276,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-CfQ49v8a.js",
"sha256": "46dbdf5dc1881b44d03804f95f9406242f6f491f534466acdfcb1f478618b61d",
"rawBytes": 1040074,
"gzipBytes": 292251,
"path": "houseplan-assets/houseplan-card-BYuSDJH2.js",
"sha256": "f39bf9406326084842e9819c4eb8591cee812635e7b78c25809a72dc8f7f5373",
"rawBytes": 1040585,
"gzipBytes": 292365,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-D6tfUEEi.js",
"houseplan-assets/editor-veGv06fc.js",
"houseplan-assets/fr-B9jqd_9T.js",
"houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js",
"houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js"
"houseplan-assets/de-CF711iTL.js",
"houseplan-assets/editor-DBDu1fmS.js",
"houseplan-assets/fr-A4-MjeBb.js",
"houseplan-assets/houseplan-editor-runtime-B9vxxIYH.js",
"houseplan-assets/houseplan-onboarding-runtime-2O6rGYyJ.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js",
"sha256": "3eabc904f3eec1ad453ad7746433e3232caa38ef0797adbf7833318ee22cb946",
"rawBytes": 595676,
"gzipBytes": 156476,
"path": "houseplan-assets/houseplan-editor-runtime-B9vxxIYH.js",
"sha256": "c0df87b7ae1657caa84c811f8efc016c0063d5a17221e221029b00176f5dc466",
"rawBytes": 597836,
"gzipBytes": 157193,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
"houseplan-assets/houseplan-card-CfQ49v8a.js"
"houseplan-assets/backdrop-pick-oWJolMYh.js",
"houseplan-assets/houseplan-card-BYuSDJH2.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js",
"sha256": "d3645c23d4dd4dc4b1dd93452f047b3936e5878f90688a14d5d8693da424a0f7",
"path": "houseplan-assets/houseplan-onboarding-runtime-2O6rGYyJ.js",
"sha256": "e7ce402a46b4f03687f86ae010905d4d75b5b972e7784a1e34b4fd36c463c117",
"rawBytes": 28088,
"gzipBytes": 6954,
"gzipBytes": 6958,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
"houseplan-assets/houseplan-card-CfQ49v8a.js"
"houseplan-assets/backdrop-pick-oWJolMYh.js",
"houseplan-assets/houseplan-card-BYuSDJH2.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "5a6cb0840f0836d5c09b7de0313c57b74c0bb1d5df585067062229b553e89bab",
"sha256": "cf7f4bd0b7bd554d86d54119c7367b553e9f5d8272ee2e95b48469c169e25928",
"rawBytes": 1183,
"gzipBytes": 795,
"gzipBytes": 798,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-CfQ49v8a.js"
"houseplan-assets/houseplan-card-BYuSDJH2.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5";import{k as e,l as o,t,dm as s,A as a,dn as i,dp as l,dq as n,E as r,c}from"./houseplan-card-CfQ49v8a.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
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="56c4f193c6899f80181491944c9d6679698be4658a0ed5ba54dc08d1409538e3";import{k as e,l as o,t,dm as s,A as a,dn as i,dp as l,dq as n,E as r,c}from"./houseplan-card-BYuSDJH2.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}
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5";import{l as t,aN as s,A as e,t as o,c as a,cc as l,cd as i,ce as h,cf as c,aK as n,cg as r,cl as p,ch as d,ci as _,cj as u,ck as g,db as m,aJ as b,aL as $,dc as v}from"./houseplan-card-CfQ49v8a.js";import{i as f,c as y,e as w,r as D,a as k,b as S,d as C,t as M}from"./backdrop-pick-DsJZC1kQ.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5";class L{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="56c4f193c6899f80181491944c9d6679698be4658a0ed5ba54dc08d1409538e3";import{l as t,aN as s,A as e,t as o,c as a,cd as l,ce as i,cf as h,cg as c,aK as n,ch as r,cm as p,ci as d,cj as _,ck as u,cl as g,dc as m,aJ as b,aL as $,dd as v}from"./houseplan-card-BYuSDJH2.js";import{i as f,c as y,e as w,r as D,a as k,b as S,d as C,t as M}from"./backdrop-pick-oWJolMYh.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="56c4f193c6899f80181491944c9d6679698be4658a0ed5ba54dc08d1409538e3";class L{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
.text=${o(h,l)} .ariaLabel=${o(h,i)}></hp-help>`:e}_openSpaceDialog(t,s){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===t){const e=this.host._serverCfg.spaces.find(t=>t.id===s);if(!e)return;const o=l(e),a=e.settings?.custom_fill&&"object"==typeof e.settings.custom_fill?i(e.settings.custom_fill):null,p="none"===o.fill?{...a||h,a:0}:a;return void(this.host._spaceDialog={mode:t,spaceId:s,title:e.title,planUrl:e.plan_url||null,planFile:null,source:e.plan_url?"file":"draw",showBorders:o.showBorders,showNames:o.showNames,zeroWallStyle:r(e),displayTouched:!0,hideDecor:o.hideDecor,hideOpenings:o.hideOpenings,roomColor:o.color,roomOpacity:o.opacity,fillMode:"none"===o.fill?"custom":o.fill,customFill:p,glowEnabled:o.glow,bgColor:o.bgColor,bgMode:"static"===e.settings?.bg_mode||"daynight"===e.settings?.bg_mode?e.settings.bg_mode:null,northDeg:n({},e.settings),sunRays:"boolean"==typeof e.settings?.sun_rays?e.settings.sun_rays:null,tempMin:o.tempMin,tempMax:o.tempMax,showLqi:o.showLqi??this.host._config?.show_signal??!0,cardFontScale:o.cardFontScale,labelTemp:o.labelTemp,labelHum:o.labelHum,labelLqi:o.labelLqi,labelLight:o.labelLight,cellCm:Number(e.cell_cm)>0?Number(e.cell_cm):5,cellCmInput:c(Number(e.cell_cm)>0?Number(e.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const e=p(this.host._imperial);this.host._spaceDialog={mode:t,title:"",planUrl:null,planFile:null,...f(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:g,roomOpacity:u,fillMode:"custom",customFill:{...h,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:_,tempMax:d,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:e,cellCmInput:c(e,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;s.value="";const o=await y(e);if("reject"===o.kind)return void this.host._showToast(this.host._t("toast.plan_formats"));if("guard"===o.kind)return void(this.host._backdropGuard=o.state);const a=await w(e,o.ext,e.name);this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:a})}_renderBackdropGuard(){return D(this.host,t=>{this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:t})},()=>{this.host._backdropGuard=null},this.host.hass)??e}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){const s=this.host._spaceDialog,e=s?.saved?.find(s=>s.name===t);if(!s||!e||e.used_by.length||e.url===s.planUrl)return;const o=await this.host._confirmDanger({key:"delete-plan",kind:"destructive",title:this.host._t("confirm.delete_plan_title"),message:this.host._t("confirm.delete_plan_body"),objectName:t,confirmLabel:this.host._t("btn.delete"),cancelLabel:this.host._t("btn.cancel")}),a=this.host._spaceDialog,l=a?.saved?.find(s=>s.name===t);if(o&&a&&l&&l.url===e.url&&l.modified===e.modified&&!l.used_by.length&&l.url!==a.planUrl)try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return a`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return a`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return a`<div class="savedplans">
${s.map(s=>{return a`
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5";try{await import("./houseplan-assets/houseplan-card-CfQ49v8a.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="56c4f193c6899f80181491944c9d6679698be4658a0ed5ba54dc08d1409538e3";try{await import("./houseplan-assets/houseplan-card-BYuSDJH2.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+92
View File
@@ -0,0 +1,92 @@
// #442: marker Save is an immutable config transaction. A rejected semantic
// write restores the accepted View while keeping the dialog draft for Retry;
// failures after config acceptance never roll the accepted marker back.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const c = window.__card;
const result = {};
await new Promise((resolve) => setTimeout(resolve, 650));
c._saveConfigDebounced?.flush?.();
await c._writeChain;
await c._areaRelocationWrite;
c._setMode('devices');
await c.updateComplete;
const device = c._devices[0];
const markerId = device.id;
const acceptedName = c._serverCfg.markers.find((item) => item.id === markerId)?.name ?? null;
let acceptedFingerprint = c._cfgContentFingerprint;
c._openMarkerDialog(device);
await c.updateComplete;
const draftName = 'Rejected marker draft #442';
c._markerDialog = { ...c._markerDialog, name: draftName };
const realWS = c.hass.callWS;
let rejectConfig = false;
let configWrites = 0;
let configGate = null;
let rejectLayout = false;
c.hass = { ...c.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/set') {
configWrites += 1;
const rejectThis = rejectConfig;
if (configGate) await configGate.promise;
if (rejectThis) throw new Error('semantic marker reject');
}
if (message.type === 'houseplan/layout/update' && rejectLayout)
throw new Error('layout unavailable');
return realWS(message);
} };
await c.updateComplete;
// Queue the rejected marker attempt behind an older accepted write. Its
// rollback guard must adopt the revision actually used after the queue wait.
let releasePrior;
configGate = { promise: new Promise((resolve) => { releasePrior = resolve; }) };
const priorWrite = c._writeConfig();
while (configWrites === 0) await new Promise((resolve) => setTimeout(resolve, 0));
acceptedFingerprint = c._cfgContentFingerprint;
rejectConfig = true;
const rejectedWrite = c._saveMarker();
releasePrior();
await priorWrite;
configGate = null;
await rejectedWrite;
await c.updateComplete;
const rejectedMarker = c._serverCfg.markers.find((item) => item.id === markerId);
result.rejectedConfigRestored = (rejectedMarker?.name ?? null) === acceptedName;
result.rejectedViewRestored = c._devices.find((item) => item.id === markerId)?.name === device.name;
result.acceptedFingerprintRestored = c._cfgContentFingerprint === acceptedFingerprint;
result.dialogDraftPreserved = c._markerDialog?.name === draftName && c._markerDialog.busy === false;
result.rejectHasNoSuccess = c._toast !== c._t('toast.marker_saved');
result.oneRejectedWrite = configWrites === 2;
rejectConfig = false;
await c._saveMarker();
await c.updateComplete;
result.retryAccepted = c._serverCfg.markers.find((item) => item.id === markerId)?.name === draftName;
result.retryClosedAndToasted = c._markerDialog === null && c._toast === c._t('toast.marker_saved');
result.retryWroteOnce = configWrites === 3;
// A new virtual marker has a layout side effect after config/set. Reject
// that second phase: the already accepted config must remain authoritative.
c._openMarkerDialog();
const virtualName = 'Accepted before layout failure #442';
c._markerDialog = { ...c._markerDialog, name: virtualName, binding: 'virtual' };
rejectLayout = true;
await c._saveMarker();
await c.updateComplete;
result.sideEffectFailureKeepsAcceptedConfig = c._serverCfg.markers
.some((item) => item.name === virtualName);
result.sideEffectFailureHasNoSuccess = c._toast !== c._t('toast.marker_saved');
result.sideEffectFailureKeepsDraft = c._markerDialog?.name === virtualName
&& c._markerDialog.busy === false;
c.hass = { ...c.hass, callWS: realWS };
return result;
});
checkAll(out);
await finish(browser, out);
+1 -1
View File
@@ -262,7 +262,7 @@ const out = await page.evaluate(async () => {
await c.updateComplete;
o.fitCornerScales = Math.abs(c._vacFit.p.s - s0) > 1e-6;
// save -> matrix stored for m2, panel closed, puck appears while cleaning
c._vacFitSave(); await c.updateComplete;
await c._vacFitSave(); await c.updateComplete;
const savedM = c._serverCfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.calibration.m2;
o.fitSavedMatrix = Array.isArray(savedM) && savedM.length === 6 && savedM.every(Number.isFinite);
o.fitClosed = !c._vacFit && !sr().querySelector('.vacfit');
+90 -15
View File
@@ -56,14 +56,23 @@ const out = await page.evaluate(async () => {
// spy on config writes: a success toast is only honest after one of these
const writes = [];
const realWS = c.hass.callWS;
c.hass.callWS = async (m) => { if (m.type === 'houseplan/config/set') writes.push(m); return realWS(m); };
let configGate = null;
let rejectConfig = false;
c.hass.callWS = async (m) => {
if (m.type === 'houseplan/config/set') {
writes.push(m);
if (configGate) await configGate.promise;
if (rejectConfig) throw new Error('calibration rejected');
}
return realWS(m);
};
const dev = c._devices.find((x) => x.id === 'd_mower');
o.devFound = !!dev;
o.freshNoMarker = (c._serverCfg.markers || []).length === 0 && !dev.marker;
// ---- auto-calibration from scratch (HP-1540-01 + HP-1540-04) ----
c._vacAutoCalibrate(dev);
await c._vacAutoCalibrate(dev);
await c.updateComplete;
const m1 = (c._serverCfg.markers || []).find((x) => x.id === 'd_mower');
o.markerMaterialised = !!m1 && m1.binding === 'device:d_mower';
@@ -79,25 +88,91 @@ const out = await page.evaluate(async () => {
o.configWritePersisted = writes.length >= 1
&& !!writes[writes.length - 1].config.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'];
// #442: a low-residual write stays busy until config/set answers. Rejecting
// it restores the accepted matrix and leaves the Device editor usable.
const acceptedBeforeReject = [...cal0];
for (const room of Object.values(robotRooms)) { room.x0 += 200; room.x1 += 200; }
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(c._devices.find((x) => x.id === 'd_mower')); await c.updateComplete;
let releaseGate;
configGate = { promise: new Promise((resolve) => { releaseGate = resolve; }) };
rejectConfig = true;
c._toast = null;
const beforeDeferredWrites = writes.length;
const rejectedAuto = c._vacAutoCalibrate(c._devices.find((x) => x.id === 'd_mower'));
while (writes.length === beforeDeferredWrites) await new Promise((resolve) => setTimeout(resolve, 0));
await c.updateComplete;
const autoButton = [...sr().querySelectorAll('.vacbtns button')]
.find((button) => button.textContent.includes(c._t('vac.autocal')));
o.autoWaitsBusyDisabled = c._markerDialog?.busy === true && autoButton?.disabled === true;
o.autoNoEarlySuccess = c._toast === null;
await c._vacAutoCalibrate(c._devices.find((x) => x.id === 'd_mower'));
o.autoDoubleClickSuppressed = writes.length === beforeDeferredWrites + 1;
releaseGate();
await rejectedAuto; await c.updateComplete;
o.autoRejectRestoresAccepted = JSON.stringify(
c._serverCfg.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'],
) === JSON.stringify(acceptedBeforeReject);
o.autoRejectKeepsDialog = c._markerDialog?.busy === false;
o.autoRejectHasNoSuccess = !String(c._toast || '').startsWith('Done: bound via');
configGate = null;
rejectConfig = false;
await c._vacAutoCalibrate(c._devices.find((x) => x.id === 'd_mower'));
await c.updateComplete;
o.autoRetryAccepted = JSON.stringify(
c._serverCfg.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'],
) !== JSON.stringify(acceptedBeforeReject)
&& String(c._toast || '').startsWith('Done: bound via');
// High residual is a proposal, never an implicit write. Cover Cancel,
// explicit Apply and manual-fit Apply from the same deterministic fixture.
const lowMatrix = [...cal0];
const lowMatrix = [
...c._serverCfg.markers.find((x) => x.id === 'd_mower').vacuum.calibration['0'],
];
robotRooms[4].x0 = 20600;
robotRooms[4].x1 = 21400;
c._vacAutoCalibrate(dev); await c.updateComplete;
await c._vacAutoCalibrate(dev); await c.updateComplete;
o.highResidualDialog = !!c._vacCalConfirm;
o.highResidualNoImplicitSave = JSON.stringify(m1.vacuum.calibration['0']) === JSON.stringify(lowMatrix);
o.highResidualNoImplicitSave = JSON.stringify(
c._serverCfg.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'],
) === JSON.stringify(lowMatrix);
c._vacCalConfirm = null; await c.updateComplete;
o.highResidualCancelUntouched = JSON.stringify(m1.vacuum.calibration['0']) === JSON.stringify(lowMatrix);
c._vacAutoCalibrate(dev); await c.updateComplete;
c._vacApplyCalibrationProposal(false); await c.updateComplete;
const appliedProposal = [...m1.vacuum.calibration['0']];
o.highResidualCancelUntouched = JSON.stringify(
c._serverCfg.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'],
) === JSON.stringify(lowMatrix);
await c._vacAutoCalibrate(dev); await c.updateComplete;
const rejectedProposalDraft = JSON.stringify(c._vacCalConfirm);
rejectConfig = true;
await c._vacApplyCalibrationProposal(false); await c.updateComplete;
o.highResidualRejectKeepsProposal = !!c._vacCalConfirm
&& c._vacCalConfirm.busy === false
&& JSON.stringify({ ...c._vacCalConfirm, busy: undefined })
=== JSON.stringify({ ...JSON.parse(rejectedProposalDraft), busy: undefined });
o.highResidualRejectRestoresMatrix = JSON.stringify(
c._serverCfg.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'],
) === JSON.stringify(lowMatrix);
rejectConfig = false;
await c._vacApplyCalibrationProposal(false); await c.updateComplete;
const appliedProposal = [
...c._serverCfg.markers.find((x) => x.id === 'd_mower').vacuum.calibration['0'],
];
o.highResidualApplySaved = JSON.stringify(appliedProposal) !== JSON.stringify(lowMatrix);
c._vacAutoCalibrate(dev); await c.updateComplete;
c._vacApplyCalibrationProposal(true); await c.updateComplete;
await c._vacAutoCalibrate(dev); await c.updateComplete;
await c._vacApplyCalibrationProposal(true); await c.updateComplete;
o.highResidualManualOpened = !!c._vacFit && c._vacFit.markerId === 'd_mower';
c._vacFitSave(); await c.updateComplete;
o.highResidualManualApplied = !c._vacFit && Array.isArray(m1.vacuum.calibration['0'])
const manualDraft = JSON.stringify(c._vacFit);
rejectConfig = true;
await c._vacFitSave(); await c.updateComplete;
o.manualRejectKeepsExactDraft = JSON.stringify({ ...c._vacFit, busy: undefined })
=== JSON.stringify({ ...JSON.parse(manualDraft), busy: undefined });
o.manualRejectRestoresMatrix = JSON.stringify(
c._serverCfg.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'],
) === JSON.stringify(appliedProposal);
rejectConfig = false;
await c._vacFitSave(); await c.updateComplete;
o.highResidualManualApplied = !c._vacFit && Array.isArray(
c._serverCfg.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'],
)
&& c._toast === c._t('vac.cal_done');
robotRooms[4].x0 = 1700;
robotRooms[4].x1 = 2500;
@@ -108,7 +183,7 @@ const out = await page.evaluate(async () => {
const dev2 = c._devices.find((x) => x.id === 'd_mower');
o.fitFreshNoMarker = !dev2.marker;
c._vacStartFit(dev2); await c.updateComplete;
c._vacFitSave(); await c.updateComplete;
await c._vacFitSave(); await c.updateComplete;
const m2 = (c._serverCfg.markers || []).find((x) => x.id === 'd_mower');
const calFit = m2?.vacuum?.calibration?.['0'];
o.fitMaterialises = !!m2 && Array.isArray(calFit) && calFit.length === 6;
@@ -167,7 +242,7 @@ const out = await page.evaluate(async () => {
c.hass = { ...c.hass, states: { ...c.hass.states,
'camera.mower_map': { state: 'idle', attributes: { vacuum_position: { x: 1, y: 2, a: 0 }, map_index: 0 } } } };
await c.updateComplete;
c._vacAutoCalibrate(c._devices.find((x) => x.id === 'd_mower'));
await c._vacAutoCalibrate(c._devices.find((x) => x.id === 'd_mower'));
o.noRoomsToastReworded = c._toast === c._t('vac.autocal_no_rooms') && !/point|точк/i.test(c._toast);
c.hass.callWS = realWS;
+1 -1
View File
@@ -94,7 +94,7 @@ const out = await page.evaluate(async () => {
routeId: 'vr2', space: 'garden', matrix: M2, rooms: 3, error: '80 см',
};
await c.updateComplete;
c._vacApplyCalibrationProposal(true);
await c._vacApplyCalibrationProposal(true);
await c.updateComplete; await new Promise((r) => setTimeout(r, 80));
o.manualFitSwitchedToRouteSpace = c._space === 'garden';
o.manualFitKeepsRoute = c._vacFit?.routeId === 'vr2';
+57 -57
View File
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5",
"fingerprint": "56c4f193c6899f80181491944c9d6679698be4658a0ed5ba54dc08d1409538e3",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-CfQ49v8a.js",
"houseplan-assets/houseplan-card-BYuSDJH2.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 293046,
"initialViewGzipBytes": 293163,
"lazyFiles": [
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
"houseplan-assets/de-D6tfUEEi.js",
"houseplan-assets/editor-veGv06fc.js",
"houseplan-assets/fr-B9jqd_9T.js",
"houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js",
"houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js"
"houseplan-assets/backdrop-pick-oWJolMYh.js",
"houseplan-assets/de-CF711iTL.js",
"houseplan-assets/editor-DBDu1fmS.js",
"houseplan-assets/fr-A4-MjeBb.js",
"houseplan-assets/houseplan-editor-runtime-B9vxxIYH.js",
"houseplan-assets/houseplan-onboarding-runtime-2O6rGYyJ.js"
],
"lazyGzipBytes": 221355,
"lazyGzipBytes": 222109,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
"houseplan-assets/editor-veGv06fc.js",
"houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js"
"houseplan-assets/backdrop-pick-oWJolMYh.js",
"houseplan-assets/editor-DBDu1fmS.js",
"houseplan-assets/houseplan-editor-runtime-B9vxxIYH.js"
],
"lazyEditorGzipBytes": 165335,
"lazyEditorGzipBytes": 166061,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
"houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js"
"houseplan-assets/backdrop-pick-oWJolMYh.js",
"houseplan-assets/houseplan-onboarding-runtime-2O6rGYyJ.js"
],
"lazyOnboardingGzipBytes": 14233,
"lazyOnboardingGzipBytes": 14241,
"lazyLocaleFiles": [
"houseplan-assets/de-D6tfUEEi.js",
"houseplan-assets/fr-B9jqd_9T.js"
"houseplan-assets/de-CF711iTL.js",
"houseplan-assets/fr-A4-MjeBb.js"
],
"lazyLocaleGzipBytes": 49066,
"lazyLocaleGzipBytes": 49090,
"files": [
{
"path": "houseplan-assets/backdrop-pick-DsJZC1kQ.js",
"sha256": "f163ec3c045e02b53c31d00b7b18b87394b1d485d01963878d390db5280e9d3c",
"path": "houseplan-assets/backdrop-pick-oWJolMYh.js",
"sha256": "77bedfa35c239b51d4395faff3decd94de86ab24d16e35746ea592696b6eeba2",
"rawBytes": 21418,
"gzipBytes": 7279,
"gzipBytes": 7283,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CfQ49v8a.js"
"houseplan-assets/houseplan-card-BYuSDJH2.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-D6tfUEEi.js",
"sha256": "8bbf9c291b9973c77de053a1677d19846cc61e44232988739af1e31412812a1e",
"path": "houseplan-assets/de-CF711iTL.js",
"sha256": "fc710917dd87b57c5d2bba9b636a28473373540d02348a4175fee903771fb6ec",
"rawBytes": 82140,
"gzipBytes": 24802,
"gzipBytes": 24814,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-veGv06fc.js",
"sha256": "40fcfabb1c6b1df59264c1a0da9d74cb67cfd2230e731f0e2c20e9fdaa21c01e",
"path": "houseplan-assets/editor-DBDu1fmS.js",
"sha256": "5e321ab61ddb1ac20c3911c9e82e8ddc6a62404d5d8dbca6780db37cc6ff0c98",
"rawBytes": 3826,
"gzipBytes": 1580,
"gzipBytes": 1585,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CfQ49v8a.js"
"houseplan-assets/houseplan-card-BYuSDJH2.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-B9jqd_9T.js",
"sha256": "4ec01fd1b61ce5f0a796edb86bcbe4f87dcfba1b37c6f12afca535590ce36b9d",
"path": "houseplan-assets/fr-A4-MjeBb.js",
"sha256": "d247900cdb0bdd2988b3f601bb08e7182c54c2ea05918a8d8edbd6cb5fdc8a70",
"rawBytes": 84267,
"gzipBytes": 24264,
"gzipBytes": 24276,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-CfQ49v8a.js",
"sha256": "46dbdf5dc1881b44d03804f95f9406242f6f491f534466acdfcb1f478618b61d",
"rawBytes": 1040074,
"gzipBytes": 292251,
"path": "houseplan-assets/houseplan-card-BYuSDJH2.js",
"sha256": "f39bf9406326084842e9819c4eb8591cee812635e7b78c25809a72dc8f7f5373",
"rawBytes": 1040585,
"gzipBytes": 292365,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-D6tfUEEi.js",
"houseplan-assets/editor-veGv06fc.js",
"houseplan-assets/fr-B9jqd_9T.js",
"houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js",
"houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js"
"houseplan-assets/de-CF711iTL.js",
"houseplan-assets/editor-DBDu1fmS.js",
"houseplan-assets/fr-A4-MjeBb.js",
"houseplan-assets/houseplan-editor-runtime-B9vxxIYH.js",
"houseplan-assets/houseplan-onboarding-runtime-2O6rGYyJ.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js",
"sha256": "3eabc904f3eec1ad453ad7746433e3232caa38ef0797adbf7833318ee22cb946",
"rawBytes": 595676,
"gzipBytes": 156476,
"path": "houseplan-assets/houseplan-editor-runtime-B9vxxIYH.js",
"sha256": "c0df87b7ae1657caa84c811f8efc016c0063d5a17221e221029b00176f5dc466",
"rawBytes": 597836,
"gzipBytes": 157193,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
"houseplan-assets/houseplan-card-CfQ49v8a.js"
"houseplan-assets/backdrop-pick-oWJolMYh.js",
"houseplan-assets/houseplan-card-BYuSDJH2.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js",
"sha256": "d3645c23d4dd4dc4b1dd93452f047b3936e5878f90688a14d5d8693da424a0f7",
"path": "houseplan-assets/houseplan-onboarding-runtime-2O6rGYyJ.js",
"sha256": "e7ce402a46b4f03687f86ae010905d4d75b5b972e7784a1e34b4fd36c463c117",
"rawBytes": 28088,
"gzipBytes": 6954,
"gzipBytes": 6958,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
"houseplan-assets/houseplan-card-CfQ49v8a.js"
"houseplan-assets/backdrop-pick-oWJolMYh.js",
"houseplan-assets/houseplan-card-BYuSDJH2.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "5a6cb0840f0836d5c09b7de0313c57b74c0bb1d5df585067062229b553e89bab",
"sha256": "cf7f4bd0b7bd554d86d54119c7367b553e9f5d8272ee2e95b48469c169e25928",
"rawBytes": 1183,
"gzipBytes": 795,
"gzipBytes": 798,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-CfQ49v8a.js"
"houseplan-assets/houseplan-card-BYuSDJH2.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5";import{k as e,l as o,t,dm as s,A as a,dn as i,dp as l,dq as n,E as r,c}from"./houseplan-card-CfQ49v8a.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
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="56c4f193c6899f80181491944c9d6679698be4658a0ed5ba54dc08d1409538e3";import{k as e,l as o,t,dm as s,A as a,dn as i,dp as l,dq as n,E as r,c}from"./houseplan-card-BYuSDJH2.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}
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,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5";import{l as t,aN as s,A as e,t as o,c as a,cc as l,cd as i,ce as h,cf as c,aK as n,cg as r,cl as p,ch as d,ci as _,cj as u,ck as g,db as m,aJ as b,aL as $,dc as v}from"./houseplan-card-CfQ49v8a.js";import{i as f,c as y,e as w,r as D,a as k,b as S,d as C,t as M}from"./backdrop-pick-DsJZC1kQ.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5";class L{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="56c4f193c6899f80181491944c9d6679698be4658a0ed5ba54dc08d1409538e3";import{l as t,aN as s,A as e,t as o,c as a,cd as l,ce as i,cf as h,cg as c,aK as n,ch as r,cm as p,ci as d,cj as _,ck as u,cl as g,dc as m,aJ as b,aL as $,dd as v}from"./houseplan-card-BYuSDJH2.js";import{i as f,c as y,e as w,r as D,a as k,b as S,d as C,t as M}from"./backdrop-pick-oWJolMYh.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="56c4f193c6899f80181491944c9d6679698be4658a0ed5ba54dc08d1409538e3";class L{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
.text=${o(h,l)} .ariaLabel=${o(h,i)}></hp-help>`:e}_openSpaceDialog(t,s){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===t){const e=this.host._serverCfg.spaces.find(t=>t.id===s);if(!e)return;const o=l(e),a=e.settings?.custom_fill&&"object"==typeof e.settings.custom_fill?i(e.settings.custom_fill):null,p="none"===o.fill?{...a||h,a:0}:a;return void(this.host._spaceDialog={mode:t,spaceId:s,title:e.title,planUrl:e.plan_url||null,planFile:null,source:e.plan_url?"file":"draw",showBorders:o.showBorders,showNames:o.showNames,zeroWallStyle:r(e),displayTouched:!0,hideDecor:o.hideDecor,hideOpenings:o.hideOpenings,roomColor:o.color,roomOpacity:o.opacity,fillMode:"none"===o.fill?"custom":o.fill,customFill:p,glowEnabled:o.glow,bgColor:o.bgColor,bgMode:"static"===e.settings?.bg_mode||"daynight"===e.settings?.bg_mode?e.settings.bg_mode:null,northDeg:n({},e.settings),sunRays:"boolean"==typeof e.settings?.sun_rays?e.settings.sun_rays:null,tempMin:o.tempMin,tempMax:o.tempMax,showLqi:o.showLqi??this.host._config?.show_signal??!0,cardFontScale:o.cardFontScale,labelTemp:o.labelTemp,labelHum:o.labelHum,labelLqi:o.labelLqi,labelLight:o.labelLight,cellCm:Number(e.cell_cm)>0?Number(e.cell_cm):5,cellCmInput:c(Number(e.cell_cm)>0?Number(e.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const e=p(this.host._imperial);this.host._spaceDialog={mode:t,title:"",planUrl:null,planFile:null,...f(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:g,roomOpacity:u,fillMode:"custom",customFill:{...h,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:_,tempMax:d,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:e,cellCmInput:c(e,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;s.value="";const o=await y(e);if("reject"===o.kind)return void this.host._showToast(this.host._t("toast.plan_formats"));if("guard"===o.kind)return void(this.host._backdropGuard=o.state);const a=await w(e,o.ext,e.name);this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:a})}_renderBackdropGuard(){return D(this.host,t=>{this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:t})},()=>{this.host._backdropGuard=null},this.host.hass)??e}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){const s=this.host._spaceDialog,e=s?.saved?.find(s=>s.name===t);if(!s||!e||e.used_by.length||e.url===s.planUrl)return;const o=await this.host._confirmDanger({key:"delete-plan",kind:"destructive",title:this.host._t("confirm.delete_plan_title"),message:this.host._t("confirm.delete_plan_body"),objectName:t,confirmLabel:this.host._t("btn.delete"),cancelLabel:this.host._t("btn.cancel")}),a=this.host._spaceDialog,l=a?.saved?.find(s=>s.name===t);if(o&&a&&l&&l.url===e.url&&l.modified===e.modified&&!l.used_by.length&&l.url!==a.planUrl)try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return a`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return a`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return a`<div class="savedplans">
${s.map(s=>{return a`
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
+1 -1
View File
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5";try{await import("./houseplan-assets/houseplan-card-CfQ49v8a.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="56c4f193c6899f80181491944c9d6679698be4658a0ed5ba54dc08d1409538e3";try{await import("./houseplan-assets/houseplan-card-BYuSDJH2.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Rejected device-marker and robot-calibration saves now restore the last
server-confirmed plan without losing the open editor, automatic proposal or
manual-fit draft; success is shown only after Home Assistant accepts the
write, and Retry no longer requires recalibration
([#442](https://github.com/Matysh/houseplan-card/issues/442)).
- Removing a robot vacuum's last map route now keeps the route list explicitly
empty instead of reviving legacy calibration; routes to deleted spaces are
grouped clearly in the editor, and the live layer avoids rescanning every
+5
View File
@@ -8,6 +8,11 @@
## Не выпущено
- Если Home Assistant отклоняет сохранение устройства или калибровки робота,
план теперь возвращается к последнему подтверждённому состоянию, но сохраняет
открытый редактор, автоматическое предложение или ручную подгонку для повтора;
сообщение об успехе появляется только после принятой записи
([#442](https://github.com/Matysh/houseplan-card/issues/442)).
- После удаления последнего маршрута карты робота список теперь остаётся явно
пустым и не возвращает старую калибровку; маршруты в удалённые пространства
собраны в понятную группу, а живой слой больше не перебирает повторно все
+16
View File
@@ -556,6 +556,22 @@ full/space transfer preserve, remap or report/drop `derived_marker_state.ref`
through the same reference seam as controls and value badges. Older clients
ignore the field and may erase it if they reconstruct the marker.
## Atomic marker writes (#442)
The Device editor builds a separate complete config candidate and treats a
successful `houseplan/config/set` response as the durable boundary. If semantic
validation, transport or schema validation rejects that request, the card
restores the preceding server-confirmed config and content fingerprint, rebuilds
the visible marker set, and keeps the independent dialog draft available for
Retry. A revision loaded after a conflict or newer local content always wins;
an older rejection cannot overwrite either one.
Layout placement, obsolete-layout cleanup and copied-file cleanup happen only
after config acceptance. Their failure can report an error and keep the dialog
open, but cannot roll back a marker already accepted by the server. This is a
frontend transaction contract only: marker schema, validators, revision wire
format and downgrade behaviour are unchanged.
## Persistent manual virtual-light state
The exact `virtual` + `is_light:true` + `tap_action:toggle` combination has a
+9
View File
@@ -126,6 +126,15 @@ At `> 40 cm` nothing is saved until the user explicitly chooses **Apply**.
**Fit manually** opens the proposal in the fit overlay; **Cancel** leaves the
saved configuration byte-for-byte unchanged.
Calibration Save is complete only after Home Assistant accepts the config
write. While it is pending, automatic-calibration controls, a high-residual
proposal or the manual-fit overlay stay busy and cannot submit, close or move a
second draft. On rejection the last accepted matrix is restored immediately:
the Device editor remains open, a high-residual proposal keeps its exact matrix,
and manual fit keeps its exact transform for Retry. A rejected first-use
calibration also removes the unaccepted synthetic marker. Success closes the
relevant proposal/overlay and appears only after server acceptance.
Map ID uses one nullish chain and deliberately ignores volatile values such as
`vacuum_json_id`:
+11 -11
View File
@@ -3,7 +3,7 @@
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"oxipng": "oxipng 10.2.0",
"sourceFingerprint": "e52b6f87a818f9abece38fe6026feede60b32a9017d7b255945720ebdbf8c23e",
"sourceFingerprint": "9ead3fe88e3ea7346dc04221f5d652b0d1bb94331a26a651550117e5e3b45f0a",
"captureScriptSha256": "cadb8e1bcab9f1dcdd7d75b3b90ddcbaaeb2b8c2a098f575a21f39ff70f5c59c",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -15,7 +15,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e52b6f87a818f9abece38fe6026feede60b32a9017d7b255945720ebdbf8c23e",
"sourceSha256": "9ead3fe88e3ea7346dc04221f5d652b0d1bb94331a26a651550117e5e3b45f0a",
"imageSha256": "80a70361dc18dd0461568df332062e6482c633af5d280954f8b675701418a76d"
},
"view-touch": {
@@ -26,7 +26,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e52b6f87a818f9abece38fe6026feede60b32a9017d7b255945720ebdbf8c23e",
"sourceSha256": "9ead3fe88e3ea7346dc04221f5d652b0d1bb94331a26a651550117e5e3b45f0a",
"imageSha256": "4106cc28847047505f46921ff95765d8abdf5b382d9d17d4c5e4ad129dd8f6be"
},
"space-create": {
@@ -37,7 +37,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e52b6f87a818f9abece38fe6026feede60b32a9017d7b255945720ebdbf8c23e",
"sourceSha256": "9ead3fe88e3ea7346dc04221f5d652b0d1bb94331a26a651550117e5e3b45f0a",
"imageSha256": "617b51b3648498787b5039980c9f3eceb75ba56ed63a1a20e616bc05bc304362"
},
"room-contour-close": {
@@ -48,7 +48,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e52b6f87a818f9abece38fe6026feede60b32a9017d7b255945720ebdbf8c23e",
"sourceSha256": "9ead3fe88e3ea7346dc04221f5d652b0d1bb94331a26a651550117e5e3b45f0a",
"imageSha256": "1dab6cc3b9d1bf7d8c40f0e5137f8c688683c9b7eabc5167da99d41ecfdd5b79"
},
"plan-context-tray": {
@@ -59,7 +59,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e52b6f87a818f9abece38fe6026feede60b32a9017d7b255945720ebdbf8c23e",
"sourceSha256": "9ead3fe88e3ea7346dc04221f5d652b0d1bb94331a26a651550117e5e3b45f0a",
"imageSha256": "94ef50762753c0ac6ddc84d2521c4232a3f9ecd89843810d2df61c517592bed7"
},
"device-editor": {
@@ -70,7 +70,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e52b6f87a818f9abece38fe6026feede60b32a9017d7b255945720ebdbf8c23e",
"sourceSha256": "9ead3fe88e3ea7346dc04221f5d652b0d1bb94331a26a651550117e5e3b45f0a",
"imageSha256": "7a601769de38aa19c2e280f6ff4cf3695b854b1d1c6d1f8de4e6b5e8f7eb1559"
},
"device-display-preview": {
@@ -81,7 +81,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e52b6f87a818f9abece38fe6026feede60b32a9017d7b255945720ebdbf8c23e",
"sourceSha256": "9ead3fe88e3ea7346dc04221f5d652b0d1bb94331a26a651550117e5e3b45f0a",
"imageSha256": "0939875f8631694f4de0ef4fd01032010ede8c7d49fb7c0a857612d4b3afff93"
},
"background-editor": {
@@ -92,7 +92,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e52b6f87a818f9abece38fe6026feede60b32a9017d7b255945720ebdbf8c23e",
"sourceSha256": "9ead3fe88e3ea7346dc04221f5d652b0d1bb94331a26a651550117e5e3b45f0a",
"imageSha256": "054170fd9ef45762b602b4d5c9c3b9ea9724858be61af137970c246f485c13bb"
},
"room-card": {
@@ -103,7 +103,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e52b6f87a818f9abece38fe6026feede60b32a9017d7b255945720ebdbf8c23e",
"sourceSha256": "9ead3fe88e3ea7346dc04221f5d652b0d1bb94331a26a651550117e5e3b45f0a",
"imageSha256": "2ae4a58853d98e10d12456b2078ec2f6a0b597722c8310d7b016abd2bc42561e"
},
"device-info": {
@@ -114,7 +114,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e52b6f87a818f9abece38fe6026feede60b32a9017d7b255945720ebdbf8c23e",
"sourceSha256": "9ead3fe88e3ea7346dc04221f5d652b0d1bb94331a26a651550117e5e3b45f0a",
"imageSha256": "cb37f7eefd936f98ef44969b21dfe8e40ee27d1f558abe8d53c0223f6488b8a6"
}
},
+271
View File
@@ -0,0 +1,271 @@
# CODE-REVIEW-442-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/442
- Этап: code (PROCESS.md §2.7)
- Заход: r1 (первый код-ревью цикл; спек-ревью r1 уже зелёный)
- Ветка: `issue/442-marker-write-rollback`, HEAD `ffbf8242abf441196f731012f454f850c102dfaa`
- ТЗ: `docs/specs/442-marker-write-rollback.md` (принято зелёным на SHA `a8426268`)
- Диапазон: `git diff origin/dev...HEAD` — 40 файлов, +1732/-459 (без учёта `dist/**`,
`custom_components/houseplan/frontend/**` — class D, не проверяется построчно,
проверена побайтовая идентичность с пересобранным `dist`)
## Скоуп
Атомарный immutable candidate + guarded rollback для трёх путей, которые могут
задеть четыре marker semantic validators: основной Save устройства
(`_saveMarker`), запись матрицы калибровки робота (`saveVacuumMatrix` /
`_vacSaveMatrix`), три сценария calibration UX (low-residual auto, high-residual
proposal, manual fit). Плюс отдельная граница durable config acceptance vs
layout/file side effects, и более строгий `rollbackOptimistic` guard (сравнение
content по фингерпринту обязательно даже при совпадении object identity, #442
AC2). Не в скоупе, и диффом не тронуто: 18 generic `_saveConfig()` вызовов
(Hide/Show, discovery seeding, обычные vacuum-настройки), общий settings-путь
`_saveSettingsDialog` (#439), backend/schema/i18n, геометрия, маршрутизация
карт кроме сохранения уже готового `persistRoutes()` (#441).
## Как проверялось
**Дешёвые гейты (прогнаны сам, зелёного Validate на этом SHA не было):**
| Гейт | Результат |
|---|---|
| `npx tsc --noEmit` | green, 0 ошибок |
| `npm test` | 1863 pass, 1 skip, 0 fail (`# tests 1864`) |
| `npm run build` | green; `git status --porcelain` после билда пуст — dist воспроизводится байт-в-байт |
| `npm run bundle:sync` | green; SHA-256 `dist/houseplan-card.js` и `houseplan-assets.json` совпадают с `custom_components/houseplan/frontend/**` |
| `node scripts/check-docs.mjs` | green: «Documentation checks passed (7 files, 10 external links)» — diff трогает `src/**`, поэтому обязателен |
**Инварианты модели:** неприменимо — diff не трогает рёбра комнат, толщину,
`layout`, `marker.space`/`open_spans`; единственная геометрическая функция
(`canonicalizeConfigGeometry`) не изменена, только обёрнута в
`_prepareConfigCandidate` без изменения поведения (подтверждено чтением и
`npm test`, включая `#278`/`#224`-серии).
**Mutation witnesses (`scripts/mutation-gate.mjs --id=<id>`), каждый
запущен лично и подтверждён как «покраснел, как обязан»:**
| id | guard | Что проверяет |
|---|---|---|
| `marker-reject-keeps-optimistic-candidate` | `smoke_marker_write_rollback` | AC1: отсутствие `rollbackOptimistic` красит смок |
| `marker-rollback-keeps-enqueue-time-revision` | `smoke_marker_write_rollback` | AC2: без ребиндинга `attempt.revision` смок красит |
| `accepted-marker-rolled-back-by-layout-failure` | `smoke_marker_write_rollback` | AC3: безусловный rollback после accept красит смок |
| `vacuum-reject-keeps-optimistic-matrix` | `test/vacuum-calibration-write.test.mjs` | AC5–AC7: без rollback в `saveVacuumMatrix` тест красит |
| `vacuum-auto-reports-success-before-acceptance` | `test/vacuum-calibration-write.test.mjs` | AC4: ранний `saved=true` до ответа сервера красит тест |
| `optimistic-rollback-skips-same-root-fingerprint` | `test/serialized-write-queue.test.mjs` | AC2: возврат identity-шортката красит тест |
Также перепроверены два **не новых**, но переехавших вместе с рефакторингом
мутанта (`src/houseplan-card.ts`→`src/houseplan-editor-runtime.ts` и
`src/vacuum-calibration-write.ts`): `vacuum-manual-fit-after-proposal-uses-the-dock`
и `child-readd-clears-parent-tombstone` — оба по-прежнему красят целевой
смок/гейт после переноса паттерна. Полный `mutation-gate` без `--id` не
гонялся: он падает на несвязанном `backend-test-guard` (`No module named
pytest`), окружение без Python-гейта — не относится к этой задаче.
**Browser smokes.** `node scripts/smoke-select.mjs --base origin/dev --head HEAD`
насчитал diff по 56 символам на изменённых строках `src/**` (4 файла), матрица
219 смоков, порог «широкого» символа — 43. Инструмент выдал 36 «прямое
совпадение» + 43 «слабая связь» (полный вывод учтён, не прикладываю целиком).
Прогнаны лично (все green), 12 из 36 прямых совпадений плюс AC8:
- `smoke_marker_write_rollback` (новый, AC1–AC3) — все 12 полей `true`
- `smoke_vacuum_firstuse` (расширен, AC4–AC7) — все 23 поля `true`
- `smoke_vacuum_multifloor`, `smoke_vacuum` — `OK`
- `smoke_vacuum_route_draft` (AC8, автор назвал явно) — все 21 поле `true`
- `smoke_dialog_zombie` (автор назвал явно) — все 8 полей `true`
- `smoke_v8_draft_write` — прямое совпадение на `_cfgContentFingerprint`,
`_cfgRev`, `_dropLegacySegments` (сигнатура которого реально изменилась:
необязательный параметр `config`), `_saveConfigDebounced` — 16 полей `true`
- `smoke_lattice_write_barrier` — прямое совпадение на `_writeConfig`
(сигнатура которого реально изменилась: параметр `attempt`) — 9 полей `true`
- `smoke_wall_thickness` — прямое совпадение на `_dropLegacySegments` — 18 полей `true`
- `smoke_save_race` — прямое совпадение на разделяемый writer/`_cfgRev` — 4 поля `true`
- `smoke_danger_confirmation` — прямое совпадение на `_markerDialog`/`_saveConfigNow`
(сигнатура изменилась: параметр `attempt`) — 18 полей `true`
- `smoke_config_writer` — прямое совпадение на `_cfgRev`/`_saveConfig` — `OK`
Решение по каждой из непрогнанных 24 «прямых совпадений» (`smoke_decor_default_persist`,
`smoke_area_relocation_safety`, `smoke_controls`, `smoke_cover_tap`,
`smoke_device_inbox`, `smoke_device_position_history`, `smoke_device_preview_parity`,
`smoke_discovery_filters`, `smoke_editor_tabs`, `smoke_help_affordance`,
`smoke_optimize_coordinate_canonicalization`, `smoke_partition_openings`,
`smoke_room_resize`, `smoke_room_tooltip_toggle`, `smoke_sun`, `smoke_tap_ctx`,
`smoke_value_face_source`, `smoke_danger_confirm_branches`, `smoke_decor_images`,
`smoke_editor_gestures`, `smoke_fixed_floor`, `smoke_junction_limits`,
`smoke_resize_audit_1550`, `smoke_space_tab_reorder`,
`smoke_zero_wall_migration_unblocked`): не прогонялись. Совпадение у них —
на общие символы (`_cfgRev`, `_showToast`, `_writeConfig`, `_commitSpace`,
`_markerDialog`/`Marker`/`DevItem` как типы), а не на изменённое поведение;
ни один не тестирует Device editor save, calibration UX или rollback guard
напрямую. Сами изменённые функции (`_writeConfig`, `_saveConfigNow`,
`_dropLegacySegments`, `rollbackOptimistic`, `optimisticAttempt`) уже
прогнаны в 6 смоках выше плюс покрыты 1863 unit-тестами, включая
`test/coordinate-canonicalization.test.mjs` (#224-серия, строчная сверка
источника `_writeConfig`) и `test/serialized-write-queue.test.mjs`. Слабую
связь (43 смока) не прогонял — ни один не совпал на изменённом символе,
только на распространённых именах вне контракта записи.
`npm run golden:verify` не гонял — diff не меняет видимый рендер, только
busy/disabled-атрибуты кнопок (`?disabled`, `aria-busy`); `check-docs`
подтвердил 0 изменённых PNG и единственно принятый `sourceFingerprint`
(скриншоты сделаны без калибровочного pending/reject UI, что ТЗ явно
допускает: «Новая постоянная golden surface не требуется»).
`python -m pytest tests_backend` не гонял — diff не трогает
`custom_components/**/*.py`.
Performance-профили не гонял — не названы в AC, diff не меняет
чувствительные к перфу пути (только optimistic write/rollback JS-логику
объёмом O(размер конфига), не в цикле рендера).
## Разбор кода
**`src/serialized-write-queue.ts`.** `rollbackOptimistic` больше не
принимает object-identity как самостоятельное основание для отката —
теперь всегда сравнивается `fingerprint(current) !== attempt.attemptedFingerprint`,
и для той же ссылки тоже. Это именно то, что требует AC2: «более новая
in-place правка не может быть затёрта старым reject». Проверено новым
unit-тестом (`newer in-place content on the attempted root also wins`) и
персонально перепроверенным мутантом `optimistic-rollback-skips-same-root-fingerprint`.
**`src/houseplan-editor-runtime.ts` — `_saveMarker()`.** Строит `candidate`
глубоким клоном `cfg` **до** отправки (`JSON.parse(JSON.stringify(cfg))`),
не мутирует исходный `cfg`. Перед установкой `optimisticAttempt` есть явная
guard-проверка на смену корня во время async file-migrate шага
(`this.host._serverCfg !== cfg || this.host._cfgRev !== baseRevision ||
contentFingerprint(cfg) !== baseContent`) — конфликт корректно показывает
`toast.conflict` и не строит candidate поверх устаревшего root. `attempt`
создаётся, кандидат устанавливается оптимистично, debounce отменяется,
`_saveConfigNow(attempt)` awaited. `configAccepted` выставляется в `true`
**только** после успешного `_saveConfigNow`; catch откатывает
(`rollbackOptimistic`) исключительно если `!configAccepted`. Layout
update/obsolete cleanup/file cleanup идут после этой границы и их отказ не
трогает `configAccepted` — соответствует контракту §1 ТЗ. Подтверждено
чтением и смоком `smoke_marker_write_rollback` (`sideEffectFailureKeepsAcceptedConfig`).
**`_writeConfig(attempt)`.** Новая строка `if (attempt && candidateFingerprint
=== attempt.attemptedFingerprint) attempt.revision = this.host._cfgRev;`
решает реальную гонку: если попытка встала в очередь `_writeChain` позади
другой уже выполняющейся записи, `_cfgRev` к моменту фактической отправки
может уже уйти вперёд, и без ребиндинга guard в `rollbackOptimistic` увидел
бы устаревшую `attempt.revision` и **тихо не откатил** законно отклонённый
кандидат. Мутант `marker-rollback-keeps-enqueue-time-revision`,
удаляющий эту строку, красит `smoke_marker_write_rollback` — сценарий
«queue behind a prior write» там воспроизведён явно (строки 44–56 смока).
**`src/vacuum-calibration-write.ts` (новый модуль).** `saveVacuumMatrix`
клонирует `previous`, находит или материализует minimal first-use marker
на клоне (не на `previous`), пишет матрицу через существующий
`writeVacuumMatrix()`, ставит `optimisticAttempt`, отменяет debounce,
ждёт `_saveConfigNow(attempt)`; на reject — `rollbackOptimistic` +
rebuild + toast. `saveAutomaticCalibration` дедуплицирует параллельные
Apply через `WeakSet<runtime>`, держит `_markerDialog.busy` до ответа,
снимает его в `finally` независимо от исхода. `applyCalibrationProposal`
и `saveManualCalibration` следуют тому же паттерну busy→await→(close+toast
| restore-draft+busy:false). Все четыре пути подтверждены и unit-тестом
(`test/vacuum-calibration-write.test.mjs`, включая проверку `assert.notEqual`
на изолированность отката от live-объекта), и browser-смоком
(`smoke_vacuum_firstuse`), и мутантами.
**`src/houseplan-card.ts`.** Только типизация (`VacuumFit`/`CalibrationProposal`
вместо инлайновых типов) и busy-wiring в разметке: `?disabled=${busy}` на
кнопках Auto-calibrate/Fit/Save/Cancel/Apply, `aria-busy` на диалоге
предложения и панели fit, Esc/scrim guard на `busy`. Согласуется с разделом
«Touch, клавиатура и доступность» ТЗ — busy это реальный disabled, а не
только визуальный индикатор.
## Находки
Ничего блокирующего. Два Low, оба вне обязательного скоупа задачи —
оставлены с запиской, не правятся и не заводятся отдельным issue (severity
ниже Medium, PROCESS.md §12 не требует issue для Low).
1. **Low — `docs/specs/README.md`, таблица раздела «P2».** Новая строка
`#442` вставлена сразу после `#10`, а не в конце секции, где физически
находится диапазон номеров `#4xx` (например, `#440`, `#443` идут в
других местах таблицы). Секция и так не строго монотонна (`#294` после
`#373`, `#443` после `#434`), так что это чисто косметическая
навигационная неровность — `check-docs.mjs` не проверяет порядок,
ссылка рабочая. Не блокирует.
2. **Low/наблюдение, не находка задачи — `_saveSettingsDialog()` (#439,
`src/houseplan-editor-runtime.ts:10260`, diff этой задачи не трогает).**
Этот путь по-прежнему вызывает `this._saveConfigNow()` **без** передачи
`attempt`, поэтому новый ребиндинг `attempt.revision = this.host._cfgRev`
в `_writeConfig` для него не работает — тот же класс гонки, который #442
AC2 закрывает для маркеров (запись в очереди позади другой, `_cfgRev`
ушёл вперёд), теоретически остаётся в settings-пути. Это **не
регрессия этой задачи**: поведение не менялось, риск существовал уже
в #439, воспроизведение требует двух параллельных pending-записей и не
проверено мной в обе стороны в рамках бюджета этого раунда. Settings
dialog не входит в скоуп #442 (не задевает четыре marker semantic
validators) и явно исключён ТЗ («атомаризация всех 18 generic
`_saveConfig()` call sites» — не-скоуп). Оставляю как наблюдение для
будущей задачи, а не как находку, требующую отдельного issue сейчас —
недостаточно данных, чтобы утверждать это как подтверждённый дефект, а
не гипотетический край.
## Что проверено и корректно
- AC1–AC9 — каждый либо доказан автотестом, который я лично прогнал и чьи
свидетели проверены на способность покраснеть (mutation-gate), либо
browser-smoke-ом с явными полями результата, все `true`.
- Guarded rollback различает: обычный reject (откатывает), conflict reload
(не трогает authoritative reload), более новую in-place правку той же
ссылки (не трогает) — по коду и по unit/smoke сценариям.
- Durable boundary между `configAccepted` и layout/file side effects — по
коду и by `sideEffectFailureKeepsAcceptedConfig`/`sideEffectFailureHasNoSuccess`
в смоке.
- First-use synthetic marker не остаётся при reject — unit
(`rejected first-use calibration leaves no synthetic marker`) и смок
(`fitFreshNoMarker`, `highResidualRejectRestoresMatrix`).
- #441 (route CRUD) не задет — `smoke_vacuum_route_draft` зелёный целиком,
основной Save по коду не пишет `map_routes` напрямую (переносит блок
`vacuum` как есть в кандидат).
- Busy — настоящий disabled на кнопках/полях, Esc/scrim не воскрешают UI
во время pending — по коду и `smoke_vacuum_firstuse`
(`autoWaitsBusyDisabled`) плюс ручное чтение `_onKey`/шаблонов.
- Оба changelog обновлены в одном user-visible коммите (`fcd4e3dd`),
трейлеры `Issue:`/`User-Visible:` корректны на всех 5 коммитах диапазона.
- `docs/CONFIG-COMPATIBILITY.md` и `docs/VACUUM.md` формулировки совпадают
с фактическим контрактом кода (revision+content guard, durable boundary,
retry-draft).
- Screenshot provenance (`docs/images/screenshots.json`): только
`sourceFingerprint` изменился на всех 10 сценариях, все `imageSha256`
идентичны — реальных визуальных изменений нет, что ожидаемо (только
busy/disabled-атрибуты, не заснятые в текущей матрице).
## Чего не проверял
- Полный `mutation-gate` без `--id` (падает на несвязанном
backend-test-guard из-за отсутствия pytest в этом окружении — вне
диапазона задачи).
- 24 из 36 «прямое совпадение» и все 43 «слабая связь» browser smoke —
см. таблицу выше с обоснованием по каждой не прогнанной группе.
- `npm run golden:verify`, `performance_smoke`, полный Linux HA harness —
предрелизный гейт по процессу, не гейт код-ревью; diff не меняет
видимый рендер (только busy/disabled-атрибуты) и не трогает backend.
- Ручное подтверждение в реальном браузере (только автоматизированные
Playwright-смоки и unit-тесты).
## Вердикт
Зелёный. AC1–AC9 закрыты и доказаны воспроизводимо (тесты умеют падать —
проверено лично на 6 новых + 2 переехавших мутантах); дешёвые гейты и
целевая browser-смок-выборка зелёные; находок уровня High/Medium нет.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/442-marker-write-rollback`, коммит `ffbf8242abf4` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `4bc5d44a158273057ec9cf1cf34591def23ad7ce`
```
git log --all --format='%H %T' | grep 4bc5d44a1582
```
- ТЗ `docs/specs/442-marker-write-rollback.md`, блоб `a9dcca2ff269a1c309387902875a4937efbd4ebf`
```
git log --all --find-object=a9dcca2ff269a1c309387902875a4937efbd4ebf -- docs/specs/442-marker-write-rollback.md
```
+216
View File
@@ -0,0 +1,216 @@
# SPEC-REVIEW-442-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/442
- ТЗ: `docs/specs/442-marker-write-rollback.md`
- Этап: `S4-spec-review`, заход r1, блокирующих циклов израсходовано 0 из 4
- Ревьюер: Claude (роль «ревьюер ТЗ», отдельная сессия от автора)
- Материал ревью: ТЗ на коммите `a8426268db90666920801f9e09554aa81343ceba`
(ветка `issue/442-marker-write-rollback`), тело issue #442 и все его
комментарии на момент разбора (2026-09-03)
## Скоуп разбора
Первый заход — разбор полный: продуктовая рамка (`docs/SCOPE.md`), процесс
(`PROCESS.md` §2.4/§7.1, `AGENTS.md`), тело issue #442 и вся цепочка
комментариев (аналитика → вопросы владельцу → решение владельца → готовое
ТЗ), связанные issue #439 (guarded rollback общих настроек) и #441 (атомарный
route CRUD), канонические `docs/CONFIG-COMPATIBILITY.md` и `docs/VACUUM.md`,
терминология `docs/USER-GUIDE.ru.md`. Отдельно верифицированы факты о текущем
коде, на которых ТЗ строит контракт: `src/serialized-write-queue.ts`,
`src/houseplan-editor-runtime.ts`, `src/editors/vacuum-maps-section.ts`,
`custom_components/houseplan/validation.py`, наличие названных тестов/смоков.
## Как проверялось
1. Прочитаны `docs/SCOPE.md`, `PROCESS.md` (§1–§10), `AGENTS.md` полностью.
2. Прочитаны тело issue #442 и все 4 комментария (аналитика с оценками
8/10 польза · 9/10 разработка · 5/10 сложность · 5/10 риск · P2 · full
track; пачка вопросов Q1/Q2 с default-вариантами; решение владельца,
принявшее оба default; финальный комментарий со ссылкой на готовое ТЗ).
3. Прочитан весь текст `docs/specs/442-marker-write-rollback.md` (379 строк).
4. Прочитаны связанные issue #439 и #441 — оба уже приняты и описывают ровно
тот прецедент (guarded rollback, атомарный route writer), на который ТЗ
#442 ссылается как на эталон и явно не дублирует.
5. Прочитан `docs/CONFIG-COMPATIBILITY.md` целиком — не найдено противоречий
между заявленным «revision + content fingerprint» guard'ом и уже
задокументированным контрактом ревизий (#340), геометрии (#282/#306/#224)
и маршрутов пылесоса (#162).
6. Прочитан `docs/VACUUM.md` целиком — раздел «Calibration» действительно не
документирует порядок toast/подтверждение относительно ответа сервера, то
есть заявленный в ТЗ release-артефакт (обновление `VACUUM.md`) не
дублирует уже существующий текст, а закрывает реальный пробел.
7. Проверена терминология `docs/USER-GUIDE.ru.md`: «Редактор устройств»,
«Карты и этажи», «ручная подгонка», «Применить» — используемые в ТЗ
термины (кроме одного, см. Low ниже) не расходятся с интерфейсом.
8. Делегирована фактологическая проверка кода отдельному агенту (Explore) по
9 конкретным пунктам — см. «Верификация фактов» ниже. Каждый пункт
перепроверен по конкретным строкам файла, не на веру.
9. Самостоятельно прочитан `src/serialized-write-queue.ts` целиком (60 строк)
— независимо подтверждён guard: `host._cfgRev !== attempt.revision ||
(current !== attempt.attempted && fingerprint(current) !==
attempt.attemptedFingerprint)`, то есть ревизия **и** контент-фингерпринт
одновременно, включая ветку одного and того же object identity —
именно то, что ТЗ требует мутационно проверить в AC2.
10. Самостоятельно найден `_writeChain`/`expected_rev` (`src/houseplan-card.ts:7662-7730`,
`src/houseplan-editor-runtime.ts:1175` и далее) — сериализация запросов,
на которую ссылается контракт §2, существует и используется.
## Верификация фактов ТЗ против текущего кода (HEAD `a8426268`)
| # | Утверждение ТЗ/issue | Результат |
|---|---|---|
| 1 | `_saveMarker()` мутирует `cfg.markers` до `await _saveConfigNow()`, `catch` только снимает busy и показывает toast | **Подтверждено** — `src/houseplan-editor-runtime.ts:8270-8285` мутация, `:8330` await, `catch` `:8359-8366` без восстановления `_serverCfg` и без rebuild devices |
| 2 | *(из тела issue, не из ТЗ)* готовый guarded rollback применён «ровно к одному пути — общим настройкам (`_updateDecorStyle`)» | **Неточность в тексте issue.** Пара `optimisticAttempt`/`rollbackOptimistic` действительно встречается 3 раза (импорт + одна пара вызовов), но пара живёт в `_saveSettingsDialog` (`:10231-10275`, вызовы на `:10260`/`:10271`), а не в `_updateDecorStyle` (`:10204`) — та не содержит optimistic-логики вовсе и делегирует в `_persistDecorStyle` → обычный debounced `_saveConfig()`. Счётчик «18 вызовов `_saveConfig()`» корректен |
| 3 | `_vacSaveMatrix()` мутирует in-place, коллеры показывают success toast и закрывают UI до подтверждения записи сервером | **Подтверждено** — мутация `m.vacuum`, `_maybeRebuildDevices()`, невawait-нутый debounced `_saveConfig()`; `toast.autocal_done`/`toast.cal_done` показываются сразу после синхронного (boolean) возврата `_vacSaveMatrix`, без ожидания сервера |
| 4 | Route CRUD в `vacuum-maps-section.ts` уже использует `optimisticAttempt`/`rollbackOptimistic` (эталон #441) | **Подтверждено** — импорт `:21`, вызовы `:105`/`:116` |
| 5 | Guard сравнивает revision **и** content fingerprint; есть `_writeChain`/`expected_rev` | **Подтверждено** самостоятельно (см. «Как проверялось» п.9-10) |
| 6 | Четыре семантических валидатора маркера существуют под этими именами | **Подтверждено** ровно с этими именами (кроме generic «проверка controls», которую ТЗ и не называет точным именем) — `validate_marker_value_badges` (`:811`), `validate_marker_vacuum_routes` (`:886`), `validate_marker_light_entities` (`:930`), `validate_marker_controls` (`:972`, цикл — `:1049`) в `custom_components/houseplan/validation.py` |
| 7 | Названные в плане тестирования файлы существуют | **Подтверждено** — все 6: `test/serialized-write-queue.test.mjs`, `demo/smoke_vacuum_route_draft.mjs`, `demo/smoke_vacuum_firstuse.mjs`, `demo/smoke_vacuum_multifloor.mjs`, `demo/smoke_vacuum.mjs`, `demo/smoke_dialog_zombie.mjs` |
| 8 | `docs/VACUUM.md` ещё не документирует reject/success UX калибровки | **Подтверждено** — раздел «Calibration» молчит именно об этом |
| 9 | `scripts/mutation-gate.mjs` существует | **Подтверждено** |
Итог верификации: единственное расхождение с кодом (пункт 2) находится в
**теле issue**, не в тексте ТЗ — сам документ `docs/specs/442-...md` нигде не
называет `_updateDecorStyle` и не строит контракт на этом имени; он ссылается
на механизм обобщённо («общий writer», «уже готовый `optimisticAttempt`/
`rollbackOptimistic`»), что после проверки оказалось верным независимо от
того, в какой именно функции этот механизм физически живёт. Так что дефект
не проникает в исполнимость ТЗ — см. Low ниже, почему он всё равно стоит
записи.
## Соответствие §7.1
Все обязательные разделы присутствуют: Сценарий · Что человек увидит до и
после · Подтверждённая проблема · Скоуп/Не-скоуп · Контракт поведения (6
подпунктов) · Данные и совместимость · Touch/клавиатура/доступность ·
Ошибки и крайние случаи · AC1–AC9 с доказательством (unit/smoke/test double
у каждого) · План тестирования · Карта реализации · Риски и rollback ·
Release-артефакты · Принятые предположения.
Владелец отвечал только на продуктовые вопросы (Q1 — какие marker-пути
входят в атомизацию; Q2 — что делать с calibration UI при отказе), оба с
default-вариантом и явным принятием; технические решения (immutable
candidate, где именно живёт guard, разбиение на pure-модуль) автор
резервирует себе явным блоком «Принятые предположения» в конце — ровно так,
как требует процесс.
## Находки
### Low-1. Неточное имя функции в теле issue (не в ТЗ)
Issue #442 в разделе «Проблема» называет уже реализованный guarded-rollback
путь `_updateDecorStyle`; фактически это `_saveSettingsDialog`
(`src/houseplan-editor-runtime.ts:10231`). `_updateDecorStyle` — соседняя
функция без optimistic-логики. Ошибка не искажает контракт ТЗ (там имя
функции не упоминается вовсе, только «общий writer» и сам механизм), поэтому
не блокирует и не требует правки ТЗ. Фиксирую как заметку для реализации:
если разработчик будет ориентироваться на текст issue при поиске «уже
готового пути», неверное имя может стоить нескольких минут поиска не там —
не более того.
**Решение ревьюера:** снимается без правки, запись сделана для истории раунда.
### Low-2. Явного раздела «i18n» нет — контент есть, но не под заголовком
§7.1 перечисляет i18n отдельным обязательным разделом. В ТЗ #442 подтверждение
«новых строк нет» разнесено по двум местам («Новых targets, жестов и строк
i18n нет» в разделе Touch; «новых текстов ошибок... не добавляется» в
Не-скоуп) без отдельного заголовка. По содержанию требование выполнено
(эксплицитно и дважды непротиворечиво), поэтому не блокирует переход в
`S5-ready` — DoR требует «i18n: ключи en+ru перечислены», а перечислять
здесь нечего, что и сказано явно. Отмечаю как стилистическую придирку, не
находку, которая должна кого-то останавливать.
**Решение ревьюера:** снимается без правки.
## Что проверено и корректно
- **Продуктовая рамка.** Задача закрывает J6 (`docs/SCOPE.md`: «Keep the plan
true as the home evolves» — «View не должен показывать конфигурацию,
которую сервер отверг»), сценарий и «что человек увидит» — оба заполнены
без терминов реализации первым предложением, персона (Home admin) и
поверхность (Device editor + calibration overlay) названы.
- **Скоуп/не-скоуп** зеркалят принятые owner defaults Q1/Q2 дословно, без
расширения и без урезания против решения владельца.
- **Технические утверждения о текущем коде** — 8 из 9 проверенных фактов
подтверждены чтением реальных строк; единственное расхождение (Low-1) не
влияет на сам контракт ТЗ.
- **Контракт поведения** опирается на реально существующий примитив
(`optimisticAttempt`/`rollbackOptimistic` с двойным guard'ом ревизия+
фингерпринт, уже обкатанным в #441 и в `_saveSettingsDialog`), а не
изобретает новый механизм с нуля — минимизирует риск.
- **AC1–AC9** каждый указывает способ доказательства (unit / browser smoke /
test double) и достаточно конкретен для написания теста без дополнительных
решений: например, AC2 явно называет два обязательных мутанта
(unconditional rollback; пропуск fingerprint для same identity),
предвосхищая требование код-ревью §2.7 о «таблице чем краснеет».
Формулировки допускают ровно одну трактовку каждая — не нашёл AC, который
можно закрыть двумя разными реализациями с разным видимым поведением.
- **Регрессионный периметр** (AC8, таблица «Ошибки и крайние случаи», строка
«route CRUD #441 reject/retry») явно защищает уже принятый атомарный route
writer от повторной реализации или порчи — соответствует правилу «не
подменять существующий механизм».
- **Данные и совместимость**: явно «миграции нет», persisted schema не
меняется, что соответствует характеру задачи (только client-side
транзакционность) и не противоречит ни одному разделу
`CONFIG-COMPATIBILITY.md`.
- **Touch/доступность**: busy — настоящий disabled, а не только визуальный
индикатор; фокус остаётся в открытом диалоге — соответствует
`TOUCH-SUPPORT.md` (редакторы desktop-first, но контракт не деградирует
специально под touch).
- **Release-артефакты** называют оба changelog, `CONFIG-COMPATIBILITY.md`,
`VACUUM.md` (подтверждено выше, что там действительно есть пробел для
заполнения), условие для docs-скриншотов и явное «golden не нужен, если
подходящей канонической поверхности нет» — не оставляет открытых пунктов
DoR.
- **Догадки, выданные за решения**: не найдено. Каждое место, где решение
не следует напрямую из существующего документа, либо помечено как
default-ответ владельца (Q1/Q2), либо вынесено в явный блок «Принятые
предположения» в конце ТЗ, что ревьюер вправе (и не находит повода)
оспорить.
## Чего не проверял
- Не запускал `npx tsc --noEmit`/`npm test`/`npm run build` — на этапе
ревью ТЗ кода ещё нет (это этап `spec`, не `code`), гейты §8 к этому
документу неприменимы.
- Не проверял мутационные сценарии AC2 практически (мутант ещё не написан —
это работа код-ревью, не спек-ревью); отметил только, что защитный
примитив уже существует и уже используется в проде (#441).
- Не проверял браузерные смоки исполнением — только их наличие как файлов
(см. таблицу верификации, п.7). Их фактическое покрытие AC — задача
реализации и код-ревью.
- Не проверял `docs/specs/README.md` на предмет обязательной строки в
таблице issue↔ТЗ — это релиз-артефакт автора ТЗ, не блокирует ревью.
## Вердикт
**Зелёный.** High: 0, Medium: 0. Обе Low-находки сняты без правки текста ТЗ
(записаны выше для истории раунда, не блокируют). ТЗ полно по §7.1, каждый
AC однозначен и снабжён способом доказательства, продуктовые вопросы решены
владельцем до написания ТЗ, а технические утверждения о существующем коде
подтверждены независимым чтением, а не приняты на веру.
## Материал раунда
- Ветка: `issue/442-marker-write-rollback`
- SHA ТЗ: `a8426268db90666920801f9e09554aa81343ceba`
- Путь ТЗ: `docs/specs/442-marker-write-rollback.md`
- Найти дерево: `git log --all --format='%H %T' | grep <дерево HEAD>`
- Найти блоб ТЗ: `git log --all --find-object=<блоб> -- docs/specs/442-marker-write-rollback.md`
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/442-marker-write-rollback`, коммит `a8426268db90` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `5d3fbe71add69e722a9f6d87b1ce04dcea586729`
```
git log --all --format='%H %T' | grep 5d3fbe71add6
```
- ТЗ `docs/specs/442-marker-write-rollback.md`, блоб `a9dcca2ff269a1c309387902875a4937efbd4ebf`
```
git log --all --find-object=a9dcca2ff269a1c309387902875a4937efbd4ebf -- docs/specs/442-marker-write-rollback.md
```
+378
View File
@@ -0,0 +1,378 @@
# ТЗ #442 — Атомарный откат отклонённых записей маркера
- Issue: https://github.com/Matysh/houseplan-card/issues/442
- Приоритет: P2, `bug`
- Статус ТЗ: готово к ревью
- Маршрут: full; меняются основной Save устройства и сохранение калибровки
робота, включая отказный UX и конкурентный контракт
- Связанные контракты: #439 (guarded optimistic rollback общих настроек),
#441 (атомарные CRUD-операции маршрутов карт), #162 (маршрутная
калибровка робота), #314 (отдельный откат физической геометрии)
## Сценарий
Home admin редактирует устройство либо калибрует карту робота. Backend
отклоняет `config/set` из-за semantic validation, конфликта или транспортной
ошибки. Карточка должна сразу снова показывать последнее подтверждённое
сервером состояние, но не терять введённый в открытом UI draft: пользователь
исправляет значение или повторяет сохранение без перезагрузки карточки и без
повторной ручной подгонки.
## Что человек увидит до и после
**До:** после отказа основной Device editor сообщает об ошибке, но локальный
marker остаётся изменённым и может попасть в View либо в следующую запись.
Калибровка закрывается и показывает success до ответа сервера; при отказе на
экране остаётся фантомная матрица, а ручную подгонку приходится повторять.
**После:** непринятый marker config автоматически возвращается к последнему
подтверждённому состоянию. Device editor остаётся открыт с введёнными полями.
Auto/manual calibration остаётся открытой и занятой до ответа: при успехе она
закрывается и только затем сообщает об успехе, при отказе сохраняет рассчитанный
draft для Retry и показывает ошибку.
## Подтверждённая проблема
1. `HouseplanEditorRuntime._saveMarker()` меняет `cfg.markers` до
`await _saveConfigNow()`. Обычный `catch` снимает `busy` и показывает toast,
но не возвращает `_serverCfg`, не перестраивает devices и не отделяет отказ
`config/set` от ошибок последующих layout/file housekeeping.
2. Backend реально может отклонить доступные из Device editor изменения через
`validate_marker_controls`, `validate_marker_light_entities`,
`validate_marker_value_badges` и `validate_marker_vacuum_routes`.
3. `_vacSaveMatrix()` мутирует marker in-place, перестраивает devices и вызывает
debounced `_saveConfig()`, после чего callers сразу закрывают
`_vacCalConfirm`/`_vacFit` и показывают `vac.autocal_done` либо
`vac.cal_done`. Promise результата у UI нет.
4. CRUD маршрутов в `src/editors/vacuum-maps-section.ts` уже использует
`optimisticAttempt()`/`rollbackOptimistic()` и остаётся эталоном, а не второй
реализацией в рамках #442.
5. Общий writer уже сериализует запросы и отдельно обрабатывает conflict и
physical geometry. #442 не должен подменять эти механизмы глобальным reload
либо откатом несвязанной транзакции.
## Скоуп
В скоупе:
- атомарный immutable candidate для основного Save устройства;
- guarded rollback всего config-кандидата при отказе именно его `config/set`;
- восстановление производных marker/device представлений после отката;
- сохранение draft Device editor и возможность Retry;
- асинхронная запись auto-calibration, принятого high-residual proposal и manual
fit;
- busy-состояние калибровочного UI до ответа сервера;
- success toast и закрытие calibration UI только после подтверждённой записи;
- сохранение рассчитанной матрицы или параметров ручной подгонки при отказе;
- конкурентные случаи conflict reload и более новой локальной ревизии;
- регрессия атомарных маршрутов #441;
- unit, browser smoke и mutation witnesses;
- документация и оба changelog.
## Не-скоуп
- атомаризация всех 18 generic `_saveConfig()` call sites;
- Hide/Show, удаление marker, discovery seeding, обычные `live`, `trail_mode` и
`source` настройки робота, если они не входят в сохраняемый calibration/route
candidate;
- изменение четырёх backend validators, schema либо формата ошибок;
- изменение математики auto-calibration, residual threshold или manual fit;
- изменение маршрутизации карт, route identity либо выбора этажа;
- откат физической геометрии или замена механизма #314;
- транзакционное удаление безвредных файлов-копий после rejected rebind;
- новый глобальный transaction manager для всех editor writes;
- новые тексты ошибок, отдельные модальные предупреждения или дополнительные
подтверждения.
## Контракт поведения
### 1. Граница marker-транзакции
Основной Save строит новый `ServerConfig` как отдельный candidate из текущего
подтверждённого root. До отправки не допускается in-place изменение предыдущего
`cfg.markers`: предыдущий config должен оставаться пригодным для точного
восстановления.
Одна попытка фиксирует:
- глубокую копию предыдущего config;
- предыдущий `_cfgContentFingerprint`;
- `_cfgRev` до отправки;
- candidate и его content fingerprint.
Candidate может быть показан оптимистично, но при отклонении его
`config/set` вызывается guarded rollback. После успешного rollback карточка
сбрасывает marker-derived caches/signature, перестраивает devices и запрашивает
render. Ни одно поле непринятого marker не остаётся в View или следующей записи.
Конфигурационная транзакция заканчивается сразу после успешного
`_saveConfigNow()`. Layout update, очистка старого layout id и файловое
housekeeping выполняются только после durable config acceptance. Ошибка такого
последующего best-effort шага не имеет права откатывать уже принятый сервером
config.
### 2. Guarded rollback и конкуренция
Rollback применяется только если текущие revision и content fingerprint всё
ещё принадлежат не принятому candidate. Сравнение content обязательно и для
того же object identity: более новая in-place правка не может быть затёрта
старым reject.
Таблица решений:
| Событие во время попытки | Итог |
|---|---|
| semantic/schema/transport reject, candidate всё ещё текущий | восстановить previous config и прежний fingerprint |
| `conflict`, `_saveConfigNow()` уже перечитал server truth | не откатывать authoritative reload |
| появилась более новая local revision/content | не откатывать новую правку |
| пользователь закрыл диалог во время запроса | config откатывается по guard; диалог не воскрешается; toast остаётся |
| config принят, затем упал layout/file side effect | принятый config остаётся; сообщается ошибка соответствующего шага |
Сериализация через существующую `_writeChain` и `expected_rev` сохраняется.
Pending debounced write не должен обгонять прямой marker/calibration save; один
и тот же candidate не отправляется повторно скрытым debounce.
### 3. Основной Device editor
При Save UI становится busy и не запускает вторую попытку. После успеха
сохраняется текущий UX: диалог закрывается, devices перестраиваются и
показывается `toast.marker_saved`.
При отказе config-записи:
- `_serverCfg` и отображение возвращаются к accepted state;
- диалог, если он ещё открыт, остаётся открыт и выходит из busy;
- его локальные поля остаются такими, какими их ввёл пользователь;
- Retry строит новый immutable candidate поверх актуального server config;
- success toast не показывается; показывается существующая локализованная
ошибка;
- если диалог закрыт пользователем, он не создаётся заново.
Rebind сохраняет порядок безопасности файлов: copy допустим до config save,
cleanup — только после acceptance. Оставшаяся после reject копия безвредна и
остаётся вне скоупа housekeeping.
### 4. Routes/maps
Добавление, переназначение и удаление `map_routes` продолжает использовать
атомарный `persistRoutes()` из #441. #442 не возвращает эти операции к общему
debounce и не создаёт второй route writer.
Основной Save устройства обязан переносить текущий vacuum block в candidate,
не стирая уже принятую route transaction. Ошибка основного Save откатывает его
полный candidate к состоянию непосредственно перед попыткой, а не к снимку до
последней успешно принятой route-операции.
### 5. Запись калибровки
`_vacSaveMatrix()` становится асинхронной атомарной операцией либо делегирует
такой операции. Она:
1. строит отдельный config candidate;
2. материализует минимальный marker в candidate для first-use vacuum, не
изменяя previous config;
3. записывает matrix в exact route/legacy target через существующий
`writeVacuumMatrix()`;
4. фиксирует optimistic attempt, присваивает candidate и перестраивает preview;
5. ожидает `_saveConfigNow()`;
6. возвращает success только после server acceptance;
7. при reject выполняет guarded rollback, перестраивает devices и возвращает
failure вызывающему UI.
### 6. Calibration UX
Для low-residual auto-calibration Device editor остаётся на экране. На время
записи соответствующие calibration controls busy/disabled; повторный клик не
создаёт второй запрос. `vac.autocal_done` появляется только после acceptance.
При reject диалог остаётся открыт, busy снимается, показывается ошибка; Retry
повторно использует тот же пользовательский вход и снова рассчитывает matrix.
Для high-residual proposal:
- Apply не очищает `_vacCalConfirm` до ответа;
- proposal получает busy state, закрытие, Cancel, Fit и повторный Apply на это
время недоступны;
- success закрывает proposal и показывает `vac.autocal_done`;
- reject оставляет тот же proposal/matrix открытым, снимает busy и позволяет
Retry либо переход в ручную подгонку.
Для manual fit:
- Save не очищает `_vacFit` до ответа;
- overlay получает busy state; drag, rotate/mirror, Save и выход, который мог бы
потерять draft, на время запроса не создают новую попытку;
- success закрывает overlay и показывает `vac.cal_done`;
- reject оставляет exact `FitParams` и route identity, снимает busy, возвращает
accepted config и позволяет Retry без повторной подгонки.
Обычный Cancel до начала записи сохраняет прежнее поведение. Нового
предупреждения при ошибке не добавляется.
## Данные и совместимость
- Persisted schema, marker/vacuum shape и revision protocol не меняются.
- Миграции данных нет.
- Успешные marker, route и calibration payloads должны быть эквивалентны
текущим после canonicalization.
- Legacy calibration и explicit `map_routes` сохраняют контракт #162/#443.
- First-use write сохраняет остальные принятые поля marker: clone-and-patch не
удаляет неизвестные/future поля marker, config или vacuum.
## Touch, клавиатура и доступность
Busy является настоящим disabled-состоянием controls, а не только визуальным
индикатором. Повторные touch/click/Enter не создают дополнительный Save.
Focus остаётся в том же открытом dialog/overlay после reject. Закрытие через
Esc/scrim во время уже начатой попытки не воскрешает UI после ответа и не
мешает config rollback. Новых targets, жестов и строк i18n нет.
## Ошибки и крайние случаи
| Случай | Ожидаемое поведение |
|---|---|
| controls cycle отклонён backend | marker в View прежний, draft dialog сохранён, Retry доступен |
| invalid light/value source отклонён | тот же guarded rollback без частичного marker |
| route calibration отклонена | accepted matrix остаётся в config, новая fit/proposal остаётся UI-draft |
| first first-use vacuum calibration отклонена | synthetic marker не остаётся локально |
| повторный Save во время pending | один `config/set` |
| conflict reload во время reject | server truth выигрывает, старый rollback no-op |
| новая локальная правка поверх candidate | новая content revision выигрывает |
| Esc закрыл Device editor в полёте | config восстановлен, dialog не воскрешён, error toast виден |
| config принят, layout update не удался | config не откатывается и не расходится с сервером |
| route CRUD #441 reject/retry | прежний атомарный UX остаётся зелёным |
## Acceptance criteria и доказательства
### AC1. Основной Save атомарен
Unit/contract test доказывает, что `_saveMarker()` строит отдельный candidate и
при semantic reject восстанавливает весь previous config/fingerprint. Browser
smoke отклоняет `controls` или другой реально валидируемый field, сверяет View,
сохранённый dialog draft, busy=false и успешный Retry.
### AC2. Откат не затирает владельца новой ревизии
Unit покрывает обычный reject, conflict reload, заменённый root и более новую
in-place правку того же candidate. Mutant с unconditional rollback и mutant,
который пропускает fingerprint для same identity, обязаны краснеть.
### AC3. Main Save отделяет durable config от side effects
Test double принимает `config/set`, затем отклоняет layout/file operation.
Принятый marker остаётся локально, старый config не восстанавливается, а
операция не показывает `toast.marker_saved` до завершения обязательной части
успешного пути.
### AC4. Auto-calibration ждёт сервер
Browser smoke держит `config/set` deferred: до resolve отсутствует success,
Device editor/proposal остаётся открыт и busy, повторный Apply не пишет второй
раз. Resolve закрывает нужный UI и даёт ровно один success toast.
### AC5. Reject auto proposal сохраняет Retry
High-residual proposal при reject остаётся с той же matrix/route identity,
accepted config восстановлен, busy снят и второй Apply может успешно записать
ровно одну калибровку.
### AC6. Reject manual fit сохраняет подгонку
Browser smoke фиксирует изменённые `FitParams`, отклоняет Save и проверяет:
overlay не закрыт, параметры не изменились, pointer/кнопки снова доступны,
config вернулся к accepted matrix. Retry success закрывает overlay и только
тогда показывает `vac.cal_done`.
### AC7. First-use и route identity сохранены
Тест отклоняет первую калибровку auto-discovered vacuum: minimal marker не
остаётся в `_serverCfg`. Существующие multifloor/first-use tests подтверждают,
что successful retry пишет matrix в exact route и не возвращает legacy path.
### AC8. #441 не регрессировал
`smoke_vacuum_route_draft` продолжает доказывать atomic add/reassign/delete,
reject/retry и отсутствие пустого route draft в persisted config. Основной
marker Save не стирает только что принятую route transaction.
### AC9. Общие гейты
Зелёные typecheck, unit, build и синхрон трёх bundle trees. Для diff в `src/**`
обязателен `check-docs`; `smoke-select` определяет дополнительные browser
scenarios. Полный Linux HA harness остаётся каноническим CI. Изменение не
трогает геометрию, поэтому `model-invariants` неприменим. Visible busy/reject UX
проверяется smoke; постоянный golden добавляется только при наличии подходящей
canonical surface.
## План тестирования
- расширить `test/serialized-write-queue.test.mjs` проверкой same-identity
content change;
- выделить pure candidate/rollback helpers для marker и calibration там, где
это уменьшает stateful browser setup;
- добавить browser smoke rejected marker Save + preserved draft + Retry;
- расширить vacuum smoke deferred/rejected auto proposal и manual fit;
- сохранить зелёными `smoke_vacuum_route_draft`, `smoke_vacuum_firstuse`,
`smoke_vacuum_multifloor`, `smoke_vacuum` и `smoke_dialog_zombie`;
- добавить mutation-gate anchors для отсутствующего marker/calibration
rollback, раннего success/close и same-identity fingerprint guard;
- выполнить `typecheck`, `npm test`, `build`, `bundle:sync`, `check-docs`,
выбранные smokes и обязательный CI на exact SHA.
## Карта реализации
- `src/serialized-write-queue.ts` — строгий guarded rollback при изменённом
content того же object identity;
- `src/houseplan-editor-runtime.ts` — immutable marker/calibration candidates,
async persistence, rollback/rebuild и delayed success/close;
- `src/houseplan-card.ts` — типы busy-state, render/disabled contract и async
delegates;
- при необходимости небольшой отдельный pure-модуль marker candidate, чтобы не
раздувать editor runtime;
- `test/`, `demo/smoke_*.mjs`, `scripts/mutation-gate.mjs` — witnesses;
- `docs/CONFIG-COMPATIBILITY.md`, `docs/VACUUM.md`, оба changelog и screenshot
fingerprint — release artifacts.
## Риски и rollback
- Главный риск — откатить authoritative conflict reload или более новую правку;
закрывается revision + unconditional content-fingerprint guard.
- Второй риск — принять config на сервере, затем ошибочно вернуть локально
previous из-за layout/file failure; закрывается явной durable boundary.
- Третий риск — потерять first-use marker либо route identity при clone; его
закрывают first-use и multifloor witnesses.
- Четвёртый риск — закрыть calibration UI до реального ответа и потерять draft;
закрывается deferred/reject/retry smoke.
- Rollback реализации — revert frontend/tests/docs/bundle. Data migration нет;
принятые marker/calibration payloads совместимы с предыдущей версией.
## Release-артефакты
- В том же user-visible implementation commit обновляются
`docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` со ссылкой на #442.
- `docs/CONFIG-COMPATIBILITY.md` фиксирует: semantic reject marker write не
остаётся локальной конфигурацией, conflict/newer revision выигрывают.
- `docs/VACUUM.md` фиксирует, что success калибровки означает подтверждённую
запись, а reject сохраняет auto/manual draft для Retry.
- Любой `src/**` diff требует канонической `Docs screenshots` съёмки и приёмки
fingerprint по процессу. Если существующий кадр реально меняется, его diff
просматривается; несвязанные локальные baseline differences не принимаются.
- Новая постоянная golden surface не требуется, если calibration pending/reject
UI отсутствует в текущей canonical matrix; light/dark и keyboard/touch
состояние подтверждается browser smoke.
- Нового performance или security artifact не требуется: payload size,
вычислительная геометрия и trust boundary не меняются.
## Принятые предположения
- Default Q1: в #442 входят только UI-пути, которые могут изменить поля четырёх
marker semantic validators; generic marker/config writes вне этого множества
не атомаризируются.
- Default Q2: calibration UI остаётся открытым и busy до ответа; reject
сохраняет рассчитанный draft для Retry, success закрывает UI после acceptance.
- Любой reject semantic marker attempt откатывает только эту попытку; conflict
reload и более новая revision/content имеют приоритет.
- Draft основного Device editor живёт отдельно от `_serverCfg` и не теряется при
rollback.
- Backend/schema/i18n, успешный View и физическая геометрия не меняются.
- Файлы, скопированные перед rejected rebind, остаются безопасным остатком и не
удаляются новой клиентской гонкой.
+1
View File
@@ -92,6 +92,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| Issue | ТЗ |
|---|---|
| [#10](https://github.com/Matysh/houseplan-card/issues/10) Roomba live position | [010-vacuum-roomba-live-position.md](010-vacuum-roomba-live-position.md) |
| [#442](https://github.com/Matysh/houseplan-card/issues/442) Атомарный откат отклонённых записей маркера | [442-marker-write-rollback.md](442-marker-write-rollback.md) |
| [#11](https://github.com/Matysh/houseplan-card/issues/11) Vacuum source health | [011-vacuum-source-health.md](011-vacuum-source-health.md) |
| [#12](https://github.com/Matysh/houseplan-card/issues/12) Room cleaning highlight | [012-vacuum-room-cleaning-highlight.md](012-vacuum-room-cleaning-highlight.md) |
| [#13](https://github.com/Matysh/houseplan-card/issues/13) Golden open context tray | [013-golden-open-context-tray.md](013-golden-open-context-tray.md) |
+83 -6
View File
@@ -450,9 +450,9 @@ const MUTANT_DEFINITIONS = [
+ 'against — opening it on the dock floor fits the robot to the wrong plan (#162, AC8, '
+ 'the stateful half the pure calibrationTarget mutant cannot reach)',
patches: [{
file: 'src/houseplan-editor-runtime.ts',
find: ' const space = proposal.space || dev.space;',
replace: ' const space = dev.space;',
file: 'src/vacuum-calibration-write.ts',
find: ' const space = proposal.space || device.space;',
replace: ' const space = device.space;',
}],
},
{
@@ -715,12 +715,12 @@ const MUTANT_DEFINITIONS = [
because: 'saving one child must preserve the device tombstone; dropping all tombstones '
+ 'resurrects the automatic parent and the siblings the person deliberately removed (#262)',
patches: [{
file: 'src/houseplan-card.ts',
find: ` cfg.markers = cfg.markers.filter(
file: 'src/houseplan-editor-runtime.ts',
find: ` candidate.markers = candidate.markers.filter(
(m) => m.id !== id && m.id !== oldId
&& (marker.binding === 'virtual' || m.binding !== marker.binding),
);`,
replace: ` cfg.markers = cfg.markers.filter(
replace: ` candidate.markers = candidate.markers.filter(
(m) => m.id !== id && m.id !== oldId
&& (marker.binding === 'virtual' || m.binding !== marker.binding)
&& (!marker.binding.startsWith('entity:') || m.removed !== true),
@@ -5367,6 +5367,83 @@ const MUTANT_DEFINITIONS = [
replace: 'const page = await (await browser.newContext()).newPage();',
}],
},
{
id: 'marker-reject-keeps-optimistic-candidate',
guard: 'node demo/smoke_marker_write_rollback.mjs',
because: 'a rejected semantic marker write must restore accepted config and View while '
+ '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'
+ " this.host._regSignature = '';",
replace: " this.host._regSignature = '';",
}],
},
{
id: 'marker-rollback-keeps-enqueue-time-revision',
guard: 'node demo/smoke_marker_write_rollback.mjs',
because: 'a marker save queued behind an accepted write must guard rollback with the revision '
+ 'its own request used, while still yielding to genuinely newer content (#442 AC2)',
patches: [{
file: 'src/houseplan-editor-runtime.ts',
find: ' if (attempt && candidateFingerprint === attempt.attemptedFingerprint) attempt.revision = this.host._cfgRev;',
replace: '',
}],
},
{
id: 'accepted-marker-rolled-back-by-layout-failure',
guard: 'node demo/smoke_marker_write_rollback.mjs',
because: 'config acceptance is the durable marker boundary: a later layout failure must not '
+ 'restore the marker that the server already accepted (#442 AC3)',
patches: [{
file: 'src/houseplan-editor-runtime.ts',
find: ' if (!configAccepted && attempt) {\n'
+ ' rollbackOptimistic(this.host, attempt, contentFingerprint);',
replace: ' if (attempt) {\n'
+ ' this.host._serverCfg = attempt.previous;',
}],
},
{
id: 'vacuum-reject-keeps-optimistic-matrix',
guard: 'node --test test/vacuum-calibration-write.test.mjs',
because: 'a rejected calibration, including first use, must not leave its matrix or synthetic '
+ 'marker in the local accepted config (#442 AC5–AC7)',
patches: [{
file: 'src/vacuum-calibration-write.ts',
find: ' rollbackOptimistic(host, attempt, contentFingerprint);\n rebuild(host);',
replace: ' rebuild(host);',
}],
},
{
id: 'vacuum-auto-reports-success-before-acceptance',
guard: 'node --test test/vacuum-calibration-write.test.mjs',
because: 'automatic calibration must stay busy and suppress both duplicate writes and success '
+ 'until config/set has actually resolved (#442 AC4)',
patches: [{
file: 'src/vacuum-calibration-write.ts',
find: ' const saved = await saveVacuumMatrix(\n'
+ ' runtime, request.markerId, request.source, request.mapId, request.matrix, request.routeId,\n'
+ ' );',
replace: ' const saved = true;\n'
+ ' void saveVacuumMatrix(\n'
+ ' runtime, request.markerId, request.source, request.mapId, request.matrix, request.routeId,\n'
+ ' );',
}],
},
{
id: 'optimistic-rollback-skips-same-root-fingerprint',
guard: 'node --test test/serialized-write-queue.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)',
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;',
}],
},
];
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
+32 -22
View File
@@ -246,6 +246,7 @@ import {
formatLatticeShiftCm,
} from './coordinate-canonicalization';
import { enqueueSerializedWrite } from './serialized-write-queue';
import type { CalibrationProposal, VacuumFit } from './vacuum-calibration-write';
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
import { LANGUAGE_RUNTIME, subscribeLanguageLoadFailures } from './i18n/registry';
import {
@@ -2503,18 +2504,13 @@ export class HouseplanCard extends LitElement {
};
private _resumeSettling = false;
private _viewportInvalidAt = 0;
private _vacFit: { markerId: string; source: string; mapId: string; routeId?: string; p: FitParams;
drag: null | { kind: 'move' | 'scale'; sx: number; sy: number; p0: FitParams;
fx: number; fy: number } } | null = null;
private _vacFit: VacuumFit | null = null;
/** Marker whose lazy «All cameras» candidate section is expanded. */
private _vacAllCamerasFor: string | null = null;
/** One snapshot per currently open global-camera section; rebuilt on reopen. */
private _vacAllCameraCache: { devId: string; candidates: VacSourceCandidate[] } | null = null;
/** Proposed high-residual auto-calibration. Config remains untouched until Apply. */
private _vacCalConfirm: {
markerId: string; source: string; mapId: string; routeId?: string; space?: string; matrix: Affine;
rooms: number; error: string;
} | null = null;
private _vacCalConfirm: CalibrationProposal | null = null;
private _kioskDots = false;
private _kioskDotsTimer?: number;
private _kioskHoldTimer?: number;
@@ -2943,6 +2939,7 @@ export class HouseplanCard extends LitElement {
private _onKey(e: KeyboardEvent): void {
if (e.key === 'Escape' && this._vacFit) {
if (this._vacFit.busy) return;
this._vacFit = null;
this._showToast(this._t('vac.cal_cancelled'));
e.stopPropagation();
@@ -2951,7 +2948,10 @@ export class HouseplanCard extends LitElement {
if (e.key === 'Escape') {
// close the topmost open dialog; info popups first, then editors
if (this._tapConfirm) { this._tapConfirm = null; return; }
if (this._vacCalConfirm) { this._vacCalConfirm = null; return; }
if (this._vacCalConfirm) {
if (!this._vacCalConfirm.busy) this._vacCalConfirm = null;
return;
}
if (this._decorEraseConfirm) { this._decorEraseConfirm = null; return; }
if (this._openingInfo) { this._openingInfo = null; return; }
if (this._infoCard) { this._closeInfoCard(); return; }
@@ -11882,15 +11882,19 @@ export class HouseplanCard extends LitElement {
: nothing}
${this._vacCalConfirm ? this._editorRuntime ? html`<hp-dialog .hass=${this.hass}
.title=${this._t('vac.residual_title')} icon="mdi:map-marker-alert-outline"
dismiss-on-scrim @hp-close=${() => (this._vacCalConfirm = null)}>
dismiss-on-scrim aria-busy=${String(!!this._vacCalConfirm.busy)}
@hp-close=${() => { if (!this._vacCalConfirm?.busy) this._vacCalConfirm = null; }}>
<div class="body">
<p>${this._t('vac.residual_message', { error: this._vacCalConfirm.error })}</p>
</div>
<div class="row" slot="footer">
<button class="btn ghost" @click=${() => (this._vacCalConfirm = null)}>${this._t('btn.cancel')}</button>
<button class="btn ghost" ?disabled=${this._vacCalConfirm.busy}
@click=${() => (this._vacCalConfirm = null)}>${this._t('btn.cancel')}</button>
<span class="spacer"></span>
<button class="btn ghost" @click=${() => this._vacApplyCalibrationProposal(true)}>${this._t('vac.fit')}</button>
<button class="btn on" @click=${() => this._vacApplyCalibrationProposal(false)}>
<button class="btn ghost" ?disabled=${this._vacCalConfirm.busy}
@click=${() => this._vacApplyCalibrationProposal(true)}>${this._t('vac.fit')}</button>
<button class="btn on" ?disabled=${this._vacCalConfirm.busy}
@click=${() => this._vacApplyCalibrationProposal(false)}>
<ha-icon icon="mdi:check"></ha-icon>${this._t('vac.apply_proposal')}
</button>
</div>
@@ -11924,12 +11928,16 @@ export class HouseplanCard extends LitElement {
</div>`
: nothing}
${this._kioskDialog ? this._renderKioskDialog() : nothing}
${this._vacFit ? html`<div class="vaccalbar">
${this._vacFit ? html`<div class="vaccalbar" aria-busy=${String(!!this._vacFit.busy)}>
<span>${this._t('vac.fit_hint')}</span>
<button class="btn ghostbtn" @click=${() => this._vacFitTurn({ rot: ((this._vacFit!.p.rot + 90) % 360) as any })}>${this._t('vac.fit_rotate')}</button>
<button class="btn ghostbtn" @click=${() => this._vacFitTurn({ mir: !this._vacFit!.p.mir })}>${this._t('vac.fit_mirror')}</button>
<button class="btn" @click=${() => this._vacFitSave()}>${this._t('btn.save')}</button>
<button class="btn ghostbtn" @click=${() => { this._vacFit = null; }}>${this._t('btn.cancel')}</button>
<button class="btn ghostbtn" ?disabled=${this._vacFit.busy}
@click=${() => this._vacFitTurn({ rot: ((this._vacFit!.p.rot + 90) % 360) as FitParams['rot'] })}>${this._t('vac.fit_rotate')}</button>
<button class="btn ghostbtn" ?disabled=${this._vacFit.busy}
@click=${() => this._vacFitTurn({ mir: !this._vacFit!.p.mir })}>${this._t('vac.fit_mirror')}</button>
<button class="btn" ?disabled=${this._vacFit.busy}
@click=${() => this._vacFitSave()}>${this._t('btn.save')}</button>
<button class="btn ghostbtn" ?disabled=${this._vacFit.busy}
@click=${() => { this._vacFit = null; }}>${this._t('btn.cancel')}</button>
</div>` : nothing}
${this._tapConfirm
? html`<hp-dialog .hass=${this.hass}
@@ -12260,8 +12268,10 @@ export class HouseplanCard extends LitElement {
/** Persist a solved matrix into marker.vacuum.calibration[mapId].
* Returns whether the write actually landed — callers must not toast
* success otherwise (HP-1540-01). */
private _vacSaveMatrix(markerId: string, source: string, mapId: string, matrix: Affine): boolean {
return this._editorRuntimeOrThrow()._vacSaveMatrix(markerId, source, mapId, matrix);
private _vacSaveMatrix(
markerId: string, source: string, mapId: string, matrix: Affine, routeId = '',
): Promise<boolean> {
return this._editorRuntimeOrThrow()._vacSaveMatrix(markerId, source, mapId, matrix, routeId);
}
/** The exact plan-room set accepted by auto-calibration and diagnostics. */
@@ -12272,11 +12282,11 @@ export class HouseplanCard extends LitElement {
}
/** «Настроить автоматически»: robot rooms ↔ plan rooms by name. */
private _vacAutoCalibrate(d: DevItem): void {
private _vacAutoCalibrate(d: DevItem): Promise<void> {
return this._editorRuntimeOrThrow()._vacAutoCalibrate(d);
}
private _vacApplyCalibrationProposal(manual: boolean): void {
private _vacApplyCalibrationProposal(manual: boolean): Promise<void> {
return this._editorRuntimeOrThrow()._vacApplyCalibrationProposal(manual);
}
@@ -12285,7 +12295,7 @@ export class HouseplanCard extends LitElement {
return this._editorRuntimeOrThrow()._vacStartFit(d);
}
private _vacFitSave(): void {
private _vacFitSave(): Promise<void> {
return this._editorRuntimeOrThrow()._vacFitSave();
}
+72 -70
View File
@@ -9,7 +9,7 @@
import { LitElement, html, svg, nothing, TemplateResult, PropertyValues } from 'lit';
import { guard } from 'lit/directives/guard.js';
import { renderVacuumMapsSection } from './editors/vacuum-maps-section';
import { calibrationTarget, planVacuumFit, writeVacuumMatrix } from './vacuum-route-edit';
import { calibrationTarget, planVacuumFit } from './vacuum-route-edit';
import { repeat } from 'lit/directives/repeat.js';
import './hp-dialog';
import type { HpDialog } from './hp-dialog';
@@ -118,7 +118,7 @@ import {
parseVacSourceCandidate, resolveVacSource, resolveCurrentVacPath, trimVacPathTarget, areaCentroid,
vacCalibrationResidualCm, vacRoomNameMatchCount, VAC_CALIBRATION_WARN_CM,
VAC_TELEPORT_GAP_MS, VAC_STALE_MS,
FitParams, fitMatrix, fitFromMatrix, initialFit, reanchorFit, VacRoom,
FitParams, fitMatrix, initialFit, reanchorFit, VacRoom,
Pt as VacPt, type VacPath, type VacSourceCandidate,
type VacSourceResolution, type VacSourceStatus,
} from './vacuum';
@@ -245,6 +245,8 @@ import {
formatLatticeShiftCm,
} from './coordinate-canonicalization';
import { enqueueSerializedWrite, optimisticAttempt, rollbackOptimistic, 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';
import { supportT, type SupportI18nKey } from './i18n/support';
import {
@@ -1147,11 +1149,11 @@ export interface HouseplanEditorHostPort {
_undoPoint: () => void;
_vacAllCameraCache: { devId: string; candidates: VacSourceCandidate[]; } | null;
_vacAllCamerasFor: string | null;
_vacCalConfirm: { markerId: string; source: string; mapId: string; routeId?: string; space?: string; matrix: Affine; rooms: number; error: string; } | null;
_vacCalConfirm: CalibrationProposal | null;
_vacEnsureMarker: (d: DevItem) => Marker | null;
_vacEntity: (d: DevItem) => string | null;
_vacMapId: (d: DevItem, tele: { mapId: string }, planHass?: any) => string;
_vacFit: { markerId: string; source: string; mapId: string; routeId?: string; p: FitParams; drag: null | { kind: "move" | "scale"; sx: number; sy: number; p0: FitParams; fx: number; fy: number; }; } | null;
_vacFit: VacuumFit | null;
_vacOpenAllCameras: (d: DevItem) => void;
_vacRt: Map<string, { trail: VacPt[]; lastKey: string; lastTs: number; moving: boolean; jump: boolean; endedTs: number; lastPos: VacPt | null; }>;
_vacSource: (d: DevItem, planHass?: any) => string | null;
@@ -1736,13 +1738,13 @@ public _samePt(a: readonly number[], b: readonly number[]): boolean {
return samePoint(a, b);
}
public _dropLegacySegments(): void {
public _dropLegacySegments(config = this.host._serverCfg): void {
// «Ripple only» was removed from the UI: keep old configs readable, then
// materialise the recognisable icon+activity presentation on any write.
for (const marker of this.host._serverCfg?.markers || []) {
for (const marker of config?.markers || []) {
if (marker.display === 'ripple') marker.display = 'icon_ripple';
}
for (const sp of this.host._serverCfg?.spaces || []) {
for (const sp of config?.spaces || []) {
delete (sp as any).segments;
const physicalIds = new Set<string>();
const validId = (id: any): id is string => typeof id === 'string'
@@ -1828,13 +1830,16 @@ public async _reloadRejectedPhysicalWrite(): Promise<void> {
await this.host._reloadConfigOnly(true);
}
public _writeConfig(): Promise<void> {
public _prepareConfigCandidate(config: ServerConfig): ServerConfig { this._dropLegacySegments(config); return canonicalizeConfigGeometry(config); }
public _writeConfig(attempt: OptimisticAttempt<ServerConfig> | null = null): Promise<void> {
this.host._writesPending++;
this.host._writeChain = enqueueSerializedWrite(this.host._writeChain, async () => {
if (!this.host._serverCfg) return;
this._dropLegacySegments();
const candidate = canonicalizeConfigGeometry(this.host._serverCfg);
const candidate = this._prepareConfigCandidate(this.host._serverCfg);
const candidateFingerprint = contentFingerprint(candidate);
// Bind a queued optimistic save to the revision its request will actually use.
if (attempt && candidateFingerprint === attempt.attemptedFingerprint) attempt.revision = this.host._cfgRev;
const strictEntries = [...this.host._pendingPhysicalWrites.entries()];
for (const [spaceId, accepted] of strictEntries) {
const candidateSpace = candidate.spaces.find((space) => space.id === spaceId);
@@ -8157,7 +8162,10 @@ public async _saveMarker(): Promise<void> {
}
const cfg = this.host._serverCfg;
if (!cfg) return;
const markers = cfg.markers || [];
const baseRevision = this.host._cfgRev;
const baseContent = contentFingerprint(cfg);
let candidate = JSON.parse(JSON.stringify(cfg)) as ServerConfig;
const markers = candidate.markers || [];
const id = markerIdForBinding(dlg.binding, dlg.devId, () => 'v_' + Date.now().toString(36));
const oldId = dlg.devId;
const prevDev = oldId ? this.host._devices.find((x) => x.id === oldId) : null;
@@ -8186,6 +8194,8 @@ public async _saveMarker(): Promise<void> {
const targetSpaceId = targetSpaceModel.id;
if (dlg.binding === 'virtual' && !space) space = targetSpaceId;
this.host._markerDialog = { ...dlg, busy: true };
let attempt: OptimisticAttempt<ServerConfig> | null = null;
let configAccepted = false;
try {
const replacedRemovedIds = dlg.binding === 'virtual'
? []
@@ -8267,8 +8277,9 @@ public async _saveMarker(): Promise<void> {
}
// Rebinding changes source identity. Rewrite every marker:* edge in the
// same config transaction before replacing the marker itself.
cfg.markers = markers;
if (oldId && oldId !== id) cfg.markers = rewriteMarkerControlReferences(cfg.markers, oldId, id);
candidate.markers = markers;
if (oldId && oldId !== id)
candidate.markers = rewriteMarkerControlReferences(candidate.markers, oldId, id);
if (oldId && oldId !== id && marker.value_badge?.source?.kind === 'derived_marker_state'
&& marker.value_badge.source.ref === `marker:${oldId}`) {
marker.value_badge.source = { kind: 'derived_marker_state', ref: `marker:${id}` };
@@ -8278,19 +8289,19 @@ public async _saveMarker(): Promise<void> {
marker.value_source = { kind: 'derived_marker_state', ref: `marker:${id}` };
}
// remove the previous marker (by the old id and by the new id)
cfg.markers = cfg.markers.filter(
candidate.markers = candidate.markers.filter(
(m) => m.id !== id && m.id !== oldId
&& (marker.binding === 'virtual' || m.binding !== marker.binding),
);
cfg.markers.push(marker);
candidate.markers.push(marker);
const obsoleteAreaSnapshotIds = new Set(replacedRemovedIds);
if (oldId && oldId !== id) obsoleteAreaSnapshotIds.add(oldId);
obsoleteAreaSnapshotIds.delete(id);
if (obsoleteAreaSnapshotIds.size && cfg.settings?.marker_area_snapshot) {
cfg.settings = {
...cfg.settings,
if (obsoleteAreaSnapshotIds.size && candidate.settings?.marker_area_snapshot) {
candidate.settings = {
...candidate.settings,
marker_area_snapshot: removeMarkerAreaSnapshots(
cfg.settings.marker_area_snapshot, obsoleteAreaSnapshotIds,
candidate.settings.marker_area_snapshot, obsoleteAreaSnapshotIds,
),
};
}
@@ -8313,7 +8324,6 @@ public async _saveMarker(): Promise<void> {
// stays in place; pin it under the (possibly new) id
if (id !== oldId || !this.host._layout[id] || roomChanged) {
newPos = { s: prevPos.s, x: prevPos.x, y: prevPos.y };
this.host._layout = { ...this.host._layout, [id]: newPos };
}
} else if (replacingRemoved || !this.host._layout[id] || roomChanged) {
let cx = targetSpaceModel.vb[0] + targetSpaceModel.vb[2] / 2;
@@ -8325,9 +8335,22 @@ public async _saveMarker(): Promise<void> {
: undefined;
if (room) [cx, cy] = this.host._roomCenter(room);
newPos = this.host._normPos(targetSpaceId, cx, cy);
this.host._layout = { ...this.host._layout, [id]: newPos };
}
await this._saveConfigNow();
// File copy is async: never install a candidate built from a replaced root.
if (this.host._serverCfg !== cfg || this.host._cfgRev !== baseRevision || contentFingerprint(cfg) !== baseContent) {
if (this.host._markerDialog) this.host._markerDialog = { ...this.host._markerDialog, busy: false };
this.host._showToast(this.host._t('toast.conflict'));
return;
}
candidate = this._prepareConfigCandidate(candidate);
attempt = optimisticAttempt(cfg, candidate, this.host._cfgContentFingerprint, this.host._cfgRev, contentFingerprint);
this.host._serverCfg = candidate;
this.host._regSignature = '';
this.host._maybeRebuildDevices();
this.host.requestUpdate();
if (this.host._saveConfigDebounced.pending()) this.host._saveConfigDebounced.cancel();
await this._saveConfigNow(attempt);
configAccepted = true;
if (newPos) {
const pos = canonicalizePosition(newPos);
this.host._layout = { ...this.host._layout, [id]: pos };
@@ -8361,6 +8384,12 @@ public async _saveMarker(): Promise<void> {
// in flight — spreading null yields a truthy husk and the renderer
// 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._regSignature = '';
this.host._maybeRebuildDevices();
this.host.requestUpdate();
}
if (this.host._markerDialog) this.host._markerDialog = { ...this.host._markerDialog, busy: false };
this.host._showToast(this.host._t('toast.error', { err: this.host._errText(e) }));
}
@@ -8931,12 +8960,12 @@ public async _deleteSpace(): Promise<void> {
}
}
public async _saveConfigNow(): Promise<void> {
public async _saveConfigNow(attempt: OptimisticAttempt<ServerConfig> | null = null): Promise<void> {
this.host._cfgEpoch++;
try {
// same queue as the debounced writer: a dialog saving while a background
// write is still out must not race it into a self-inflicted conflict
await this._writeConfig();
await this._writeConfig(attempt);
} catch (e: any) {
if (e?.physicalGeometryRolledBack) await this._reloadRejectedPhysicalWrite();
else if (e?.code === 'conflict') await this.host._reloadConfigOnly();
@@ -10952,9 +10981,9 @@ public _renderVacSection(dlg: any): TemplateResult | typeof nothing {
</div>
</details>
<div class="vacbtns">
${tierA ? html`<button class="btn" @click=${() => this._vacAutoCalibrate(dev)}>${this.host._t('vac.autocal')}</button>` : nothing}
${tierA ? html`<button class="btn" ?disabled=${this.host._markerDialog?.busy} @click=${() => this._vacAutoCalibrate(dev)}>${this.host._t('vac.autocal')}</button>` : nothing}
${canUseSource
? html`<button class="btn ghostbtn" @click=${() => this._vacStartFit(dev)}>${this.host._t('vac.fit')}</button>`
? html`<button class="btn ghostbtn" ?disabled=${this.host._markerDialog?.busy} @click=${() => this._vacStartFit(dev)}>${this.host._t('vac.fit')}</button>`
: nothing}
<a class="btn ghostbtn" href="https://github.com/Matysh/houseplan-card/blob/main/docs/VACUUM.md"
target="_blank" rel="noopener">${this.host._t('vac.documentation')}</a>
@@ -10981,20 +11010,10 @@ public _vacMapId(d: DevItem, tele: { mapId: string }, planHass = this.host._plan
return this.host._vacMapId(d, tele, planHass);
}
public _vacSaveMatrix(markerId: string, source: string, mapId: string, matrix: Affine, routeId = ''): boolean {
// HP-1540-01: a first-use vacuum has no marker yet — materialise it
const dev = this.host._devices.find((x) => x.id === markerId);
const m = dev ? this.host._vacEnsureMarker(dev)
: this.host._serverCfg?.markers?.find((x: Marker) => x.id === markerId);
if (!m) return false;
// #162: with explicit routes the matrix belongs to the route, not to a
// marker-wide dictionary that cannot tell two floors apart.
m.vacuum = writeVacuumMatrix(m.vacuum || {}, { source, mapId, routeId, matrix });
this.host._regSignature = '';
this.host._maybeRebuildDevices();
this._saveConfig();
this.host.requestUpdate();
return true;
public _vacSaveMatrix(
markerId: string, source: string, mapId: string, matrix: Affine, routeId = '',
): Promise<boolean> {
return saveVacuumMatrix(this, markerId, source, mapId, matrix, routeId);
}
public _vacPlanRoomAnchors(spaceId: string | null | undefined): Array<{
@@ -11012,7 +11031,8 @@ public _vacPlanRoomAnchors(spaceId: string | null | undefined): Array<{
.filter(Boolean) as Array<{ name: string; cx: number; cy: number }>;
}
public _vacAutoCalibrate(d: DevItem): void {
public async _vacAutoCalibrate(d: DevItem): Promise<void> {
if (this.host._markerDialog?.busy) return;
const src = this.host._vacSource(d);
const tele = src ? readVacTelemetry(this.host.hass?.states[src]?.attributes) : null;
if (!src || !tele || tele.rooms.length < 3) {
@@ -11038,32 +11058,18 @@ public _vacAutoCalibrate(d: DevItem): void {
};
return;
}
if (!this._vacSaveMatrix(d.id, src, mapId, res.matrix, target.routeId)) return;
this.host._showToast(subst(this.host._t('vac.autocal_done'), { rooms: String(res.matched.length) }));
await saveAutomaticCalibration(this, {
markerId: d.id, source: src, mapId, routeId: target.routeId, space: target.space,
matrix: res.matrix, rooms: res.matched.length,
});
}
public _vacApplyCalibrationProposal(manual: boolean): void {
const proposal = this.host._vacCalConfirm;
if (!proposal) return;
this.host._vacCalConfirm = null;
if (manual) {
const dev = this.host._devices.find((candidate) => candidate.id === proposal.markerId);
const fit = fitFromMatrix(proposal.matrix);
if (!dev || !fit) return;
this.host._markerDialog = null;
// #162: матрица решена против пространства МАРШРУТА, а не дока.
const space = proposal.space || dev.space;
if (space !== this.host._space && !this.host._commitSpace(space)) return;
this.host._vacFit = { markerId: proposal.markerId, source: proposal.source,
routeId: proposal.routeId, mapId: proposal.mapId, p: fit, drag: null };
return;
}
if (this._vacSaveMatrix(
proposal.markerId, proposal.source, proposal.mapId, proposal.matrix, proposal.routeId,
)) this.host._showToast(subst(this.host._t('vac.autocal_done'), { rooms: String(proposal.rooms) }));
public _vacApplyCalibrationProposal(manual: boolean): Promise<void> {
return applyCalibrationProposal(this, manual);
}
public _vacStartFit(d: DevItem, routeId = ''): void {
if (this.host._markerDialog?.busy) return;
const src = this.host._vacSource(d);
const tele = src ? readVacTelemetry(this.host.hass?.states[src]?.attributes) : null;
if (!src || !tele) {
@@ -11081,18 +11087,13 @@ public _vacStartFit(d: DevItem, routeId = ''): void {
this.host._vacFit = { markerId: d.id, source: src, mapId, routeId: plan.routeId, p: plan.params, drag: null };
}
public _vacFitSave(): void {
const f = this.host._vacFit;
if (!f) return;
// HP-1540-01: no success toast for a save that did not happen
const ok = this._vacSaveMatrix(f.markerId, f.source, f.mapId, fitMatrix(f.p), f.routeId);
this.host._vacFit = null;
if (ok) this.host._showToast(this.host._t('vac.cal_done'));
public _vacFitSave(): Promise<void> {
return saveManualCalibration(this, fitMatrix);
}
public _vacFitTurn(patch: Partial<FitParams>): void {
const f = this.host._vacFit;
if (!f) return;
if (!f || f.busy) return;
const tele = readVacTelemetry(this.host.hass?.states[f.source]?.attributes);
const c = this._vacGhostCentre(tele?.rooms || []);
const next = { ...f.p, ...patch } as FitParams;
@@ -11119,6 +11120,7 @@ public _vacFitPointer(ev: PointerEvent, view: { x: number; y: number; w: number;
const f = this.host._vacFit;
if (!f) return;
ev.stopPropagation();
if (f.busy) return;
if (ev.type === 'pointerdown') {
const t = ev.target as HTMLElement;
const corner = t.getAttribute?.('data-corner');
+1 -2
View File
@@ -51,8 +51,7 @@ export function rollbackOptimistic<T>(
): boolean {
const current = host._serverCfg;
if (!current || host._cfgRev !== attempt.revision
|| (current !== attempt.attempted
&& fingerprint(current) !== attempt.attemptedFingerprint)) return false;
|| fingerprint(current) !== attempt.attemptedFingerprint) return false;
host._serverCfg = attempt.previous;
host._cfgContentFingerprint = attempt.previousFingerprint;
host.requestUpdate();
+208
View File
@@ -0,0 +1,208 @@
/** Atomic persistence and retry state for vacuum calibration (#442). */
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';
export type CalibrationProposal = {
markerId: string;
source: string;
mapId: string;
routeId?: string;
space?: string;
matrix: Affine;
rooms: number;
error: string;
busy?: boolean;
};
export type VacuumFit = {
markerId: string;
source: string;
mapId: string;
routeId?: string;
p: FitParams;
busy?: boolean;
drag: null | {
kind: 'move' | 'scale';
sx: number;
sy: number;
p0: FitParams;
fx: number;
fy: number;
};
};
export interface VacuumCalibrationWriteHost {
_serverCfg: ServerConfig | null;
_devices: DevItem[];
_cfgContentFingerprint: string;
_cfgRev: number;
_saveConfigDebounced: { pending: () => boolean; cancel: () => void };
_regSignature: string;
_markerDialog: { busy: boolean } | null;
_vacCalConfirm: CalibrationProposal | null;
_vacFit: VacuumFit | null;
_space: string;
_commitSpace: (spaceId: string) => boolean;
_maybeRebuildDevices: () => void;
_showToast: (message: string) => void;
_t: (key: I18nKey, vars?: Record<string, string | number>) => string;
_errText: (error: unknown) => string;
requestUpdate: () => unknown;
}
export interface VacuumCalibrationWriteRuntime {
host: VacuumCalibrationWriteHost;
_prepareConfigCandidate: (config: ServerConfig) => ServerConfig;
_saveConfigNow: (attempt?: OptimisticAttempt<ServerConfig>) => Promise<void>;
}
const automaticWrites = new WeakSet<VacuumCalibrationWriteRuntime>();
const rebuild = (host: VacuumCalibrationWriteHost): void => {
host._regSignature = '';
host._maybeRebuildDevices();
host.requestUpdate();
};
/** Persist one exact route matrix without ever mutating the accepted config. */
export async function saveVacuumMatrix(
runtime: VacuumCalibrationWriteRuntime,
markerId: string,
source: string,
mapId: string,
matrix: Affine,
routeId = '',
): Promise<boolean> {
const host = runtime.host;
const previous = host._serverCfg;
if (!previous) return false;
let candidate = JSON.parse(JSON.stringify(previous)) as ServerConfig;
candidate.markers = candidate.markers || [];
let marker = candidate.markers.find((item) => item.id === markerId);
if (!marker) {
const device = host._devices.find((item) => item.id === markerId);
if (!device || (device.bindingKind !== 'device' && device.bindingKind !== 'entity')
|| !device.bindingRef) return false;
marker = {
id: device.id,
binding: `${device.bindingKind}:${device.bindingRef}`,
space: device.space || null,
area: device.area || null,
hidden: device.hidden ? true : false,
} as Marker;
candidate.markers.push(marker);
}
marker.vacuum = writeVacuumMatrix(marker.vacuum || {}, {
source, mapId, routeId, matrix,
});
candidate = runtime._prepareConfigCandidate(candidate);
const attempt = optimisticAttempt(
previous, candidate, host._cfgContentFingerprint, host._cfgRev, contentFingerprint,
);
host._serverCfg = candidate;
rebuild(host);
if (host._saveConfigDebounced.pending()) host._saveConfigDebounced.cancel();
try {
await runtime._saveConfigNow(attempt);
return true;
} catch (error) {
rollbackOptimistic(host, attempt, contentFingerprint);
rebuild(host);
host._showToast(host._t('toast.cfg_save_failed', { err: host._errText(error) }));
return false;
}
}
/** Low-residual auto calibration: keep its source dialog and suppress doubles. */
export async function saveAutomaticCalibration(
runtime: VacuumCalibrationWriteRuntime,
request: Omit<CalibrationProposal, 'error' | 'busy'>,
): Promise<void> {
if (automaticWrites.has(runtime)) return;
automaticWrites.add(runtime);
const host = runtime.host;
const dialog = host._markerDialog;
const busyDialog = dialog ? { ...dialog, busy: true } : null;
if (busyDialog) {
host._markerDialog = busyDialog;
host.requestUpdate();
}
try {
const saved = await saveVacuumMatrix(
runtime, request.markerId, request.source, request.mapId, request.matrix, request.routeId,
);
if (saved) host._showToast(host._t('vac.autocal_done', { rooms: String(request.rooms) }));
} finally {
automaticWrites.delete(runtime);
if (busyDialog && host._markerDialog === busyDialog) {
host._markerDialog = { ...busyDialog, busy: false };
host.requestUpdate();
}
}
}
/** Apply a high-residual proposal, or transfer it unchanged into manual fit. */
export async function applyCalibrationProposal(
runtime: VacuumCalibrationWriteRuntime,
manual: boolean,
): Promise<void> {
const host = runtime.host;
const proposal = host._vacCalConfirm;
if (!proposal || proposal.busy) return;
if (manual) {
const device = host._devices.find((item) => item.id === proposal.markerId);
const fit = fitFromMatrix(proposal.matrix);
if (!device || !fit) return;
const space = proposal.space || device.space;
if (space !== host._space && !host._commitSpace(space)) return;
host._vacCalConfirm = null;
host._markerDialog = null;
host._vacFit = {
markerId: proposal.markerId,
source: proposal.source,
routeId: proposal.routeId,
mapId: proposal.mapId,
p: fit,
drag: null,
};
return;
}
const busyProposal = { ...proposal, busy: true };
host._vacCalConfirm = busyProposal;
host.requestUpdate();
const saved = await saveVacuumMatrix(
runtime, proposal.markerId, proposal.source, proposal.mapId, proposal.matrix, proposal.routeId,
);
if (host._vacCalConfirm === busyProposal) {
host._vacCalConfirm = saved ? null : { ...proposal, busy: false };
host.requestUpdate();
}
if (saved) host._showToast(host._t('vac.autocal_done', { rooms: String(proposal.rooms) }));
}
/** Save manual-fit parameters, retaining the exact overlay draft on rejection. */
export async function saveManualCalibration(
runtime: VacuumCalibrationWriteRuntime,
matrixOf: (params: FitParams) => Affine,
): Promise<void> {
const host = runtime.host;
const fit = host._vacFit;
if (!fit || fit.busy) return;
const busyFit = { ...fit, busy: true, drag: null };
host._vacFit = busyFit;
host.requestUpdate();
const saved = await saveVacuumMatrix(
runtime, fit.markerId, fit.source, fit.mapId, matrixOf(fit.p), fit.routeId,
);
if (host._vacFit === busyFit) {
host._vacFit = saved ? null : { ...fit, busy: false, drag: null };
host.requestUpdate();
}
if (saved) host._showToast(host._t('vac.cal_done'));
}
+1 -1
View File
@@ -183,7 +183,7 @@ test('frontend write paths adopt canonical candidates before persistence (#224)'
assert.match(source, /enqueueSerializedWrite\(this\._writeChain, async \(\) =>/);
assert.match(
source,
/const candidate = canonicalizeConfigGeometry\(this\._serverCfg\);/,
/const candidate = this\._prepareConfigCandidate\(this\._serverCfg\);/,
);
assert.match(
eagerCardSource,
+1 -1
View File
@@ -49,7 +49,7 @@ test('stable space ids use exact lookup and abort before side effects', () => {
const guardAt = saveMarker.indexOf('if (!targetSpaceModel) return;');
const busyAt = saveMarker.indexOf('busy: true');
const migrateAt = saveMarker.indexOf("type: 'houseplan/files/migrate'");
const configMutationAt = saveMarker.indexOf('cfg.markers = markers');
const configMutationAt = saveMarker.indexOf('candidate.markers = markers');
assert.ok(exactAt >= 0 && exactAt < guardAt && guardAt < busyAt
&& busyAt < migrateAt && migrateAt < configMutationAt);
+1 -1
View File
@@ -169,6 +169,6 @@ test('marker delete/re-add and opening save remain separate config transactions'
const saveMarker = methodBody(source, '_saveMarker');
assert.match(saveOpening, /sp\.openings/);
assert.doesNotMatch(saveOpening, /cfg\.markers|this\._markers/);
assert.match(saveMarker, /cfg\.markers/);
assert.match(saveMarker, /candidate\.markers/);
assert.doesNotMatch(saveMarker, /\.openings/);
});
+17
View File
@@ -100,3 +100,20 @@ test('a conflict reload or newer mutation wins over a rejected candidate (#439)'
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);
});
+147
View File
@@ -0,0 +1,147 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyCalibrationProposal,
saveAutomaticCalibration,
saveManualCalibration,
saveVacuumMatrix,
} from '../test-build/vacuum-calibration-write.js';
const fingerprint = (value) => JSON.stringify(value);
function deferred() {
let resolve;
let reject;
const promise = new Promise((done, fail) => { resolve = done; reject = fail; });
return { promise, resolve, reject };
}
function fixture({ marker = true, save } = {}) {
const accepted = {
spaces: [{ id: 'ground', title: 'Ground', rooms: [] }],
markers: marker ? [{
id: 'vac', binding: 'entity:vacuum.demo', space: 'ground',
vacuum: {
calibration: { old: [1, 0, 10, 0, 1, 20] },
map_routes: [{
id: 'upstairs', source: 'camera.map', map_id: 'new', space: 'ground',
calibration: [1, 0, 10, 0, 1, 20],
}],
},
}] : [],
};
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' },
_vacCalConfirm: null,
_vacFit: null,
_space: 'ground',
_commitSpace: () => true,
_maybeRebuildDevices: () => { rebuilds += 1; },
_showToast: (message) => { toasts.push(message); },
_t: (key, vars) => `${key}${vars ? `:${JSON.stringify(vars)}` : ''}`,
_errText: (error) => String(error?.message || error),
requestUpdate: () => {},
};
const runtime = {
host,
_prepareConfigCandidate: (config) => config,
_saveConfigNow: save || (async () => {
host._cfgContentFingerprint = fingerprint(host._serverCfg);
}),
};
return { accepted, host, runtime, toasts, rebuilds: () => rebuilds };
}
test('rejected matrix restores the accepted marker and a retry can persist (#442)', async () => {
let reject = true;
const f = fixture({ save: async () => {
if (reject) throw new Error('semantic reject');
f.host._cfgContentFingerprint = fingerprint(f.host._serverCfg);
} });
assert.equal(await saveVacuumMatrix(
f.runtime, 'vac', 'camera.map', 'new', [2, 0, 30, 0, 2, 40], 'upstairs',
), false);
assert.deepEqual(f.host._serverCfg, f.accepted);
assert.notEqual(f.host._serverCfg, f.accepted, 'rollback uses an isolated accepted snapshot');
assert.match(f.toasts.at(-1), /^toast\.cfg_save_failed/);
reject = false;
assert.equal(await saveVacuumMatrix(
f.runtime, 'vac', 'camera.map', 'new', [2, 0, 30, 0, 2, 40], 'upstairs',
), true);
const route = f.host._serverCfg.markers[0].vacuum.map_routes
.find((item) => item.id === 'upstairs');
assert.deepEqual(route.calibration, [2, 0, 30, 0, 2, 40]);
});
test('rejected first-use calibration leaves no synthetic marker (#442)', async () => {
const f = fixture({ marker: false, save: async () => { throw new Error('offline'); } });
assert.equal(await saveVacuumMatrix(
f.runtime, 'vac', 'camera.map', 'default', [1, 0, 0, 0, 1, 0],
), false);
assert.deepEqual(f.host._serverCfg.markers, []);
});
test('automatic calibration stays busy and has no early success (#442)', async () => {
const gate = deferred();
const f = fixture({ save: () => gate.promise });
const pending = saveAutomaticCalibration(f.runtime, {
markerId: 'vac', source: 'camera.map', mapId: 'default', space: 'ground',
matrix: [1, 0, 0, 0, 1, 0], rooms: 3,
});
assert.equal(f.host._markerDialog.busy, true);
assert.equal(f.toasts.some((toast) => toast.startsWith('vac.autocal_done')), false);
const duplicate = saveAutomaticCalibration(f.runtime, {
markerId: 'vac', source: 'camera.map', mapId: 'default', space: 'ground',
matrix: [1, 0, 0, 0, 1, 0], rooms: 3,
});
await duplicate;
gate.resolve();
await pending;
assert.equal(f.host._markerDialog.busy, false);
assert.equal(f.toasts.filter((toast) => toast.startsWith('vac.autocal_done')).length, 1);
});
test('rejected proposal and manual fit preserve their exact retry drafts (#442)', async () => {
let reject = true;
const f = fixture({ save: async () => {
if (reject) throw new Error('invalid calibration');
} });
const matrix = [1, 0, 11, 0, 1, 22];
const proposal = {
markerId: 'vac', source: 'camera.map', mapId: 'default', routeId: 'ground',
space: 'ground', matrix, rooms: 4, error: '42 cm',
};
f.host._vacCalConfirm = proposal;
await applyCalibrationProposal(f.runtime, false);
assert.deepEqual(f.host._vacCalConfirm, { ...proposal, busy: false });
const fit = {
markerId: 'vac', source: 'camera.map', mapId: 'default', routeId: 'ground',
p: { s: 1.25, rot: 90, mir: true, ox: 12, oy: 34 }, drag: null,
};
f.host._vacCalConfirm = null;
f.host._vacFit = fit;
await saveManualCalibration(f.runtime, () => matrix);
assert.deepEqual(f.host._vacFit, { ...fit, busy: false, drag: null });
reject = false;
await saveManualCalibration(f.runtime, () => matrix);
assert.equal(f.host._vacFit, null);
assert.equal(f.toasts.at(-1), 'vac.cal_done');
});