mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 23:19:14 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a02130620 | ||
|
|
f8cfac9c77 | ||
|
|
13593c1be9 | ||
|
|
d5f050fdd3 | ||
|
|
5d2f29572f | ||
|
|
d3412e6fab | ||
|
|
61d4300b51 | ||
|
|
c67c5b935b | ||
|
|
7721d7d2ac | ||
|
|
7a40ae72d2 | ||
|
|
8d31896aba | ||
|
|
a1a2d566c2 | ||
|
|
40a2642710 |
@@ -1,125 +1,125 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "bd405d5ce3531f7484e3d780d84bf9a6120e8bf7226acbfbcd15eb29ef573e40",
|
||||
"fingerprint": "b36c2306f25315638c0178090efd6865650040c5565d6ad92430103558fe37db",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-cmbllkdu.js",
|
||||
"houseplan-assets/houseplan-card-Bi2pkTUe.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 287575,
|
||||
"initialViewGzipBytes": 287370,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-D66Ner2L.js",
|
||||
"houseplan-assets/de-C9HvYpJv.js",
|
||||
"houseplan-assets/editor-CyKPZvNS.js",
|
||||
"houseplan-assets/fr-BjAKtQwB.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-bwdFd5UE.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-B7KlxNo_.js"
|
||||
"houseplan-assets/backdrop-pick-BPPgYv_-.js",
|
||||
"houseplan-assets/de-r_k_qwtG.js",
|
||||
"houseplan-assets/editor-9X7zagKL.js",
|
||||
"houseplan-assets/fr-iDaS4KiM.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-pL7NqSUJ.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Bzt-dklc.js"
|
||||
],
|
||||
"lazyGzipBytes": 199808,
|
||||
"lazyGzipBytes": 199383,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-D66Ner2L.js",
|
||||
"houseplan-assets/editor-CyKPZvNS.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-bwdFd5UE.js"
|
||||
"houseplan-assets/backdrop-pick-BPPgYv_-.js",
|
||||
"houseplan-assets/editor-9X7zagKL.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-pL7NqSUJ.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 144676,
|
||||
"lazyEditorGzipBytes": 144689,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-D66Ner2L.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-B7KlxNo_.js"
|
||||
"houseplan-assets/backdrop-pick-BPPgYv_-.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Bzt-dklc.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 14024,
|
||||
"lazyOnboardingGzipBytes": 14031,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-C9HvYpJv.js",
|
||||
"houseplan-assets/fr-BjAKtQwB.js"
|
||||
"houseplan-assets/de-r_k_qwtG.js",
|
||||
"houseplan-assets/fr-iDaS4KiM.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 48176,
|
||||
"lazyLocaleGzipBytes": 47735,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-D66Ner2L.js",
|
||||
"sha256": "6f8e893fe459b025f2f89ef3cf48afd07be82699a34489d446e6d2bbea1e0f44",
|
||||
"path": "houseplan-assets/backdrop-pick-BPPgYv_-.js",
|
||||
"sha256": "af775ad674123c0fe86c1f367fddab92f6c95cc1a3a455709c46bee48b143872",
|
||||
"rawBytes": 20636,
|
||||
"gzipBytes": 7068,
|
||||
"gzipBytes": 7072,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-cmbllkdu.js"
|
||||
"houseplan-assets/houseplan-card-Bi2pkTUe.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-C9HvYpJv.js",
|
||||
"sha256": "c48bad47855be263551852c9e4350e6148c3bc0206ddd63136982d4414110c48",
|
||||
"rawBytes": 81096,
|
||||
"gzipBytes": 24342,
|
||||
"path": "houseplan-assets/de-r_k_qwtG.js",
|
||||
"sha256": "3495a17186b5f746f7465999ae6892033644d26b730c076ed83a08b6d118f0e6",
|
||||
"rawBytes": 79660,
|
||||
"gzipBytes": 24118,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-CyKPZvNS.js",
|
||||
"sha256": "f285f0141083e5ea3bb7069126474c1e9cfb7e9073f58cb8fed8963cc131848a",
|
||||
"path": "houseplan-assets/editor-9X7zagKL.js",
|
||||
"sha256": "795170e9a06f0bf8928a32e04463f4e897a9ac1c39ca73d8203ce182539150b1",
|
||||
"rawBytes": 3826,
|
||||
"gzipBytes": 1578,
|
||||
"gzipBytes": 1582,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-cmbllkdu.js"
|
||||
"houseplan-assets/houseplan-card-Bi2pkTUe.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-BjAKtQwB.js",
|
||||
"sha256": "b443ec61052a852b1f7d1982b402ee3cf85f9600382dd317b55caa0543da39e2",
|
||||
"rawBytes": 83283,
|
||||
"gzipBytes": 23834,
|
||||
"path": "houseplan-assets/fr-iDaS4KiM.js",
|
||||
"sha256": "2984c303f30680207d7a069105eae0654d618dda3d57149e1d08fa783b0bf811",
|
||||
"rawBytes": 81807,
|
||||
"gzipBytes": 23617,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-cmbllkdu.js",
|
||||
"sha256": "1ac69d06e6d8717d0daa9634bb0ce911e4fded918904b06c34ac8290b3842d75",
|
||||
"rawBytes": 1019911,
|
||||
"gzipBytes": 286781,
|
||||
"path": "houseplan-assets/houseplan-card-Bi2pkTUe.js",
|
||||
"sha256": "4eb22945e063cc1cb1ea3dc7c32fe2c34a8ef66a6896243ffdbe23d06d699115",
|
||||
"rawBytes": 1017921,
|
||||
"gzipBytes": 286572,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-C9HvYpJv.js",
|
||||
"houseplan-assets/editor-CyKPZvNS.js",
|
||||
"houseplan-assets/fr-BjAKtQwB.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-bwdFd5UE.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-B7KlxNo_.js"
|
||||
"houseplan-assets/de-r_k_qwtG.js",
|
||||
"houseplan-assets/editor-9X7zagKL.js",
|
||||
"houseplan-assets/fr-iDaS4KiM.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-pL7NqSUJ.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Bzt-dklc.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-bwdFd5UE.js",
|
||||
"sha256": "01529edd319c84ce373d6086db952d6bdcad27d303c96c0dadb717bf16f9306e",
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-pL7NqSUJ.js",
|
||||
"sha256": "8dc46cb722e0d1703cde49ace8587d2871e8acff44a19108ab5a6e78ef9957ae",
|
||||
"rawBytes": 527184,
|
||||
"gzipBytes": 136030,
|
||||
"gzipBytes": 136035,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-D66Ner2L.js",
|
||||
"houseplan-assets/houseplan-card-cmbllkdu.js"
|
||||
"houseplan-assets/backdrop-pick-BPPgYv_-.js",
|
||||
"houseplan-assets/houseplan-card-Bi2pkTUe.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-B7KlxNo_.js",
|
||||
"sha256": "67bbf22592e7fcb3060012f104c0ff3ebe2b6ea29fa771e650d2fae8b4085ccd",
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-Bzt-dklc.js",
|
||||
"sha256": "d0ee6193635d42fff228dd4e0bf52ceec5075351032524cb50920887a128128f",
|
||||
"rawBytes": 28088,
|
||||
"gzipBytes": 6956,
|
||||
"gzipBytes": 6959,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-D66Ner2L.js",
|
||||
"houseplan-assets/houseplan-card-cmbllkdu.js"
|
||||
"houseplan-assets/backdrop-pick-BPPgYv_-.js",
|
||||
"houseplan-assets/houseplan-card-Bi2pkTUe.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "46e873cfe52720c02c77556c3e39ec7f96b7171f7be332c59116216b49009691",
|
||||
"sha256": "3ce2c954888ca57942b9b3f138da95f1fa5177d5c9965a89d07d179c26ab3300",
|
||||
"rawBytes": 1183,
|
||||
"gzipBytes": 794,
|
||||
"gzipBytes": 798,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-cmbllkdu.js"
|
||||
"houseplan-assets/houseplan-card-Bi2pkTUe.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,4 +1,4 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="bd405d5ce3531f7484e3d780d84bf9a6120e8bf7226acbfbcd15eb29ef573e40";import{b as e,l as o,t,d8 as s,A as a,d9 as i,da as l,db as n,E as r,c}from"./houseplan-card-cmbllkdu.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b36c2306f25315638c0178090efd6865650040c5565d6ad92430103558fe37db";import{b as e,l as o,t,d8 as s,A as a,d9 as i,da as l,db as n,E as r,c}from"./houseplan-card-Bi2pkTUe.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
+50
-34
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="bd405d5ce3531f7484e3d780d84bf9a6120e8bf7226acbfbcd15eb29ef573e40";try{await import("./houseplan-assets/houseplan-card-cmbllkdu.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b36c2306f25315638c0178090efd6865650040c5565d6ad92430103558fe37db";try{await import("./houseplan-assets/houseplan-card-Bi2pkTUe.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)}
|
||||
|
||||
@@ -320,6 +320,53 @@ const res = await page.evaluate(async () => {
|
||||
const staticEl = staticCard.renderRoot.querySelector('.dev[data-id="d_leak"]');
|
||||
const staticLeft = Number.parseFloat(staticEl?.style.left || '0');
|
||||
|
||||
// Lifecycle metadata is bounded by the authoritative registry, not by
|
||||
// explicit editor cleanup alone. A disappeared device with no saved marker
|
||||
// must lose its snapshot on the next full pass.
|
||||
const orphanId = 'device-that-disappeared';
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
settings: {
|
||||
...c._serverCfg.settings,
|
||||
marker_area_snapshot: {
|
||||
...c._serverCfg.settings.marker_area_snapshot,
|
||||
[orphanId]: { binding: `device:${orphanId}`, area: 'living_room' },
|
||||
},
|
||||
},
|
||||
};
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
await wait(450);
|
||||
const authoritativeOrphanRemoved = !c._serverCfg.settings.marker_area_snapshot?.[orphanId];
|
||||
|
||||
// A separate connection whose full registry calls fail models startup or a
|
||||
// limited account. The exact same orphan evidence is not authoritative and
|
||||
// therefore must not be destructive.
|
||||
const limitedConfig = structuredClone(c._serverCfg);
|
||||
limitedConfig.settings.marker_area_snapshot[orphanId] = {
|
||||
binding: `device:${orphanId}`, area: 'living_room',
|
||||
};
|
||||
const limitedHass = window.__mkHass();
|
||||
const limitedCallWS = limitedHass.callWS;
|
||||
limitedHass.connection = {};
|
||||
limitedHass.callWS = async (message) => {
|
||||
if (message.type === 'config/device_registry/list'
|
||||
|| message.type === 'config/entity_registry/list') throw new Error('synthetic limited registry');
|
||||
if (message.type === 'houseplan/config/get') {
|
||||
return { config: limitedConfig, rev: 30, can_write: true };
|
||||
}
|
||||
return limitedCallWS(message);
|
||||
};
|
||||
const limitedCard = document.createElement('houseplan-card');
|
||||
limitedCard.setConfig({ type: 'custom:houseplan-card', title: 'Limited registry' });
|
||||
limitedCard.hass = limitedHass;
|
||||
document.body.append(limitedCard);
|
||||
await wait(500);
|
||||
await paint(limitedCard);
|
||||
const nonAuthoritativeOrphanPreserved = !!limitedCard._serverCfg?.settings
|
||||
?.marker_area_snapshot?.[orphanId];
|
||||
limitedCard.remove();
|
||||
|
||||
return {
|
||||
movedToRegistryArea: moved?.area === 'kitchen' && moved?.space === 'f1',
|
||||
staleLayoutDeleted: !c._layout.d_light1,
|
||||
@@ -362,6 +409,8 @@ const res = await page.evaluate(async () => {
|
||||
message.type === 'houseplan/config/set'
|
||||
|| ['houseplan/layout/set', 'houseplan/layout/update', 'houseplan/layout/delete']
|
||||
.includes(message.type)),
|
||||
authoritativeOrphanRemoved,
|
||||
nonAuthoritativeOrphanPreserved,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,56 @@ const out = await page.evaluate(async () => {
|
||||
await card.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const settleElement = async (element) => {
|
||||
await element.updateComplete;
|
||||
const shell = element.querySelector('hp-dialog');
|
||||
if (shell) await shell.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const deepActiveElement = () => {
|
||||
let active = document.activeElement;
|
||||
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
|
||||
return active;
|
||||
};
|
||||
const directConfirm = async (kind, action, key) => {
|
||||
const element = document.createElement('hp-confirm');
|
||||
element.token = Math.floor(Math.random() * 1_000_000) + 1;
|
||||
element.request = {
|
||||
key,
|
||||
kind,
|
||||
title: kind === 'destructive' ? 'Delete?' : 'Unlock?',
|
||||
message: kind === 'destructive'
|
||||
? 'The plan and all its rooms will be deleted.'
|
||||
: 'House Plan will send an unlock command.',
|
||||
confirmLabel: kind === 'destructive' ? 'Delete' : 'Unlock',
|
||||
cancelLabel: 'Cancel',
|
||||
};
|
||||
const decision = new Promise((resolve) => element.addEventListener(
|
||||
'hp-confirm-decision', (event) => resolve(event.detail.accepted), { once: true },
|
||||
));
|
||||
document.body.append(element);
|
||||
await settleElement(element);
|
||||
const shell = element.querySelector('hp-dialog');
|
||||
const native = shell?.shadowRoot?.querySelector('dialog');
|
||||
const body = element.querySelector('.danger-confirm-body');
|
||||
const cancel = element.querySelector('button[autofocus]');
|
||||
const semantics = native?.getAttribute('role') === 'alertdialog'
|
||||
&& native.getAttribute('aria-describedby') === body?.id
|
||||
&& !!body?.textContent?.trim();
|
||||
const focused = deepActiveElement() === cancel;
|
||||
const stayedNative = !!native && !shell?.shadowRoot?.querySelector('ha-dialog');
|
||||
if (action === 'escape') {
|
||||
cancel?.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape', bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
} else {
|
||||
cancel?.click();
|
||||
}
|
||||
const accepted = await decision;
|
||||
element.remove();
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
return { semantics, focused, stayedNative, cancelled: accepted === false };
|
||||
};
|
||||
const dialogs = () => root().querySelectorAll('hp-confirm').length;
|
||||
const request = (key) => ({
|
||||
key,
|
||||
@@ -44,6 +94,16 @@ const out = await page.evaluate(async () => {
|
||||
|
||||
await settle();
|
||||
|
||||
result.standaloneStartsWithoutHaDialog = !customElements.get('ha-dialog');
|
||||
const noHaDestructive = await directConfirm('destructive', 'click', 'no-ha-delete');
|
||||
result.noHaDestructiveIsDescribedAlert = noHaDestructive.semantics;
|
||||
result.noHaDestructiveFocusesCancel = noHaDestructive.focused;
|
||||
result.noHaDestructiveCancelResolvesFalse = noHaDestructive.cancelled;
|
||||
const noHaWarning = await directConfirm('warning', 'escape', 'no-ha-unlock');
|
||||
result.noHaWarningIsDescribedAlert = noHaWarning.semantics;
|
||||
result.noHaWarningFocusesCancel = noHaWarning.focused;
|
||||
result.noHaWarningEscapeResolvesFalse = noHaWarning.cancelled;
|
||||
|
||||
// Основная ветка: ровно один диалог, а не два (риск двойного рендера при
|
||||
// выносе — если бы блок остался и в ветке, и в обёртке).
|
||||
const single = card._confirmDanger(request('single'));
|
||||
@@ -133,8 +193,68 @@ const out = await page.evaluate(async () => {
|
||||
await settle();
|
||||
result.notReadyCardRefusesInsteadOfHanging = (await refused) === false;
|
||||
|
||||
// Match the public surface of HA's pinned ha-dialog closely enough to prove
|
||||
// branch selection and ARIA forwarding without depending on private shadow
|
||||
// DOM. Alert confirmations must still avoid it: the pinned component does
|
||||
// not forward its reflected `type` to the actual dialog role.
|
||||
class HaDialogStub extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' }).innerHTML = '<slot name="headerTitle"></slot>'
|
||||
+ '<slot></slot><slot name="footer"></slot>';
|
||||
}
|
||||
connectedCallback() {
|
||||
queueMicrotask(() => this.dispatchEvent(new Event('opened')));
|
||||
}
|
||||
}
|
||||
customElements.define('ha-dialog', HaDialogStub);
|
||||
|
||||
const ordinary = document.createElement('hp-dialog');
|
||||
ordinary.title = 'Ordinary device editor';
|
||||
ordinary.describedBy = 'ordinary-description';
|
||||
ordinary.innerHTML = '<p id="ordinary-description">Ordinary dialog description</p>';
|
||||
document.body.append(ordinary);
|
||||
await ordinary.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const haShell = ordinary.shadowRoot.querySelector('ha-dialog');
|
||||
result.ordinaryDialogUsesHaBranch = !!haShell
|
||||
&& !ordinary.shadowRoot.querySelector('dialog');
|
||||
result.ordinaryDialogForwardsHaAria = haShell?.ariaLabelledBy?.startsWith('hp-dialog-title-')
|
||||
&& haShell?.ariaDescribedBy === 'ordinary-description';
|
||||
ordinary.remove();
|
||||
|
||||
const haDestructive = await directConfirm('destructive', 'click', 'ha-delete');
|
||||
result.haDestructiveStaysNativeAlert = haDestructive.semantics
|
||||
&& haDestructive.stayedNative;
|
||||
result.haDestructiveFocusesCancel = haDestructive.focused;
|
||||
result.haDestructiveCancelResolvesFalse = haDestructive.cancelled;
|
||||
const haWarning = await directConfirm('warning', 'escape', 'ha-unlock');
|
||||
result.haWarningStaysNativeAlert = haWarning.semantics && haWarning.stayedNative;
|
||||
result.haWarningFocusesCancel = haWarning.focused;
|
||||
result.haWarningEscapeResolvesFalse = haWarning.cancelled;
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const element = document.createElement('hp-confirm');
|
||||
element.token = 4_060_000;
|
||||
element.request = {
|
||||
key: 'accessibility-probe', kind: 'destructive', title: 'Accessibility probe',
|
||||
message: 'All rooms in this plan will be permanently deleted.',
|
||||
confirmLabel: 'Delete', cancelLabel: 'Cancel',
|
||||
};
|
||||
document.body.append(element);
|
||||
await element.updateComplete;
|
||||
await element.querySelector('hp-dialog')?.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
});
|
||||
const accessibilitySnapshot = await page.getByRole('alertdialog', {
|
||||
name: 'Accessibility probe',
|
||||
}).ariaSnapshot();
|
||||
out.realAccessibilityTreeIncludesConsequence = accessibilitySnapshot
|
||||
.includes('All rooms in this plan will be permanently deleted.');
|
||||
await page.evaluate(() => document.querySelector('hp-confirm')?.remove());
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
|
||||
Vendored
+58
-58
@@ -1,125 +1,125 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "bd405d5ce3531f7484e3d780d84bf9a6120e8bf7226acbfbcd15eb29ef573e40",
|
||||
"fingerprint": "b36c2306f25315638c0178090efd6865650040c5565d6ad92430103558fe37db",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-cmbllkdu.js",
|
||||
"houseplan-assets/houseplan-card-Bi2pkTUe.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 287575,
|
||||
"initialViewGzipBytes": 287370,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-D66Ner2L.js",
|
||||
"houseplan-assets/de-C9HvYpJv.js",
|
||||
"houseplan-assets/editor-CyKPZvNS.js",
|
||||
"houseplan-assets/fr-BjAKtQwB.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-bwdFd5UE.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-B7KlxNo_.js"
|
||||
"houseplan-assets/backdrop-pick-BPPgYv_-.js",
|
||||
"houseplan-assets/de-r_k_qwtG.js",
|
||||
"houseplan-assets/editor-9X7zagKL.js",
|
||||
"houseplan-assets/fr-iDaS4KiM.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-pL7NqSUJ.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Bzt-dklc.js"
|
||||
],
|
||||
"lazyGzipBytes": 199808,
|
||||
"lazyGzipBytes": 199383,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-D66Ner2L.js",
|
||||
"houseplan-assets/editor-CyKPZvNS.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-bwdFd5UE.js"
|
||||
"houseplan-assets/backdrop-pick-BPPgYv_-.js",
|
||||
"houseplan-assets/editor-9X7zagKL.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-pL7NqSUJ.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 144676,
|
||||
"lazyEditorGzipBytes": 144689,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-D66Ner2L.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-B7KlxNo_.js"
|
||||
"houseplan-assets/backdrop-pick-BPPgYv_-.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Bzt-dklc.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 14024,
|
||||
"lazyOnboardingGzipBytes": 14031,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-C9HvYpJv.js",
|
||||
"houseplan-assets/fr-BjAKtQwB.js"
|
||||
"houseplan-assets/de-r_k_qwtG.js",
|
||||
"houseplan-assets/fr-iDaS4KiM.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 48176,
|
||||
"lazyLocaleGzipBytes": 47735,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-D66Ner2L.js",
|
||||
"sha256": "6f8e893fe459b025f2f89ef3cf48afd07be82699a34489d446e6d2bbea1e0f44",
|
||||
"path": "houseplan-assets/backdrop-pick-BPPgYv_-.js",
|
||||
"sha256": "af775ad674123c0fe86c1f367fddab92f6c95cc1a3a455709c46bee48b143872",
|
||||
"rawBytes": 20636,
|
||||
"gzipBytes": 7068,
|
||||
"gzipBytes": 7072,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-cmbllkdu.js"
|
||||
"houseplan-assets/houseplan-card-Bi2pkTUe.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-C9HvYpJv.js",
|
||||
"sha256": "c48bad47855be263551852c9e4350e6148c3bc0206ddd63136982d4414110c48",
|
||||
"rawBytes": 81096,
|
||||
"gzipBytes": 24342,
|
||||
"path": "houseplan-assets/de-r_k_qwtG.js",
|
||||
"sha256": "3495a17186b5f746f7465999ae6892033644d26b730c076ed83a08b6d118f0e6",
|
||||
"rawBytes": 79660,
|
||||
"gzipBytes": 24118,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-CyKPZvNS.js",
|
||||
"sha256": "f285f0141083e5ea3bb7069126474c1e9cfb7e9073f58cb8fed8963cc131848a",
|
||||
"path": "houseplan-assets/editor-9X7zagKL.js",
|
||||
"sha256": "795170e9a06f0bf8928a32e04463f4e897a9ac1c39ca73d8203ce182539150b1",
|
||||
"rawBytes": 3826,
|
||||
"gzipBytes": 1578,
|
||||
"gzipBytes": 1582,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-cmbllkdu.js"
|
||||
"houseplan-assets/houseplan-card-Bi2pkTUe.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-BjAKtQwB.js",
|
||||
"sha256": "b443ec61052a852b1f7d1982b402ee3cf85f9600382dd317b55caa0543da39e2",
|
||||
"rawBytes": 83283,
|
||||
"gzipBytes": 23834,
|
||||
"path": "houseplan-assets/fr-iDaS4KiM.js",
|
||||
"sha256": "2984c303f30680207d7a069105eae0654d618dda3d57149e1d08fa783b0bf811",
|
||||
"rawBytes": 81807,
|
||||
"gzipBytes": 23617,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-cmbllkdu.js",
|
||||
"sha256": "1ac69d06e6d8717d0daa9634bb0ce911e4fded918904b06c34ac8290b3842d75",
|
||||
"rawBytes": 1019911,
|
||||
"gzipBytes": 286781,
|
||||
"path": "houseplan-assets/houseplan-card-Bi2pkTUe.js",
|
||||
"sha256": "4eb22945e063cc1cb1ea3dc7c32fe2c34a8ef66a6896243ffdbe23d06d699115",
|
||||
"rawBytes": 1017921,
|
||||
"gzipBytes": 286572,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-C9HvYpJv.js",
|
||||
"houseplan-assets/editor-CyKPZvNS.js",
|
||||
"houseplan-assets/fr-BjAKtQwB.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-bwdFd5UE.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-B7KlxNo_.js"
|
||||
"houseplan-assets/de-r_k_qwtG.js",
|
||||
"houseplan-assets/editor-9X7zagKL.js",
|
||||
"houseplan-assets/fr-iDaS4KiM.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-pL7NqSUJ.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Bzt-dklc.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-bwdFd5UE.js",
|
||||
"sha256": "01529edd319c84ce373d6086db952d6bdcad27d303c96c0dadb717bf16f9306e",
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-pL7NqSUJ.js",
|
||||
"sha256": "8dc46cb722e0d1703cde49ace8587d2871e8acff44a19108ab5a6e78ef9957ae",
|
||||
"rawBytes": 527184,
|
||||
"gzipBytes": 136030,
|
||||
"gzipBytes": 136035,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-D66Ner2L.js",
|
||||
"houseplan-assets/houseplan-card-cmbllkdu.js"
|
||||
"houseplan-assets/backdrop-pick-BPPgYv_-.js",
|
||||
"houseplan-assets/houseplan-card-Bi2pkTUe.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-B7KlxNo_.js",
|
||||
"sha256": "67bbf22592e7fcb3060012f104c0ff3ebe2b6ea29fa771e650d2fae8b4085ccd",
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-Bzt-dklc.js",
|
||||
"sha256": "d0ee6193635d42fff228dd4e0bf52ceec5075351032524cb50920887a128128f",
|
||||
"rawBytes": 28088,
|
||||
"gzipBytes": 6956,
|
||||
"gzipBytes": 6959,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-D66Ner2L.js",
|
||||
"houseplan-assets/houseplan-card-cmbllkdu.js"
|
||||
"houseplan-assets/backdrop-pick-BPPgYv_-.js",
|
||||
"houseplan-assets/houseplan-card-Bi2pkTUe.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "46e873cfe52720c02c77556c3e39ec7f96b7171f7be332c59116216b49009691",
|
||||
"sha256": "3ce2c954888ca57942b9b3f138da95f1fa5177d5c9965a89d07d179c26ab3300",
|
||||
"rawBytes": 1183,
|
||||
"gzipBytes": 794,
|
||||
"gzipBytes": 798,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-cmbllkdu.js"
|
||||
"houseplan-assets/houseplan-card-Bi2pkTUe.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,4 +1,4 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="bd405d5ce3531f7484e3d780d84bf9a6120e8bf7226acbfbcd15eb29ef573e40";import{b as e,l as o,t,d8 as s,A as a,d9 as i,da as l,db as n,E as r,c}from"./houseplan-card-cmbllkdu.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b36c2306f25315638c0178090efd6865650040c5565d6ad92430103558fe37db";import{b as e,l as o,t,d8 as s,A as a,d9 as i,da as l,db as n,E as r,c}from"./houseplan-card-Bi2pkTUe.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}
|
||||
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+50
-34
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="bd405d5ce3531f7484e3d780d84bf9a6120e8bf7226acbfbcd15eb29ef573e40";try{await import("./houseplan-assets/houseplan-card-cmbllkdu.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b36c2306f25315638c0178090efd6865650040c5565d6ad92430103558fe37db";try{await import("./houseplan-assets/houseplan-card-Bi2pkTUe.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)}
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Delete and unlock confirmations now use House Plan's native dialog shell and
|
||||
expose an alert dialog together with its consequence text to screen readers;
|
||||
ordinary dialogs continue to use Home Assistant's dialog chrome
|
||||
([#406](https://github.com/Matysh/houseplan-card/issues/406)).
|
||||
|
||||
- A failed config write while following a device to another Home Assistant
|
||||
Area no longer silently discards its manually placed position: House Plan
|
||||
restores the point before retrying, or marks the marker for attention if the
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Подтверждения удаления и разблокировки теперь используют нативную оболочку
|
||||
House Plan и передаются скринридеру как срочный диалог вместе с текстом
|
||||
последствий; обычные окна по-прежнему используют оболочку Home Assistant
|
||||
([#406](https://github.com/Matysh/houseplan-card/issues/406)).
|
||||
|
||||
- Отказ записи конфига при переносе устройства в другую HA-зону больше не
|
||||
стирает молча поставленную вручную позицию: House Plan восстанавливает точку
|
||||
перед повтором, а если не удалось и это — отмечает маркер для внимания.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"fixture": "synthetic-only",
|
||||
"chromium": "151.0.7922.34",
|
||||
"oxipng": "oxipng 10.2.0",
|
||||
"sourceFingerprint": "a66d7a0dfa661afdec7abb4de2c5a55a5320aec6b1cb40b34725b0fb40e343b1",
|
||||
"sourceFingerprint": "b705858a61f4b8e16b45922074b0e55402d044e318afb931e4d08c8b107f2aea",
|
||||
"captureScriptSha256": "9229c467130e1f3d3abd042e2dcabaca9d4d5a918d0d77eadc814848df597ba6",
|
||||
"command": "npm run build && node demo/docs/capture.mjs",
|
||||
"scenarios": {
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "a66d7a0dfa661afdec7abb4de2c5a55a5320aec6b1cb40b34725b0fb40e343b1",
|
||||
"sourceSha256": "b705858a61f4b8e16b45922074b0e55402d044e318afb931e4d08c8b107f2aea",
|
||||
"imageSha256": "9119ab87502038f787529f621c39e1e0d01f3bc3b0289051c3791a1886e97a6b"
|
||||
},
|
||||
"view-touch": {
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "a66d7a0dfa661afdec7abb4de2c5a55a5320aec6b1cb40b34725b0fb40e343b1",
|
||||
"sourceSha256": "b705858a61f4b8e16b45922074b0e55402d044e318afb931e4d08c8b107f2aea",
|
||||
"imageSha256": "21c6fe7d79ff5c878ade8e98a64cc90dfd7bcc66338c864fb5a0bd411471313d"
|
||||
},
|
||||
"space-create": {
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "a66d7a0dfa661afdec7abb4de2c5a55a5320aec6b1cb40b34725b0fb40e343b1",
|
||||
"sourceSha256": "b705858a61f4b8e16b45922074b0e55402d044e318afb931e4d08c8b107f2aea",
|
||||
"imageSha256": "fb32ea4a785fedb0e99b0bca6dbcf19022b32ced763ed875a57eb453be550bf5"
|
||||
},
|
||||
"room-contour-close": {
|
||||
@@ -48,7 +48,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "a66d7a0dfa661afdec7abb4de2c5a55a5320aec6b1cb40b34725b0fb40e343b1",
|
||||
"sourceSha256": "b705858a61f4b8e16b45922074b0e55402d044e318afb931e4d08c8b107f2aea",
|
||||
"imageSha256": "75e0bc3dedff68e29be46531df0cc205f69988e86229580cc021017932049b34"
|
||||
},
|
||||
"plan-context-tray": {
|
||||
@@ -59,7 +59,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "a66d7a0dfa661afdec7abb4de2c5a55a5320aec6b1cb40b34725b0fb40e343b1",
|
||||
"sourceSha256": "b705858a61f4b8e16b45922074b0e55402d044e318afb931e4d08c8b107f2aea",
|
||||
"imageSha256": "5fc29f4a71572ed43221131cd6e5f9a3da19fcc9bf63c879b12664bee98e292a"
|
||||
},
|
||||
"device-editor": {
|
||||
@@ -70,7 +70,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "a66d7a0dfa661afdec7abb4de2c5a55a5320aec6b1cb40b34725b0fb40e343b1",
|
||||
"sourceSha256": "b705858a61f4b8e16b45922074b0e55402d044e318afb931e4d08c8b107f2aea",
|
||||
"imageSha256": "f9a8c003ed0fbd6ba7d26cd5642207beabb78a3b62b972e35505ac95830a9a95"
|
||||
},
|
||||
"device-display-preview": {
|
||||
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "a66d7a0dfa661afdec7abb4de2c5a55a5320aec6b1cb40b34725b0fb40e343b1",
|
||||
"sourceSha256": "b705858a61f4b8e16b45922074b0e55402d044e318afb931e4d08c8b107f2aea",
|
||||
"imageSha256": "7185c75cf0e3629c7bc880381d1eab1d1f67a2484ca3d17e86ad53e0ed6387c8"
|
||||
},
|
||||
"background-editor": {
|
||||
@@ -92,7 +92,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "a66d7a0dfa661afdec7abb4de2c5a55a5320aec6b1cb40b34725b0fb40e343b1",
|
||||
"sourceSha256": "b705858a61f4b8e16b45922074b0e55402d044e318afb931e4d08c8b107f2aea",
|
||||
"imageSha256": "3d0d088f89da53a73efd13ef5bb5403f407011ab918e88671974459b5967ca25"
|
||||
},
|
||||
"room-card": {
|
||||
@@ -103,7 +103,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "a66d7a0dfa661afdec7abb4de2c5a55a5320aec6b1cb40b34725b0fb40e343b1",
|
||||
"sourceSha256": "b705858a61f4b8e16b45922074b0e55402d044e318afb931e4d08c8b107f2aea",
|
||||
"imageSha256": "93d88ed84e2e3f9071e94e4b74cc3f3a86c7f898a76d1fb26bcc499e7dc4f538"
|
||||
},
|
||||
"device-info": {
|
||||
@@ -114,7 +114,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "a66d7a0dfa661afdec7abb4de2c5a55a5320aec6b1cb40b34725b0fb40e343b1",
|
||||
"sourceSha256": "b705858a61f4b8e16b45922074b0e55402d044e318afb931e4d08c8b107f2aea",
|
||||
"imageSha256": "4d2257e49b28d3be517ed382269569b0e3bc18cec9d84d56b5f407c505a9b398"
|
||||
}
|
||||
},
|
||||
@@ -133,6 +133,7 @@
|
||||
],
|
||||
"witnesses": 0,
|
||||
"floor": 1,
|
||||
"witnessesSkippedBecause": "Полная смена растеризации канонического Linux runner: прежний baseline не воспроизводится ни CI, ни локальной контрольной съемкой; все 10 пар визуально проверены, продуктовых изменений нет"
|
||||
"witnessesSkippedBecause": "Полная смена растеризации канонического Linux runner: прежний baseline не воспроизводится ни CI, ни локальной контрольной съемкой; все 10 пар визуально проверены, продуктовых изменений нет",
|
||||
"lastWriteWasFingerprintOnly": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
# CODE-REVIEW-406-r1
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/406
|
||||
- Этап: code (PROCESS.md §2.7)
|
||||
- Заход: r1 · блокирующих циклов израсходовано 0 из 4 (перед этим раундом)
|
||||
- Ветка: `issue/406-beta2-polish`
|
||||
- Коммит на ревью: `fe385cbf9d5a0fc11f0bcfacbe4ba03e01167949`
|
||||
- ТЗ: `docs/specs/406-beta2-polish.md`, revision 4, зелёный `SPEC-REVIEW-406-r4`
|
||||
- База сравнения: `origin/dev` = `b04387ce914aa8559fb3bc446f99a5751f67f32c`
|
||||
|
||||
## Скоуп
|
||||
|
||||
Четыре независимые правки из ТЗ:
|
||||
(а) удаление 13 мёртвых ключей i18n + AST-гейт `test/i18n-dead-keys.test.mjs`;
|
||||
(б) `role="alertdialog"` + `aria-describedby` для `hp-confirm` (`destructive` и
|
||||
`warning`), обычные диалоги остаются на HA-ветке (`src/hp-dialog.ts`,
|
||||
`src/hp-confirm.ts`);
|
||||
(в) смок `demo/smoke_danger_confirm_branches.mjs` покрывает и HA-, и
|
||||
нативную ветку;
|
||||
(г) уборка `marker_area_snapshot` для исчезнувших устройств только при
|
||||
авторитетном реестре + разворот правила усечения по лимиту
|
||||
(`src/device-area-relocation.ts`).
|
||||
|
||||
Материал ревью — `git log --oneline origin/dev..HEAD` (9 коммитов, из них 5
|
||||
`docs: review document for #406`/`docs: resolve...`/`docs: revise...` — все
|
||||
имеют отношение к ТЗ, не к коду) и `git diff origin/dev...HEAD` (42 файла:
|
||||
продуктовый код, тесты, смоки, мутанты, i18n, бандлы, changelog).
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Ручного тестирования в цикле нет — правильность работы устанавливается
|
||||
чтением кода, автотестами и браузерными смоками.
|
||||
|
||||
**Прогнано лично на `fe385cbf` (зелёного Validate на этом SHA нет):**
|
||||
|
||||
| Гейт | Результат |
|
||||
|---|---|
|
||||
| `npx tsc --noEmit` | green, без вывода |
|
||||
| `npm test` | 1706 passed, 0 failed, 1 skipped — совпадает с заявкой автора |
|
||||
| `npm run build` | green, `dist` собран за 16.3s |
|
||||
| `node scripts/bundle-sync.mjs --check` (после build) | green, три копии бандла синхронны |
|
||||
| `npm run bundle:budget` | initial 287320 B / budget 300000 B, headroom 12680 B (>0 → AC12 выполнен); есть предупреждение о низком запасе — это фон #367, не регрессия этой задачи |
|
||||
| `node scripts/mutation-gate.mjs --check` | все мутанты, включая три новых (`i18n-dead-key-returns`, `confirm-dialog-loses-alertdialog`, `area-snapshot-cleanup-ignores-authority`), — `ok` |
|
||||
| `node scripts/check-docs.mjs` | **RED** — см. находку ниже |
|
||||
| `node demo/smoke_danger_confirm_branches.mjs` | green, все 24 поля `true` (AC6–AC8) |
|
||||
| `node demo/smoke_area_relocation.mjs` | green, все 20 полей `true`, включая `authoritativeOrphanRemoved`/`nonAuthoritativeOrphanPreserved` (AC9–AC10) |
|
||||
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | «НЕОПРЕДЕЛЁННОСТЬ»: изменённых символов проекта — 6 (`MARKER_AREA_SNAPSHOT_LIMIT`, `MarkerAreaBinding`, `_useHaDialog`, `_usesHaDialog`, `validBinding`, `validText`), матрица — 212 смоков, ни один не назван связанным доказуемо |
|
||||
|
||||
**Не прогонял и почему:**
|
||||
|
||||
- Полная матрица `demo/smoke_*.mjs` (212 файлов) — диапазон затрагивает
|
||||
только `hp-dialog`/`hp-confirm` и `device-area-relocation`, у обоих есть
|
||||
прямо названные в AC6–AC10 смоки, которые я прогнал. Символы из вывода
|
||||
`smoke-select` — внутренние помощники (`_usesHaDialog`, `validBinding`,
|
||||
`MARKER_AREA_SNAPSHOT_LIMIT`), уже покрытые юнитами
|
||||
`test/device-area-relocation.test.mjs` построчно (проверено чтением: тест
|
||||
на переполнение лимита обращается к `MARKER_AREA_SNAPSHOT_LIMIT` напрямую).
|
||||
Полный прогон матрицы — предрелизная обязанность (§8), не гейт этого раунда.
|
||||
- `npm run golden:verify` — прочитал `demo/golden/matrix.mjs`: ни один
|
||||
сценарий не открывает destructive/warning `hp-confirm` (есть только
|
||||
`device-dialog-*`, `decor-color`, `optimize-preflight-dialog` — другие
|
||||
диалоги). Видимый рендер, который затрагивает диапазон, в голден-матрице не
|
||||
зафиксирован — прогон не даст сигнала по этой задаче.
|
||||
- `npm run invariants -- --config …` — диапазон не трогает рёбра комнат,
|
||||
записи толщины, `layout`-формат, `marker.space` или `open_spans`;
|
||||
`device-area-relocation.ts` меняет только момент уборки записи снапшота и
|
||||
порядок среза при переполнении, не геометрию. Не применимо.
|
||||
- `python -m pytest tests_backend -q` — `custom_components/**/*.py` в диффе
|
||||
нет (только скомпилированные frontend-бандлы).
|
||||
- Перфоманс-профили — не названы в AC, чувствительные к перфу пути не
|
||||
тронуты.
|
||||
|
||||
## Находки
|
||||
|
||||
### Medium (в скоупе, чинится в этой же задаче) — стал устаревшим отпечаток скриншотов документации
|
||||
|
||||
`node scripts/check-docs.mjs` красный на `fe385cbf`:
|
||||
|
||||
```
|
||||
ERROR screenshot source fingerprint is stale; run npm run build && node demo/docs/capture.mjs
|
||||
```
|
||||
|
||||
Воспроизведение (после `npm run build`, который уже входит в проверки
|
||||
автора):
|
||||
|
||||
```
|
||||
$ node scripts/check-docs.mjs
|
||||
ERROR screenshot source fingerprint is stale; run npm run build && node demo/docs/capture.mjs
|
||||
```
|
||||
|
||||
Причина механическая и ожидаемая: `visualFingerprint(ROOT)`
|
||||
(`scripts/source-fingerprint.mjs`) считается по всему `src/**`, а диапазон
|
||||
меняет `src/hp-dialog.ts`, `src/hp-confirm.ts`, `src/device-area-relocation.ts`
|
||||
и все четыре словаря `src/i18n/*.json` — то есть отпечаток обязан был
|
||||
измениться, и `docs/images/screenshots.json` обязан был обновиться вместе с
|
||||
ним, независимо от того, меняются ли реально показанные пиксели. Я
|
||||
дополнительно прогнал `node demo/docs/capture.mjs` во временной копии рабочей
|
||||
дерева и подтвердил: все 10 сценариев (`view-desktop`, `view-touch`,
|
||||
`space-create`, `room-contour-close`, `plan-context-tray`, `device-editor`,
|
||||
`device-display-preview`, `background-editor`, `room-card`, `device-info`) —
|
||||
из `demo/docs/screenshots.mjs`, ни один не открывает destructive/warning
|
||||
`hp-confirm`, так что заявление автора «скриншоты не меняются» по существу,
|
||||
вероятно, верно (файлы отличались побайтово в моей проверке, но это из-за
|
||||
отсутствующего в песочнице `oxipng`, не из-за иного содержимого кадра — само
|
||||
сообщение инструмента `oxipng не найден: кадры пишутся как есть, без
|
||||
перепаковки` называет причину; я откатил эту пробную капчу перед
|
||||
продолжением ревью). Но идентичность пикселей не отменяет обязательности
|
||||
шага: `docs/images/screenshots.json` в диффе не тронут вообще
|
||||
(`git diff origin/dev...HEAD -- docs/images/` — пусто), а `check-docs.mjs`
|
||||
проверяет именно факт совпадения записанного отпечатка с текущим деревом, не
|
||||
факт визуального совпадения.
|
||||
|
||||
Это ровно тот класс регрессии, который уже дважды оставлял `dev` с красным
|
||||
job `docs` до следующей задачи (#230, #234, #237): гейт красный на коммите,
|
||||
который автор передаёт на ревью, и останется красным после мержа, если его
|
||||
не прогнать перед мержем.
|
||||
|
||||
**Ожидаемо**: `npm run build && node demo/docs/capture.mjs && npm run
|
||||
docs:accept` (или эквивалент), обновлённые `docs/images/screenshots.json` и
|
||||
(если байты правда меняются вне зависимости от oxipng) PNG — в том же PR.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **AC1–AC3** (мёртвые ключи, гейт, законные динамические/производные
|
||||
семейства). `test/i18n-dead-keys.test.mjs` строит множество через реальный
|
||||
TS AST (`ts.createSourceFile` по каждому файлу `src/**/*.ts`): литералы,
|
||||
шаблонные выражения с горизонтом `+`/`` ` ``, и производное правило
|
||||
`_help('x.help')` → `x.help.aria`. Второй тест в файле жёстко фиксирует
|
||||
`derivedHelpAria.size === 19` — то есть падает, если производных пар станет
|
||||
больше или меньше 19, а не просто «не меньше». `src/i18n/en.json` до/после:
|
||||
13 ключей удалены (`history.partition_add`, `confirm.delete_draft`,
|
||||
`confirm.delete_draft_segment`, `title.markup`, `history.delete_room`,
|
||||
`markup.delete`, `marker.display_hint`, `marker.display_hint_icon`,
|
||||
`confirm.delete_room`, `confirm.remove_marker`, `confirm.delete_space`,
|
||||
`confirm.unlock`, `confirm.delete_plan`) — совпадает построчно со списком
|
||||
ТЗ. `npm test` включает оба теста файла и прошёл.
|
||||
- **AC4** (коллизия с `test/unified-wall-tool-source.test.mjs`). Строка
|
||||
`'history.partition_add'` убрана из проверяемого списка теста тем же
|
||||
коммитом, что и ключ из словарей — оба гейта зелёные одновременно
|
||||
(проверено прогоном `npm test`).
|
||||
- **AC5** (паритет словарей). Диф по `de.json`/`fr.json`/`ru.json` даёт ту же
|
||||
тринадцатку удалённых строк, что и `en.json` (сверил `git diff` по каждому
|
||||
файлу построчно) — синхронно, 52 строки суммарно. `test/i18n.test.mjs`
|
||||
входит в `npm test` и прошёл.
|
||||
- **AC6/AC7** (роль и `aria-describedby`). `src/hp-confirm.ts`: оба `kind`
|
||||
(`destructive` и `warning`) получают `.alert=${true}` безусловно —
|
||||
дихотомии по виду нет, что и требовал r1/r2 SPEC-REVIEW. `src/hp-dialog.ts`
|
||||
добавляет `alert`/`describedBy` как реактивные свойства,
|
||||
`_usesHaDialog()` возвращает `false`, когда `alert` истинен, — то есть
|
||||
alert-подтверждение принудительно идёт в нативную ветку независимо от
|
||||
`customElements.get('ha-dialog')`; нативный `<dialog>` получает
|
||||
`role=${this.alert ? 'alertdialog' : 'dialog'}` и
|
||||
`aria-describedby=${this.describedBy || nothing}`. Проверил по всему `src/`
|
||||
(`grep -n '\.alert='`), что `.alert` выставляет только `hp-confirm.ts` —
|
||||
ни один из ~30 других вызовов `<hp-dialog>` (маркер, калибровка,
|
||||
бэкдроп, импорт/экспорт, kiosk, слияние комнат и т.д.) не трогает
|
||||
`alert`/`describedBy`, то есть остаются обычным `role="dialog"` на
|
||||
HA-ветке. `demo/smoke_danger_confirm_branches.mjs` — green, поля
|
||||
`noHaDestructiveIsDescribedAlert`/`noHaWarningIsDescribedAlert`/
|
||||
`haDestructiveStaysNativeAlert`/`haWarningStaysNativeAlert` истинны;
|
||||
последняя пара доказывает именно то, что было провалом в r2 SPEC-REVIEW —
|
||||
alert не уходит в зарегистрированный `ha-dialog`. Отдельный `getByRole`
|
||||
на реальном accessibility-дереве Chromium (`realAccessibilityTreeIncludesConsequence`)
|
||||
подтверждает, что текст последствий действительно долетает до дерева
|
||||
доступности, а не только до DOM-атрибута.
|
||||
- **AC8** (инварианты подтверждения в обоих окружениях). Тот же смок:
|
||||
фокус на «Отмена» (`noHa*FocusesCancel`/`ha*FocusesCancel`), Esc → `false`
|
||||
(`noHaWarningEscapeResolvesFalse`/`haWarningEscapeResolvesFalse`), клик по
|
||||
«Отмена» → `false` в обоих окружениях — все `true`.
|
||||
- **AC9/AC10** (уборка снапшота только при авторитетном реестре).
|
||||
`resolveDeviceAreaRelocations` строит `liveIds`/`liveBindings` из
|
||||
`options.devices` и помечает `removeSnapshot: true` для записей снапшота,
|
||||
чей id и binding не совпадают ни с одним живым устройством/маркером, — но
|
||||
делает это только после `if (!options.authoritative) return ...` в начале
|
||||
функции, то есть цикл недостижим при неавторитетном реестре. Проверил
|
||||
обоих вызывающих в продукте: `houseplan-card.ts:5054/5166` передаёт
|
||||
`devices: this._devices` — полный список, только что пересобранный
|
||||
`buildDevices(...)` из актуального `registry`/`markers`/`settings`, а не
|
||||
отфильтрованное подмножество; `_syncAreaRelocations` (запись) вызывается
|
||||
только при `this._haRegistry.authoritative`. Читающие проекции
|
||||
(`space-render.ts:283`, `space-card.ts:455`) используют только
|
||||
`.relocateIds` и никогда не вызывают `applyAreaRelocationResolution` — то
|
||||
есть read-only карточка не может стереть запись сама
|
||||
(`staticMadeNoWrites: true` в смоке это подтверждает). Юниты
|
||||
`test/device-area-relocation.test.mjs` («authoritative registry removes
|
||||
orphan snapshots but preserves live marker ids» / «non-authoritative
|
||||
registry preserves orphan snapshots») и смок
|
||||
`demo/smoke_area_relocation.mjs` (`authoritativeOrphanRemoved` /
|
||||
`nonAuthoritativeOrphanPreserved`) оба зелёные. Мутант
|
||||
`area-snapshot-cleanup-ignores-authority` (снимает условие `authoritative`)
|
||||
ловится именно этим юнитом — проверил прогоном `mutation-gate.mjs --check`.
|
||||
- **AC11** (усечение снапшота сохраняет последние записи). Один символ
|
||||
правки: `.slice(0, MARKER_AREA_SNAPSHOT_LIMIT)` →
|
||||
`.slice(-MARKER_AREA_SNAPSHOT_LIMIT)`. Новый юнит-тест строит вход
|
||||
`LIMIT + 2` записей и проверяет, что первые две (`entry-0`, `entry-1`)
|
||||
исчезли, а последняя (`entry-${LIMIT+1}`) осталась, — тест умеет падать:
|
||||
на старом `.slice(0, LIMIT)` он утверждал бы обратное и провалился бы.
|
||||
- **AC12** (бюджет initial). `npm run bundle:budget` → 287320 B при бюджете
|
||||
300000 B, положительный запас — не растёт (совпадает с ожиданием ТЗ:
|
||||
удаление 52 строк уменьшает бюджет, атрибуты добавляют единицы байт).
|
||||
- **Трейлеры и changelog**. Коммит `fe385cbf`: `Issue: #406`,
|
||||
`User-Visible: yes`. `docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md` получили по
|
||||
одному пункту про переход на нативную оболочку и `alertdialog` — в том же
|
||||
коммите, что и код (проверено `git show --stat fe385cbf`).
|
||||
- **Одно число — один источник**. Диапазон не добавляет и не меняет ни одной
|
||||
видимой пользователю величины (роль/aria — не число; лимит `20_000` не
|
||||
показывается пользователю ни в каком UI) — раздел неприменим по существу,
|
||||
а не пропущен. `test/single-source-numbers.test.mjs` входит в `npm test` и
|
||||
прошёл.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Полную матрицу `demo/smoke_*.mjs` (212 файлов) — обоснование в разделе
|
||||
«Как проверялось».
|
||||
- `npm run golden:verify` — ни один голден-сценарий не открывает
|
||||
затронутый диалог; обоснование там же.
|
||||
- `npm run invariants` и `python -m pytest tests_backend` — диапазон их не
|
||||
задевает (геометрия/толщина/`layout` не тронуты; `.py`-файлов в диффе нет).
|
||||
- Перфоманс-профили — не названы в AC, не тронуты.
|
||||
- Реальный визуальный вид нативного диалога в живом Home Assistant (смена
|
||||
HA-хрома на нативную оболочку, которую ТЗ прямо называет видимым
|
||||
компромиссом) — только по коду и accessibility-дереву Chromium; ручного
|
||||
просмотра в настоящем HA в этом цикле нет и не может быть.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Единственная находка — Medium, в скоупе задачи (устаревший отпечаток
|
||||
`docs/images/screenshots.json` после правок в `src/**`), чинится тем же
|
||||
шагом, что автор уже применяет в релизном процессе. Все 12 AC доказаны
|
||||
тестом или смоком, который я лично прогнал и который умеет падать (мутанты
|
||||
подтверждают это для трёх ключевых контрактов). High-находок нет.
|
||||
|
||||
**Жёлтый.**
|
||||
@@ -0,0 +1,268 @@
|
||||
# CODE-REVIEW-406-r2
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/406
|
||||
- Этап: code (PROCESS.md §2.7)
|
||||
- Заход: r2 · блокирующих циклов израсходовано 1 из 4 (перед этим раундом)
|
||||
- Ветка: `issue/406-beta2-polish`
|
||||
- Коммит на ревью: `f8cfac9c77332d3061b81239fe0c965add54b7df`
|
||||
- ТЗ: `docs/specs/406-beta2-polish.md`, revision 4, зелёный `SPEC-REVIEW-406-r4`
|
||||
- Предыдущий раунд: `docs/reviews/CODE-REVIEW-406-r1.md`, жёлтый, коммит на ревью `fe385cbf` (SHA не сохранился — ветка была перебазирована после r1, см. ниже), единственная находка Medium (устаревший отпечаток скриншотов документации)
|
||||
- База сравнения: `origin/dev` = `2903374b72a1b16d82eb87acac7233a1db3a3c5e`
|
||||
|
||||
## Почему разбор полный, а не по дельте
|
||||
|
||||
PROCESS.md §2.10 требует полного разбора, если «дельта не локальна: ребейз на
|
||||
ушедший вперёд `dev`». Именно это произошло между r1 и r2:
|
||||
|
||||
1. r1 (16:14:53) вынес жёлтый вердикт по коммиту `fe385cbf` с единственной
|
||||
находкой — устаревший отпечаток `docs/images/screenshots.json`.
|
||||
2. Автор (16:16:16) сообщил, что на финальном SHA `6a34396f` отпечаток уже
|
||||
обновлён, и попросил повторить ревью.
|
||||
3. Повторный запуск (16:16:45) **не состоялся** — при ребейзе ветки на
|
||||
`origin/dev` обнаружился конфликт в `scripts/mutation-gate.mjs` (там же
|
||||
независимо приземлился #404). Цикл не израсходован: код никто не читал,
|
||||
вердикта не было.
|
||||
4. Автор (16:18:41) разрешил конфликт, сохранив мутанты и #404, и #406,
|
||||
перебазировал ветку.
|
||||
|
||||
Из-за этого у всех коммитов ветки, начиная со старого `fe385cbf`, изменились
|
||||
хеши — старый SHA не существует в текущей истории (`git cat-file -e
|
||||
fe385cbf9d5…` → отсутствует), поэтому прямой `git diff fe385cbf..HEAD`
|
||||
невозможен. Восстановил дельту по содержимому: сравнил текст находок r1 с
|
||||
текущим деревом и с телом самих коммитов (`git show <sha>` по каждому
|
||||
коммиту диапазона).
|
||||
|
||||
Дельта оказалась **не локальной** ещё по одной причине, отдельной от ребейза:
|
||||
между `fe385cbf` (проверено r1) и финальным авторским SHA `6a34396f`
|
||||
приземнился не только `chore: refresh docs source fingerprint` (закрытие
|
||||
находки r1), но и отдельный поведенческий коммит `fix: preserve the ordinary
|
||||
dialog path`, которого r1 не видел и не мог видеть — он был запушен позже
|
||||
(комментарий 16:14:38, уже после хендоффа на ревью). Это реальная правка кода в
|
||||
`src/hp-dialog.ts`, а не техническая перестановка. Поэтому разбор веду по всем
|
||||
двенадцати AC заново, а не только по находке r1.
|
||||
|
||||
## Скоуп
|
||||
|
||||
Тот же, что в r1 — четыре независимых пункта ТЗ:
|
||||
(а) удаление 13 мёртвых ключей i18n + AST-гейт; (б) `role="alertdialog"` +
|
||||
`aria-describedby` для `hp-confirm`; (в) смок на обе ветки (HA/native);
|
||||
(г) уборка `marker_area_snapshot` для исчезнувших устройств при авторитетном
|
||||
реестре + разворот правила усечения по лимиту.
|
||||
|
||||
Материал — `git log --oneline origin/dev..HEAD` (12 коммитов) и
|
||||
`git diff origin/dev...HEAD` (44 файла).
|
||||
|
||||
## Что изменилось с r1 (не только rebase)
|
||||
|
||||
`git show` по каждому коммиту диапазона восстанавливает содержательную
|
||||
последовательность после спек-ревью (`d3412e6f`):
|
||||
|
||||
| Коммит (текущий хеш) | Содержание | Был ли виден r1 |
|
||||
|---|---|---|
|
||||
| `5d2f2957` fix: close beta 2 polish gaps | основная реализация (а)-(г), `User-Visible: yes` | да, это и есть `fe385cbf` по содержанию |
|
||||
| `d5f050fd` fix: preserve the ordinary dialog path | **новая находка ниже** — правка `src/hp-dialog.ts`, добавляет второй `<ha-dialog>`-шаблон | **нет**, запушен после хендоффа r1 |
|
||||
| `13593c1b` chore: refresh docs source fingerprint | закрывает Medium r1 | нет, запушен в ответ на вердикт r1 |
|
||||
| `f8cfac9c` docs: review document for #406 | публикация `CODE-REVIEW-406-r1.md` | — |
|
||||
| `2903374b` (в `origin/dev`, не в ветке) fix: exception guard… (#404) | не относится к #406, но конфликтовал с #406 в `scripts/mutation-gate.mjs` при ребейзе | — |
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Ручного тестирования в цикле нет. Зелёного Validate на `f8cfac9c` не найдено —
|
||||
всё ниже прогнано лично.
|
||||
|
||||
| Гейт | Результат |
|
||||
|---|---|
|
||||
| `npx tsc --noEmit` | green, без вывода |
|
||||
| `npm test` | 1711 passed, 0 failed, 1 skipped (было 1706 в r1 — разница ровно от рёбер #404 в `dev`) |
|
||||
| `npm run build` | green, 15.7s |
|
||||
| `npm run bundle:sync` (build + `bundle-sync.mjs --check` эквивалент — прогнал `bundle:sync` целиком и проверил `git status` после) | green, `git status --porcelain` пуст после — три копии бандла синхронны |
|
||||
| `npm run bundle:budget` | initial 287370 B / budget 300000 B, headroom 12630 B (>0 → AC12 выполнен); предупреждение о низком запасе — фон #367, не регрессия |
|
||||
| `node scripts/mutation-gate.mjs --check` | все мутанты `ok` (331), включая три мутанта #406 (`i18n-dead-key-returns`, `confirm-dialog-loses-alertdialog`, `area-snapshot-cleanup-ignores-authority`) и два мутанта #404, приземлившихся рядом при конфликте — оба набора пережили ребейз |
|
||||
| `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | «Новых any нет» (проверено 55 добавленных строк в 3 файлах) |
|
||||
| `node scripts/check-docs.mjs` | **green** — находка r1 закрыта |
|
||||
| `node scripts/check-docs.mjs --external` | green, 10 внешних ссылок |
|
||||
| `node scripts/process-gate.mjs` | «гейт пройден, предупреждений 0» |
|
||||
| `node demo/smoke_danger_confirm_branches.mjs` | green, все 24 поля `true` (AC6–AC8), включая `ordinaryDialogForwardsHaAria`/`ordinaryDialogUsesHaBranch` |
|
||||
| `node demo/smoke_area_relocation.mjs` | green, все 20 полей `true` (AC9–AC10) |
|
||||
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | «Прямое совпадение (1)»: `demo/smoke_help_affordance.mjs ← _focusInitial` — символ появился на изменённой строке именно из-за нового дублированного `<ha-dialog>`-шаблона (см. находку); прогнал |
|
||||
| `node demo/smoke_help_affordance.mjs` | green, все поля `true`, включая `keyboardFocus` |
|
||||
|
||||
**Не прогонял и почему:**
|
||||
|
||||
- Полная матрица `demo/smoke_*.mjs` (212 файлов) — диапазон touches только
|
||||
`hp-dialog`/`hp-confirm`, `device-area-relocation` и словари; единственный
|
||||
«прямой» кандидат из `smoke-select` прогнан, остальные символы (в основном
|
||||
внутренние геттеры `device-area-relocation.ts`) уже покрыты юнитами
|
||||
построчно (проверено чтением). Полный прогон — предрелизная обязанность.
|
||||
- `npm run golden:verify` — `demo/golden/matrix.mjs` не менялся с r1 (сверил
|
||||
диффом с `origin/dev`); ни один сценарий (`device-dialog-*` и др.) не
|
||||
открывает destructive/warning `hp-confirm`.
|
||||
- `npm run invariants` / `python -m pytest tests_backend` — диапазон не
|
||||
трогает `.py`, рёбра комнат, `layout`, `marker.space`, `open_spans`
|
||||
(проверено `git diff --stat` по `custom_components/**/*.py` — пусто).
|
||||
- Перфоманс-профили — не названы в AC.
|
||||
- Полный HA-харнесс / реальный визуальный просмотр в HA — вне цикла ревью.
|
||||
|
||||
## Находки
|
||||
|
||||
### Low (в скоупе, снимается с записью) — правка `fix: preserve the ordinary dialog path` не имеет собственного быстрого регресс-теста
|
||||
|
||||
`src/hp-dialog.ts:452-467` (текущий диапазон, коммит `d5f050fd`, ранее не
|
||||
рецензировался): рендер обычного (не-alert) `hp-dialog` при зарегистрированном
|
||||
`ha-dialog` теперь разделён на два шаблона:
|
||||
|
||||
```html
|
||||
if (this.describedBy) {
|
||||
return html`<ha-dialog … .ariaDescribedBy=${this.describedBy} …>…</ha-dialog>…`;
|
||||
}
|
||||
return html`<ha-dialog … (без .ariaDescribedBy вовсе) …>…</ha-dialog>…`;
|
||||
```
|
||||
|
||||
До этой правки (в версии, которую видел r1, коммит `5d2f2957`) была одна
|
||||
ветка с `.ariaDescribedBy=${this.describedBy || undefined}` — то есть свойство
|
||||
`ariaDescribedBy` явно выставлялось в `undefined` на **всех** ~30 существующих
|
||||
вызовах `<hp-dialog>` без описания (маркер, калибровка, бэкдроп, импорт/экспорт
|
||||
и т.д. — весь обычный, не-`hp-confirm`, путь). Автор обнаружил это не тестом, а
|
||||
побочно: обязательная пересъёмка `check-docs.mjs` дала пиксельную дельту в двух
|
||||
кадрах обычных диалогов (комментарий 16:14:38), и это был реальный визуальный
|
||||
регресс на самом частом пути карточки — большинство диалогов не альтернативны.
|
||||
|
||||
Правка верна и подтверждена: канонический прогон «Docs screenshots»
|
||||
(run [33530447151](https://github.com/Matysh/houseplan-card/actions/runs/33530447151))
|
||||
дал побайтовое совпадение всех 10 PNG после фикса — я сверил
|
||||
`docs/images/screenshots.json`: `imageSha256` всех сценариев не изменился,
|
||||
поменялись только `sourceFingerprint`/`sourceSha256`. Это надёжное
|
||||
доказательство отсутствия визуальной регрессии на зафиксированных кадрах.
|
||||
|
||||
Но у самого фикса (ветка «нет описания» на HA-пути) нет **быстрого**
|
||||
автотеста: `demo/smoke_danger_confirm_branches.mjs` проверяет только случай «с
|
||||
описанием» (`ordinaryDialogForwardsHaAria`, `describedBy = 'ordinary-description'`
|
||||
— истинное значение); AC7 в ТЗ тоже требует доказательство только для этого
|
||||
случая. Случай «без описания», который и был реальным дефектом, не покрыт ни
|
||||
юнитом, ни смоком, ни мутантом `mutation-gate.mjs` — единственная защита от
|
||||
повторного регресса это сам `check-docs.mjs` (обязателен по механике при любой
|
||||
правке `src/**`) плюс ручная пиксельная сверка при следующей пересъёмке.
|
||||
Это тот же класс риска, что и «тихий успех» из PROCESS.md §2.9 (#171, #207):
|
||||
если кто-то в будущем снова объединит обе ha-dialog-ветки в одну ради
|
||||
дедупликации кода, `npm test`/`mutation-gate --check`/оба browser-смока этой
|
||||
задачи останутся зелёными, и только пиксельный дифф на следующей пересъёмке
|
||||
скриншотов покажет проблему — то есть с существенной задержкой относительно
|
||||
коммита, который её внёс.
|
||||
|
||||
**Снимаю без возврата в работу**: поведение на этом SHA доказанно корректно
|
||||
(канонический скриншот-прогон + факт, что `check-docs.mjs` green), находка не
|
||||
блокирует AC7 и не относится к сценарию, который ТЗ просило доказать. Рекомендация
|
||||
на будущее — не для этого цикла: добавить в `mutation-gate.mjs` мутанта,
|
||||
убирающего ветвление `if (this.describedBy)` (schlagen: замена на старую
|
||||
безусловную форму), guard — `node demo/smoke_danger_confirm_branches.mjs`
|
||||
с новым полем-утверждением «обычный диалог без описания не передаёт
|
||||
`ariaDescribedBy`». Дешёво и ловит именно этот класс регрессии за секунды, а
|
||||
не за цикл релиза.
|
||||
|
||||
## Закрытие раунда r1
|
||||
|
||||
| Находка r1 | Чем закрыта | Где это видно |
|
||||
|---|---|---|
|
||||
| Medium: `node scripts/check-docs.mjs` красный на `fe385cbf` — устаревший отпечаток `docs/images/screenshots.json` | Коммит `13593c1b` (`chore: refresh docs source fingerprint`) обновил `sourceFingerprint`/`sourceSha256` по каноническому прогону «Docs screenshots» run 33530447151; `imageSha256` не изменился ни для одного из 10 сценариев | `git diff origin/dev...HEAD -- docs/images/screenshots.json`: меняются только два поля-хеша фингерпринта на строку, `imageSha256` идентичен. `node scripts/check-docs.mjs` — green (прогнал лично) |
|
||||
|
||||
## Унаследовано из r1 — с оговоркой
|
||||
|
||||
Формально это полный разбор (§2.10, ребейз на ушедший вперёд `dev`), поэтому
|
||||
все AC перепроверены заново в этом раунде, а не унаследованы бланково. Но там,
|
||||
где текущий файл байт-в-байт совпадает с версией, которую видел и проверил
|
||||
тестом/смоком r1, я не повторяю тот же путь доказательства второй раз, а
|
||||
опираюсь на сочетание «r1 уже прогнал тест X» + «файл не менялся с тех пор,
|
||||
проверено `git diff origin/dev...HEAD -- <file>` и содержимым коммитов между
|
||||
`fe385cbf`-эквивалентом и `HEAD`»:
|
||||
|
||||
- **`src/device-area-relocation.ts` (AC9–AC11)** — не менялся ни одним из
|
||||
коммитов после `5d2f2957`. Логика (авторитетный реестр строит `liveIds`/
|
||||
`liveBindings`, инвариант `.slice(-MARKER_AREA_SNAPSHOT_LIMIT)`) идентична
|
||||
описанной в `CODE-REVIEW-406-r1.md`. Перечитал файл и оба продуктовых
|
||||
вызова (`houseplan-card.ts:5054/5166`) самостоятельно — совпадает с
|
||||
описанием r1. Тесты `test/device-area-relocation.test.mjs` (14/14) и смок
|
||||
`demo/smoke_area_relocation.mjs` (20/20 полей) прогнаны мной лично в этом
|
||||
раунде, не по памяти r1.
|
||||
- **AC1–AC5 (мёртвые ключи i18n)** — файлы словарей и
|
||||
`test/i18n-dead-keys.test.mjs`/`test/unified-wall-tool-source.test.mjs` не
|
||||
менялись после `5d2f2957`. Перепрогнал `node --test
|
||||
test/i18n-dead-keys.test.mjs` (2/2) и полный `npm test` лично.
|
||||
- **Раздел «одно число — один источник»** — диапазон по-прежнему не добавляет
|
||||
видимых пользователю величин (роль/aria — не число); `initial View` в
|
||||
`bundle:budget` — единственное число, и оно считается один раз сборкой,
|
||||
что я перепроверил (287370 B совпадает и в выводе `bundle:budget`, и в
|
||||
`houseplan-assets.json`). `test/single-source-numbers.test.mjs` в составе
|
||||
`npm test`, прошёл.
|
||||
- **Трейлеры/changelog** — перепроверил самостоятельно все 12 коммитов
|
||||
диапазона (`git show -s --format=%B`): `Issue: #406` на каждом,
|
||||
`User-Visible: yes` только на `5d2f2957`, оба changelog правятся в этом же
|
||||
коммите (сверил `git show --stat 5d2f2957`).
|
||||
|
||||
Ссылка на документ прежнего раунда: `docs/reviews/CODE-REVIEW-406-r1.md`
|
||||
(доступен в дереве этой ветки, коммит `f8cfac9c`).
|
||||
|
||||
## Что проверено и корректно (AC1–AC12, этот раунд)
|
||||
|
||||
- **AC1–AC3**: `test/i18n-dead-keys.test.mjs` (AST по `src/**/*.ts`,
|
||||
`derivedHelpAria.size === 19`) — 2/2, прогнано лично.
|
||||
- **AC4**: коллизия с `test/unified-wall-tool-source.test.mjs` разрешена той
|
||||
же строкой, что и в r1 — `'history.partition_add'` убрана из проверяемого
|
||||
списка в том же коммите, что и ключ из словарей.
|
||||
- **AC5**: паритет словарей — `npm test` включает `test/i18n.test.mjs`,
|
||||
прошёл; сверил `git diff` по всем четырём словарям — 13×4=52 строки.
|
||||
- **AC6**: оба `kind` `hp-confirm` безусловно получают `.alert=${true}` и
|
||||
`.describedBy=${descriptionId}` (`src/hp-confirm.ts:40-41`, перечитал файл
|
||||
целиком) → `_usesHaDialog()` (`this._useHaDialog && !this.alert`) всегда
|
||||
`false` для confirm → нативный `<dialog role=${this.alert ? 'alertdialog' :
|
||||
'dialog'}>` (`src/hp-dialog.ts:483`). Смок: `noHa*IsDescribedAlert`,
|
||||
`ha*StaysNativeAlert`, `realAccessibilityTreeIncludesConsequence` — все
|
||||
`true`. Мутант `confirm-dialog-loses-alertdialog` ловится (`mutation-gate
|
||||
--check`, ok).
|
||||
- **AC7**: перечитал новую (двухветочную) реализацию `_usesHaDialog()===true`
|
||||
пути — новая находка описана выше, но контракт AC7 доказан:
|
||||
`ordinaryDialogUsesHaBranch`/`ordinaryDialogForwardsHaAria` — `true` в
|
||||
смоке, прогнан лично.
|
||||
- **AC8**: фокус на «Отмена», Esc → `false`, клик по «Отмена» → `false` в
|
||||
обоих окружениях — все соответствующие поля смока `true`, прогнан лично.
|
||||
- **AC9/AC10**: см. раздел «Унаследовано» — перепроверено чтением и тестом
|
||||
заново в этом раунде, не по памяти r1.
|
||||
- **AC11**: юнит-тест `defensive snapshot reader keeps the newest entries
|
||||
when over its limit` — `ok 13` в `test/device-area-relocation.test.mjs`,
|
||||
прогнан лично; символ правки не менялся с r1.
|
||||
- **AC12**: `npm run bundle:budget` → 287370 B / 300000 B, положительный
|
||||
запас; число на 50 Б больше, чем в r1 (287320 → 287370) — ожидаемо: новая
|
||||
ветка `if (this.describedBy)` добавляет несколько байт разметки.
|
||||
- **Трейлеры и changelog**: см. «Унаследовано», перепроверено самостоятельно
|
||||
на всех 12 коммитах диапазона, не только на `5d2f2957`.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Полную матрицу `demo/smoke_*.mjs` (212 файлов) — обоснование в «Как
|
||||
проверялось».
|
||||
- `npm run golden:verify` — ни один голден-сценарий не открывает
|
||||
затронутый диалог, сверил `demo/golden/matrix.mjs` заново на этом SHA.
|
||||
- `npm run invariants` / `python -m pytest tests_backend` — диапазон их не
|
||||
задевает.
|
||||
- Перфоманс-профили — не названы в AC, не тронуты.
|
||||
- Подлинность прогона `run 33530447151` — доверяю ссылке на GitHub Actions,
|
||||
не открывал сам артефакт; сверил только зафиксированные в
|
||||
`docs/images/screenshots.json` хеши образов (не изменились) как косвенное,
|
||||
но веское подтверждение.
|
||||
- Реальный визуальный вид нативного диалога в живом Home Assistant — только
|
||||
по коду, accessibility-дереву Chromium и каноническому скриншот-прогону;
|
||||
ручного просмотра в этом цикле нет и не может быть.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Единственная находка этого раунда — Low, по правке, которая появилась в
|
||||
дельте после r1 (`fix: preserve the ordinary dialog path`), не покрыта
|
||||
собственным быстрым тестом, но её корректность доказана канонической
|
||||
пересъёмкой скриншотов (побайтовое совпадение) и обычным browser-смоком для
|
||||
того случая, который требует AC7. Не блокирует, снимаю с рекомендацией на
|
||||
будущее (см. находку). Находка r1 (устаревший отпечаток документации) закрыта
|
||||
по существу и проверена лично, а не по слову автора. Все 12 AC доказаны
|
||||
тестом или смоком, который я лично прогнал в этом раунде и который умеет
|
||||
падать (мутанты подтверждают три ключевых контракта, включая оба
|
||||
приземлившихся после ребейза набора — #406 и #404). High-находок нет.
|
||||
|
||||
**Зелёный.**
|
||||
@@ -0,0 +1,198 @@
|
||||
# SPEC-REVIEW-406-r1
|
||||
|
||||
- Issue: #406
|
||||
- ТЗ: `docs/specs/406-beta2-polish.md`, ветка `issue/406-beta2-polish`, SHA `8248f9e4`
|
||||
- Этап: spec (PROCESS.md §2.4)
|
||||
- Заход: r1 · блокирующих циклов израсходовано 0 из 4
|
||||
- Вердикт: **жёлтый**
|
||||
|
||||
## Скоуп
|
||||
|
||||
Пять независимых мелочей (полный трек, критерий «одна поверхность» для `small`
|
||||
не выполняется — согласен, обоснование корректно и подкреплено прецедентами
|
||||
#385/#400):
|
||||
|
||||
- (а) мёртвые ключи `confirm.*` и смежных семейств в 4 словарях + гейт;
|
||||
- (б) роль `alertdialog`/`aria-describedby` в `hp-dialog`/`hp-confirm`;
|
||||
- (в) смок HA-ветки диалога подтверждения;
|
||||
- (г) уборка `marker_area_snapshot` для исчезнувших устройств + порядок
|
||||
обрезки при лимите;
|
||||
- (д) сохранение `acceptance.declared` при пустой замене в `docs-accept.mjs`.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Ревью аналитическое (этап spec, кода ещё нет). Читал тело issue #406, оба
|
||||
комментария владельца (S2-разбор, ссылка на ТЗ), сам файл ТЗ на SHA `8248f9e4`,
|
||||
`PROCESS.md` (§2.4, §7.1, §7.2), `docs/SCOPE.md`. Затем **перепроверил
|
||||
фактические утверждения ТЗ по текущему коду** (`git archive` того же SHA,
|
||||
`src/i18n/en.json`, `src/hp-dialog.ts`, `src/hp-confirm.ts`,
|
||||
`src/danger-confirm.ts`, `src/device-area-relocation.ts`,
|
||||
`scripts/docs-acceptance.mjs`, `demo/smoke_*`), поскольку ТЗ строит контракт
|
||||
(а) на конкретных числах и списке ключей, а разошедшееся число здесь — не
|
||||
стилистика, а входные данные для гейта, который эта же задача пишет.
|
||||
|
||||
Обязательные разделы §7.1 присутствуют все: сценарий · что человек увидит до/
|
||||
после · проблема+контракт (по каждому из пяти пунктов) · скоуп/не-скоуп · UX ·
|
||||
модель данных и миграция · i18n · AC1…AC14 с доказательством · план автотестов
|
||||
· риски · откат · release-артефакты.
|
||||
|
||||
## Находки
|
||||
|
||||
### [Medium, в скоупе] (а) Инвентарь мёртвых ключей `*.help.aria` неверен — все 19, а не 6 из 19, используются
|
||||
|
||||
**Файл**: `docs/specs/406-beta2-polish.md`, раздел «(а) Мёртвые строки в
|
||||
словарях», строка со списком «13 ключей `*.help.aria`».
|
||||
|
||||
**Что не так**: ТЗ утверждает, что из ключей семейства `*.help.aria` 13 (из 19
|
||||
в словаре) — «задел, не подключённый ни одним потребителем». Проверка кода на
|
||||
том же SHA это опровергает: **все 19** ключей `*.help.aria` в `en.json`
|
||||
используются — не литералом, а через паттерн `` `${key}.aria` `` в
|
||||
`_help(key)` (`houseplan-editor-runtime.ts:1194`: `const ariaKey =
|
||||
\`${key}.aria\``). Для каждого из 19 базовых `*.help` ключей нашёлся ровно один
|
||||
литеральный вызов `this._help('xxx.help')` в `houseplan-editor-runtime.ts`
|
||||
и/или `houseplan-onboarding-runtime.ts` (`space.cell_cm.help`,
|
||||
`space.zero_wall_style.help`, `space.fill_mode.help`,
|
||||
`device_inbox.show_hidden.help`, `gs.glow_radius.help`, `marker.controls.help`,
|
||||
`marker.glow_radius.help`, `marker.value_badge.help`,
|
||||
`marker.value_badge_source.help`, `marker.value_badge_position.help`,
|
||||
`marker.value_source.help`, `marker.light_role.help`,
|
||||
`marker.light_entity.help`, `marker.toggle_entity.help`,
|
||||
`marker.glow_mode.help`, `gs.bg_mode.help`, `gs.north.help`,
|
||||
`space.bg_mode.help`, `space.north.help`) — то есть ровно тот самый паттерн
|
||||
«динамический суффикс от литерального ключа», который ТЗ признаёт законным
|
||||
классом (`furn.cat_${id}` и «48 семейств»), но почему-то не признал для этого
|
||||
конкретного случая.
|
||||
|
||||
Отдельно: список «Остальные двадцать три» в этом же разделе перечисляет только
|
||||
19 позиций (13 `.help.aria` + `marker.display_hint` + `marker.display_hint_icon`
|
||||
+ `history.delete_room` + `markup.delete` + `title.markup` +
|
||||
`history.partition_add` = 19), а не 23 — четыре ключа из заявленных 23 нигде не
|
||||
названы.
|
||||
|
||||
**Сценарий отказа**: если реализация будет опираться на нарратив ТЗ (список
|
||||
«30 доказанно мёртвых ключей», явно включающий 13 `.help.aria`) вместо
|
||||
самостоятельной перепроверки, из словарей будут удалены 13 реально
|
||||
показываемых строк подсказки-для-скринридера в панели настроек — то есть
|
||||
задача, соседний пункт которой (б) *чинит* доступность для скринридера,
|
||||
пунктом (а) её же и *поломает*. Если же реализация полагается только на
|
||||
механический гейт (AC1) — риск переносится на сам гейт: правило распознавания
|
||||
«динамических семейств» (AC3) не называет паттерн `${key}.help → ${key}.aria`
|
||||
явно (назван только пример `furn.cat_${id}`), и создатель гейта имеет
|
||||
основания не покрыть его — а именно это и произошло у автора ТЗ при ручном
|
||||
аудите.
|
||||
|
||||
**Ожидаемо**: пересчитать список действительно мёртвых ключей раздела (а)
|
||||
(проверено выше: 6 из 19 `confirm.*`-ключей подтверждаются, `marker.
|
||||
display_hint*`, `history.delete_room`, `markup.delete`, `title.markup`,
|
||||
`history.partition_add` подтверждаются — эти 12 факт-чекнуты и мертвы; ни один
|
||||
`*.help.aria` мёртвым не подтверждён при ручной проверке) и явно включить
|
||||
правило «литеральный вызов `_help('x.help')` делает живыми оба ключа, `x.help`
|
||||
и `x.help.aria`» в контракт AC3 как отдельно названное семейство, а не
|
||||
подразумевать его в «и прочие 48».
|
||||
|
||||
### [Medium, в скоупе] (б) AC6/AC7 не решают роль для `HpConfirmKind.warning` (диалог разблокировки)
|
||||
|
||||
**Файл**: `docs/specs/406-beta2-polish.md`, раздел «(б) Роль диалога и текст
|
||||
последствий», AC6/AC7.
|
||||
|
||||
**Что не так**: `hp-confirm` — общий компонент для двух видов запросов,
|
||||
`kind: 'destructive'` (используется 7 раз, все — удаления) и `kind: 'warning'`
|
||||
(используется ровно один раз — подтверждение разблокировки двери,
|
||||
`houseplan-card.ts:13001-13006`, с собственным текстом последствий
|
||||
`confirm.unlock_body`). Контракт ТЗ формулирует дихотомию «destructive →
|
||||
alertdialog» / «обычный → dialog», а в качестве примера «обычных» диалогов
|
||||
называет только диалоги, вообще не проходящие через `hp-confirm` (маркер,
|
||||
калибровка пылесоса). Про `kind: 'warning'` контракт молчит: буквальное
|
||||
прочтение AC6 («Подтверждение **разрушающего** действия объявляется как
|
||||
alertdialog») позволяет реализовать признак как `destructive =
|
||||
request.kind === 'destructive'` и оставить диалог разблокировки на
|
||||
`role="dialog"` без `aria-describedby` — то есть ровно тот же дефект, ради
|
||||
починки которого заведён пункт (б), сохранится на диалоге, который
|
||||
`docs/SCOPE.md` называет единственной санкционированной поверхностью опасного
|
||||
действия (лок/разлок).
|
||||
|
||||
**Сценарий отказа**: AC6 и AC7 оба зелёные (пройдены буквально по тексту), но
|
||||
пользователь со скринридером по-прежнему не услышит «Устройство разблокируется»
|
||||
при нажатии Unlock — потому что это `warning`, а не `destructive`, и контракт
|
||||
не сказал, что с ним делать.
|
||||
|
||||
**Ожидаемо**: явное решение в контракте (б) — получает ли `kind: 'warning'`
|
||||
тот же `alertdialog` + `aria-describedby`, что и `destructive` (агент вправе
|
||||
решить сам, это не видимая пользователю форма, а объявляемая роль ARIA, но
|
||||
решение должно быть явным и внесено в AC6/AC7, а не оставлено читателю
|
||||
угадывать по бинарной формулировке), и соответствующий пункт в AC8/плане
|
||||
автотестов, если ветка `warning` должна тоже проверяться смоком.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **Числа по словарю в целом**: 1201 ключ в `en.json` — подтверждено точным
|
||||
подсчётом. 7 ключей `confirm.*` (delete_draft, delete_draft_segment,
|
||||
delete_plan, delete_room, delete_space, remove_marker, unlock) — подтверждено
|
||||
отсутствием любого литерального использования в `src/*.ts` на SHA
|
||||
`8248f9e4`. `marker.display_hint`, `marker.display_hint_icon`,
|
||||
`history.delete_room`, `markup.delete`, `title.markup`,
|
||||
`history.partition_add` — подтверждено тем же способом, действительно мертвы.
|
||||
- **(а) коллизия с `test/unified-wall-tool-source.test.mjs`** — реальна и
|
||||
корректно описана как открытая: тест требует `history.partition_add`, ключ
|
||||
в `src/` не используется; ТЗ явно требует разрешить конфликт, а не тихо
|
||||
оставить.
|
||||
- **(в) HA-ветка не покрыта** — подтверждено: `smoke_free_walls.mjs` —
|
||||
единственный смок, определяющий `ha-dialog`, и он стабит `_confirmDanger`,
|
||||
так что сам диалог не рисуется; в `smoke_danger_confirmation.mjs` и
|
||||
`smoke_danger_confirm_branches.mjs` слова `ha-dialog` нет. Прецедент стаба
|
||||
(`smoke_free_walls.mjs:20-22`), на который ссылается AC8, реален и рабочий.
|
||||
- **(г) утечка снапшота** — подтверждено чтением `device-area-relocation.ts`:
|
||||
`resolveDeviceAreaRelocations` строит `decisions` только по
|
||||
`options.devices`; устройство, отсутствующее в этом списке целиком, никогда
|
||||
не попадёт ни в `decisions`, ни, соответственно, в `removeSnapshot` — запись
|
||||
`marker_area_snapshot[id]` переживёт его исчезновение бессрочно.
|
||||
`removeMarkerAreaSnapshots` действительно вызывается только из ручного
|
||||
редактирования/удаления маркера (`houseplan-editor-runtime.ts:8326,8448`) —
|
||||
номера строк точны. Термин «авторитетный реестр» — не изобретение ТЗ, это
|
||||
существующее в коде понятие (`device-area-relocation.ts:50,133,142`,
|
||||
`ha-binding-status.ts`), контракт AC10 (не трогать при неавторитетном
|
||||
реестре) корректно продолжает уже действующий ранний `return` в
|
||||
`resolveDeviceAreaRelocations` при `!authoritative`.
|
||||
`.slice(0, MARKER_AREA_SNAPSHOT_LIMIT)` при чтении — подтверждено
|
||||
(`device-area-relocation.ts:64-75`), обрезка по вставке действительно режет
|
||||
новые записи, а не старые; контракт на переворот правила (AC11) реализуем.
|
||||
- **(д) `acceptance.declared`** — подтверждено чтением
|
||||
`scripts/docs-acceptance.mjs` и `scripts/docs-accept.mjs:141-145`:
|
||||
`docsAcceptancePlan` при пустом `declared` не отказывает (не тот класс
|
||||
ошибок, что «объявлены, но не изменились»), и текущий код действительно
|
||||
безусловно пишет `declared: [...decision.replace]` — при `replace: []` это
|
||||
стирает предыдущий список. AC12/AC13 реализуемы без правки существующих
|
||||
тестовых утверждений — `docsAcceptancePlan` не меняется, меняется только
|
||||
сборка `accepted.acceptance` в `docs-accept.mjs`.
|
||||
- **Скоуп/не-скоуп** — границы названы явно и по делу (например, отказ от
|
||||
подключения `*.help.aria`-задела к интерфейсу — не в этой задаче), пересечение
|
||||
с #403/#405 разведено по разным контрактам того же файла.
|
||||
- **Продуктовые вопросы владельцу** — ни одного технического вопроса не
|
||||
вынесено владельцу; UX-раздел корректно ссылается на терминологию
|
||||
`USER-GUIDE.ru.md` вместо изобретения новой.
|
||||
- Release-артефакты: изменение User-Visible (скринридер объявляет
|
||||
подтверждение как alertdialog) корректно привязано к обоим CHANGELOG,
|
||||
остальные четыре пункта верно помечены внутренними.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Не пересчитывал вручную полный список из «48 динамических семейств» и
|
||||
итоговые «937 использовано / 30 не используется» построчно — точечно
|
||||
перепроверил категории, названные в тексте (`confirm.*`, `*.help.aria`,
|
||||
единичные ключи), этого достаточно, чтобы найти расхождение; исчерпывающий
|
||||
пересчёт всех 1201 ключей — задача самого гейта (AC1), не ревью ТЗ.
|
||||
- Не проверял, останется ли запись `marker_area_snapshot` живой, если
|
||||
устройство пропало из `_devices`, но по тому же id всё ещё существует
|
||||
маркер (контракт (г) говорит «пока жив маркер ИЛИ устройство», AC9
|
||||
формулирует доказательство только через отсутствие в `_devices`, без явной
|
||||
проверки маркера) — не смог дёшево установить, входят ли такие маркеры в
|
||||
`options.devices` по построению; если нет — AC9 в текущей формулировке может
|
||||
требовать удаления записи, которую контракт просит сохранить. Не поднимаю
|
||||
до отдельной находки: не смог воспроизвести конкретный сценарий отказа за
|
||||
разумное время, это кандидат на внимание в код-ревью, а не блокер ТЗ.
|
||||
- Гейты (`tsc`/`test`/`build`) не гонял — на этапе spec кода ещё нет, дерево
|
||||
ветки состоит из одного doc-коммита.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Вердикт: жёлтый · заход r1 · блокирующих циклов 0/4 · High: 0 · Medium: 2 → в задаче
|
||||
@@ -0,0 +1,299 @@
|
||||
# SPEC-REVIEW-406-r2
|
||||
|
||||
- Issue: #406
|
||||
- ТЗ: `docs/specs/406-beta2-polish.md`, ветка `issue/406-beta2-polish`,
|
||||
ревизия 2, SHA `0d5b52d1` (HEAD ветки `44715dd0` добавляет поверх только
|
||||
`docs/reviews/SPEC-REVIEW-406-r1.md`, самого ТЗ не трогает)
|
||||
- Этап: spec (PROCESS.md §2.4)
|
||||
- Заход: r2 · блокирующих циклов израсходовано 1 из 4
|
||||
- Вердикт: **жёлтый**
|
||||
|
||||
## Почему разбор полный, а не по дельте
|
||||
|
||||
Владелец перебазировал ветку на `origin/dev` (`b04387ce`) между r1 и r2.
|
||||
SHA r1, названный в его собственном документе, — `8248f9e4` — на текущем
|
||||
дереве не существует (`git cat-file -t 8248f9e4` → `Not a valid object
|
||||
name`): это не «SHA не назван», SHA назван корректно, но ребейз его
|
||||
переписал. Это ровно случай PROCESS.md §2.10: «ребейз на ушедший вперёд dev
|
||||
(после ребейза это другой код)» — разбор обязан быть полным. Дополнительное
|
||||
основание для полноты: ребейз протащил на ветку чужой коммит
|
||||
(`8119c523`, issue #409), который меняет ровно тот файл и ровно то место,
|
||||
которое контракт (д) описывает как открытый дефект — см. находку №1.
|
||||
|
||||
## Скоуп
|
||||
|
||||
Без изменений от r1: пять независимых мелочей полного трека (i18n-мусор +
|
||||
гейт, роль `hp-dialog`/`hp-confirm`, смок HA-ветки, уборка снапшотов area,
|
||||
сохранение следа приёмки скриншотов).
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Прочитан `docs/SCOPE.md` (раздел «Partially covered»: «Accessibility:
|
||||
… no ARIA labelling of the plan» — задача (б)/(в) законно попадает сюда),
|
||||
`PROCESS.md` §2.4/§2.9/§2.10/§7.1/§7.2, тело issue #406 и все комментарии
|
||||
(включая S2-разбор, ссылку на ТЗ, вердикт r1, ответ владельца на r1),
|
||||
`docs/USER-GUIDE.ru.md` (терминология подтверждения/разблокировки, строки
|
||||
672-730, 824-963 — новых формулировок ТЗ не вводит).
|
||||
|
||||
Само ТЗ вычитано целиком построчно. Каждое фактическое утверждение
|
||||
перепроверено на текущем дереве (не на SHA r1 — его больше нет), а не
|
||||
принято на слово:
|
||||
|
||||
- `src/i18n/en.json|ru.json|de.json|fr.json` — подсчёт ключей и паритет
|
||||
(`node -e` подсчёт), точечная проверка литерального использования всех
|
||||
13 заявленных мёртвых ключей и представителей заявленных динамических
|
||||
семейств (`furn.cat_*`, `furn.sym_*`, `wall_model.reason.*`,
|
||||
`resize.disabled.*`, `junction.limit_*`, `decor.*`, `_help()`/`.aria`);
|
||||
- `src/hp-dialog.ts`, `src/hp-confirm.ts`, `src/danger-confirm.ts`,
|
||||
`src/houseplan-card.ts` (строки вокруг `_lockAction`) — роль диалога,
|
||||
устройство `HpConfirmKind`, место вызова `warning`-подтверждения;
|
||||
- `src/device-area-relocation.ts` целиком — `resolveDeviceAreaRelocations`,
|
||||
`applyAreaRelocationResolution`, `markerAreaSnapshotOf`, `git log` файла
|
||||
(единственный коммит с #126, после рёбейза не менялся);
|
||||
- `scripts/docs-accept.mjs` целиком, `git log -p` по нему — здесь нашлась
|
||||
находка №1;
|
||||
- `test/unified-wall-tool-source.test.mjs:32`, `demo/smoke_free_walls.mjs`
|
||||
(ha-dialog-стаб), `demo/smoke_esc_dialogs.mjs`, `demo/smoke_danger_confirm_
|
||||
branches.mjs`, `demo/smoke_danger_confirmation.mjs` — покрытие веток и
|
||||
прецедент стаба.
|
||||
|
||||
Гейты (`tsc`/`test`/`build`) не гонял: `git diff --stat origin/dev...HEAD`
|
||||
показывает только два doc-файла (`docs/specs/406-beta2-polish.md`,
|
||||
`docs/reviews/SPEC-REVIEW-406-r1.md`), продуктового кода на ветке ещё нет —
|
||||
то же основание, что в r1.
|
||||
|
||||
## Закрытие раунда r1
|
||||
|
||||
| Находка r1 | Чем закрыта | Где видно |
|
||||
|---|---|---|
|
||||
| (а) Medium: инвентарь `*.help.aria` неверен (13 из 19 объявлены мёртвыми, а живы все 19) | Раздел «(а)» переписан: явно выделен абзац «Важное динамическое семейство, которое удалять нельзя» — все 19 `*.help.aria` признаны живыми через производную `${key}.aria` от литерального `_help('*.help')`; список мёртвых сокращён до 13 (7 `confirm.*` + 6 прочих); правило внесено в AC3 явно, а не подразумевается в «прочих семействах» | `docs/specs/406-beta2-polish.md:59-64,217-221`. Перепроверено заново на текущем дереве (не наследовано): все 19 `*.help.aria`-ключей действительно имеют по одному литеральному `_help('x.help')`-потребителю, 13 заявленных мёртвых ключей — без потребителей ни в одном из 4 словарей |
|
||||
| (б) Medium: AC6/AC7 не решают роль для `HpConfirmKind.warning` | Контракт (б) переписан: явная дихотомия снята, оба `kind` — `destructive` и `warning` — объявлены as `alertdialog`; `warning` назван явно как разблокировка двери с `confirm.unlock_body`; AC6 переформулирован на «оба вида» | `docs/specs/406-beta2-polish.md:100-105,227-230`. Перепроверено: `houseplan-card.ts:13041-13049` — единственный `warning`-запрос, ссылка точна |
|
||||
|
||||
Обе находки r1 закрыты по существу, не косметически. Полный повторный разбор
|
||||
(см. выше) вскрыл в разделе (б) отдельную, ранее не поднимавшуюся проблему
|
||||
феазибилити (находка №2) — она не переоткрывает находку r1, это независимый
|
||||
дефект того же раздела.
|
||||
|
||||
## Унаследовано из r1
|
||||
|
||||
Формально пусто: ребейз на `dev` обесценил SHA r1, поэтому по правилу §2.10
|
||||
разбор в этом раунде — полный, а не по дельте, и каждое утверждение ТЗ
|
||||
перепроверено заново на текущем дереве (см. «Как проверялось»), а не принято
|
||||
на основании документа r1. Структурная оценка §7.1 (все обязательные разделы
|
||||
присутствуют, продуктовых вопросов владельцу не вынесено, скоуп/не-скоуп
|
||||
корректны, `docs/SCOPE.md` подтверждает легитимность полного трека) не
|
||||
поменялась между r1 и r2 и не пересчитывалась заново построчно — в остальном
|
||||
результат r1 в этой части подтверждён независимым прочтением r2, а не
|
||||
переносится слепо.
|
||||
|
||||
## Находки
|
||||
|
||||
### [Medium, в скоупе] №1 — (д) контракт описывает уже исправленный дефект и расходится с тем, что реально попало в дерево ребейзом
|
||||
|
||||
**Файл**: `docs/specs/406-beta2-polish.md`, раздел «(д) Затирание следа
|
||||
приёмки» (строки 157-172) и AC12/AC13 (строки 246-251).
|
||||
|
||||
**Что не так**: ТЗ строит контракт (д) на утверждении «`scripts/docs-
|
||||
accept.mjs:145` пишет `declared: [...decision.replace]`» безусловно, и
|
||||
приводит модельный прогон: «`replace=[]` → в манифест уйдёт
|
||||
`acceptance.declared = []`». На SHA r1 (`8248f9e4`) это было верно — сам
|
||||
дефект и являлся предметом п. (д). Но ветка с тех пор перебазирована на
|
||||
`origin/dev`, и в `dev` уже есть коммит `8119c523` («fix: the screenshot
|
||||
witness floor comes from the set, not from survivors», issue #409, попал в
|
||||
дерево этим самым ребейзом), который правит именно эту строку:
|
||||
|
||||
```js
|
||||
// scripts/docs-accept.mjs (текущее дерево)
|
||||
const previous = JSON.parse(readFileSync(resolve(ROOT, 'docs/images/screenshots.json'), 'utf8'))
|
||||
.acceptance;
|
||||
const accepted = {
|
||||
...manifest,
|
||||
acceptance: decision.replace.length
|
||||
? { declared: [...decision.replace], witnesses: decision.witnesses.length, floor: decision.floor, ... }
|
||||
: { ...(previous || {}), lastWriteWasFingerprintOnly: true },
|
||||
};
|
||||
```
|
||||
|
||||
Commit message коммита `8119c523` прямым текстом: «Попутно Low из #405:
|
||||
повторная приёмка неизменённого набора затирала `acceptance.declared`
|
||||
пустым списком … Прежний след сохраняется и помечается
|
||||
`lastWriteWasFingerprintOnly`.» — то есть ровно дефект (д), заведённый в
|
||||
#406 как самостоятельный пункт, уже был отмечен как Low в #405 и уже
|
||||
устранён в #409 до того, как ТЗ #406 дошло до ревизии 2 (ревизия 2 датирована
|
||||
2026-09-01, тем же днём, что и коммит фикса, но после ребейза на `dev`,
|
||||
согласно комментарию владельца «Ветка перебазирована… ТЗ поднято до
|
||||
revision 2»).
|
||||
|
||||
Хуже того — исправление, которое реально попало в дерево, **не совпадает**
|
||||
с контрактом (д) в тексте ТЗ. ТЗ требует: «Свежие числа (`witnesses`,
|
||||
`floor`) обновляются, список `declared` … сохраняется» — то есть на пустом
|
||||
`replace` `witnesses`/`floor` должны пересчитываться заново. Фактически
|
||||
смёрженный код делает обратное: при `replace.length === 0` он **не**
|
||||
пересчитывает `witnesses`/`floor` вовсе, а замораживает весь предыдущий
|
||||
`acceptance`-блок целиком (`{...(previous || {})}`) и лишь помечает его
|
||||
флагом `lastWriteWasFingerprintOnly` — что по своему обоснованию в коммите
|
||||
специально сигнализирует «эти числа не свежие, а старые от последней
|
||||
настоящей проверки».
|
||||
|
||||
**Сценарий отказа**: если реализация будет буквально следовать тексту ТЗ
|
||||
(«свежие числа `witnesses`/`floor` обновляются»), она отменит уже
|
||||
смёрженное и обоснованное поведение `lastWriteWasFingerprintOnly` — то есть
|
||||
пункт (д) этой же задачи внесёт **регресс** в код, который #409 только что
|
||||
починил, и уберёт сигнал «эти цифры устарели», ради которого фикс и писался.
|
||||
Если же реализация просто оставит код как есть (ничего не делая, потому что
|
||||
контракт уже выполнен фактически), AC12/AC13 будут пройдены случайно, без
|
||||
теста на функцию `main()` (`docs-accept.mjs`) — единственный существующий
|
||||
тест, `test/docs-accept.test.mjs`, покрывает только `verifyDocsCandidate`,
|
||||
а не запись `acceptance` в `main()`; `test/docs-acceptance.test.mjs`
|
||||
покрывает только чистую функцию `docsAcceptancePlan`, тоже без записи файла.
|
||||
То есть заявленное AC12 «доказательство: тест на функции приёмки — `replace:
|
||||
[]` оставляет прежний список» ни разу не написано и по этой ревизии ТЗ не
|
||||
попадёт в план работы как отдельная задача, потому что автор считает пункт
|
||||
ещё непочиненным.
|
||||
|
||||
**Ожидаемо**: раздел (д) переписан с учётом текущего состояния дерева —
|
||||
либо признаётся, что дефект уже устранён коммитом `8119c523`/#409 и пункт
|
||||
(д) сокращается до «добавить недостающий тест на запись `main()` в
|
||||
`docs-accept.mjs`, поведение не менять», либо, если владелец сознательно
|
||||
хочет другое поведение (`witnesses`/`floor` обновлять даже на пустом
|
||||
`replace`, отказавшись от `lastWriteWasFingerprintOnly`), это должно быть
|
||||
явно названо как осознанное расхождение с #409, а не как продолжение
|
||||
нарратива «дефект ещё жив».
|
||||
|
||||
### [Medium, в скоупе] №2 — (б) контракт не называет механизм, которым HA-ветка `hp-dialog` получает `alertdialog`/`aria-describedby`, хотя рядом есть свидетельство, что «просто атрибут» не сработает
|
||||
|
||||
**Файл**: `docs/specs/406-beta2-polish.md`, раздел «(б)» (строки 107-115) и
|
||||
AC6 (строки 227-230).
|
||||
|
||||
**Что не так**: контракт формулирует требование как факт, а не как
|
||||
инженерную задачу: «Практически: `hp-dialog` получает семантический признак
|
||||
alert/обычный и id описания … Обе ветки — HA и запасная — объявляют роль и
|
||||
описание одинаково.» Это утверждение о поведении стороннего компонента
|
||||
`ha-dialog`, которого нет в этом репозитории (это компонент фронтенда Home
|
||||
Assistant, подключаемый в рантайме; локально существует только тестовый
|
||||
стаб, `demo/smoke_free_walls.mjs:20-31`, — фейковый `HTMLElement` с
|
||||
произвольным `shadowRoot`, написанный этим же проектом, а не поведение
|
||||
настоящего `ha-dialog`).
|
||||
|
||||
В том же файле, который цитирует контракт, уже есть прецедент именно этой
|
||||
проблемы: для связывания заголовка (`aria-labelledby`) HA-ветка не
|
||||
принимает обычный HTML-атрибут — она использует специальное свойство
|
||||
`ha-dialog`, `.ariaLabelledBy` (`src/hp-dialog.ts:449`,
|
||||
`.ariaLabelledBy=${this._titleId}`), тогда как запасная ветка использует
|
||||
обычный атрибут `aria-labelledby=${this._titleId}` (`:462`). Это разделение
|
||||
не случайно: `ha-dialog` рендерит собственную внутреннюю структуру в своём
|
||||
`shadowRoot`, и произвольный ARIA-атрибут, поставленный на внешний тег
|
||||
`<ha-dialog>`, необязательно долетает до реального узла диалога внутри —
|
||||
именно поэтому для заголовка потребовалось выделенное свойство, а не
|
||||
атрибут.
|
||||
|
||||
Контракт (б) не называет для `alertdialog`/`aria-describedby` никакого
|
||||
аналога `.ariaLabelledBy` — ни существующего свойства `ha-dialog` (в этом
|
||||
репозитории такого свойства нигде не встречается — `grep -rn
|
||||
"ariaDescribedBy|ariaLive|type=.\{0,5\}alert"` по `src/*.ts` пуст), ни
|
||||
явного признания, что такого свойства может не быть и нужно исследование
|
||||
на этапе реализации. При этом ни один существующий смок не проверяет
|
||||
роль/описание на HA-ветке вообще (`demo/smoke_esc_dialogs.mjs:34` — только
|
||||
на запасной `dialog`; ни в одном файле `demo/smoke_*.mjs` нет `getAttribute
|
||||
('role')` рядом с `ha-dialog`), то есть у проекта пока нет ни одного
|
||||
подтверждённого прецедента, что произвольный ARIA-признак на HA-ветке вообще
|
||||
долетает до реального диалога в настоящем Home Assistant — только
|
||||
локальный, полностью подконтрольный автору стаб.
|
||||
|
||||
**Сценарий отказа**: реализация ставит `role="alertdialog"` и
|
||||
`aria-describedby` атрибутами прямо на тег `<ha-dialog>`, локальный смок
|
||||
(тестирующий собственный же стаб) зеленеет по AC6/AC8, PR проходит ревью —
|
||||
а в реальном Home Assistant скринридер по-прежнему не объявляет
|
||||
`alertdialog`, потому что настоящий `ha-dialog` эти атрибуты на внешнем
|
||||
теге игнорирует так же, как проигнорировал бы `aria-labelledby`, если бы
|
||||
для него не завели `.ariaLabelledBy`. Ровно тот класс регрессии, который
|
||||
локальный тест из-за природы стаба не способен поймать.
|
||||
|
||||
**Ожидаемо**: контракт (б) явно называет способ донести
|
||||
alert-семантику/описание до реального `ha-dialog` (существующее свойство,
|
||||
если оно есть — тогда назвать его, как назван `.ariaLabelledBy`), либо
|
||||
явно помечает это как открытый технический вопрос реализации с планом
|
||||
проверки на реальной Home Assistant (не только на локальном стабе), а не
|
||||
формулирует как решённый факт «обе ветки объявляют одинаково».
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **(а)** Числа сходятся на текущем дереве независимо от r1: 1201 ключ во
|
||||
всех четырёх словарях (`en`/`ru`/`de`/`fr`), все 13 заявленных мёртвых
|
||||
ключей (7 `confirm.*` + `marker.display_hint(_icon)`, `history.
|
||||
delete_room`, `markup.delete`, `title.markup`, `history.partition_add`)
|
||||
без единого литерального потребителя ни в одном из четырёх словарей; все
|
||||
19 `*.help.aria` действительно производятся из литеральных
|
||||
`_help('*.help')` на строке `houseplan-editor-runtime.ts:1194`, номер
|
||||
строки точен. Заявленные динамические семейства AC3 (`furn.cat_*`,
|
||||
`furn.sym_*`, `wall_model.reason.*`, `resize.disabled.*`,
|
||||
`junction.limit_*`, `decor.*`) реально существуют как шаблонные литералы
|
||||
в `houseplan-editor-runtime.ts`/`houseplan-card.ts`. `52 = 13×4` верно.
|
||||
Коллизия с `test/unified-wall-tool-source.test.mjs:32` подтверждена
|
||||
дословно на текущей строке.
|
||||
- **(б) дихотомия destructive/warning** — подтверждена: ровно 7 мест
|
||||
`kind: 'destructive'` (все — удаления, `houseplan-editor-runtime.ts` и
|
||||
`houseplan-onboarding-runtime.ts`) и ровно одно `kind: 'warning'`
|
||||
(`houseplan-card.ts:13041-13049`, разблокировка, `confirm.unlock_body`).
|
||||
`hp-confirm.ts` на текущем дереве действительно не передаёт `hp-dialog`
|
||||
никакого alert-признака — контракт описывает реальный, ещё не закрытый
|
||||
разрыв, только механизм его закрытия для HA-ветки не назван (находка №2).
|
||||
- **(в) непокрытая HA-ветка** — подтверждена без изменений от r1:
|
||||
`smoke_free_walls.mjs` — единственный файл с `ha-dialog`-стабом и глушит
|
||||
`_confirmDanger`; `smoke_danger_confirmation.mjs`/`smoke_danger_
|
||||
confirm_branches.mjs` слова `ha-dialog` не содержат. Файл `demo/smoke_
|
||||
danger_confirm_branches.mjs` действительно существует (создан #402,
|
||||
подтверждено содержимым) — план расширить именно его реалистичен.
|
||||
- **(г) утечка снапшота и порядок обрезки** — файл `device-area-
|
||||
relocation.ts` не менялся с исходного коммита #126 (`633cb20e`, до
|
||||
ребейза), поэтому не задет им; перечитан целиком заново, а не наследован.
|
||||
`resolveDeviceAreaRelocations` действительно строит `decisions` только из
|
||||
цикла по `options.devices` (строка 155) — устройство, отсутствующее в
|
||||
этом списке целиком, не порождает decision и, соответственно,
|
||||
`applyAreaRelocationResolution` (строка 239) его запись не тронет никогда.
|
||||
Условие `!options.authoritative` (строка 142) — ранний `return` с пустым
|
||||
результатом, соответствует контракту «не трогать при неавторитетном
|
||||
реестре». `.slice(0, MARKER_AREA_SNAPSHOT_LIMIT)` (строка 68,
|
||||
`MARKER_AREA_SNAPSHOT_LIMIT = 20_000`, строка 9) читает по порядку
|
||||
вставки Object.entries — обрезка действительно режет самые свежие записи;
|
||||
разворот правила (AC11) реализуем без переписывания структуры.
|
||||
`removeMarkerAreaSnapshots` (строки 8326, 8448 `houseplan-editor-
|
||||
runtime.ts`) — номера строк точны.
|
||||
- **UX/терминология** — `docs/USER-GUIDE.ru.md` уже описывает
|
||||
подтверждение разблокировки (строки 729-730) и удаления (672-673,
|
||||
824-847) тем же языком, который использует ТЗ; новых терминов не введено.
|
||||
`docs/SCOPE.md`, раздел «Partially covered»: «Accessibility: … no ARIA
|
||||
labelling of the plan» — пункты (б)/(в) закрывают именно эту декларативно
|
||||
признанную продуктом брешь, скоуп легитимен.
|
||||
- **Скоуп/не-скоуп, отсутствие технических вопросов владельцу** — как и в
|
||||
r1, границы очерчены явно, пересечения с #403/#405 разведены по разным
|
||||
контрактам одного файла; ни одного технического вопроса не вынесено
|
||||
владельцу.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Гейты (`tsc`/`test`/`build`, `check-docs.mjs`) — не гонял: диапазон
|
||||
`origin/dev...HEAD` содержит только два `.md`-файла, продуктового кода
|
||||
ещё нет. То же основание, что в r1.
|
||||
- Не пересчитывал вручную полный список «48 динамических семейств» и
|
||||
«1188 использовано / 13 не используется» по всем 1201 ключам — точечно
|
||||
перепроверил категории, названные в тексте (`confirm.*`, `*.help.aria`,
|
||||
единичные ключи, шесть заявленных семейств AC3); этого достаточно, чтобы
|
||||
подтвердить или опровергнуть числа в тексте, исчерпывающий пересчёт —
|
||||
задача самого гейта (AC1), а не ревью ТЗ.
|
||||
- Не проверял (унаследовано из r1 без изменений, дельта его не касается):
|
||||
останется ли запись `marker_area_snapshot` живой, если устройство пропало
|
||||
из `_devices`, но по тому же id ещё существует маркер — открытый вопрос
|
||||
r1 остаётся открытым, контракт (г) не переформулирован в этой ревизии,
|
||||
граница AC9 та же, что и была.
|
||||
- Не проверял поведение настоящего `ha-dialog` в реальной Home Assistant
|
||||
(нет доступа к его исходнику из этого репозитория) — отсюда и находка №2:
|
||||
это ровно то, что нельзя подтвердить чтением этого репозитория, и спецификация
|
||||
обязана либо назвать механизм, либо явно пометить как неисследованное.
|
||||
- Не проверял `scripts/mutation-gate.mjs` на предмет технической
|
||||
реализуемости трёх заявленных мутантов — на этапе ТЗ это описание
|
||||
намерения, а не код.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Вердикт: жёлтый · заход r2 · блокирующих циклов 1/4 · High: 0 · Medium: 2 → в задаче
|
||||
@@ -0,0 +1,235 @@
|
||||
# SPEC-REVIEW-406-r3
|
||||
|
||||
- Issue: #406
|
||||
- ТЗ: `docs/specs/406-beta2-polish.md`, ветка `issue/406-beta2-polish`,
|
||||
ревизия 3, SHA `c43051ac` (тот же коммит, что назвал владелец в передаче на
|
||||
ревью: «Head: `c43051ac`»)
|
||||
- Этап: spec (PROCESS.md §2.4)
|
||||
- Заход: r3 · блокирующих циклов израсходовано 2 из 4
|
||||
- Вердикт: **жёлтый**
|
||||
|
||||
## Почему разбор по дельте, а не заново
|
||||
|
||||
Предыдущий вердикт (SPEC-REVIEW-406-r2) получен на SHA `0d5b52d1`. Этот SHA
|
||||
существует на текущем дереве (`git cat-file -t 0d5b52d1` → `commit`), и
|
||||
`git merge-base 0d5b52d1 origin/dev` равен `b04387ce` — тому же коммиту, что
|
||||
и сам `origin/dev` сейчас: ветка dev не продвинулась с момента ребейза перед
|
||||
r2, повторного ребейза между r2 и r3 не было. Это не случай §2.10 «ребейз на
|
||||
ушедший вперёд dev».
|
||||
|
||||
Дельта объявлена как `git diff 0d5b52d1..c43051ac` (плюс два новых файла
|
||||
`docs/reviews/SPEC-REVIEW-406-r{1,2}.md`, не относящихся к ТЗ). Диапазон
|
||||
`origin/dev...HEAD` по-прежнему состоит только из документов — продуктового
|
||||
кода нет, гейты `tsc`/`test`/`build` неприменимы, тот же факт, что в r1/r2.
|
||||
|
||||
Дельта в самом ТЗ ограничена: убран раздел (д) целиком, переписаны контракты
|
||||
(б) и (в) (они образуют одну технически связанную развилку — HA-ветка против
|
||||
нативной), скорректированы AC6–AC8, план автотестов, риски, откат,
|
||||
release-артефакты и скоуп/не-скоуп в частях, которые ссылались на (д). Разделы
|
||||
(а) и (г) в дифф не попали ни строкой. Контракт поведения меняется — именно
|
||||
для (б)/(в), и именно поэтому ниже они разобраны заново по существу, а не
|
||||
по формуле «раз тема та же — переносим вывод r2». (а) и (г) наследуются.
|
||||
|
||||
## Скоуп
|
||||
|
||||
Без изменений по существу, но пересчитан: было пять пунктов на трёх
|
||||
поверхностях, стало четыре пункта на трёх поверхностях (словари i18n,
|
||||
`hp-dialog`, снапшоты переезда area) — пункт (д) выведен из скоупа, так как
|
||||
дефект уже устранён в #409 коммитом `8119c523` до того, как эта ревизия
|
||||
дошла до ревью. Обоснование трека `small`/полный трек не изменилось.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Прочитаны `PROCESS.md` §2.4, §2.9, §2.10, §4, §7.1, §7.2; тело issue #406 и
|
||||
все комментарии, включая передачу на повторное ревью revision 3 (описывает
|
||||
оба исправления и три проверки: `check-docs.mjs`, `process-gate.mjs
|
||||
--issues`, `git diff --check` — все green); `docs/reviews/SPEC-REVIEW-406-r1.md`
|
||||
и `-r2.md` целиком.
|
||||
|
||||
Само ТЗ вычитано целиком построчно на SHA `c43051ac`, не только дельта —
|
||||
чтобы поймать противоречия между новым текстом (б) и разделами, которые дифф
|
||||
не тронул (UX, Release-артефакты), что и дало находку №1 ниже.
|
||||
|
||||
Специально для закрытия находки r2 №2 (механизм HA-ветки не назван) —
|
||||
**внешняя проверка, не чтение только этого репозитория**: скачан реальный
|
||||
исходник `home-assistant-frontend==20260729.7` (версия подтверждена
|
||||
`tests_backend/requirements.txt:34`, комментарий там же объясняет, что она
|
||||
берётся из констрейнтов закреплённого HA 2026.8.3, а не назначена вручную) —
|
||||
`https://raw.githubusercontent.com/home-assistant/frontend/20260729.7/src/components/ha-dialog.ts`,
|
||||
560 строк, прочитан целиком. Проверено построчно:
|
||||
|
||||
- `@property({ attribute: 'aria-describedby' }) public ariaDescribedBy?: string`
|
||||
(строка 92-93) действительно передаётся во внутренний `<wa-dialog
|
||||
aria-describedby=${ifDefined(this.ariaDescribedBy)}>` (строка 168, внутри
|
||||
`render()`, строки 158-224);
|
||||
- `@property({ reflect: true }) public type: 'alert' | 'standard' = 'standard'`
|
||||
(строка 98-99) отражается только на хосте (`grep -n "this\.type"` по всему
|
||||
файлу — пусто вне JSDoc/деклараций свойства) и используется только в CSS
|
||||
через `:host([type="standard"])` (строки 448, 454) — в `render()` тег
|
||||
`<wa-dialog>` не получает от `this.type` ни атрибута, ни свойства, роль
|
||||
нигде явно не устанавливается.
|
||||
|
||||
Это ровно то, что ТЗ теперь утверждает буквально — не пересказ, а
|
||||
подтверждённый цитированием факт.
|
||||
|
||||
Дополнительно перечитаны `src/hp-dialog.ts` (весь файл, включая шапочный
|
||||
doc-comment строки 24-31, стили строки 41-216, `render()` строки 437-482,
|
||||
`connectedCallback` строки 231-242), `src/hp-confirm.ts` (весь файл, 72
|
||||
строки) и `src/houseplan-card.ts` вокруг `kind: 'warning'` — не для
|
||||
подтверждения того, что уже подтвердили r1/r2 (это унаследовано), а чтобы
|
||||
установить, действительно ли сегодня `hp-confirm` рендерится через HA-ветку в
|
||||
реальном HA, — это стало основанием находки №1 ниже.
|
||||
|
||||
## Закрытие раунда r2
|
||||
|
||||
| Находка r2 | Чем закрыта | Где видно |
|
||||
|---|---|---|
|
||||
| №1 (д) контракт описывает уже исправленный дефект, расходится с реально смёрженным поведением | Раздел (д) удалён из ТЗ целиком: заголовок, контракт, AC12/AC13 (были — теперь бюджет initial стал AC12), пункт плана автотестов «7. Приёмка с `replace: []`», мутант не заводился и не упоминается. «Не в скоупе» получил явную строку с точной причиной: «история screenshot-acceptance — дефект уже исправлен в #409 коммитом `8119c523`… #406 не меняет и не дублирует этот контракт» | `docs/specs/406-beta2-polish.md:176-190` (скоуп/не-скоуп), diff `0d5b52d1..c43051ac` показывает раздел (д) только в `-`-строках. Перепроверено: `grep -n "AC13\|AC14"` по текущему файлу — пусто; «Откат» пересчитан с пяти правок на четыре (`:321-326`); Release-артефакты — с «остальные четыре пункта» на «остальные три» (`:328-333`) |
|
||||
| №2 (б) контракт не называет механизм, которым HA-ветка получает `alertdialog`/`aria-describedby` | Раздел (б) получил новый абзац с цитатой закреплённой версии `home-assistant-frontend` (строки 110-118) и переписанный практический абзац (строки 120-129): решение явное — `.ariaDescribedBy` есть и реально долетает до внутреннего диалога, но `type="alert"` роль не передаёт, поэтому alert-подтверждения **всегда** используют нативный `<dialog role="alertdialog">`, даже когда `ha-dialog` зарегистрирован; обычные диалоги вне `hp-confirm` — по-прежнему HA-ветка с `.ariaLabelledBy`/`.ariaDescribedBy`. AC6/AC7/AC8 (`:232-249`) и план автотестов (`:266-273`) переписаны под эту развилку | Независимо перепроверено чтением реального `ha-dialog.ts` тега `20260729.7` (раздел «Как проверялось» выше) — утверждение ТЗ о поведении стороннего компонента подтверждено, а не принято на слово |
|
||||
|
||||
Обе находки r2 закрыты по существу. Разбор (б)/(в) по факту закрытия вскрыл
|
||||
новую находку — она не переоткрывает находки r1/r2, это независимый дефект,
|
||||
ставший видимым только после того, как r3 назвал конкретный механизм
|
||||
(до этого, в r1/r2, контракт был расплывчат, и вопрос «что увидит
|
||||
пользователь» было не к чему привязать).
|
||||
|
||||
## Унаследовано из r2
|
||||
|
||||
Разделы (а) «Мёртвые строки в словарях» и (г) «Снапшот копит записи
|
||||
исчезнувших устройств» дельтой не задеты (не встречаются ни в одной `-`/`+`
|
||||
строке `git diff 0d5b52d1..c43051ac`, кроме одного упоминания (а) в
|
||||
перечислении «Приоритет»/«Сценарий», которое не меняет контракт). Их
|
||||
фактическая проверка (числа по словарю, 13 мёртвых ключей, 19 живых
|
||||
`*.help.aria`, коллизия с `test/unified-wall-tool-source.test.mjs`, механика
|
||||
`resolveDeviceAreaRelocations`/`markerAreaSnapshotOf`, номера строк) принята
|
||||
без повторной перепроверки из `docs/reviews/SPEC-REVIEW-406-r2.md`, разделы
|
||||
«Что проверено и корректно», на SHA `0d5b52d1`.
|
||||
|
||||
Также унаследована структурная оценка §7.1 (все обязательные разделы
|
||||
присутствуют, продуктовых технических вопросов владельцу не вынесено,
|
||||
легитимность полного трека по `docs/SCOPE.md`) — она подтверждена в r2 и не
|
||||
пересчитывалась заново построчно для разделов, которых дельта не касается;
|
||||
для (б)/(в)/скоупа/UX/release-артефактов она перепроверена заново (см. ниже).
|
||||
|
||||
## Находки
|
||||
|
||||
### [Medium, в скоупе] №1 — UX и Release-артефакты утверждают «оформление не меняется», хотя раздел (б) в этой же ревизии вводит видимый пользователю переход confirm-диалогов с HA-оформления на нативное
|
||||
|
||||
**Файл**: `docs/specs/406-beta2-polish.md`, раздел (б) «Практически» (строки
|
||||
120-129) против раздела «UX» (строки 192-197) и «Release-артефакты» (строки
|
||||
328-333).
|
||||
|
||||
**Что не так**: раздел (б) в этой ревизии впервые называет конкретный
|
||||
механизм (закрытие находки r2 №2) — и этот механизм состоит в том, что
|
||||
`hp-confirm` (все виды: и `destructive`, и `warning`) **больше не следует**
|
||||
общему правилу `hp-dialog._useHaDialog = !!customElements.get('ha-dialog')`
|
||||
(`hp-dialog.ts:240`), а принудительно рендерит нативный `<dialog>` даже когда
|
||||
`ha-dialog` зарегистрирован (строки 124-127: «hp-dialog намеренно выбирает
|
||||
нативный `<dialog role="alertdialog">` даже при наличии `ha-dialog`»).
|
||||
|
||||
Сегодня, до этой задачи, `hp-confirm.ts` не делает никакого выбора — он
|
||||
безусловно передаёт `<hp-dialog>` (`hp-confirm.ts:36-61`), и собственный
|
||||
doc-comment `hp-dialog.ts:24-31` прямым текстом говорит: «Home Assistant
|
||||
provides the visual surface and focus trap through ha-dialog. The native
|
||||
dialog branch keeps the standalone demo usable without mocking HA frontend
|
||||
internals» — то есть в реальном Home Assistant (где `ha-dialog` всегда
|
||||
зарегистрирован) все семь `destructive`-подтверждений и один `warning`
|
||||
сегодня рендерятся через `ha-dialog`: собственный заголовок HA
|
||||
(`ha-dialog-header`), анимации, `wa-dialog`-обвязку. Нативная ветка сегодня —
|
||||
исключительно путь standalone-демо без HA. Проверено также, что в коде нет ни
|
||||
одного прецедента принудительного игнорирования `_useHaDialog` (`grep -rn
|
||||
"_useHaDialog" src/*.ts` — пять использований, ни одно не форсирует ветку
|
||||
для конкретного компонента) — то есть это решение (б) вводит новый класс
|
||||
поведения, а не продолжает существующий.
|
||||
|
||||
После задачи это же множество диалогов — каждое подтверждение удаления и
|
||||
единственное подтверждение разблокировки замка — у **каждого реального
|
||||
пользователя** Home Assistant сменит визуальную оболочку с HA-хрома на
|
||||
собственную вёрстку `hp-dialog` (`.header`/`.close`/`.surface`,
|
||||
строки 127-216 того же файла): другой DOM/CSS-путь заголовка, другая
|
||||
анимация появления, другой фокус-трап (нативный `<dialog>` вместо
|
||||
`wa-dialog`). Цвета границы/фона/скругления действительно частично совпадают
|
||||
(`--hp-accent`, `--card-background-color`, `--rad-l` заданы для обеих веток),
|
||||
но это не то же самое, что «оформление не меняется» — структура заголовка,
|
||||
анимация и поведение фокуса всё равно другие.
|
||||
|
||||
При этом раздел «UX» (не менявшийся ни в одной из трёх ревизий, `git diff`
|
||||
подтверждает отсутствие правок этого раздела за весь трек) заявляет:
|
||||
«Оформление не меняется. Меняется то, что слышит пользователь скринридера…»
|
||||
— и раздел «Release-артефакты» относит к User-Visible только факт объявления
|
||||
скринридером, помечая остальные три пункта внутренними, а строку
|
||||
«Скриншоты не меняются: диалог в статике не открыт» приводит как
|
||||
единственное следствие для документации.
|
||||
|
||||
**Сценарий отказа**: реализация по тексту ТЗ корректно уходит с HA-хрома на
|
||||
нативный `<dialog>` для всех alert-подтверждений (AC6/AC8 зелёные), но
|
||||
CHANGELOG не получает пункта об этом (Release-артефакты явно требуют только
|
||||
одну строку — про скринридер), а автор код-ревью следующего раунда, доверяя
|
||||
разделу UX буквально, не станет искать визуальный регресс на реальном HA —
|
||||
хотя пользователь при первом же «Удалить комнату?» увидит непривычно
|
||||
выглядящий диалог вместо знакомого HA-стиля. Отдельный риск: если
|
||||
несоответствие всплывёт в проде, это будет воспринято как баг (незапланированный
|
||||
визуальный дрейф), хотя по факту это — осознанное, но нигде не заявленное
|
||||
инженерное решение.
|
||||
|
||||
**Ожидаемо**: раздел «UX» и «Release-артефакты» приведены в соответствие с
|
||||
разделом (б) — либо явно признаётся и документируется видимая смена
|
||||
оформления confirm-диалогов (HA-хром → нативный `hp-dialog`) как
|
||||
User-Visible-пункт в обоих CHANGELOG, либо владелец фиксирует иной баланс
|
||||
(например, принять расхождение только для `warning`, а не для всех
|
||||
`destructive`, если визуальная консистентность важнее для частых операций) —
|
||||
но текущее молчаливое «не меняется» рядом с разделом, который явно описывает
|
||||
смену ветки рендера, оставлять нельзя.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **Обе находки r2 закрыты по существу**, не косметически — подробности в
|
||||
разделе «Закрытие раунда r2» выше; находка №2 закрыта с независимой
|
||||
внешней проверкой (реальный исходник закреплённой версии
|
||||
`home-assistant-frontend`, а не доверие цитате в ТЗ).
|
||||
- **§7.1, обязательные разделы** — все присутствуют и после сокращения
|
||||
документа: сценарий, что человек увидит до/после, проблема+контракт по
|
||||
каждому из четырёх пунктов, скоуп/не-скоуп, UX, модель данных и миграция,
|
||||
i18n, AC1-AC12 с доказательством, план автотестов, риски, откат,
|
||||
release-артефакты.
|
||||
- **Внутренняя согласованность после удаления (д)**: нет висячих ссылок на
|
||||
AC13/AC14, счётчики «пять правок» → «четыре», «остальные четыре пункта» →
|
||||
«остальные три» пересчитаны верно, мутант и юнит-пункт плана,
|
||||
относившиеся к (д), убраны вместе с разделом, а не оставлены сиротами.
|
||||
- **AC6-AC8 и обновлённый план автотестов внутренне непротиворечивы**: они
|
||||
корректно описывают именно ту развилку, которую называет обновлённый (б) —
|
||||
alert всегда нативный, обычные диалоги вне `hp-confirm` — всегда HA-ветка
|
||||
при её наличии.
|
||||
- **Продуктовых технических вопросов владельцу не вынесено** — как и в r1/r2.
|
||||
- **Три проверки автора** (`check-docs.mjs`, `process-gate.mjs --issues`,
|
||||
`git diff --check`), названные в передаче на ревью, — дешёвые, применимые к
|
||||
doc-only дереву; не перепроверял отдельно, так как они не относятся к
|
||||
предмету находок этого раунда (документная гигиена, не контракт).
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Гейты `tsc`/`test`/`build` — не гонял: диапазон `origin/dev...HEAD`
|
||||
по-прежнему состоит только из doc-файлов (три `.md`), продуктового кода
|
||||
нет. То же основание, что в r1/r2.
|
||||
- Не пересчитывал заново инвентарь мёртвых ключей (а) и механику снапшотов
|
||||
(г) — унаследовано из r2 (SHA `0d5b52d1`), дельта их не касается (см.
|
||||
раздел «Унаследовано»).
|
||||
- Не проверял поведение реального `wa-dialog` (`@home-assistant/webawesome`)
|
||||
на предмет того, передаёт ли *он сам* `role` от `type`, помимо того, что
|
||||
`ha-dialog.ts` этого не делает явно, — для контракта (б) достаточно
|
||||
доказать, что `ha-dialog.ts` не передаёт `type`/`role` внутрь, а глубже в
|
||||
сторонний пакет ТЗ и не заходит (сознательно, см. риск «#406 не патчит
|
||||
shadow DOM»).
|
||||
- Не проверял субъективную величину визуального расхождения между HA-хромом
|
||||
и нативной веткой на глаз (скриншот/рендер) — вывод находки №1 основан на
|
||||
структурном сравнении кода (разные DOM-пути, разный фокус-трап, разный
|
||||
элемент заголовка), не на визуальном сравнении пикселей; для целей этого
|
||||
ревью (обнаружить неучтённое расхождение с разделом UX) этого достаточно,
|
||||
количественная оценка — дело код-ревью или ручного смотра при реализации.
|
||||
- Не проверял `scripts/mutation-gate.mjs` на техническую реализуемость трёх
|
||||
заявленных мутантов — на этапе ТЗ это описание намерения, не код; та же
|
||||
позиция, что в r1/r2.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Вердикт: жёлтый · заход r3 · блокирующих циклов 2/4 · High: 0 · Medium: 1 → в задаче
|
||||
@@ -0,0 +1,208 @@
|
||||
# SPEC-REVIEW-406-r4
|
||||
|
||||
- Issue: #406
|
||||
- ТЗ: `docs/specs/406-beta2-polish.md`, ветка `issue/406-beta2-polish`,
|
||||
ревизия 4, SHA `7a3dcfd7` (тот же коммит, что назвал автор в передаче на
|
||||
ревью: «Head: `7a3dcfd7`»; `git rev-parse HEAD` подтверждает)
|
||||
- Этап: spec (PROCESS.md §2.4)
|
||||
- Заход: r4 · блокирующих циклов израсходовано 3 из 4
|
||||
- Вердикт: **зелёный**
|
||||
|
||||
## Почему разбор по дельте, а не заново
|
||||
|
||||
Предыдущий вердикт (SPEC-REVIEW-406-r3) получен на SHA `c43051ac`.
|
||||
`origin/dev` = `b04387ce` — тот же коммит, что был актуален и на момент r2/r3
|
||||
(`git merge-base c43051ac origin/dev` = `b04387ce` = сам `origin/dev`,
|
||||
`git merge-base --is-ancestor c43051ac origin/dev` → false, т.е. ветка задачи
|
||||
не сливалась и не ребейзилась поверх ушедшего вперёд `dev`). Не §2.10-случай
|
||||
«ребейз на ушедший вперёд dev».
|
||||
|
||||
Дельта объявлена как `git diff c43051ac..7a3dcfd7`:
|
||||
|
||||
```
|
||||
docs/reviews/SPEC-REVIEW-406-r3.md | 235 +++++++++++++ (новый файл — документ прошлого раунда)
|
||||
docs/specs/406-beta2-polish.md | 26 +++++++------
|
||||
```
|
||||
|
||||
В самом ТЗ дельта — три места: (1) ревизия 3→4 в шапке, (2) одно предложение
|
||||
в разделе «Что человек увидит… После», (3) полностью переписанные разделы
|
||||
«UX» и «Release-артефакты». Контракты (а), (б), (в), (г), AC1–AC12, план
|
||||
автотестов, риски, откат, скоуп/не-скоуп дельтой не задеты ни строкой —
|
||||
инвариант проверен `git diff c43051ac..7a3dcfd7 -- docs/specs/406-beta2-polish.md`
|
||||
построчно. Диапазон `origin/dev...HEAD` по-прежнему состоит только из
|
||||
doc-файлов (три ревью-документа + сам ТЗ) — продуктового кода нет, гейты
|
||||
`tsc`/`test`/`build` неприменимы, тот же факт, что в r1–r3.
|
||||
|
||||
Дельта локальна и закрывает единственную Medium-находку r3 — это ровно
|
||||
случай, для которого §2.10 предписывает разбор по дельте, а не заново.
|
||||
|
||||
## Скоуп
|
||||
|
||||
Без изменений: пять пунктов из тела issue → четыре после того, как (д)
|
||||
выведен из скоупа в r3 (дефект уже исправлен в #409 коммитом `8119c523`).
|
||||
Четыре пункта на трёх поверхностях: словари i18n (а), роль/описание диалога
|
||||
подтверждения (б) вместе со смок-покрытием HA-ветки (в), уборка снапшотов
|
||||
area (г). Полный трек обоснован (три несвязанные поверхности, критерий
|
||||
«одна поверхность» для `small` не выполняется) — не пересматривался, дельта
|
||||
трек не касается.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Прочитаны заново `docs/SCOPE.md`, `PROCESS.md` целиком (включая §2.10, §4,
|
||||
§7.1, §7.2), `AGENTS.md`. Прочитано тело issue #406 и все восемь
|
||||
комментариев (два S2-разбора, четыре передачи на ревью автором, два
|
||||
предыдущих вердикта). Прочитаны целиком `docs/reviews/SPEC-REVIEW-406-r1.md`
|
||||
(входит в SHA дерева как исторический контекст), `-r2.md` и `-r3.md`.
|
||||
|
||||
Само ТЗ (`docs/specs/406-beta2-polish.md`, 344 строки) прочитано целиком
|
||||
построчно на SHA `7a3dcfd7`, не только дельта — чтобы убедиться, что
|
||||
переписанные разделы UX/Release-артефакты не противоречат ничему из
|
||||
разделов, которые дельта не тронула (сценарий, контракт (б), AC6–AC8).
|
||||
|
||||
Точечно перепроверена по коду цифра, впервые появившаяся в этой ревизии текста
|
||||
(раздел UX, строка 197: «восемь точек входа: семь `destructive` и одна
|
||||
`warning`») — это не пересказ старой находки r1, а новое количественное
|
||||
утверждение в новом месте документа, поэтому дельта его касается:
|
||||
|
||||
```
|
||||
grep -n "kind: *'destructive'" src/*.ts → 7 вхождений
|
||||
houseplan-onboarding-runtime.ts:224, 437
|
||||
houseplan-editor-runtime.ts:3127, 3153, 8418, 8658, 8884
|
||||
grep -n "kind: *'warning'" src/*.ts → 1 вхождение
|
||||
houseplan-card.ts:13044
|
||||
```
|
||||
|
||||
7 + 1 = 8, число в тексте точное.
|
||||
|
||||
Проверена терминология «нативная оболочка House Plan» против
|
||||
`docs/USER-GUIDE.ru.md`: строки 730 и 1904 этого документа уже используют
|
||||
оборот «общий диалог House Plan» / «единый диалог House Plan» для того же
|
||||
подтверждения — новая формулировка ТЗ не изобретает термин, а совпадает с
|
||||
уже принятым в проекте словарём интерфейса.
|
||||
|
||||
Продуктового кода в диапазоне `origin/dev...HEAD` нет (`git diff
|
||||
origin/dev...HEAD --stat` — только четыре `.md`-файла), поэтому `tsc`/`test`/
|
||||
`build` не гонялись — неприменимы, тот же факт, что в r1–r3.
|
||||
|
||||
## Закрытие раунда r3
|
||||
|
||||
| Находка r3 | Чем закрыта | Где это видно |
|
||||
|---|---|---|
|
||||
| №1 (Medium, в скоупе) — раздел «UX» и «Release-артефакты» утверждали «оформление не меняется», хотя раздел (б) в r3 впервые ввёл видимый переход confirm-диалогов с HA-хрома на нативную оболочку `hp-dialog` | Раздел «UX» переписан: явно назван видимый переход для всех восьми точек входа («…в реальном Home Assistant переходят с оболочки `ha-dialog` на уже поддерживаемую нативную оболочку `hp-dialog`: возможны небольшие отличия заголовка и анимации»), явно перечислено, что не меняется (кнопки, цвет, защита от случайного закрытия, начальный фокус, Esc), явно названо, что это «осознанный видимый компромисс… а не обещание pixel parity». Раздел «Release-артефакты» требует пункт CHANGELOG именно про нативную оболочку **и** про `alertdialog`, `User-Visible: yes` | `docs/specs/406-beta2-polish.md:193-206` (UX), `:339-343` (Release-артефакты); в дифф `c43051ac..7a3dcfd7` эти два раздела — единственные содержательно переписанные блоки |
|
||||
|
||||
Находка закрыта по существу: числовое утверждение, добавленное в закрывающем
|
||||
тексте («восемь точек входа: семь + одна»), перепроверено независимо по
|
||||
коду (см. «Как проверялось») и совпадает; формулировка не противоречит ни
|
||||
одному месту ТЗ, которое дельта не трогала (AC6–AC8, контракт (б), раздел
|
||||
«Проверена ровно закреплённая проектом версия…»); терминология не
|
||||
изобретена, а совпадает с `docs/USER-GUIDE.ru.md`.
|
||||
|
||||
## Унаследовано из r3
|
||||
|
||||
Разделы, дельтой `c43051ac..7a3dcfd7` не задетые ни строкой, приняты без
|
||||
повторной построчной проверки контракта — из `docs/reviews/SPEC-REVIEW-406-r3.md`
|
||||
(и через него — из r2/r1), на SHA `c43051ac`:
|
||||
|
||||
- **(а) Мёртвые строки в словарях** — инвентарь 13 мёртвых ключей (7
|
||||
`confirm.*` + 6 прочих семейств), правило извлечения производных
|
||||
`*.help.aria` из литеральных `_help('*.help')` для всех 19 пар, коллизия с
|
||||
`test/unified-wall-tool-source.test.mjs` и её разрешение как открытого
|
||||
вопроса реализации — вся фактура из r1/r2, подтверждена в r2 и не
|
||||
пересчитывалась в r3 и в этом раунде.
|
||||
- **(б) контракт роли/описания** (без изменённого в этом раунде текста UX) —
|
||||
дихотомия `destructive`/`warning` → `alertdialog`, механизм
|
||||
`.ariaDescribedBy`/`type` в закреплённой версии `home-assistant-frontend
|
||||
==20260729.7`, независимо подтверждённый в r3 построчным чтением реального
|
||||
`ha-dialog.ts` (560 строк). Эта часть контракта дельтой r3→r4 не менялась.
|
||||
- **(в) смок-покрытие HA-ветки** — контракт «смок с зарегистрированным
|
||||
`ha-dialog` проверяет обе развилки», прецедент стаба `smoke_free_walls.mjs`,
|
||||
требование к стабу моделировать публичные свойства, а не внутренний shadow
|
||||
DOM — переписано и подтверждено в r3, дельтой r4 не тронуто.
|
||||
- **(г) снапшоты area** — условие «уборка только при авторитетном реестре»,
|
||||
связь с #403, порядок обрезки `markerAreaSnapshotOf` (сохранять последние,
|
||||
а не первые) — фактура из r1/r2, не пересчитывалась в r3 и в этом раунде.
|
||||
- **AC1–AC12, план автотестов, мутанты, риски, откат, модель данных/миграция,
|
||||
i18n** — текст этих разделов не изменился ни на строку между `c43051ac` и
|
||||
`7a3dcfd7`; их разбор в r3 («AC6-AC8 и обновлённый план автотестов
|
||||
внутренне непротиворечивы», «нет висячих ссылок на AC13/AC14», «§7.1 —
|
||||
все разделы присутствуют») принят как есть.
|
||||
- **Отсутствие продуктовых вопросов владельцу** — подтверждено в r1/r2/r3, в
|
||||
этом раунде тоже не появилось: единственная правка (UX/Release-артефакты)
|
||||
технический вопрос не поднимает, она документирует уже принятое
|
||||
техническое решение (б).
|
||||
|
||||
## Находки
|
||||
|
||||
Блокирующих (High) и находок Medium в скоупе, не закрытых этой ревизией, нет.
|
||||
|
||||
**[Low, снято с записью]** DoR-пункты «влияние на touch по
|
||||
`docs/TOUCH-SUPPORT.md`» и «влияние на производительность названо явно» не
|
||||
выделены в ТЗ отдельной строкой (ни в этой, ни в предыдущих трёх ревизиях —
|
||||
проверено по `docs/reviews/SPEC-REVIEW-406-r{1,2,3}.md`, ни один не поднимал
|
||||
этот пункт). Разбираю по существу, не откладываю: производительность
|
||||
покрыта явно — AC12 фиксирует бюджет initial (удаление ключей уменьшает его,
|
||||
роль/`aria-describedby` добавляют единицы байт), доказательство —
|
||||
`bundle:budget` до/после. Touch не упомянут вовсе, но по факту неприменим:
|
||||
все четыре пункта задачи — словари, диалог `hp-confirm` (используется
|
||||
исключительно в двух редакторах, `SCOPE.md`: View никогда не показывает
|
||||
destructive/warning-действий), снапшоты area (внутренняя уборка данных, не
|
||||
интерфейс) — не меняют ни один жест, ни один drag/resize/tap-путь
|
||||
`TOUCH-SUPPORT.md`; смена HA-хрома на нативный `<dialog>` — рендер-путь,
|
||||
одинаковый независимо от способа ввода. Снимаю без возврата автору: правка
|
||||
тривиальна (одна фраза «влияние на touch: нет, диалог не входит в
|
||||
touch-специфичные пути редакторов»), но её отсутствие не меняет ни одного
|
||||
AC и не создаёт риска — решение реализации от неё не зависит.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **Единственная Medium-находка r3 закрыта по существу**, не косметически:
|
||||
новый текст UX/Release-артефактов называет ровно то видимое изменение,
|
||||
которое раздел (б) вводит, ничего не занижая и не расширяя сверх
|
||||
доказанного в r3.
|
||||
- **Новое количественное утверждение в закрывающем тексте («8 точек входа:
|
||||
7 + 1») точное** — перепроверено независимым `grep` по `src/*.ts`, не
|
||||
принято на слово автора.
|
||||
- **Терминология не изобретена** — «нативная оболочка House Plan» совпадает
|
||||
с уже используемым в `docs/USER-GUIDE.ru.md` оборотом («общий диалог
|
||||
House Plan», «единый диалог House Plan»).
|
||||
- **Внутренняя согласованность документа после правки** — раздел UX не
|
||||
противоречит AC6–AC8, контракту (б) и разделу «Практически» (строки
|
||||
120-130); раздел Release-артефакты корректно относит к User-Visible ровно
|
||||
один пункт (б) и оставляет три остальных (а, в, г) внутренними.
|
||||
- **§7.1, обязательные разделы** — все присутствуют и после правки: сценарий,
|
||||
что человек увидит до/после, проблема+контракт по каждому из четырёх
|
||||
пунктов, скоуп/не-скоуп, UX, модель данных и миграция, i18n, AC1-AC12 с
|
||||
доказательством, план автотестов, риски, откат, release-артефакты.
|
||||
- **Дельта c43051ac..7a3dcfd7 не задевает ни один AC** — контракты и план
|
||||
автотестов текстуально не изменились, поэтому доказательства AC1-AC12
|
||||
наследуются из r3 без пересчёта.
|
||||
- **origin/dev не продвинулся** с r2/r3 (`b04387ce`) — основание для разбора
|
||||
по дельте, а не заново, сохраняется.
|
||||
- **Продуктовых технических вопросов владельцу не вынесено** — как и в
|
||||
r1/r2/r3.
|
||||
- **Три проверки автора** (`check-docs.mjs`, `process-gate.mjs --issues`,
|
||||
`git diff --check`), названные в передаче revision 4 неявно (те же, что
|
||||
называл автор в revision 3) — doc-only дерево, не относятся к предмету
|
||||
находок этого раунда.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Гейты `tsc`/`test`/`build` — не гонял: `git diff origin/dev...HEAD --stat`
|
||||
показывает только четыре `.md`-файла, продуктового кода нет. То же
|
||||
основание, что в r1-r3.
|
||||
- Не пересчитывал заново инвентарь мёртвых ключей (а), механику снапшотов
|
||||
(г), контракт (б)/(в) по существу — унаследовано из r3 (SHA `c43051ac`),
|
||||
дельта r3→r4 их не касается (см. раздел «Унаследовано из r3»).
|
||||
- Не проверял поведение реального `wa-dialog` глубже, чем сделал r3 (та же
|
||||
граница: `ha-dialog.ts` не передаёт `type`/`role` внутрь — этого достаточно
|
||||
для контракта (б), глубже в сторонний пакет ТЗ сознательно не заходит).
|
||||
- Не проверял `scripts/mutation-gate.mjs` на техническую реализуемость трёх
|
||||
заявленных мутантов — на этапе ТЗ это описание намерения, не код.
|
||||
- Не запрашивал у владельца touch/perf-строку DoR — разобрал сам как Low и
|
||||
снял с записью (см. «Находки»); оба ответа выводятся из уже
|
||||
зафиксированного в `SCOPE.md`/`TOUCH-SUPPORT.md` факта («View никогда не
|
||||
показывает destructive-действий»), продуктовым вопросом не является.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Вердикт: зелёный · заход r4 · блокирующих циклов 3/4 · High: 0 · Medium: 0
|
||||
@@ -0,0 +1,343 @@
|
||||
# ТЗ #406 — Полиш v1.70.0-beta.2: мёртвые строки, роль диалога, непокрытая ветка, растущий снапшот
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/406
|
||||
- Приоритет: P3, polish; полный трек — три несвязанные поверхности (словари
|
||||
i18n, `hp-dialog`, снапшоты переезда area), критерий
|
||||
«одна поверхность» для `small` не выполняется — прецеденты #385, #400
|
||||
- Ревизия: 4 (2026-09-01), после SPEC-REVIEW-406-r3
|
||||
|
||||
## Сценарий
|
||||
|
||||
Четыре мелочи, оставшиеся после крупной работы #32 и #126. Переводчик
|
||||
поддерживает строки, которых никто не показывает. Незрячий пользователь слышит
|
||||
заголовок диалога удаления, но не слышит, что именно исчезнет. Смоки проверяют
|
||||
запасную ветку диалога, а не ту, что работает у людей. Снапшот переезда копит
|
||||
записи об устройствах, которых давно нет.
|
||||
|
||||
## Что человек увидит до и после
|
||||
|
||||
**До**: (1) 52 строки в четырёх словарях никому не показываются, но их переводят
|
||||
и поддерживают; (2) скринридер объявляет диалог удаления как обычный `dialog` и
|
||||
не читает текст последствий; (3) HA-ветка диалога не покрыта ни одним смоком —
|
||||
проверяется только запасная; (4) записи снапшота исчезнувших устройств живут
|
||||
вечно.
|
||||
**После**: словари содержат только используемое, и это проверяется гейтом;
|
||||
подтверждение опасного действия — удаления или разблокировки — объявляется как
|
||||
`alertdialog` вместе с последствиями и использует собственную нативную оболочку
|
||||
House Plan вместо HA-хрома; смок ходит по обеим веткам диалога; снапшот убирает
|
||||
записи устройств, которых достоверно нет.
|
||||
|
||||
## Проблема и контракты по пунктам
|
||||
|
||||
### (а) Мёртвые строки в словарях
|
||||
|
||||
Сверка ключей `src/i18n/en.json` с литералами и производными ключами в
|
||||
`src/*.ts` (динамические шаблоны вида `` `furn.cat_${id}` `` и механически
|
||||
производные суффиксы учтены):
|
||||
|
||||
| Ключей в словаре | Есть потребитель | Не используется никак |
|
||||
|---|---|---|
|
||||
| 1201 | 1188 | **13** |
|
||||
|
||||
Из тринадцати семь — предмет issue (`confirm.delete_draft`,
|
||||
`confirm.delete_draft_segment`, `confirm.delete_plan`, `confirm.delete_room`,
|
||||
`confirm.delete_space`, `confirm.remove_marker`, `confirm.unlock`): это
|
||||
односоставные строки браузерного `confirm()`, которые #32 заменил парами
|
||||
`…_title` + `…_body`.
|
||||
|
||||
Остальные шесть — тот же узор в других семействах:
|
||||
|
||||
- `marker.display_hint`, `marker.display_hint_icon` — вытеснены
|
||||
`marker.display_hint_badge/_icon_ripple/_value/_static_icon`
|
||||
(`houseplan-card.ts:404-407`);
|
||||
- `history.delete_room` — вытеснен парой `history.delete_room_keep_walls` /
|
||||
`_with_walls` (`houseplan-editor-runtime.ts:5953`);
|
||||
- `markup.delete` — вытеснен `markup.delete_room` (`:11877`);
|
||||
- `title.markup`, `history.partition_add` — остатки прежнего инструмента стен.
|
||||
|
||||
**Важное динамическое семейство, которое удалять нельзя**: все 19 ключей
|
||||
`*.help.aria` используются фабрикой `_help('literal.help')`. В
|
||||
`houseplan-editor-runtime.ts:1194` и onboarding-ветке доступный ключ выводится
|
||||
как `` `${key}.aria` ``; для каждого из 19 базовых `*.help` есть литеральный
|
||||
вызов `_help()`. Гейт обязан распознавать эту производную пару так же, как
|
||||
шаблоны `furn.cat_*`, а не требовать буквального появления полного aria-ключа.
|
||||
|
||||
**Контракт**: словарь содержит только то, что кто-то показывает. Проверяется
|
||||
гейтом, который строит множество литералов, динамических префиксов и
|
||||
механически производных ключей из `src/` и объявляет остальное мёртвым.
|
||||
|
||||
**Скоуп сознательно шире тела issue, и вот почему**: гейт нельзя ввести
|
||||
наполовину. Если он судит весь словарь — он покраснеет на всех тринадцати; если
|
||||
только на `confirm.*` — он не поймает следующий такой ключ, а именно за этим
|
||||
его и заводят. Поэтому в задаче удаляются все доказанно мёртвые ключи, а не
|
||||
только семь. Живые производные семейства, включая `*.help.aria`, описываются
|
||||
правилом извлечения, а не временным списком исключений.
|
||||
|
||||
**Известная коллизия, которую обязана разрешить реализация**:
|
||||
`test/unified-wall-tool-source.test.mjs:32` **требует наличия**
|
||||
`history.partition_add` в трёх словарях (`assert.equal(typeof locale[key],
|
||||
'string')`), хотя в `src/` он не используется. Одно из двух: либо ключ живой и
|
||||
реализация показывает потребителя, либо он удаляется вместе с этой строкой
|
||||
теста. Молча оставить нельзя — гейт и тест будут противоречить друг другу.
|
||||
|
||||
`test/i18n.test.mjs` мёртвые ключи не ловит по устройству: он проверяет паритет
|
||||
словарей, непустоту значений и совпадение плейсхолдеров — то есть что словари
|
||||
одинаковы, а не что ключи нужны.
|
||||
|
||||
### (б) Роль диалога и текст последствий
|
||||
|
||||
`src/hp-dialog.ts:459-462`, запасная ветка:
|
||||
|
||||
```html
|
||||
<dialog role="dialog" aria-modal="true" aria-labelledby=${this._titleId} …>
|
||||
```
|
||||
|
||||
`aria-describedby` в файле не встречается ни разу. Скринридер объявит заголовок
|
||||
и не объявит предложение, ради которого диалог и показывают («Устройство
|
||||
исчезнет с плана вместе с настройками»).
|
||||
|
||||
**Контракт**: любой запрос `hp-confirm` — и `HpConfirmKind.destructive`, и
|
||||
`HpConfirmKind.warning` — объявляется как `alertdialog`, и его текст последствий
|
||||
связан с диалогом через `aria-describedby`. `warning` здесь не означает обычное
|
||||
информационное окно: единственный такой запрос — разблокировка двери
|
||||
(`houseplan-card.ts:13042-13046`) с последствиями из `confirm.unlock_body`, то
|
||||
есть санкционированная `SCOPE.md` поверхность опасного действия.
|
||||
|
||||
Роль **выбирается, а не меняется глобально**: `hp-dialog` несёт и обычные
|
||||
диалоги (маркер, калибровка пылесоса), для которых `alertdialog` неверен — эта
|
||||
роль обязывает screen reader прервать чтение. Обычным считается диалог, который
|
||||
не представлен `hp-confirm`; внутри `hp-confirm` ветки «обычного» вида нет.
|
||||
|
||||
Проверена ровно закреплённая проектом версия
|
||||
`home-assistant-frontend==20260729.7` из `tests_backend/requirements.txt`.
|
||||
[Её `src/components/ha-dialog.ts`](https://github.com/home-assistant/frontend/blob/20260729.7/src/components/ha-dialog.ts)
|
||||
показывает, что публичный API `ha-dialog` содержит свойства `.ariaDescribedBy` и
|
||||
`.type: "alert" | "standard"`; первое передаётся внутреннему `wa-dialog` как
|
||||
`aria-describedby`. Однако `type` только отражается на host и используется в
|
||||
CSS: в `render()` нет ни `this.type`, ни передачи `role` внутреннему
|
||||
`wa-dialog`. Поэтому привязка `.type="alert"` или `role` на внешнем
|
||||
`<ha-dialog>` **не считается** доказательством роли настоящего диалога.
|
||||
|
||||
Практически: `hp-dialog` получает семантический признак alert/обычный и id
|
||||
описания; `hp-confirm` передаёт alert-семантику для обоих своих kind и связывает
|
||||
с ней `.danger-confirm-body`. Для обычного окна при зарегистрированном
|
||||
`ha-dialog` сохраняется HA-ветка с `.ariaLabelledBy` и, когда описание задано,
|
||||
`.ariaDescribedBy`. Для alert-подтверждения `hp-dialog` намеренно выбирает
|
||||
нативный `<dialog role="alertdialog">` даже при наличии `ha-dialog`: это
|
||||
единственный поддерживаемый из нашего дерева путь, который гарантирует роль и
|
||||
не лезет во внутренний shadow DOM стороннего компонента. При отсутствии
|
||||
`ha-dialog` используется та же нативная ветка. Внешность нативной ветки уже
|
||||
является поддерживаемой частью `hp-dialog`; отдельного CSS-дубля не появляется.
|
||||
|
||||
### (в) HA-ветка диалога не покрыта
|
||||
|
||||
`hp-dialog.ts:240`: `this._useHaDialog = !!customElements.get('ha-dialog')`.
|
||||
Единственный смок во всём `demo/`, определяющий `ha-dialog`, —
|
||||
`smoke_free_walls.mjs:20-22`, и он же на `:16` глушит `_confirmDanger`, то есть
|
||||
диалог подтверждения там не рисуется. В смоках подтверждений
|
||||
(`smoke_danger_confirmation.mjs`, `smoke_danger_confirm_branches.mjs`) слова
|
||||
`ha-dialog` нет.
|
||||
|
||||
Итог: покрыта только запасная ветка, существующая ради standalone-демо, а не
|
||||
та, что работает у пользователя.
|
||||
|
||||
**Контракт**: смок с зарегистрированным `ha-dialog` проверяет обе реально
|
||||
достижимые развилки: обычный `hp-dialog` пользуется HA-компонентом, а
|
||||
alert-подтверждение остаётся нативным и сохраняет `alertdialog`, описание,
|
||||
начальный фокус на «Отмена» и Esc → `false`. Смок без `ha-dialog` проверяет те
|
||||
же инварианты нативного подтверждения. Прецедент стаба `ha-dialog` уже написан
|
||||
и лежит в соседнем файле; стаб обязан моделировать публичные свойства
|
||||
`.ariaLabelledBy`, `.ariaDescribedBy` и `.type`, а не произвольную роль во
|
||||
внутреннем shadow DOM.
|
||||
|
||||
### (г) Снапшот копит записи исчезнувших устройств
|
||||
|
||||
`applyAreaRelocationResolution` (`device-area-relocation.ts`) итерирует только
|
||||
по `resolution.decisions`, а решения строятся по текущему списку устройств
|
||||
карточки. Устройство, пропавшее из Home Assistant и не имеющее маркера, в
|
||||
decisions не попадает — его запись `marker_area_snapshot[id]` не будет ни
|
||||
обновлена, ни удалена никогда. Единственная уборка —
|
||||
`removeMarkerAreaSnapshots` при явном редактировании или удалении маркера
|
||||
(`houseplan-editor-runtime.ts:8326, 8448`), то есть по действию человека.
|
||||
|
||||
**Контракт**: запись снапшота живёт, пока живёт устройство или маркер, к
|
||||
которому она относится. Запись, не соответствующая ни одному из них,
|
||||
убирается — **но только когда реестр авторитетен**. Это условие не
|
||||
факультативное: во время перезапуска Home Assistant или до первой полной
|
||||
загрузки реестра устройства временно отсутствуют, и уборка «по факту
|
||||
отсутствия» стёрла бы законные записи — ровно тот класс потери, который чинит
|
||||
#403.
|
||||
|
||||
**Побочный пункт того же места**: `markerAreaSnapshotOf` режет вход
|
||||
`.slice(0, MARKER_AREA_SNAPSHOT_LIMIT)` (20 000) при **чтении**. Порядок ключей
|
||||
— порядок вставки, поэтому при переполнении молча отбрасываются самые свежие
|
||||
записи, а не самые старые: мусор вытесняет живое. Правило переворачивается
|
||||
заодно — при обрезке сохраняются последние записи.
|
||||
|
||||
## Скоуп / не-скоуп
|
||||
|
||||
**В скоупе**: удаление доказанно мёртвых ключей из четырёх словарей и гейт,
|
||||
который не даёт им вернуться; роль и `aria-describedby` в `hp-dialog` и
|
||||
`hp-confirm`; смок выбора HA/нативной ветки; уборка снапшотов при авторитетном
|
||||
реестре и порядок обрезки.
|
||||
|
||||
**Не в скоупе**: изменение содержимого или оформления help-подсказок — их 19
|
||||
пар `.help` + `.help.aria` уже подключены и сохраняются; содержимое и оформление
|
||||
диалога (#32); переезд area и его отказы (#403); порог свидетелей (#405 —
|
||||
соседняя строка того же файла, другой контракт); рост `known_devices` — тот же
|
||||
класс, но своя поверхность; история screenshot-acceptance — дефект уже исправлен
|
||||
в #409 коммитом `8119c523`, включая сохранение всего предыдущего блока и
|
||||
`lastWriteWasFingerprintOnly`, поэтому #406 не меняет и не дублирует этот
|
||||
контракт.
|
||||
|
||||
## UX
|
||||
|
||||
Пользователь скринридера слышит подтверждение удаления или разблокировки как
|
||||
оповещение вместе с текстом последствий. Для всех пользователей эти восемь
|
||||
точек входа (семь `destructive` и одна `warning`) в реальном Home Assistant
|
||||
переходят с оболочки `ha-dialog` на уже поддерживаемую нативную оболочку
|
||||
`hp-dialog`: возможны небольшие отличия заголовка и анимации. Содержимое,
|
||||
порядок и подписи кнопок, цвет destructive/warning, запрет случайного закрытия,
|
||||
начальный фокус на «Отмена» и Esc → отмена не меняются. Обычные диалоги вне
|
||||
`hp-confirm` сохраняют HA-хром.
|
||||
|
||||
Это осознанный видимый компромисс для гарантированной роли `alertdialog` на
|
||||
закреплённой версии HA, а не обещание pixel parity. Терминология берётся из
|
||||
`docs/USER-GUIDE.ru.md`, новых формулировок не вводится.
|
||||
|
||||
## Модель данных и миграция
|
||||
|
||||
Формат `marker_area_snapshot` не меняется — меняется момент уборки записей.
|
||||
Миграции нет: запись, оставшаяся от исчезнувшего устройства, исчезает при
|
||||
первом авторитетном проходе.
|
||||
|
||||
Удаление ключей словаря — не миграция конфига: ключи не хранятся в
|
||||
пользовательских данных.
|
||||
|
||||
## i18n
|
||||
|
||||
Новых строк нет; тринадцать мёртвых ключей удаляются из каждого из четырёх
|
||||
словарей — 52 строки суммарно. Все 19 пар `*.help` + `*.help.aria` сохраняются.
|
||||
Паритет сохраняется — удаление синхронное во всех четырёх словарях.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- **AC1**. Ни один ключ словаря не остаётся без потребителя: гейт строит
|
||||
множество литералов, динамических префиксов и производных ключей из `src/` и
|
||||
краснеет на любом ключе, не покрытом этими правилами. Доказательство: гейт
|
||||
зелёный после чистки.
|
||||
- **AC2**. **Отрицательный прогон обязателен**: возвращённый в словарь мёртвый
|
||||
ключ роняет гейт. Доказательство: мутант через штатный раннер.
|
||||
- **AC3**. Гейт не обвиняет динамические и производные ключи: `furn.cat_*`,
|
||||
`furn.sym_*`, `wall_model.reason.*`, `resize.disabled.*`,
|
||||
`junction.limit_*`, `decor.*`, а также все 19 `*.help.aria`, получаемые из
|
||||
литеральных `_help('*.help')`, остаются зелёными. Доказательство: гейт
|
||||
зелёный на текущем словаре после удаления ровно 13 доказанно мёртвых ключей.
|
||||
- **AC4**. Коллизия с `test/unified-wall-tool-source.test.mjs` разрешена явно:
|
||||
либо показан потребитель `history.partition_add`, либо ключ удалён вместе со
|
||||
строкой теста. Доказательство: оба гейта зелёные одновременно.
|
||||
- **AC5**. Паритет словарей сохранён: `test/i18n.test.mjs` зелёный без правок
|
||||
его утверждений, в четырёх словарях одинаковый набор ключей.
|
||||
- **AC6**. Оба вида `hp-confirm` — `destructive` (например, удаление) и
|
||||
`warning` (разблокировка двери) — объявляются как `alertdialog`, а их текст
|
||||
последствий связан через `aria-describedby`. Доказательство: смок читает
|
||||
атрибуты и доступное описание у нативного диалога для обоих kind, в том числе
|
||||
когда `ha-dialog` зарегистрирован.
|
||||
- **AC7**. Диалоги вне `hp-confirm` (маркер, калибровка) остались `dialog` и не
|
||||
получают alert-семантику. Доказательство: при зарегистрированном стабе
|
||||
обычный `hp-dialog` рендерит `ha-dialog`, передаёт ему `.ariaLabelledBy` и
|
||||
при наличии описания `.ariaDescribedBy`; нативного диалога в этой ветке нет.
|
||||
- **AC8**. Инварианты подтверждения проверены в обоих окружениях: с
|
||||
зарегистрированным `ha-dialog` и без него диалог показан, «Отмена» резолвит
|
||||
`false`, начальный фокус на «Отмена», Esc отменяет. Доказательство: смок
|
||||
подтверждений с конформным стабом `ha-dialog` (прецедент
|
||||
`smoke_free_walls.mjs:20-22`) плюс существующий прогон без стаба; в первом
|
||||
случае дополнительно доказано, что alert не уходит в HA-ветку.
|
||||
- **AC9**. Запись снапшота исчезнувшего устройства убирается при авторитетном
|
||||
реестре. Доказательство: смок — устройство пропадает из `_devices`, после
|
||||
прохода записи в `marker_area_snapshot` нет.
|
||||
- **AC10**. При неавторитетном реестре записи **не** трогаются. Доказательство:
|
||||
тот же смок, ветка `authoritative: false` — запись на месте.
|
||||
- **AC11**. При обрезке снапшота сверх лимита сохраняются последние записи, а
|
||||
не первые. Доказательство: юнит на `markerAreaSnapshotOf` с входом больше
|
||||
лимита.
|
||||
- **AC12**. Бюджет initial не растёт: удаление тринадцати ключей из словарей
|
||||
уменьшает его, роль и `aria-describedby` добавляют единицы байт.
|
||||
Доказательство: `npm run bundle:budget` до и после.
|
||||
|
||||
## План автотестов
|
||||
|
||||
**Гейт** (`test/i18n-dead-keys.test.mjs`, новый):
|
||||
|
||||
1. Множество ключей минус литералы минус динамические префиксы — пусто (AC1,
|
||||
AC3).
|
||||
|
||||
**Browser smoke** (`demo/smoke_danger_confirm_branches.mjs` — дополнение; файл
|
||||
уже создан #402 и держит свои фикстуры):
|
||||
|
||||
2. Роль и `aria-describedby` у destructive- и warning-подтверждения без
|
||||
`ha-dialog` (AC6, AC8).
|
||||
3. Конформный стаб `ha-dialog` принимает публичные `.ariaLabelledBy`,
|
||||
`.ariaDescribedBy` и `.type`: обычный `hp-dialog` выбирает HA-ветку, а оба
|
||||
alert-подтверждения — нативную; фокус, Esc и результат не расходятся (AC6–8).
|
||||
|
||||
**Browser smoke** (`demo/smoke_area_relocation.mjs` — дополнение):
|
||||
|
||||
4. Устройство исчезло из `_devices`, реестр авторитетен → запись убрана (AC9).
|
||||
5. То же при `authoritative: false` → запись на месте (AC10).
|
||||
|
||||
**Юниты**:
|
||||
|
||||
6. `markerAreaSnapshotOf` на входе больше лимита — сохранены последние (AC11).
|
||||
|
||||
**Мутанты** (`scripts/mutation-gate.mjs`):
|
||||
|
||||
- `i18n-dead-key-returns`: вернуть `confirm.unlock` в словари → гейт краснеет;
|
||||
- `confirm-dialog-loses-alertdialog`: вернуть `role="dialog"` подтверждению
|
||||
либо разрешить alert уйти в зарегистрированный `ha-dialog` → смок AC6
|
||||
краснеет;
|
||||
- `area-snapshot-cleanup-ignores-authority`: убрать условие авторитетности →
|
||||
смок AC10 краснеет.
|
||||
|
||||
Третий мутант важнее первых двух: он проверяет не наличие уборки, а её
|
||||
осторожность — то есть ровно то свойство, потеря которого стоит пользователю
|
||||
данных.
|
||||
|
||||
## Риски
|
||||
|
||||
- **Гейт мёртвых ключей даст ложные обвинения на будущих динамических ключах.**
|
||||
Разработчик, написавший `` _t(`new_family.${x}`) ``, получит красный гейт на
|
||||
все ключи семейства. Смягчение: гейт извлекает префиксы из исходников
|
||||
автоматически (48 семейств уже покрыты), а сообщение об отказе называет
|
||||
ключ и подсказывает обе законные дороги — использовать или удалить.
|
||||
- **`alertdialog` меняет поведение скринридера сильнее, чем кажется**: он
|
||||
прерывает текущее чтение. Смягчение: роль назначается только двум видам
|
||||
`hp-confirm` (`destructive` и `warning`); обычные диалоги вне подтверждения
|
||||
проверяются отдельным AC.
|
||||
- **Будущая версия HA может исправить `ha-dialog.type="alert"`.** Текущий
|
||||
выбор нативной ветки останется корректным, только консервативным. Переход на
|
||||
HA alert в будущем — отдельное изменение после проверки новой закреплённой
|
||||
версии; #406 не патчит shadow DOM и не распознаёт приватную структуру
|
||||
`wa-dialog`.
|
||||
- **Уборка снапшотов может стереть законные записи.** Это ровно тот класс, что
|
||||
чинит #403. Смягчение: уборка только при авторитетном реестре, AC10
|
||||
проверяет обратный случай напрямую, мутант закреплён.
|
||||
- **Гейт примет производный ключ за мёртвый.** Наиболее опасный текущий пример —
|
||||
19 живых `*.help.aria`, полный ключ которых собирается в `_help()`. Смягчение:
|
||||
производное правило `.help` → `.help.aria` является частью AC3 и проверяется
|
||||
всем текущим набором, а не ручным исключением отдельных имён.
|
||||
|
||||
## Откат
|
||||
|
||||
Четыре правки независимы и откатываются по отдельности: удаление ключей —
|
||||
обратным коммитом, выбор нативной alert-ветки и `aria-describedby` — одним
|
||||
локальным изменением `hp-dialog`/`hp-confirm`, HA-smoke — удалением сценария,
|
||||
уборка снапшотов — снятием условия.
|
||||
|
||||
## Release-артефакты
|
||||
|
||||
- `docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md`: пункт о том, что подтверждение
|
||||
удаления или разблокировки теперь использует нативную оболочку House Plan и
|
||||
объявляется скринридером как `alertdialog` вместе с последствиями
|
||||
(User-Visible: yes). Остальные три пункта — внутренние.
|
||||
- Скриншоты не меняются: диалог в статике не открыт.
|
||||
@@ -128,6 +128,42 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: '',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'i18n-dead-key-returns',
|
||||
guard: 'node --test test/i18n-dead-keys.test.mjs',
|
||||
because: 'a translation key without any literal, dynamic-family or derived consumer must '
|
||||
+ 'not silently return to all shipped dictionaries (#406)',
|
||||
patches: [{
|
||||
file: 'src/i18n/en.json',
|
||||
find: ' "confirm.unlock_title": "Unlock?",',
|
||||
replace: ' "confirm.unlock": "Unlock {name}?",\n'
|
||||
+ ' "confirm.unlock_title": "Unlock?",',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'confirm-dialog-loses-alertdialog',
|
||||
guard: 'node demo/smoke_danger_confirm_branches.mjs',
|
||||
because: 'delete and unlock confirmations must expose the real dialog as an alertdialog '
|
||||
+ 'with its consequence text in both standalone and HA environments (#406)',
|
||||
patches: [{
|
||||
file: 'src/hp-dialog.ts',
|
||||
find: " role=${this.alert ? 'alertdialog' : 'dialog'}",
|
||||
replace: ' role="dialog"',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'area-snapshot-cleanup-ignores-authority',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="non-authoritative registry preserves orphan snapshots" '
|
||||
+ 'test/device-area-relocation.test.mjs',
|
||||
because: 'temporary registry absence must not erase device Area provenance before the '
|
||||
+ 'registry is authoritative (#406)',
|
||||
patches: [{
|
||||
file: 'src/device-area-relocation.ts',
|
||||
find: ' if (!options.authoritative) return { decisions, relocateIds };',
|
||||
replace: ' if (false && !options.authoritative) return { decisions, relocateIds };',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'settings-help-party1-placement-removed',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
|
||||
@@ -65,7 +65,7 @@ export function markerAreaSnapshotOf(value: unknown): MarkerAreaSnapshot {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
const result: MarkerAreaSnapshot = {};
|
||||
for (const [id, raw] of Object.entries(value as Record<string, unknown>)
|
||||
.slice(0, MARKER_AREA_SNAPSHOT_LIMIT)) {
|
||||
.slice(-MARKER_AREA_SNAPSHOT_LIMIT)) {
|
||||
if (!validText(id) || !raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
|
||||
const binding = (raw as { binding?: unknown }).binding;
|
||||
const area = (raw as { area?: unknown }).area;
|
||||
@@ -142,6 +142,22 @@ export function resolveDeviceAreaRelocations(
|
||||
if (!options.authoritative) return { decisions, relocateIds };
|
||||
|
||||
const snapshot = markerAreaSnapshotOf(options.snapshot);
|
||||
const liveIds = new Set<string>();
|
||||
const liveBindings = new Set<MarkerAreaBinding>();
|
||||
for (const device of options.devices) {
|
||||
if (validText(device.id)) liveIds.add(device.id);
|
||||
if (validText(device.marker?.id)) liveIds.add(device.marker.id);
|
||||
if ((device.bindingKind === 'device' || device.bindingKind === 'entity')
|
||||
&& validText(device.bindingRef)) {
|
||||
liveBindings.add(`${device.bindingKind}:${device.bindingRef}`);
|
||||
}
|
||||
if (validBinding(device.marker?.binding)) liveBindings.add(device.marker.binding);
|
||||
}
|
||||
for (const [id, entry] of Object.entries(snapshot)) {
|
||||
if (!liveIds.has(id) && !liveBindings.has(entry.binding)) decisions.push({
|
||||
...unresolved(id, 'registry-unverified'), removeSnapshot: true,
|
||||
});
|
||||
}
|
||||
const scale = Number.isFinite(options.coordinateScale) && Number(options.coordinateScale) > 0
|
||||
? Number(options.coordinateScale) : 1000;
|
||||
const targets = new Map<string, Array<{ space: SpaceModel; room: RoomCfg }>>();
|
||||
|
||||
+4
-1
@@ -33,14 +33,17 @@ export class HpConfirm extends LitElement {
|
||||
const request = this.request;
|
||||
if (!request) return null;
|
||||
const destructive = request.kind === 'destructive';
|
||||
const descriptionId = `hp-confirm-description-${this.token}`;
|
||||
return html`<hp-dialog class="danger-confirm-dialog"
|
||||
.hass=${this.hass}
|
||||
.title=${request.title}
|
||||
.alert=${true}
|
||||
.describedBy=${descriptionId}
|
||||
.icon=${request.icon || (destructive
|
||||
? 'mdi:alert-outline' : 'mdi:lock-open-alert-outline')}
|
||||
dismiss-on-scrim
|
||||
@hp-close=${() => this._decide(false)}>
|
||||
<div class="body danger-confirm-body" data-confirm-key=${request.key}>
|
||||
<div id=${descriptionId} class="body danger-confirm-body" data-confirm-key=${request.key}>
|
||||
${request.objectName
|
||||
? html`<strong class="danger-confirm-object">${request.objectName}</strong>`
|
||||
: null}
|
||||
|
||||
+34
-8
@@ -24,9 +24,10 @@ let dialogSequence = 0;
|
||||
/**
|
||||
* Shared modal shell for Houseplan.
|
||||
*
|
||||
* Home Assistant provides the visual surface and focus trap through ha-dialog.
|
||||
* The native dialog branch keeps the standalone demo usable without mocking HA
|
||||
* frontend internals. Both branches expose the same `hp-close` contract and
|
||||
* Home Assistant provides the visual surface and focus trap through ha-dialog
|
||||
* for ordinary dialogs. Alert confirmations deliberately use the native branch:
|
||||
* the pinned HA component exposes an alert type but does not forward its role
|
||||
* to the actual dialog. Both branches expose the same `hp-close` contract and
|
||||
* restore focus to the control that opened the dialog.
|
||||
*/
|
||||
export class HpDialog extends LitElement {
|
||||
@@ -34,6 +35,8 @@ export class HpDialog extends LitElement {
|
||||
title: { type: String },
|
||||
icon: { type: String },
|
||||
wide: { type: Boolean, reflect: true },
|
||||
alert: { type: Boolean, reflect: true },
|
||||
describedBy: { type: String, attribute: 'described-by' },
|
||||
dismissOnScrim: { type: Boolean, attribute: 'dismiss-on-scrim' },
|
||||
hass: { attribute: false },
|
||||
};
|
||||
@@ -218,6 +221,8 @@ export class HpDialog extends LitElement {
|
||||
title = '';
|
||||
icon = '';
|
||||
wide = false;
|
||||
alert = false;
|
||||
describedBy = '';
|
||||
dismissOnScrim = false;
|
||||
hass: any = null;
|
||||
|
||||
@@ -228,6 +233,10 @@ export class HpDialog extends LitElement {
|
||||
private _overlays: OverlayEntry[] = [];
|
||||
private readonly _titleId = `hp-dialog-title-${++dialogSequence}`;
|
||||
|
||||
private _usesHaDialog(): boolean {
|
||||
return this._useHaDialog && !this.alert;
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._opener = this._deepActiveElement();
|
||||
@@ -274,7 +283,7 @@ export class HpDialog extends LitElement {
|
||||
|
||||
protected firstUpdated(changed: PropertyValues): void {
|
||||
super.firstUpdated(changed);
|
||||
if (!this._useHaDialog) {
|
||||
if (!this._usesHaDialog()) {
|
||||
const dialog = this.renderRoot.querySelector('dialog');
|
||||
if (dialog && !dialog.open) dialog.showModal();
|
||||
}
|
||||
@@ -340,7 +349,7 @@ export class HpDialog extends LitElement {
|
||||
const focusable = this._focusableElements();
|
||||
const autofocus = focusable.find((el) => el.hasAttribute('autofocus'));
|
||||
const target = autofocus || focusable[0]
|
||||
|| (!this._useHaDialog ? this.renderRoot.querySelector<HTMLElement>('.close') : null)
|
||||
|| (!this._usesHaDialog() ? this.renderRoot.querySelector<HTMLElement>('.close') : null)
|
||||
|| this.renderRoot.querySelector<HTMLElement>('.surface')
|
||||
|| this.renderRoot.querySelector<HTMLElement>('ha-dialog');
|
||||
target?.focus({ preventScroll: true });
|
||||
@@ -405,7 +414,7 @@ export class HpDialog extends LitElement {
|
||||
this._requestClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab' || this._useHaDialog) return;
|
||||
if (event.key !== 'Tab' || this._usesHaDialog()) return;
|
||||
const close = this.renderRoot.querySelector<HTMLElement>('.close');
|
||||
const focusable = close ? [close, ...this._focusableElements()] : this._focusableElements();
|
||||
if (!focusable.length) {
|
||||
@@ -440,7 +449,23 @@ export class HpDialog extends LitElement {
|
||||
<span class="title-text">${this.title}</span>
|
||||
</span>`;
|
||||
|
||||
if (this._useHaDialog) {
|
||||
if (this._usesHaDialog()) {
|
||||
if (this.describedBy) {
|
||||
return html`<ha-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${true}
|
||||
width=${this.wide ? 'medium' : 'small'}
|
||||
.preventScrimClose=${!this.dismissOnScrim}
|
||||
.ariaLabelledBy=${this._titleId}
|
||||
.ariaDescribedBy=${this.describedBy}
|
||||
@opened=${this._focusInitial}
|
||||
@closed=${this._requestClose}
|
||||
>
|
||||
<span class="header-title-slot" slot="headerTitle">${title}</span>
|
||||
<slot></slot>
|
||||
<span class="footer" slot="footer"><slot name="footer"></slot></span>
|
||||
</ha-dialog><div class="overlay-portal"></div>`;
|
||||
}
|
||||
return html`<ha-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${true}
|
||||
@@ -457,9 +482,10 @@ export class HpDialog extends LitElement {
|
||||
}
|
||||
|
||||
return html`<dialog
|
||||
role="dialog"
|
||||
role=${this.alert ? 'alertdialog' : 'dialog'}
|
||||
aria-modal="true"
|
||||
aria-labelledby=${this._titleId}
|
||||
aria-describedby=${this.describedBy || nothing}
|
||||
@cancel=${this._onFallbackCancel}
|
||||
@click=${this._onFallbackClick}
|
||||
>
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"history.draft_segment": "Raum-Entwurfsegment hinzufügen",
|
||||
"history.draft_merge": "Unvollständige Raumumrisse verbinden",
|
||||
"history.draft_segment_delete": "Raum-Entwurfssegment entfernen",
|
||||
"history.partition_add": "Trennwand hinzufügen",
|
||||
"history.column_add": "Säule hinzufügen",
|
||||
"history.physical_edit": "Physisches Objekt ändern",
|
||||
"history.physical_delete": "Physisches Objekt löschen",
|
||||
@@ -37,8 +36,6 @@
|
||||
"history.wall_chain_finish": "Wandkette fertigstellen",
|
||||
"history.wall_face_batch": "Räume aus Wänden erstellen",
|
||||
"toast.column_duplicate": "Eine Säule mit demselben Zentrum und äußerer Größe existiert bereits",
|
||||
"confirm.delete_draft": "Sofort den gesamten unvollständigen Raumumriss löschen?",
|
||||
"confirm.delete_draft_segment": "Dieses Entwurfssegment löschen? Der verbleibende Umriss könnte sich in zwei teilen.",
|
||||
"confirm.delete_draft_title": "Unvollständigen Umriss löschen?",
|
||||
"confirm.delete_draft_body": "Der gesamte unvollständige Raumumriss wird gelöscht. Beim Abbrechen wird nichts geändert.",
|
||||
"confirm.delete_draft_segment_title": "Umrisssegment löschen?",
|
||||
@@ -76,7 +73,6 @@
|
||||
"title.zoom_in": "Zoomen",
|
||||
"title.zoom_out": "Herauszoomen",
|
||||
"title.zoom_fit": "Alles anpassen",
|
||||
"title.markup": "Raum-Markierung: Gitter, Linien, Konturen",
|
||||
"title.configure_space": "Bereich konfigurieren",
|
||||
"title.add_space": "Bereich hinzufügen",
|
||||
"title.markup_add": "Wände: eine durchgehende Kette zeichnen; Shift sperrt auf 45°. Werkzeugwechsel oder Verlassen des Editors beendet die Kette und wandelt sie in unabhängige Wände",
|
||||
@@ -121,7 +117,6 @@
|
||||
"history.edit_opening": "Öffnung bearbeiten",
|
||||
"history.move_opening": "Öffnung verschieben",
|
||||
"history.delete_opening": "Öffnung löschen",
|
||||
"history.delete_room": "Raum löschen",
|
||||
"history.delete_room_keep_walls": "Raum löschen, Wände behalten",
|
||||
"history.delete_room_with_walls": "Raum und Wände löschen",
|
||||
"history.decor_add": "Dekorobjekt hinzufügen",
|
||||
@@ -163,7 +158,6 @@
|
||||
"confirm.delete_partition_openings_title": "Wand und Öffnungen löschen?",
|
||||
"confirm.delete_partition_openings_body": "Diese Wand enthält {count} Öffnung(en). Sie werden zusammen gelöscht.",
|
||||
"confirm.delete_partition_openings_item": "• {type}, {length}",
|
||||
"markup.delete": "Löschen",
|
||||
"markup.hint_points": "Punkte: {n} · Shift — 45° Schritte · Esc — Kette beenden · Ctrl+Z — Punkt rückgängig machen · Schließen eines Bereichs erstellt einen Raum",
|
||||
"markup.hint_start": "Klicken Sie auf einen Rasterpunkt, um eine Wandkette zu starten",
|
||||
"tip.lqi": "durchschnittliches Zigbee-Signal:",
|
||||
@@ -192,9 +186,7 @@
|
||||
"display.badge": "Symbol + Zustand",
|
||||
"display.icon_ripple": "Symbol + Zustand und Aktivität",
|
||||
"display.static_icon": "Immer statisches Symbol",
|
||||
"marker.display_hint": "Symbol + Zustand ändert die Platte für die Zustände Arbeit, geöffnet, Alarm und nicht verfügbar. Symbol + Zustand und Aktivität zeigt zusätzlich einen kurzen Impuls für Ereignisse und einen kontinuierlichen Impuls für laufende Arbeit, Bewegung oder Präsenz. Wert + Zustand ersetzt das Symbol durch einen eindeutigen HA-Wert. Ein statisches Symbol reagiert niemals auf Gerätezustände.",
|
||||
"marker.display_hint_badge": "Das Symbol und die dynamische Platte zeigen den Gerätezustand ohne den üblichen Aktivitätspuls. Rote Alarme bleiben sichtbar.",
|
||||
"marker.display_hint_icon": "Das Symbol und die dynamische Platte zeigen den Gerätezustand ohne den üblichen Aktivitätspuls. Rote Alarme bleiben sichtbar.",
|
||||
"marker.display_hint_icon_ripple": "Das Symbol, die dynamische Platte und der Puls zeigen einen kurzen Puls für Ereignisse und einen kontinuierlichen Puls für Arbeit, Bewegung oder Anwesenheit. Rote Alarme haben eine separate Priorität.",
|
||||
"marker.display_hint_value": "Ein ausgewählter oder automatisch ermittelter Home Assistant-Wert ersetzt das Symbol, während die Platte weiterhin den Zustand anzeigt. Rote Alarme bleiben sichtbar.",
|
||||
"marker.display_hint_static_icon": "Die themenabhängige Hülle und das Symbol bleiben immer gleich. Zustand, Aktivität, Nichtverfügbarkeit und Alarme verändern nicht das Erscheinungsbild.",
|
||||
@@ -293,14 +285,11 @@
|
||||
"device.light_group": "Lichtgruppe",
|
||||
"device.fallback": "Gerät",
|
||||
"device.virtual": "Virtuelles Gerät",
|
||||
"confirm.delete_room": "Raum \"{name}\" löschen?",
|
||||
"confirm.delete_room_title": "Raum \"{name}\" löschen?",
|
||||
"confirm.delete_room_body": "Festlegen, ob die ausschließlich zu diesem Raum gehörenden physischen Wände erhalten bleiben. Mit einem anderen Raum geteilte Wände bleiben immer erhalten.",
|
||||
"confirm.remove_marker": "„{name}“ aus dem Plan löschen? Das Gerät verschwindet vollständig und trägt nicht mehr zu den Plandaten bei. Es kann später erneut hinzugefügt werden.",
|
||||
"confirm.remove_marker_title": "Gerät aus dem Plan löschen?",
|
||||
"confirm.remove_marker_body": "Das Gerät verschwindet vollständig und trägt nicht mehr zu den Plandaten bei. Es kann später erneut hinzugefügt werden.",
|
||||
"confirm.erase_decor": "Das Objekt „{kind}“ löschen? Diese Aktion kann im Editor rückgängig gemacht werden.",
|
||||
"confirm.delete_space": "Bereich „{title}“ mit allen Räumen und Markierungen löschen?",
|
||||
"confirm.delete_space_title": "Bereich löschen?",
|
||||
"confirm.delete_space_body": "Der Bereich, alle seine Räume und die gesamte Planmarkierung werden gelöscht.",
|
||||
"space.delete_blocked": "Dieser Bereich wird noch von {n} Gerät(en) verwendet. Die Geräte zuerst in einen anderen Bereich verschieben oder löschen.",
|
||||
@@ -737,7 +726,6 @@
|
||||
"marker.control_passive": "passive Quelle",
|
||||
"toast.marker_control_cycle": "Dieser Link würde eine kreisförmige Kette von Lichtsteuerungen erzeugen.",
|
||||
"toast.marker_binding_required": "Wählen Sie ein Home Assistant-Gerät, bevor Sie eine andere Lichtquelle verlinken.",
|
||||
"confirm.unlock": "„{name}“ entsperren?",
|
||||
"confirm.unlock_title": "Schloss öffnen?",
|
||||
"confirm.unlock_body": "House Plan sendet einen Befehl zum Öffnen an dieses Schloss.",
|
||||
"toast.files_migrate_failed": "Anhänge konnten nicht zur neuen Bindung verschoben werden, Links zeigen weiterhin auf die alten Dateien: {err}",
|
||||
@@ -748,7 +736,6 @@
|
||||
"space.used_by": "in Benutzung: {list}",
|
||||
"space.in_use": "Ein Bereich verwendet diesen Plan noch — zuerst die Verknüpfung lösen",
|
||||
"btn.use": "Verwenden",
|
||||
"confirm.delete_plan": "Plandatei „{name}“ vom Server löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"confirm.delete_plan_title": "Plandatei löschen?",
|
||||
"confirm.delete_plan_body": "Die Datei wird vom Server gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden.",
|
||||
"toast.plans_list_failed": "Konnte die gespeicherten Pläne nicht auflisten: {err}",
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"history.draft_segment": "Add room-draft segment",
|
||||
"history.draft_merge": "Join unfinished room outlines",
|
||||
"history.draft_segment_delete": "Remove room-draft segment",
|
||||
"history.partition_add": "Add partition",
|
||||
"history.column_add": "Add column",
|
||||
"history.physical_edit": "Change physical object",
|
||||
"history.physical_delete": "Delete physical object",
|
||||
@@ -37,8 +36,6 @@
|
||||
"history.wall_chain_finish": "Finish wall chain",
|
||||
"history.wall_face_batch": "Create rooms from walls",
|
||||
"toast.column_duplicate": "A column with the same centre and outer size already exists",
|
||||
"confirm.delete_draft": "Delete the whole unfinished room contour?",
|
||||
"confirm.delete_draft_segment": "Delete this draft segment? The remaining contour may split in two.",
|
||||
"confirm.delete_draft_title": "Delete unfinished contour?",
|
||||
"confirm.delete_draft_body": "The entire unfinished room contour will be deleted. Cancelling makes no changes.",
|
||||
"confirm.delete_draft_segment_title": "Delete contour segment?",
|
||||
@@ -76,7 +73,6 @@
|
||||
"title.zoom_in": "Zoom in",
|
||||
"title.zoom_out": "Zoom out",
|
||||
"title.zoom_fit": "Fit all",
|
||||
"title.markup": "Room markup: grid, lines, outlines",
|
||||
"title.configure_space": "Configure space",
|
||||
"title.add_space": "Add space",
|
||||
"title.markup_add": "Walls: draw a continuous chain; Shift locks to 45°. Changing tool or leaving the editor finishes it as independent walls",
|
||||
@@ -121,7 +117,6 @@
|
||||
"history.edit_opening": "Edit opening",
|
||||
"history.move_opening": "Move opening",
|
||||
"history.delete_opening": "Delete opening",
|
||||
"history.delete_room": "Delete room",
|
||||
"history.delete_room_keep_walls": "Delete room, keep walls",
|
||||
"history.delete_room_with_walls": "Delete room and walls",
|
||||
"history.decor_add": "Add decor object",
|
||||
@@ -163,7 +158,6 @@
|
||||
"confirm.delete_partition_openings_title": "Delete wall and openings?",
|
||||
"confirm.delete_partition_openings_body": "This wall contains {count} opening(s). They will be deleted together.",
|
||||
"confirm.delete_partition_openings_item": "• {type}, {length}",
|
||||
"markup.delete": "Delete",
|
||||
"markup.hint_points": "points: {n} · Shift — 45° steps · Esc — finish chain · Ctrl+Z — undo a point · closing an area offers a room",
|
||||
"markup.hint_start": "click a grid dot to start a wall chain",
|
||||
"tip.lqi": "average zigbee signal:",
|
||||
@@ -192,9 +186,7 @@
|
||||
"display.badge": "Icon + state",
|
||||
"display.icon_ripple": "Icon + state and activity",
|
||||
"display.static_icon": "Always static icon",
|
||||
"marker.display_hint": "Icon + state changes the plate for work, open, alarm and unavailable states. Icon + state and activity additionally shows a short pulse for events and a continuous pulse for ongoing work, movement or presence. Value + state replaces the icon with one unambiguous HA value. A static icon never reacts to device states.",
|
||||
"marker.display_hint_badge": "The icon and dynamic plate show device state without the ordinary activity pulse. Red alarms remain visible.",
|
||||
"marker.display_hint_icon": "The icon and dynamic plate show device state without the ordinary activity pulse. Red alarms remain visible.",
|
||||
"marker.display_hint_icon_ripple": "The icon, dynamic plate and pulse show a short pulse for events and a continuous pulse for work, motion or presence. Red alarms have separate priority.",
|
||||
"marker.display_hint_value": "A selected or automatically resolved Home Assistant value replaces the icon while the plate continues to show state. Red alarms remain visible.",
|
||||
"marker.display_hint_static_icon": "The theme-aware shell and icon always stay the same. State, activity, unavailability and alarms do not change the face.",
|
||||
@@ -293,14 +285,11 @@
|
||||
"device.light_group": "light group",
|
||||
"device.fallback": "device",
|
||||
"device.virtual": "virtual device",
|
||||
"confirm.delete_room": "Delete room \"{name}\"?",
|
||||
"confirm.delete_room_title": "Delete room \"{name}\"?",
|
||||
"confirm.delete_room_body": "Choose whether the room's exclusive physical walls should remain. Walls shared with another room are always kept.",
|
||||
"confirm.remove_marker": "Delete \"{name}\" from the plan? The device will disappear completely and stop contributing to plan data. You can add it again later.",
|
||||
"confirm.remove_marker_title": "Delete device from the plan?",
|
||||
"confirm.remove_marker_body": "The device will disappear completely and stop contributing to plan data. You can add it again later.",
|
||||
"confirm.erase_decor": "Erase the {kind} object? You can undo this action from the editor history.",
|
||||
"confirm.delete_space": "Delete space \"{title}\" with all its rooms and markup?",
|
||||
"confirm.delete_space_title": "Delete space?",
|
||||
"confirm.delete_space_body": "The space, all its rooms and all its plan markup will be deleted.",
|
||||
"space.delete_blocked": "This space is still used by {n} device(s). Move them to another space or delete them first.",
|
||||
@@ -737,7 +726,6 @@
|
||||
"marker.control_passive": "passive source",
|
||||
"toast.marker_control_cycle": "This link would create a circular chain of light controls.",
|
||||
"toast.marker_binding_required": "Choose a Home Assistant device before linking another light source.",
|
||||
"confirm.unlock": "Unlock “{name}”?",
|
||||
"confirm.unlock_title": "Unlock?",
|
||||
"confirm.unlock_body": "House Plan will send an unlock command to this lock.",
|
||||
"toast.files_migrate_failed": "Attachments could not be moved to the new binding, links keep pointing at the old files: {err}",
|
||||
@@ -748,7 +736,6 @@
|
||||
"space.used_by": "in use: {list}",
|
||||
"space.in_use": "A space still uses this plan — detach it first",
|
||||
"btn.use": "Use",
|
||||
"confirm.delete_plan": "Delete the plan file \"{name}\" from the server? This cannot be undone.",
|
||||
"confirm.delete_plan_title": "Delete plan file?",
|
||||
"confirm.delete_plan_body": "The file will be deleted from the server. This action cannot be undone.",
|
||||
"toast.plans_list_failed": "Could not list the stored plans: {err}",
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"history.draft_segment": "Ajouter un segment de brouillon de pièce",
|
||||
"history.draft_merge": "Relier des contours de pièce inachevés",
|
||||
"history.draft_segment_delete": "Supprimer un segment de brouillon de pièce",
|
||||
"history.partition_add": "Ajouter une cloison",
|
||||
"history.column_add": "Ajouter une colonne",
|
||||
"history.physical_edit": "Modifier l’objet physique",
|
||||
"history.physical_delete": "Supprimer l’objet physique",
|
||||
@@ -37,8 +36,6 @@
|
||||
"history.wall_chain_finish": "Terminer la chaîne de murs",
|
||||
"history.wall_face_batch": "Créer des pièces à partir des murs",
|
||||
"toast.column_duplicate": "Une colonne avec le même centre et la même dimension extérieure existe déjà",
|
||||
"confirm.delete_draft": "Supprimer tout le contour de pièce inachevé ?",
|
||||
"confirm.delete_draft_segment": "Supprimer ce segment de brouillon ? Le contour restant peut se scinder en deux.",
|
||||
"confirm.delete_draft_title": "Supprimer le contour inachevé ?",
|
||||
"confirm.delete_draft_body": "Tout le contour de pièce inachevé sera supprimé. L’annulation ne modifie rien.",
|
||||
"confirm.delete_draft_segment_title": "Supprimer le segment de contour ?",
|
||||
@@ -76,7 +73,6 @@
|
||||
"title.zoom_in": "Zoomer",
|
||||
"title.zoom_out": "Dézoomer",
|
||||
"title.zoom_fit": "Tout ajuster",
|
||||
"title.markup": "Traçage des pièces : grille, lignes, contours",
|
||||
"title.configure_space": "Configurer l’espace",
|
||||
"title.add_space": "Ajouter un espace",
|
||||
"title.markup_add": "Murs : tracez une chaîne continue ; Maj verrouille à 45°. Changer d’outil ou quitter l’éditeur termine la chaîne en murs indépendants",
|
||||
@@ -121,7 +117,6 @@
|
||||
"history.edit_opening": "Modifier l’ouverture",
|
||||
"history.move_opening": "Déplacer l’ouverture",
|
||||
"history.delete_opening": "Supprimer l’ouverture",
|
||||
"history.delete_room": "Supprimer la pièce",
|
||||
"history.delete_room_keep_walls": "Supprimer la pièce, conserver les murs",
|
||||
"history.delete_room_with_walls": "Supprimer la pièce et les murs",
|
||||
"history.decor_add": "Ajouter un objet décoratif",
|
||||
@@ -163,7 +158,6 @@
|
||||
"confirm.delete_partition_openings_title": "Supprimer le mur et les ouvertures ?",
|
||||
"confirm.delete_partition_openings_body": "Ce mur contient {count} ouverture(s). Elles seront supprimées avec lui.",
|
||||
"confirm.delete_partition_openings_item": "• {type}, {length}",
|
||||
"markup.delete": "Supprimer",
|
||||
"markup.hint_points": "points : {n} · Maj — pas de 45° · Échap — terminer la chaîne · Ctrl+Z — annuler un point · fermer une zone propose de créer une pièce",
|
||||
"markup.hint_start": "cliquez sur un point de la grille pour commencer une chaîne de murs",
|
||||
"tip.lqi": "signal Zigbee moyen :",
|
||||
@@ -192,9 +186,7 @@
|
||||
"display.badge": "Icône + état",
|
||||
"display.icon_ripple": "Icône + état et activité",
|
||||
"display.static_icon": "Icône toujours statique",
|
||||
"marker.display_hint": "Icône + état modifie le fond pour les états en fonctionnement, ouvert, alarme et indisponible. Icône + état et activité ajoute une brève pulsation pour les événements et une pulsation continue pour une activité, un mouvement ou une présence en cours. Valeur + état remplace l’icône par une valeur HA non ambiguë. Une icône statique ne réagit jamais aux états de l’appareil.",
|
||||
"marker.display_hint_badge": "L’icône et le fond dynamique affichent l’état de l’appareil sans la pulsation d’activité habituelle. Les alarmes rouges restent visibles.",
|
||||
"marker.display_hint_icon": "L’icône et le fond dynamique affichent l’état de l’appareil sans la pulsation d’activité habituelle. Les alarmes rouges restent visibles.",
|
||||
"marker.display_hint_icon_ripple": "L’icône, le fond dynamique et la pulsation affichent une brève animation pour les événements et une animation continue pour le fonctionnement, le mouvement ou la présence. Les alarmes rouges restent prioritaires.",
|
||||
"marker.display_hint_value": "Une valeur Home Assistant sélectionnée ou résolue automatiquement remplace l’icône tandis que le fond continue d’indiquer l’état. Les alarmes rouges restent visibles.",
|
||||
"marker.display_hint_static_icon": "Le contour adapté au thème et l’icône restent toujours identiques. L’état, l’activité, l’indisponibilité et les alarmes ne modifient pas l’apparence.",
|
||||
@@ -293,14 +285,11 @@
|
||||
"device.light_group": "groupe de lumières",
|
||||
"device.fallback": "appareil",
|
||||
"device.virtual": "appareil virtuel",
|
||||
"confirm.delete_room": "Supprimer la pièce « {name} » ?",
|
||||
"confirm.delete_room_title": "Supprimer la pièce « {name} » ?",
|
||||
"confirm.delete_room_body": "Choisissez si les murs physiques propres à la pièce doivent être conservés. Les murs partagés avec une autre pièce sont toujours conservés.",
|
||||
"confirm.remove_marker": "Supprimer « {name} » du plan ? L’appareil disparaîtra complètement et ne contribuera plus aux données du plan. Vous pourrez l’ajouter de nouveau plus tard.",
|
||||
"confirm.remove_marker_title": "Supprimer l’appareil du plan ?",
|
||||
"confirm.remove_marker_body": "L’appareil disparaîtra complètement et ne contribuera plus aux données du plan. Vous pourrez l’ajouter de nouveau plus tard.",
|
||||
"confirm.erase_decor": "Effacer l’objet {kind} ? Vous pouvez annuler cette action depuis l’historique de l’éditeur.",
|
||||
"confirm.delete_space": "Supprimer l’espace « {title} » avec toutes ses pièces et son tracé ?",
|
||||
"confirm.delete_space_title": "Supprimer l’espace ?",
|
||||
"confirm.delete_space_body": "L’espace, toutes ses pièces et tout le tracé du plan seront supprimés.",
|
||||
"space.delete_blocked": "Cet espace est encore utilisé par {n} appareil(s). Déplacez-les vers un autre espace ou supprimez-les d’abord.",
|
||||
@@ -737,7 +726,6 @@
|
||||
"marker.control_passive": "source passive",
|
||||
"toast.marker_control_cycle": "Ce lien créerait une chaîne circulaire de contrôles de lumière.",
|
||||
"toast.marker_binding_required": "Choisissez un appareil Home Assistant avant de lier une autre source lumineuse.",
|
||||
"confirm.unlock": "Déverrouiller « {name} » ?",
|
||||
"confirm.unlock_title": "Déverrouiller ?",
|
||||
"confirm.unlock_body": "House Plan enverra une commande de déverrouillage à cette serrure.",
|
||||
"toast.files_migrate_failed": "Les pièces jointes n’ont pas pu être déplacées vers la nouvelle association ; les liens continuent de pointer vers les anciens fichiers : {err}",
|
||||
@@ -748,7 +736,6 @@
|
||||
"space.used_by": "utilisé par : {list}",
|
||||
"space.in_use": "Un espace utilise encore ce plan — dissociez-le d’abord",
|
||||
"btn.use": "Utiliser",
|
||||
"confirm.delete_plan": "Supprimer le fichier de plan « {name} » du serveur ? Cette action est irréversible.",
|
||||
"confirm.delete_plan_title": "Supprimer le fichier du plan ?",
|
||||
"confirm.delete_plan_body": "Le fichier sera supprimé du serveur. Cette action est irréversible.",
|
||||
"toast.plans_list_failed": "Impossible de lister les plans stockés : {err}",
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"history.draft_segment": "Добавление сегмента черновика комнаты",
|
||||
"history.draft_merge": "Соединение незавершённых контуров комнаты",
|
||||
"history.draft_segment_delete": "Удаление сегмента черновика комнаты",
|
||||
"history.partition_add": "Добавление перегородки",
|
||||
"history.column_add": "Добавление колонны",
|
||||
"history.physical_edit": "Изменение физического объекта",
|
||||
"history.physical_delete": "Удаление физического объекта",
|
||||
@@ -37,8 +36,6 @@
|
||||
"history.wall_chain_finish": "Завершение цепочки стен",
|
||||
"history.wall_face_batch": "Создание комнат из стен",
|
||||
"toast.column_duplicate": "Колонна с тем же центром и внешним размером уже существует",
|
||||
"confirm.delete_draft": "Удалить весь незавершённый контур комнаты?",
|
||||
"confirm.delete_draft_segment": "Удалить этот сегмент черновика? Оставшийся контур может разделиться на два.",
|
||||
"confirm.delete_draft_title": "Удалить незавершённый контур?",
|
||||
"confirm.delete_draft_body": "Весь незавершённый контур комнаты будет удалён. При отмене ничего не изменится.",
|
||||
"confirm.delete_draft_segment_title": "Удалить сегмент контура?",
|
||||
@@ -76,7 +73,6 @@
|
||||
"title.zoom_in": "Приблизить",
|
||||
"title.zoom_out": "Отдалить",
|
||||
"title.zoom_fit": "Вписать всё",
|
||||
"title.markup": "Разметка комнат: сетка, линии, контуры",
|
||||
"title.configure_space": "Настроить пространство",
|
||||
"title.add_space": "Добавить пространство",
|
||||
"title.markup_add": "Стены: рисуйте непрерывную цепочку; Shift фиксирует 45°. Смена инструмента или выход завершает её как независимые стены",
|
||||
@@ -121,7 +117,6 @@
|
||||
"history.edit_opening": "Изменение проёма",
|
||||
"history.move_opening": "Перемещение проёма",
|
||||
"history.delete_opening": "Удаление проёма",
|
||||
"history.delete_room": "Удаление комнаты",
|
||||
"history.delete_room_keep_walls": "Удаление комнаты с сохранением стен",
|
||||
"history.delete_room_with_walls": "Удаление комнаты и её стен",
|
||||
"history.decor_add": "Добавление объекта декора",
|
||||
@@ -163,7 +158,6 @@
|
||||
"confirm.delete_partition_openings_title": "Удалить стену и проёмы?",
|
||||
"confirm.delete_partition_openings_body": "В стене есть проёмы: {count}. Они будут удалены вместе со стеной.",
|
||||
"confirm.delete_partition_openings_item": "• {type}, {length}",
|
||||
"markup.delete": "Удалить",
|
||||
"markup.hint_points": "точек: {n} · Shift — шаг 45° · Esc — завершить цепочку · Ctrl+Z — убрать точку · при замыкании будет предложена комната",
|
||||
"markup.hint_start": "кликните точку сетки, чтобы начать цепочку стен",
|
||||
"tip.lqi": "средний сигнал zigbee:",
|
||||
@@ -192,9 +186,7 @@
|
||||
"display.badge": "Значок + состояние",
|
||||
"display.icon_ripple": "Значок + состояние и активность",
|
||||
"display.static_icon": "Всегда статичный значок",
|
||||
"marker.display_hint": "Значок + состояние меняет подложку при работе, открытии, тревоге и недоступности. Значок + состояние и активность дополнительно показывает короткую пульсацию для событий и постоянную — для работы, движения или присутствия. Значение + состояние заменяет значок одним однозначным значением HA. Статичный значок не реагирует на состояния устройства.",
|
||||
"marker.display_hint_badge": "Значок и динамическая подложка показывают состояние устройства без обычной пульсации активности. Красная тревога сохраняется.",
|
||||
"marker.display_hint_icon": "Значок и динамическая подложка показывают состояние устройства без обычной пульсации активности. Красная тревога сохраняется.",
|
||||
"marker.display_hint_icon_ripple": "Значок, динамическая подложка и пульсация: короткая — для событий, постоянная — для работы, движения или присутствия. Красная тревога имеет отдельный приоритет.",
|
||||
"marker.display_hint_value": "Значок заменяется выбранным или автоматически найденным значением Home Assistant; подложка продолжает показывать состояние. Красная тревога сохраняется.",
|
||||
"marker.display_hint_static_icon": "Подложка в цветах темы и значок всегда остаются одинаковыми. Состояния, активность, недоступность и тревоги не меняют отображение.",
|
||||
@@ -293,14 +285,11 @@
|
||||
"device.light_group": "группа света",
|
||||
"device.fallback": "устройство",
|
||||
"device.virtual": "виртуальное устройство",
|
||||
"confirm.delete_room": "Удалить комнату «{name}»?",
|
||||
"confirm.delete_room_title": "Удалить комнату «{name}»?",
|
||||
"confirm.delete_room_body": "Выберите, нужно ли оставить физические стены, принадлежащие только этой комнате. Общие с другой комнатой стены сохраняются всегда.",
|
||||
"confirm.remove_marker": "Удалить «{name}» с плана? Устройство исчезнет полностью и перестанет участвовать в данных плана. Позже его можно будет добавить заново.",
|
||||
"confirm.remove_marker_title": "Удалить устройство с плана?",
|
||||
"confirm.remove_marker_body": "Устройство исчезнет полностью и перестанет участвовать в данных плана. Позже его можно будет добавить заново.",
|
||||
"confirm.erase_decor": "Стереть объект «{kind}»? Действие можно отменить из истории редактора.",
|
||||
"confirm.delete_space": "Удалить пространство «{title}» со всеми комнатами и разметкой?",
|
||||
"confirm.delete_space_title": "Удалить пространство?",
|
||||
"confirm.delete_space_body": "Пространство, все его комнаты и вся разметка плана будут удалены.",
|
||||
"space.delete_blocked": "Это пространство всё ещё используется устройствами: {n}. Сначала перенесите их в другое пространство или удалите.",
|
||||
@@ -737,7 +726,6 @@
|
||||
"marker.control_passive": "пассивный источник",
|
||||
"toast.marker_control_cycle": "Такая связь создаст циклическую цепочку управления светом.",
|
||||
"toast.marker_binding_required": "Сначала выберите устройство Home Assistant, затем добавьте управляемый источник света.",
|
||||
"confirm.unlock": "Открыть замок «{name}»?",
|
||||
"confirm.unlock_title": "Открыть замок?",
|
||||
"confirm.unlock_body": "House Plan отправит этому замку команду открытия.",
|
||||
"toast.files_migrate_failed": "Не удалось перенести вложения к новой привязке, ссылки остались на старые файлы: {err}",
|
||||
@@ -748,7 +736,6 @@
|
||||
"space.used_by": "используется: {list}",
|
||||
"space.in_use": "План используется пространством — сначала отцепите его",
|
||||
"btn.use": "Выбрать",
|
||||
"confirm.delete_plan": "Удалить файл плана «{name}» с сервера? Действие необратимо.",
|
||||
"confirm.delete_plan_title": "Удалить файл плана?",
|
||||
"confirm.delete_plan_body": "Файл будет удалён с сервера. Это действие нельзя отменить.",
|
||||
"toast.plans_list_failed": "Не удалось получить список планов: {err}",
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAreaRelocationResolution,
|
||||
MARKER_AREA_SNAPSHOT_LIMIT,
|
||||
markerAreaSnapshotOf,
|
||||
removeMarkerAreaSnapshots,
|
||||
registryFollowingBinding,
|
||||
@@ -166,6 +167,37 @@ test('unresolved duplicate Area and non-authoritative registry are no-ops', () =
|
||||
assert.deepEqual(resolve({ authoritative: false }), { decisions: [], relocateIds: new Set() });
|
||||
});
|
||||
|
||||
test('authoritative registry removes orphan snapshots but preserves live marker ids', () => {
|
||||
const snapshot = {
|
||||
orphan: { binding: 'device:orphan', area: 'area-a' },
|
||||
'marker-id': { binding: 'device:marker-id', area: 'area-a' },
|
||||
'canonical-device-id': { binding: 'device:device-row', area: 'area-a' },
|
||||
};
|
||||
const result = resolve({
|
||||
devices: [device({
|
||||
id: 'device-row',
|
||||
bindingRef: 'device-row',
|
||||
marker: { id: 'marker-id', binding: 'device:marker-id', removed: true },
|
||||
})],
|
||||
snapshot,
|
||||
});
|
||||
const orphan = result.decisions.find((decision) => decision.id === 'orphan');
|
||||
assert.equal(orphan?.reason, 'registry-unverified');
|
||||
assert.equal(orphan?.removeSnapshot, true);
|
||||
assert.equal(result.decisions.some((decision) => decision.id === 'marker-id'), false);
|
||||
assert.deepEqual(applyAreaRelocationResolution(snapshot, result), {
|
||||
'marker-id': snapshot['marker-id'],
|
||||
'canonical-device-id': snapshot['canonical-device-id'],
|
||||
});
|
||||
});
|
||||
|
||||
test('non-authoritative registry preserves orphan snapshots', () => {
|
||||
const snapshot = { orphan: { binding: 'device:orphan', area: 'area-a' } };
|
||||
const result = resolve({ devices: [], snapshot, authoritative: false });
|
||||
assert.deepEqual(result, { decisions: [], relocateIds: new Set() });
|
||||
assert.deepEqual(applyAreaRelocationResolution(snapshot, result), snapshot);
|
||||
});
|
||||
|
||||
test('failed delete does not advance relocation provenance', () => {
|
||||
const previous = { 'device-1': { binding: 'device:device-1', area: 'area-a' } };
|
||||
const result = resolve({ snapshot: previous });
|
||||
@@ -180,6 +212,21 @@ test('defensive snapshot reader drops malformed entries without poisoning valid
|
||||
}), { good: { binding: 'entity:sensor.good', area: 'kitchen' } });
|
||||
});
|
||||
|
||||
test('defensive snapshot reader keeps the newest entries when over its limit', () => {
|
||||
const source = Object.fromEntries(Array.from(
|
||||
{ length: MARKER_AREA_SNAPSHOT_LIMIT + 2 },
|
||||
(_, index) => [`entry-${index}`, { binding: `device:${index}`, area: 'area-a' }],
|
||||
));
|
||||
const snapshot = markerAreaSnapshotOf(source);
|
||||
assert.equal(Object.keys(snapshot).length, MARKER_AREA_SNAPSHOT_LIMIT);
|
||||
assert.equal(Object.hasOwn(snapshot, 'entry-0'), false);
|
||||
assert.equal(Object.hasOwn(snapshot, 'entry-1'), false);
|
||||
assert.deepEqual(snapshot['entry-2'], { binding: 'device:2', area: 'area-a' });
|
||||
assert.deepEqual(snapshot[`entry-${MARKER_AREA_SNAPSHOT_LIMIT + 1}`], {
|
||||
binding: `device:${MARKER_AREA_SNAPSHOT_LIMIT + 1}`, area: 'area-a',
|
||||
});
|
||||
});
|
||||
|
||||
test('marker deletion and rebind cleanup remove only their own lifecycle entries', () => {
|
||||
const source = {
|
||||
keep: { binding: 'device:keep', area: 'living' },
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
|
||||
const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const dictionary = JSON.parse(readFileSync(join(repoRoot, 'src/i18n/en.json'), 'utf8'));
|
||||
const dictionaryKeys = Object.keys(dictionary);
|
||||
|
||||
const sourceFiles = [];
|
||||
const visitDirectory = (directory) => {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) visitDirectory(path);
|
||||
else if (entry.isFile() && entry.name.endsWith('.ts')) sourceFiles.push(path);
|
||||
}
|
||||
};
|
||||
visitDirectory(join(repoRoot, 'src'));
|
||||
|
||||
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
/** Convert a string-producing expression into a dictionary-key matcher. */
|
||||
const expressionPattern = (node) => {
|
||||
if (ts.isStringLiteralLike(node)) return { source: escapeRegExp(node.text), dynamic: false };
|
||||
if (ts.isParenthesizedExpression(node)) return expressionPattern(node.expression);
|
||||
if (ts.isTemplateExpression(node)) {
|
||||
let source = escapeRegExp(node.head.text);
|
||||
for (const span of node.templateSpans) {
|
||||
source += '.+' + escapeRegExp(span.literal.text);
|
||||
}
|
||||
return { source, dynamic: true };
|
||||
}
|
||||
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
||||
const left = expressionPattern(node.left);
|
||||
const right = expressionPattern(node.right);
|
||||
if (!left && !right) return null;
|
||||
return {
|
||||
source: (left?.source || '.+') + (right?.source || '.+'),
|
||||
dynamic: (left?.dynamic ?? true) || (right?.dynamic ?? true),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const literalConsumers = new Set();
|
||||
const dynamicConsumers = [];
|
||||
const derivedHelpAria = new Set();
|
||||
|
||||
for (const path of sourceFiles) {
|
||||
const source = ts.createSourceFile(
|
||||
path,
|
||||
readFileSync(path, 'utf8'),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
);
|
||||
const visit = (node) => {
|
||||
if (ts.isStringLiteralLike(node)) literalConsumers.add(node.text);
|
||||
|
||||
if (ts.isTemplateExpression(node)
|
||||
|| (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken)) {
|
||||
const pattern = expressionPattern(node);
|
||||
if (pattern?.dynamic && /[A-Za-z]/.test(pattern.source)) {
|
||||
dynamicConsumers.push(new RegExp(`^${pattern.source}$`));
|
||||
}
|
||||
}
|
||||
|
||||
if (ts.isCallExpression(node) && node.arguments.length) {
|
||||
const callee = ts.isPropertyAccessExpression(node.expression)
|
||||
? node.expression.name.text
|
||||
: ts.isIdentifier(node.expression) ? node.expression.text : '';
|
||||
const key = node.arguments[0];
|
||||
if (callee === '_help' && ts.isStringLiteralLike(key) && key.text.endsWith('.help')) {
|
||||
derivedHelpAria.add(`${key.text}.aria`);
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(source);
|
||||
}
|
||||
|
||||
test('every i18n key has a literal, dynamic-family or derived help consumer', () => {
|
||||
const unused = dictionaryKeys.filter((key) => !literalConsumers.has(key)
|
||||
&& !derivedHelpAria.has(key)
|
||||
&& !dynamicConsumers.some((pattern) => pattern.test(key)));
|
||||
assert.deepEqual(unused, [], unused.length
|
||||
? `Unused i18n keys: ${unused.join(', ')}. Use each key from src/ or delete it from every locale.`
|
||||
: undefined);
|
||||
});
|
||||
|
||||
test('help accessibility copy is derived from every literal help consumer', () => {
|
||||
assert.equal(derivedHelpAria.size, 19, 'the current settings surface has 19 help descriptions');
|
||||
for (const key of derivedHelpAria) {
|
||||
assert.equal(typeof dictionary[key], 'string', `${key} must accompany its .help consumer`);
|
||||
}
|
||||
});
|
||||
@@ -29,7 +29,6 @@ test('only old tool copy is removed while persisted partition copy remains', ()
|
||||
for (const key of [
|
||||
'markup.partition',
|
||||
'physical.partition_properties',
|
||||
'history.partition_add',
|
||||
]) assert.equal(typeof locale[key], 'string', key);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user