Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e78c00672 | ||
|
|
0bd5570ae5 | ||
|
|
d30cc799f9 | ||
|
|
33a0110ec2 | ||
|
|
47c9e3f3a9 | ||
|
|
52e7033496 | ||
|
|
13181bd473 | ||
|
|
8096179ece | ||
|
|
c80c002bab | ||
|
|
4aadf6f077 | ||
|
|
ec879b3b2d | ||
|
|
e4eb8ed06f | ||
|
|
0964f488a2 | ||
|
|
df450c9c5e | ||
|
|
59be9c8565 | ||
|
|
4a61407282 | ||
|
|
a211861141 |
@@ -82,17 +82,13 @@ jobs:
|
||||
set -euo pipefail
|
||||
npm ci
|
||||
npm run build
|
||||
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
|
||||
npm run bundle:budget
|
||||
VERSION=${TAG#v}
|
||||
grep -Fq "$VERSION" dist/houseplan-card.js
|
||||
grep -RFq "$VERSION" dist
|
||||
(cd custom_components/houseplan && zip -qr ../../houseplan.zip .)
|
||||
unzip -l houseplan.zip | grep -q "manifest.json"
|
||||
ZIP_VERSION=$(unzip -p houseplan.zip manifest.json | node -e \
|
||||
"let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).version))")
|
||||
test "$ZIP_VERSION" = "$VERSION" || {
|
||||
echo "::error::houseplan.zip manifest version $ZIP_VERSION != $VERSION"
|
||||
exit 1
|
||||
}
|
||||
node scripts/verify-houseplan-zip.mjs houseplan.zip \
|
||||
custom_components/houseplan/frontend "$VERSION"
|
||||
test -s dist/houseplan-card.js
|
||||
test -s houseplan.zip
|
||||
- name: Create or verify the annotated tag
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
- name: Build houseplan.zip (contents of custom_components/houseplan at zip root)
|
||||
run: cd custom_components/houseplan && zip -qr ../../houseplan.zip .
|
||||
- name: Sanity check
|
||||
run: unzip -l houseplan.zip | grep -q "manifest.json"
|
||||
run: node scripts/verify-houseplan-zip.mjs houseplan.zip custom_components/houseplan/frontend
|
||||
- name: Upload asset
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -67,7 +67,8 @@ jobs:
|
||||
with:
|
||||
name: continuity-screencast
|
||||
path: artifacts/continuity-screencast
|
||||
- run: cp dist/houseplan-card.js custom_components/houseplan/frontend/
|
||||
- name: Verify the complete committed frontend tree
|
||||
run: node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
|
||||
- name: Attach card to release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
|
||||
@@ -314,9 +314,11 @@ jobs:
|
||||
run: npm test
|
||||
- name: Build
|
||||
run: npm run build
|
||||
# Копия стенда больше не коммитится (#255): сверяются две обязательные.
|
||||
- name: Card bundle snapshots in sync
|
||||
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
# Entry, manifest and every content-hashed chunk are one artifact (#337).
|
||||
- name: Card bundle trees in sync
|
||||
run: |
|
||||
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
|
||||
npm run bundle:budget
|
||||
# Бандл собирается один раз и уезжает артефактом (#336). Прежде каждая из
|
||||
# пяти браузерных job собирала его заново: `bundle:sync` — это `tsc
|
||||
# --noEmit` плюс rollup, около минуты на job.
|
||||
@@ -328,7 +330,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: card-bundle
|
||||
path: dist/houseplan-card.js
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
@@ -12,3 +12,5 @@ artifacts/
|
||||
# она только росла — 364 версии по 1.16 МБ за семь недель (#255). Обязательных
|
||||
# копий две: `dist/` (артефакт сборки) и `custom_components/` (её ставит HACS).
|
||||
demo/srv/assets/houseplan-card.js
|
||||
demo/srv/assets/houseplan-assets.json
|
||||
demo/srv/assets/houseplan-assets/
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
House Plan is one HACS package with two parts plus a demo harness:
|
||||
|
||||
- **Lovelace card** (`src/`, TypeScript + Lit) — the primary product, bundled to `dist/houseplan-card.js`.
|
||||
- **Lovelace card** (`src/`, TypeScript + Lit) — the primary product, bundled to
|
||||
the entry, manifest and hashed chunks under `dist/`.
|
||||
- **Storage integration** (`custom_components/houseplan/`, Python) — the Home Assistant backend.
|
||||
- **Demo harness** (`demo/`) — a self-contained Playwright page (`demo/srv/demo.html`) that renders the card against a fake `hass`, used for screenshots and the `smoke_*.mjs` end-to-end suite.
|
||||
|
||||
@@ -93,7 +94,7 @@ Start with the spec?" is the correct answer, not a smaller patch.
|
||||
| **A — product** | `src/**`, `custom_components/houseplan/**/*.py`, `manifest.json`, `hacs.json`, i18n, `custom_components/**/translations/**` | yes |
|
||||
| **B — gates and tooling** | `test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, `.github/workflows/**`, `rollup.config.mjs`, `tsconfig*.json` | yes; may reuse the issue it covers |
|
||||
| **C — documentation** | `docs/**`, `README*`, `CHANGELOG*`, `AGENTS.md` | not if it is part of its issue's DoD |
|
||||
| **D — generated** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` | never changes on its own. The stand copy `demo/srv/assets/houseplan-card.js` is no longer committed (#255): build it with `npm run bundle:sync` |
|
||||
| **D — generated** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` | never changes on its own. The stand copy `demo/srv/assets/**` is no longer committed (#255): build the complete tree with `npm run bundle:sync` |
|
||||
|
||||
The table above is a summary; `PROCESS.md` §1 is the authority and now covers the
|
||||
configuration files this one omits — `package.json`, `package-lock.json`,
|
||||
@@ -325,12 +326,12 @@ npm run inventory # the only correct way to get test counts
|
||||
|
||||
Never copy test counts into documents by hand; they go stale in days.
|
||||
|
||||
After building, keep all three bundle snapshots in sync — CI compares them
|
||||
byte-for-byte:
|
||||
After building, keep the complete manifest-driven bundle trees in sync — CI
|
||||
verifies every listed file byte-for-byte:
|
||||
|
||||
```
|
||||
cp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
npm run bundle:sync # dist → custom_components + demo/srv/assets (#255)
|
||||
npm run bundle:budget # initial View graph <= 256000 B gzip (#337)
|
||||
```
|
||||
|
||||
During the implementation cycle the fast gates always run. Since 2026-08-14 the
|
||||
|
||||
@@ -108,8 +108,10 @@ serve that path as a JavaScript module.
|
||||
|
||||
### Manual installation
|
||||
|
||||
Copy `custom_components/houseplan` to `config/custom_components`, restart Home
|
||||
Assistant, and add the House Plan integration.
|
||||
Copy the complete `custom_components/houseplan` release folder to
|
||||
`config/custom_components`, restart Home Assistant, and add the House Plan
|
||||
integration. Do not copy only `houseplan-card.js`: the card also uses an
|
||||
internal manifest and content-hashed modules from the same release.
|
||||
|
||||
### Add the card
|
||||
|
||||
|
||||
@@ -112,8 +112,10 @@ resources:
|
||||
|
||||
### Вручную
|
||||
|
||||
Скопируйте `custom_components/houseplan` в `config/custom_components`,
|
||||
перезапустите Home Assistant и добавьте интеграцию House Plan.
|
||||
Скопируйте всю папку `custom_components/houseplan` из релиза в
|
||||
`config/custom_components`, перезапустите Home Assistant и добавьте интеграцию
|
||||
House Plan. Одного `houseplan-card.js` недостаточно: карточке также нужны
|
||||
внутренний манифест и хешированные модули из того же релиза.
|
||||
|
||||
### Добавление карточки
|
||||
|
||||
|
||||
@@ -39,10 +39,12 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hp_ws.async_register(hass)
|
||||
from .http_api import HouseplanContentView, HouseplanImportPreviewView, HouseplanUploadView
|
||||
from .frontend_assets import HouseplanFrontendAssetView
|
||||
|
||||
hass.http.register_view(HouseplanUploadView())
|
||||
hass.http.register_view(HouseplanContentView())
|
||||
hass.http.register_view(HouseplanImportPreviewView())
|
||||
hass.http.register_view(HouseplanFrontendAssetView())
|
||||
return True
|
||||
|
||||
|
||||
@@ -98,7 +100,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
|
||||
# NOTE (audit B1): plans and marker files are NO LONGER static.
|
||||
# They are served by HouseplanContentView, which requires auth.
|
||||
# Only the card bundle stays public — Lovelace resources must be.
|
||||
# Only the entry and manifest-gated JS chunks stay public —
|
||||
# Lovelace modules must be loadable without an auth header.
|
||||
if static_paths:
|
||||
await hass.http.async_register_static_paths(static_paths)
|
||||
except ImportError: # very old HA versions
|
||||
|
||||
@@ -7,6 +7,7 @@ STORAGE_VIRTUAL_LIGHTS_KEY = f"{DOMAIN}.virtual_lights"
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_MINOR_VERSION = 2
|
||||
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
|
||||
FRONTEND_ASSETS_URL = "/houseplan_files/houseplan-assets"
|
||||
PLANS_URL = "/houseplan_files/plans"
|
||||
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
||||
FILES_URL = "/houseplan_files/files"
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "edcb61bd3c2c9a6056c747c366bbd7c73f1678dce2c656f631b56a4241a4b8dc",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-CdKyxHZ7.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 255910,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/editor-MqqOS4sP.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-Bbmj7A6f.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-C28J-NJP.js",
|
||||
"houseplan-assets/space-deletion-BxF_6aDU.js"
|
||||
],
|
||||
"lazyGzipBytes": 138425,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/editor-MqqOS4sP.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-Bbmj7A6f.js",
|
||||
"houseplan-assets/space-deletion-BxF_6aDU.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 131779,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/houseplan-onboarding-runtime-C28J-NJP.js",
|
||||
"houseplan-assets/space-deletion-BxF_6aDU.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 7199,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/editor-MqqOS4sP.js",
|
||||
"sha256": "140369d29021c71a88b788224fca477b97fc4fc1d5254bbad9e68f01fee403c9",
|
||||
"rawBytes": 3699,
|
||||
"gzipBytes": 1529,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CdKyxHZ7.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-CdKyxHZ7.js",
|
||||
"sha256": "66c490a5ab13cff53463d315b0d18c8dfd26f1caf150836de5559ac3df2aa82e",
|
||||
"rawBytes": 910654,
|
||||
"gzipBytes": 255727,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/editor-MqqOS4sP.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-Bbmj7A6f.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-C28J-NJP.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-Bbmj7A6f.js",
|
||||
"sha256": "0d2dd1b6cfc0c05a010e7b2345623f1063ed5e0fe0f9a95be9afb81e90c26e6b",
|
||||
"rawBytes": 504952,
|
||||
"gzipBytes": 129697,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CdKyxHZ7.js",
|
||||
"houseplan-assets/space-deletion-BxF_6aDU.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-C28J-NJP.js",
|
||||
"sha256": "38044f0836917a8f91705b61b5d48fcf4c0ca72d41b919831d2b66a0a78e4ff9",
|
||||
"rawBytes": 26356,
|
||||
"gzipBytes": 6646,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CdKyxHZ7.js",
|
||||
"houseplan-assets/space-deletion-BxF_6aDU.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/space-deletion-BxF_6aDU.js",
|
||||
"sha256": "137b0f78bc5e11c7b1e95c138c1fcf0f982f89013053a1cc648e6df52092401e",
|
||||
"rawBytes": 911,
|
||||
"gzipBytes": 553,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "3a1818c954f8d4a8a8cb143d7b9c39196a8a340a43c578fd6ca5b6fc01ac8065",
|
||||
"rawBytes": 190,
|
||||
"gzipBytes": 183,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CdKyxHZ7.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="edcb61bd3c2c9a6056c747c366bbd7c73f1678dce2c656f631b56a4241a4b8dc";import{c as e,l as o,t,d2 as s,A as a,b as l}from"./houseplan-card-CdKyxHZ7.js";class i 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")}],l=this._floorToken;l&&a.push({value:l,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const i="string"==typeof this._config?.floor?this._config.floor:"";i&&!e.some(e=>e.value===i)&&a.push({value:i,label:i}),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;this._loadSpaces();const e=this._lang,o={title:t(e,"editor.title"),floor:t(e,"editor.floor"),default_floor:t(e,"editor.default_floor"),language:t(e,"editor.language"),icon_size:t(e,"editor.icon_size"),show_temperature:t(e,"editor.show_temperature"),live_states:t(e,"editor.live_states"),show_signal:t(e,"editor.show_signal"),kiosk:t(e,"editor.kiosk"),cycle:t(e,"editor.cycle")},s=this._schema,i=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),n=e=>l`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>o[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return l`
|
||||
${n(s.slice(0,3))}
|
||||
${i?l`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(e,"editor.default_floor_missing",{id:i})}
|
||||
</div>`:a}
|
||||
${n(s.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}i.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",i);
|
||||
@@ -0,0 +1 @@
|
||||
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function t(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),t=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),n=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&t.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:n,count:n.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="edcb61bd3c2c9a6056c747c366bbd7c73f1678dce2c656f631b56a4241a4b8dc";export{t as a,e as c,o as i,r as s,s as t};
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Pure manifest/path resolver for generated frontend assets."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
_MANIFEST = "houseplan-assets.json"
|
||||
_ASSET_DIR = "houseplan-assets"
|
||||
|
||||
|
||||
def resolve_frontend_asset(frontend_root: Path, filename: object) -> Path | None:
|
||||
"""Resolve one generated JS asset only when the current manifest lists it."""
|
||||
if not isinstance(filename, str) or not filename or filename != Path(filename).name:
|
||||
return None
|
||||
if "/" in filename or "\\" in filename or filename in {".", ".."}:
|
||||
return None
|
||||
if not filename.endswith(".js"):
|
||||
return None
|
||||
try:
|
||||
manifest = json.loads((frontend_root / _MANIFEST).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(manifest, dict) or manifest.get("schema") != 1:
|
||||
return None
|
||||
expected = f"{_ASSET_DIR}/{filename}"
|
||||
listed = {
|
||||
item.get("path")
|
||||
for item in manifest.get("files", [])
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
if expected not in listed:
|
||||
return None
|
||||
asset_root = (frontend_root / _ASSET_DIR).resolve()
|
||||
candidate = (asset_root / filename).resolve()
|
||||
if candidate.parent != asset_root or not candidate.is_file():
|
||||
return None
|
||||
return candidate
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Public, manifest-gated JavaScript chunks for the Lovelace card."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
try:
|
||||
from homeassistant.components.http import KEY_HASS
|
||||
except ImportError: # pragma: no cover - old HA compatibility
|
||||
KEY_HASS = "hass" # type: ignore[assignment]
|
||||
|
||||
from .const import FRONTEND_ASSETS_URL
|
||||
from .frontend_asset_manifest import resolve_frontend_asset
|
||||
|
||||
_FRONTEND_ROOT = Path(__file__).parent / "frontend"
|
||||
|
||||
|
||||
class HouseplanFrontendAssetView(HomeAssistantView):
|
||||
"""Serve only generated chunks named by the current build manifest."""
|
||||
|
||||
url = f"{FRONTEND_ASSETS_URL}/{{filename}}"
|
||||
name = "api:houseplan:frontend-asset"
|
||||
requires_auth = False
|
||||
|
||||
async def get(self, request: web.Request, filename: str) -> web.StreamResponse:
|
||||
hass = request.app[KEY_HASS]
|
||||
path = await hass.async_add_executor_job(
|
||||
resolve_frontend_asset, _FRONTEND_ROOT, filename
|
||||
)
|
||||
if path is None:
|
||||
raise web.HTTPNotFound()
|
||||
return web.FileResponse(
|
||||
path,
|
||||
headers={"Cache-Control": "no-cache", "X-Content-Type-Options": "nosniff"},
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { relative, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
|
||||
|
||||
@@ -13,17 +14,39 @@ const fingerprintForTree = async (root) => {
|
||||
return module.sourceFingerprint(root);
|
||||
};
|
||||
|
||||
const verifyManifestTree = (root, expected) => {
|
||||
const assetRoot = resolve(root, 'demo/srv/assets');
|
||||
const manifestPath = resolve(assetRoot, 'houseplan-assets.json');
|
||||
// Comparative benchmarks can target releases from before the multi-asset
|
||||
// contract. Their own embedded fingerprint remains the legacy authority.
|
||||
if (!existsSync(manifestPath)) return;
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||
if (manifest?.schema !== 1 || manifest.fingerprint !== expected
|
||||
|| !Array.isArray(manifest.files)) {
|
||||
throw new Error('demo bundle manifest is stale or malformed; run npm run bundle:sync');
|
||||
}
|
||||
for (const file of manifest.files) {
|
||||
const path = resolve(assetRoot, String(file?.path || ''));
|
||||
const rel = relative(assetRoot, path);
|
||||
if (!rel || rel.startsWith('..') || rel.includes(':') || !existsSync(path)) {
|
||||
throw new Error(`demo bundle manifest asset is missing or unsafe: ${file?.path}`);
|
||||
}
|
||||
const hash = createHash('sha256').update(readFileSync(path)).digest('hex');
|
||||
if (hash !== file.sha256) throw new Error(`demo bundle asset hash mismatch: ${file.path}`);
|
||||
}
|
||||
};
|
||||
|
||||
/** Refuse measurements/screenshots made by a committed bundle from old source. */
|
||||
export async function assertFreshDemoBundle(page, root = process.cwd()) {
|
||||
// A comparative performance run may load an older tree whose fingerprint
|
||||
// contract is intentionally different from the candidate's. Validate that
|
||||
// tree with the implementation that built it, not with today's algorithm.
|
||||
const expected = await fingerprintForTree(root);
|
||||
verifyManifestTree(root, expected);
|
||||
const loaded = await page.evaluate(() => globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__ ?? null);
|
||||
if (loaded !== expected) {
|
||||
throw new Error(
|
||||
'demo/srv/assets/houseplan-card.js is stale. Run npm run build and copy '
|
||||
+ 'dist/houseplan-card.js to demo/srv/assets/houseplan-card.js first. '
|
||||
'demo/srv/assets bundle tree is stale. Run npm run bundle:sync first. '
|
||||
+ `Expected ${expected}, loaded ${loaded || 'no fingerprint'}.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto';
|
||||
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
// #337: a clean CI checkout has no ignored demo bundle. Materialize the whole
|
||||
// manifest-owned tree before launching Chromium; copying only the stable entry
|
||||
// leaves every content-hashed import at 404.
|
||||
import '../../scripts/bundle-sync.mjs';
|
||||
import { visualFingerprint } from '../../scripts/source-fingerprint.mjs';
|
||||
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
|
||||
import { goldenClip, prepareGoldenScenario } from '../golden/harness.mjs';
|
||||
@@ -11,9 +15,6 @@ import { DOC_SCREENSHOT_VERSION, DOC_SCREENSHOTS } from './screenshots.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const OUTPUT = resolve(ROOT, 'docs/images');
|
||||
const BUNDLE = resolve(ROOT, 'dist/houseplan-card.js');
|
||||
const DEMO_BUNDLE = resolve(ROOT, 'demo/srv/assets/houseplan-card.js');
|
||||
const INTEGRATION_BUNDLE = resolve(ROOT, 'custom_components/houseplan/frontend/houseplan-card.js');
|
||||
const SCRIPT = fileURLToPath(import.meta.url);
|
||||
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
@@ -103,8 +104,6 @@ const applyDocumentationState = (page, scenario) => page.evaluate(async (current
|
||||
}, scenario);
|
||||
|
||||
mkdirSync(OUTPUT, { recursive: true });
|
||||
copyFileSync(BUNDLE, DEMO_BUNDLE);
|
||||
copyFileSync(BUNDLE, INTEGRATION_BUNDLE);
|
||||
|
||||
const { page, browser } = await launch();
|
||||
const browserErrors = [];
|
||||
|
||||
@@ -773,6 +773,12 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
const expectedDevices = Object.keys(fixture.devices || {}).length;
|
||||
if (expectedDevices) await until(() => card._devices?.length >= expectedDevices);
|
||||
await until(() => card._booting === false);
|
||||
// Golden scenarios intentionally call internal editor commands directly.
|
||||
// Preload the lazy runtime for that legacy harness contract; cold-View
|
||||
// loading and retry semantics are covered by smoke_lazy_editor_chunk.
|
||||
if (!(await card._ensureEditorRuntime())) {
|
||||
throw new Error(`golden editor runtime failed to load: ${scenario.id}`);
|
||||
}
|
||||
await frame();
|
||||
return card;
|
||||
};
|
||||
@@ -1492,7 +1498,8 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
});
|
||||
}
|
||||
const raw = scenario.cardEditorInvalidDefaultFloor;
|
||||
const editor = document.createElement('houseplan-card-editor');
|
||||
const cardClass = customElements.get('houseplan-card');
|
||||
const editor = await cardClass.getConfigElement();
|
||||
editor.setConfig({
|
||||
type: 'custom:houseplan-card', title: 'Golden invalid floor',
|
||||
language: scenario.language || 'en', default_floor: raw,
|
||||
|
||||
@@ -44,7 +44,8 @@ export async function finish(browser, out) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function launch(
|
||||
async function launchInternal(
|
||||
preloadEditorRuntime,
|
||||
viewport = { width: 820, height: 760 },
|
||||
scale = 1,
|
||||
browserArgs = [],
|
||||
@@ -57,7 +58,7 @@ export async function launch(
|
||||
viewport, deviceScaleFactor: scale, ...contextOptions,
|
||||
})).newPage();
|
||||
// audit T1: an exception inside the card used to be logged and ignored
|
||||
page.on('pageerror', (e) => { _pageErrors++; console.log('EXC', e.message); });
|
||||
page.on('pageerror', (e) => { _pageErrors++; console.log('EXC', e.stack || e.message); });
|
||||
await page.route('**/*', (r) => {
|
||||
const u = new URL(r.request().url());
|
||||
let p = decodeURIComponent(u.pathname);
|
||||
@@ -84,5 +85,15 @@ export async function launch(
|
||||
// hass flows continuously in production; the stub sets it once — nudge a rebuild
|
||||
await page.evaluate(() => { const c = window.__card; c.hass = { ...c.hass }; });
|
||||
await page.waitForFunction(() => window.__card._devices.length > 0, { timeout: 9000 });
|
||||
// Existing product smokes exercise editor internals directly. They preload
|
||||
// the new #337 runtime without changing mode; the dedicated network smoke
|
||||
// uses launchColdView() and proves the real cold-View boundary separately.
|
||||
if (preloadEditorRuntime) {
|
||||
const ready = await page.evaluate(() => window.__card._ensureEditorRuntime());
|
||||
if (!ready) throw new Error('editor runtime did not preload for browser smoke');
|
||||
}
|
||||
return { page, browser };
|
||||
}
|
||||
|
||||
export const launch = (...args) => launchInternal(true, ...args);
|
||||
export const launchColdView = (...args) => launchInternal(false, ...args);
|
||||
|
||||
@@ -72,7 +72,7 @@ const res = await page.evaluate(async () => {
|
||||
out.legacyCardStillRestoresNav = ordinary._space === ids[1]
|
||||
&& ordinary._fixedFloorState().kind === 'absent';
|
||||
|
||||
const editor = document.createElement('houseplan-card-editor');
|
||||
const editor = await customElements.get('houseplan-card').getConfigElement();
|
||||
editor.hass = base.hass;
|
||||
editor.setConfig({ type: 'custom:houseplan-card', floor: 1, title: 'Fixed' });
|
||||
document.body.appendChild(editor);
|
||||
|
||||
@@ -58,7 +58,12 @@ const res = await page.evaluate(async () => {
|
||||
// terser либо инлайнит строку (v1.56.0), либо оставляет переменную (v${xx}) —
|
||||
// во втором случае доразрешаем её по присваиванию xx="1.56.0".
|
||||
// Версия — SemVer, у пре-релиза есть суффикс (1.58.0-beta.1), он тоже часть строки.
|
||||
const bundle = readFileSync(new URL('./srv/assets/houseplan-card.js', import.meta.url), 'utf8');
|
||||
const assetsRoot = new URL('./srv/assets/', import.meta.url);
|
||||
const assetManifest = JSON.parse(readFileSync(new URL('houseplan-assets.json', assetsRoot), 'utf8'));
|
||||
const bundle = assetManifest.files
|
||||
.filter((file) => file.path.endsWith('.js'))
|
||||
.map((file) => readFileSync(new URL(file.path, assetsRoot), 'utf8'))
|
||||
.join('\n');
|
||||
const SEMVER = '\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?';
|
||||
const m = bundle.match(new RegExp(`HOUSEPLAN-CARD %c v(?:(${SEMVER})|\\$\\{(\\w+)\\})`));
|
||||
const BUNDLE_VERSION = m?.[1] ?? (m?.[2] && bundle.match(new RegExp(`[^\\w$]${m[2]}="(${SEMVER})"`))?.[1]);
|
||||
|
||||
@@ -38,7 +38,9 @@ await page.evaluate(async () => {
|
||||
d_temp: { s: 'f1', x: 0.72, y: 0.24 },
|
||||
};
|
||||
const sharedLayout = card._layout;
|
||||
const originalGridLevels = card._gridLevels.bind(card);
|
||||
await card._ensureEditorRuntime();
|
||||
const runtime = card._editorRuntime;
|
||||
const originalGridLevels = runtime._gridLevels.bind(runtime);
|
||||
const scalePoint = (point, factor) => point.map((value) => value * factor);
|
||||
const scaledFixture = (cellCm) => {
|
||||
const factor = 5 / cellCm;
|
||||
@@ -96,7 +98,8 @@ await page.evaluate(async () => {
|
||||
card._decorTool = mode === 'decor' ? 'backdrop' : 'select';
|
||||
// Grid density is the one allowed difference. Mask it in every editor
|
||||
// raster pair and prove the precision ratio separately below.
|
||||
card._gridLevels = mode === 'view' ? originalGridLevels : () => null;
|
||||
runtime._gridLevels = mode === 'view' ? originalGridLevels : () => null;
|
||||
card._gridLevels = () => runtime._gridLevels();
|
||||
card._labs = { ...card._labs, active: projection === 'iso' ? ['iso'] : [] };
|
||||
card._viewPreference = { ...card._viewPreference, f1: projection };
|
||||
card._isoFallback.clear();
|
||||
|
||||
@@ -83,8 +83,9 @@ const res = await page.evaluate(async () => {
|
||||
// The production registry adapter is covered by unit/backend tests. Stub
|
||||
// only the transactional projection here so the smoke owns no shared
|
||||
// registry singleton state and remains deterministic.
|
||||
const realPreview = c._markerPreviewDevice.bind(c);
|
||||
c._markerPreviewDevice = (draft) => draft.devId === multi.id ? {
|
||||
const runtime = c._editorRuntime;
|
||||
const realPreview = runtime._markerPreviewDevice.bind(runtime);
|
||||
runtime._markerPreviewDevice = (draft) => draft.devId === multi.id ? {
|
||||
...multi,
|
||||
entities: [multi.primary, extra],
|
||||
marker: {
|
||||
@@ -99,7 +100,7 @@ const res = await page.evaluate(async () => {
|
||||
});
|
||||
await c.updateComplete;
|
||||
out.leadingDraftIsAlways = c._markerDialog.lightRole === 'always';
|
||||
out.leadingPreviewHasExtra = c._markerPreviewDevice(c._markerDialog)?.entities?.includes(extra) === true;
|
||||
out.leadingPreviewHasExtra = runtime._markerPreviewDevice(c._markerDialog)?.entities?.includes(extra) === true;
|
||||
const leading = sr().querySelector('hp-dialog #marker-light-entity');
|
||||
out.leadingSelectorForMultiple = !!leading && leading.options.length === 3;
|
||||
out.savedLeadingEntitySelected = leading?.value === extra
|
||||
@@ -114,7 +115,7 @@ const res = await page.evaluate(async () => {
|
||||
out.leadingUpdatesPreview = false;
|
||||
}
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
c._markerPreviewDevice = realPreview;
|
||||
runtime._markerPreviewDevice = realPreview;
|
||||
|
||||
const single = c._devices.find((device) => device.id === 'd_lamp');
|
||||
c._openMarkerDialog(single); await c.updateComplete;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// #337: the display-only card must stay independent from the editor runtime.
|
||||
// Exercise the production bundle, including its content-hashed retry URL.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { launchColdView, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const manifest = JSON.parse(readFileSync('dist/houseplan-assets.json', 'utf8'));
|
||||
const runtimePath = manifest.files
|
||||
.map((file) => file.path)
|
||||
.find((path) => /houseplan-editor-runtime-[^/]+\.js$/.test(path));
|
||||
if (!runtimePath) throw new Error('editor runtime is absent from the bundle manifest');
|
||||
const runtimeName = runtimePath.split('/').at(-1);
|
||||
const runtimeUrlPattern = `**/${runtimeName}*`;
|
||||
const runtimeFile = `demo/srv/assets/${runtimePath}`;
|
||||
const onboardingPath = manifest.files
|
||||
.map((file) => file.path)
|
||||
.find((path) => /houseplan-onboarding-runtime-[^/]+\.js$/.test(path));
|
||||
if (!onboardingPath) throw new Error('onboarding runtime is absent from the bundle manifest');
|
||||
const onboardingName = onboardingPath.split('/').at(-1);
|
||||
|
||||
const clickEditor = async (page, index, mode) => {
|
||||
await page.locator('houseplan-card').evaluate((card, tabIndex) => {
|
||||
const root = card.shadowRoot || card.renderRoot;
|
||||
root.querySelectorAll('.modetab')[tabIndex]?.click();
|
||||
}, index);
|
||||
await page.waitForFunction((expected) => window.__card._mode === expected, mode);
|
||||
await page.waitForFunction(() => window.__card._modeTransitionBusy === false);
|
||||
};
|
||||
|
||||
const { page, browser } = await launchColdView();
|
||||
const requested = [];
|
||||
page.on('request', (request) => requested.push(new URL(request.url()).pathname));
|
||||
const initialResources = await page.evaluate(() => performance.getEntriesByType('resource')
|
||||
.map((entry) => new URL(entry.name).pathname));
|
||||
const out = {
|
||||
editorAbsentBeforeIntent: !initialResources.some((path) => path.endsWith(`/${runtimeName}`)),
|
||||
};
|
||||
|
||||
await clickEditor(page, 0, 'plan');
|
||||
await clickEditor(page, 1, 'devices');
|
||||
await clickEditor(page, 2, 'decor');
|
||||
out.oneRuntimeRequestForAllEditors = requested.filter((path) => path.endsWith(`/${runtimeName}`)).length === 1;
|
||||
out.allEditorsUseInstalledRuntime = await page.evaluate(() =>
|
||||
window.__card._editorRuntimeLoader.state === 'ready' && window.__card._mode === 'decor');
|
||||
|
||||
// Empty-install onboarding is a separate lazy surface. It may fetch its own
|
||||
// dialog chunk, but must not fetch or install the editor until Save explicitly
|
||||
// continues into Plan mode.
|
||||
const onboarding = await launchColdView();
|
||||
const onboardingRequests = [];
|
||||
onboarding.page.on('request', (request) => {
|
||||
onboardingRequests.push(new URL(request.url()).pathname);
|
||||
});
|
||||
await onboarding.page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
card._onboardingShown = false;
|
||||
card._serverCfg = { ...card._serverCfg, spaces: [] };
|
||||
card._model = [];
|
||||
card.hass = { ...card.hass, floors: {}, areas: {} };
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
});
|
||||
await onboarding.page.waitForFunction(() => {
|
||||
const card = window.__card;
|
||||
return card._onboardingRuntime && card.renderRoot.querySelector('hp-dialog');
|
||||
});
|
||||
out.onboardingUsesOwnChunk = onboardingRequests
|
||||
.filter((path) => path.endsWith(`/${onboardingName}`)).length === 1;
|
||||
out.onboardingDoesNotLoadEditor = onboardingRequests
|
||||
.every((path) => !path.endsWith(`/${runtimeName}`))
|
||||
&& await onboarding.page.evaluate(() => !window.__card._editorRuntime);
|
||||
await onboarding.page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const root = card.renderRoot;
|
||||
const title = root.querySelector('hp-dialog input.namein');
|
||||
title.value = 'Cold onboarding';
|
||||
title.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||
await card.updateComplete;
|
||||
const sources = root.querySelectorAll('hp-dialog input[name="plansrc"]');
|
||||
sources[1].click();
|
||||
await card.updateComplete;
|
||||
const buttons = [...root.querySelectorAll('hp-dialog button')];
|
||||
buttons.find((button) => button.textContent.includes(card._t('btn.save')))?.click();
|
||||
});
|
||||
await onboarding.page.waitForFunction(() => {
|
||||
const card = window.__card;
|
||||
return card._editorRuntime && card._mode === 'plan'
|
||||
&& card._serverCfg.spaces.some((space) => space.title === 'Cold onboarding');
|
||||
});
|
||||
out.onboardingSaveContinuesToPlan = onboardingRequests
|
||||
.filter((path) => path.endsWith(`/${runtimeName}`)).length === 1;
|
||||
|
||||
const gui = await launchColdView();
|
||||
out.guiEditorLoadsAsynchronously = await gui.page.evaluate(async () => {
|
||||
const ctor = customElements.get('houseplan-card');
|
||||
const editor = await ctor.getConfigElement();
|
||||
return editor?.localName === 'houseplan-card-editor';
|
||||
});
|
||||
|
||||
// Two network failures are terminal for this page. View stays live and the
|
||||
// retry is the same immutable chunk with a cache-busting query string.
|
||||
const failed = await launchColdView();
|
||||
let failedRequests = 0;
|
||||
await failed.page.route(runtimeUrlPattern, async (route) => {
|
||||
failedRequests += 1;
|
||||
await route.abort('failed');
|
||||
});
|
||||
await failed.page.locator('houseplan-card').evaluate((card) => {
|
||||
const root = card.shadowRoot || card.renderRoot;
|
||||
root.querySelectorAll('.modetab')[0]?.click();
|
||||
});
|
||||
await failed.page.waitForFunction(() => window.__card._editorRuntimeLoader.state === 'failed');
|
||||
out.networkFailureRetriesExactlyOnce = failedRequests === 2;
|
||||
out.networkFailureKeepsView = await failed.page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
return card._mode === 'view'
|
||||
&& !card._editorRuntime
|
||||
&& card._toast.includes(card._t('editor.load_failed'))
|
||||
&& card._toast.includes(card._t('editor.refresh_advice'));
|
||||
});
|
||||
|
||||
// A valid module from a different build is no safer than a 404. Both attempts
|
||||
// are fulfilled deliberately so this checks the fingerprint handshake rather
|
||||
// than the network branch above.
|
||||
const mismatch = await launchColdView();
|
||||
let mismatchRequests = 0;
|
||||
const incompatibleRuntime = readFileSync(runtimeFile, 'utf8')
|
||||
.replaceAll(manifest.fingerprint, `${manifest.fingerprint}-mismatch`);
|
||||
await mismatch.page.route(runtimeUrlPattern, async (route) => {
|
||||
mismatchRequests += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/javascript',
|
||||
body: incompatibleRuntime,
|
||||
});
|
||||
});
|
||||
await mismatch.page.locator('houseplan-card').evaluate((card) => {
|
||||
const root = card.shadowRoot || card.renderRoot;
|
||||
root.querySelectorAll('.modetab')[1]?.click();
|
||||
});
|
||||
await mismatch.page.waitForFunction(() => window.__card._editorRuntimeLoader.state === 'failed');
|
||||
out.fingerprintMismatchRetriesExactlyOnce = mismatchRequests === 2;
|
||||
out.fingerprintMismatchKeepsView = await mismatch.page.evaluate(() =>
|
||||
window.__card._mode === 'view' && !window.__card._editorRuntime);
|
||||
|
||||
await failed.browser.close();
|
||||
await mismatch.browser.close();
|
||||
await onboarding.browser.close();
|
||||
await gui.browser.close();
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -3,6 +3,7 @@ const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
await c._ensureEditorRuntime();
|
||||
// Permission-delayed warm navigation must use the complete transition
|
||||
// authority instead of assigning the private mode field directly.
|
||||
c._setMode('view', false); await c.updateComplete;
|
||||
@@ -38,7 +39,7 @@ const res = await page.evaluate(async () => {
|
||||
document.body.appendChild(legacy);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
out.legacySpaceOnly = legacy._space === 'garden' && legacy._mode === 'view';
|
||||
legacy._setMode('devices'); await legacy.updateComplete;
|
||||
await legacy._requestMode('devices'); await legacy.updateComplete;
|
||||
const migrated = JSON.parse(localStorage.getItem('houseplan_card_nav_v1'));
|
||||
out.legacyRewrittenOnNextNav = migrated.space === 'garden'
|
||||
&& !Object.hasOwn(migrated, 'mode');
|
||||
|
||||
@@ -188,7 +188,7 @@ const out = await page.evaluate(async () => {
|
||||
card._spaceDialog = null;
|
||||
await card.updateComplete;
|
||||
|
||||
const editor = document.createElement('houseplan-card-editor');
|
||||
const editor = await customElements.get('houseplan-card').getConfigElement();
|
||||
editor.hass = {
|
||||
...card.hass,
|
||||
callWS: async (message) => message.type === 'houseplan/config/get'
|
||||
|
||||
@@ -44,7 +44,7 @@ const out = await page.evaluate(async () => {
|
||||
card._planSnapGeometryCache = null;
|
||||
card.requestUpdate();
|
||||
};
|
||||
card._setMode('plan');
|
||||
await card._requestMode('plan');
|
||||
card._activateOpeningPlacement('door');
|
||||
await update();
|
||||
|
||||
@@ -71,16 +71,17 @@ const out = await page.evaluate(async () => {
|
||||
&& Math.abs(endpointCandidate.host.t - (endpointX - 250) / 500) < 1e-9
|
||||
&& !!endpointCandidate.measure.labels[0].text;
|
||||
|
||||
const originalSvgPoint = card._svgPoint;
|
||||
const runtime = card._editorRuntime;
|
||||
const originalSvgPoint = runtime._svgPoint;
|
||||
card._opDrag = {
|
||||
id: saved.id, moved: false, sx: 0, sy: 0, dirty: false,
|
||||
before: card._geometrySnapshot(),
|
||||
};
|
||||
card._svgPoint = () => [250, 500];
|
||||
runtime._svgPoint = () => [250, 500];
|
||||
card._opPointerMove(new PointerEvent('pointermove', {
|
||||
pointerId: 186, clientX: 20, clientY: 0, bubbles: true,
|
||||
}), saved);
|
||||
card._svgPoint = originalSvgPoint;
|
||||
runtime._svgPoint = originalSvgPoint;
|
||||
card._opPointerUp(new PointerEvent('pointerup', { pointerId: 186, bubbles: true }), saved);
|
||||
await update();
|
||||
result.directDragStopsAtSameJambBoundary = Math.abs(saved.host.t - (endpointX - 250) / 500) < 1e-9
|
||||
|
||||
@@ -51,6 +51,7 @@ const rec = await page.evaluate(async () => {
|
||||
// A detach during resume/mode motion kills the one RAF owner. The transient
|
||||
// state must die with it, otherwise the same element comes back inert or
|
||||
// permanently pinned to an intermediate camera.
|
||||
await c._ensureEditorRuntime();
|
||||
c._resumeSettling = true;
|
||||
c._slide = 'left';
|
||||
const from = c._currentModeVisual('view');
|
||||
|
||||
@@ -43,7 +43,7 @@ await page.waitForFunction(() => {
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
card._setMode('plan');
|
||||
await card._requestMode('plan');
|
||||
await card.updateComplete;
|
||||
const button = [...card.renderRoot.querySelectorAll('button')]
|
||||
.find((entry) => entry.textContent?.trim() === 'Resize');
|
||||
|
||||
@@ -192,6 +192,29 @@ await settle();
|
||||
await page.waitForTimeout(550);
|
||||
out.outsideReleaseEndedDrag = await page.evaluate(() => window.__card._tabDrag === null);
|
||||
out.outsideReleaseDidNotWrite = await writeCount() === writesBeforeOutside;
|
||||
|
||||
// Exercise the window-level release fallback independently. Real mouse input
|
||||
// normally grants pointer capture, which would route pointerup back to the
|
||||
// source tab and let a missing window listener pass this regression.
|
||||
const fallbackIds = await ids();
|
||||
const writesBeforeFallback = await writeCount();
|
||||
await holdDrag(fallbackIds.at(-1), fallbackIds[0]);
|
||||
out.outsideFallbackReleasedCapture = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const drag = c._tabDrag;
|
||||
const source = [...c.renderRoot.querySelectorAll('[data-hp="space-tab"]')]
|
||||
.find((tab) => tab.dataset.id === drag?.id);
|
||||
if (!source || !drag || !source.hasPointerCapture(drag.pointerId)) return false;
|
||||
source.releasePointerCapture(drag.pointerId);
|
||||
return !source.hasPointerCapture(drag.pointerId);
|
||||
});
|
||||
await page.mouse.move(stagePoint.x, stagePoint.y, { steps: 3 });
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
await page.waitForTimeout(550);
|
||||
out.outsideFallbackReleaseEndedDrag = await page.evaluate(() => window.__card._tabDrag === null);
|
||||
out.outsideFallbackReleaseDidNotWrite = await writeCount() === writesBeforeFallback;
|
||||
|
||||
const activeBeforeRecovery = await page.evaluate(() => window.__card._space);
|
||||
const recoveryTarget = (await ids()).find((id) => id !== activeBeforeRecovery);
|
||||
const recoveryPoint = await tabPoint(recoveryTarget);
|
||||
|
||||
@@ -35,6 +35,11 @@ const res = await page.evaluate(async () => {
|
||||
while (c._booting && performance.now() - t0 < 2500) await sleep(30);
|
||||
await sleep(350);
|
||||
};
|
||||
const waitFor = async (predicate, timeout = 2500) => {
|
||||
const started = performance.now();
|
||||
while (!predicate() && performance.now() - started < timeout) await sleep(20);
|
||||
return predicate();
|
||||
};
|
||||
const rect = (c) => (c._view ? [c._view.x, c._view.y, c._view.w, c._view.h] : null);
|
||||
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
/** покадрово: ни один кадр после пересоздания не отличается от эталона */
|
||||
@@ -61,6 +66,7 @@ const res = await page.evaluate(async () => {
|
||||
c.requestUpdate(); await c.updateComplete; await sleep(100);
|
||||
const zoomA = c._zoom, viewA = rect(c);
|
||||
out.aPanned = viewA[0] > 1 || viewA[1] > 1; // sanity: вид действительно смещён
|
||||
await c._ensureEditorRuntime();
|
||||
c._openSpaceDialog('edit', c._space); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, title: 'ЧЕРНОВИК-42' }; // недосохранённая правка
|
||||
await c.updateComplete;
|
||||
@@ -80,7 +86,8 @@ const res = await page.evaluate(async () => {
|
||||
out.aNoZombieAfterClose = !c._spaceDialog;
|
||||
|
||||
// ================= B. редактор устройств: зум редактора + карточка =======
|
||||
c._setMode('devices'); await c.updateComplete; await sleep(120);
|
||||
await c._requestMode('devices'); await c.updateComplete;
|
||||
await waitFor(() => !c._modeTransitionBusy);
|
||||
c._applyView(3.4, 430, 380); c.requestUpdate(); await c.updateComplete; await sleep(100);
|
||||
const zoomB = c._zoom, viewB = rect(c);
|
||||
const dev = c._devices.find((d) => d.space === c._space);
|
||||
@@ -91,6 +98,8 @@ const res = await page.evaluate(async () => {
|
||||
|
||||
c.remove(); await sleep(20);
|
||||
c = mk();
|
||||
await waitFor(() => c._mode === 'devices' && !!c._markerDialog);
|
||||
await c.updateComplete;
|
||||
out.bModeRestored = c._mode === 'devices';
|
||||
out.bViewBitExact = await watchView(c, zoomB, viewB);
|
||||
out.bDialogSurvived = !!c._markerDialog;
|
||||
@@ -116,7 +125,7 @@ const res = await page.evaluate(async () => {
|
||||
out.dAlignNotRevived = !c._alignDialog;
|
||||
|
||||
// ================= E. реальный уход с маршрута завершает редактор =======
|
||||
c._setMode('devices'); await c.updateComplete; await sleep(60);
|
||||
await c._requestMode('devices'); await c.updateComplete; await sleep(60);
|
||||
const returnUrl = `${location.pathname}${location.search}${location.hash}`;
|
||||
const returnSpace = c._space;
|
||||
const routeDev = c._devices.find((d) => d.space === returnSpace);
|
||||
|
||||
@@ -48,13 +48,14 @@ const res = await page.evaluate(async () => {
|
||||
A._applyView(2.15, 300, 420);
|
||||
A.requestUpdate(); await A.updateComplete; await sleep(60);
|
||||
const zoomA = A._zoom;
|
||||
await A._ensureEditorRuntime();
|
||||
A._openSpaceDialog('edit', A._space); await A.updateComplete;
|
||||
A._spaceDialog = { ...A._spaceDialog, title: 'DRAFT-FROM-A' };
|
||||
await A.updateComplete; await sleep(30);
|
||||
|
||||
const B = mk(); // вторая карточка, тот же конфиг
|
||||
await settle(B);
|
||||
B._setMode('devices'); await B.updateComplete; await settle(B);
|
||||
await B._requestMode('devices'); await B.updateComplete; await settle(B);
|
||||
B._applyView(3.35, 200, 200);
|
||||
B.requestUpdate(); await B.updateComplete; await sleep(60);
|
||||
const zoomB = B._zoom;
|
||||
@@ -77,6 +78,7 @@ const res = await page.evaluate(async () => {
|
||||
HP._warmBootReset(400);
|
||||
let c = mk();
|
||||
await settle(c);
|
||||
await c._ensureEditorRuntime();
|
||||
c._openSpaceDialog('edit', c._space); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, title: 'DRAFT-RAPID' };
|
||||
await c.updateComplete; await sleep(30);
|
||||
@@ -95,6 +97,7 @@ const res = await page.evaluate(async () => {
|
||||
HP._warmBootReset(400);
|
||||
c = mk();
|
||||
await settle(c);
|
||||
await c._ensureEditorRuntime();
|
||||
c._openSpaceDialog('edit', c._space); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, title: 'DRAFT-TTL', planFile: 'x'.repeat(4096) };
|
||||
await c.updateComplete; await sleep(30);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "edcb61bd3c2c9a6056c747c366bbd7c73f1678dce2c656f631b56a4241a4b8dc",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-CdKyxHZ7.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 255910,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/editor-MqqOS4sP.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-Bbmj7A6f.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-C28J-NJP.js",
|
||||
"houseplan-assets/space-deletion-BxF_6aDU.js"
|
||||
],
|
||||
"lazyGzipBytes": 138425,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/editor-MqqOS4sP.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-Bbmj7A6f.js",
|
||||
"houseplan-assets/space-deletion-BxF_6aDU.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 131779,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/houseplan-onboarding-runtime-C28J-NJP.js",
|
||||
"houseplan-assets/space-deletion-BxF_6aDU.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 7199,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/editor-MqqOS4sP.js",
|
||||
"sha256": "140369d29021c71a88b788224fca477b97fc4fc1d5254bbad9e68f01fee403c9",
|
||||
"rawBytes": 3699,
|
||||
"gzipBytes": 1529,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CdKyxHZ7.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-CdKyxHZ7.js",
|
||||
"sha256": "66c490a5ab13cff53463d315b0d18c8dfd26f1caf150836de5559ac3df2aa82e",
|
||||
"rawBytes": 910654,
|
||||
"gzipBytes": 255727,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/editor-MqqOS4sP.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-Bbmj7A6f.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-C28J-NJP.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-Bbmj7A6f.js",
|
||||
"sha256": "0d2dd1b6cfc0c05a010e7b2345623f1063ed5e0fe0f9a95be9afb81e90c26e6b",
|
||||
"rawBytes": 504952,
|
||||
"gzipBytes": 129697,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CdKyxHZ7.js",
|
||||
"houseplan-assets/space-deletion-BxF_6aDU.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-C28J-NJP.js",
|
||||
"sha256": "38044f0836917a8f91705b61b5d48fcf4c0ca72d41b919831d2b66a0a78e4ff9",
|
||||
"rawBytes": 26356,
|
||||
"gzipBytes": 6646,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CdKyxHZ7.js",
|
||||
"houseplan-assets/space-deletion-BxF_6aDU.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/space-deletion-BxF_6aDU.js",
|
||||
"sha256": "137b0f78bc5e11c7b1e95c138c1fcf0f982f89013053a1cc648e6df52092401e",
|
||||
"rawBytes": 911,
|
||||
"gzipBytes": 553,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "3a1818c954f8d4a8a8cb143d7b9c39196a8a340a43c578fd6ca5b6fc01ac8065",
|
||||
"rawBytes": 190,
|
||||
"gzipBytes": 183,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CdKyxHZ7.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="edcb61bd3c2c9a6056c747c366bbd7c73f1678dce2c656f631b56a4241a4b8dc";import{c as e,l as o,t,d2 as s,A as a,b as l}from"./houseplan-card-CdKyxHZ7.js";class i 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")}],l=this._floorToken;l&&a.push({value:l,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const i="string"==typeof this._config?.floor?this._config.floor:"";i&&!e.some(e=>e.value===i)&&a.push({value:i,label:i}),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;this._loadSpaces();const e=this._lang,o={title:t(e,"editor.title"),floor:t(e,"editor.floor"),default_floor:t(e,"editor.default_floor"),language:t(e,"editor.language"),icon_size:t(e,"editor.icon_size"),show_temperature:t(e,"editor.show_temperature"),live_states:t(e,"editor.live_states"),show_signal:t(e,"editor.show_signal"),kiosk:t(e,"editor.kiosk"),cycle:t(e,"editor.cycle")},s=this._schema,i=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),n=e=>l`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>o[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return l`
|
||||
${n(s.slice(0,3))}
|
||||
${i?l`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(e,"editor.default_floor_missing",{id:i})}
|
||||
</div>`:a}
|
||||
${n(s.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}i.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",i);
|
||||
@@ -0,0 +1 @@
|
||||
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function t(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),t=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),n=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&t.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:n,count:n.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="edcb61bd3c2c9a6056c747c366bbd7c73f1678dce2c656f631b56a4241a4b8dc";export{t as a,e as c,o as i,r as s,s as t};
|
||||
@@ -22,7 +22,10 @@ media-wrapper survival) are pinned by `test/styles-split.test.mjs`, and
|
||||
```
|
||||
houseplan-card/
|
||||
├─ src/ # card sources (TypeScript + Lit 3)
|
||||
│ ├─ houseplan-card.ts # the card: rendering, states, drag, tooltip, sticky header
|
||||
│ ├─ houseplan-card.ts # eager View shell, HA lifecycle and projection
|
||||
│ ├─ editor-runtime-loader.ts # lazy loader: dedupe, retry and build handshake
|
||||
│ ├─ houseplan-editor-runtime.ts # Plan/Devices/Background composition root
|
||||
│ ├─ houseplan-onboarding-runtime.ts # first-space/import dialogs, independent of editor
|
||||
│ ├─ hp-dialog.ts # shared HA/native modal shell, focus and transient-overlay lifecycle
|
||||
│ ├─ hp-help.ts # presentation-only, localized contextual-help surface
|
||||
│ ├─ floating-surface.ts # pure visual-viewport flip/shift geometry for dialog surfaces
|
||||
@@ -37,7 +40,7 @@ houseplan-card/
|
||||
│ └─ data/
|
||||
│ ├─ house.ts # geometry: ROOMS (rooms→area), FLOOR_VB (viewBox), names
|
||||
│ └─ backgrounds.ts # VECTOR plans (SVG base64) + FLOOR_BG_RECT (positioning)
|
||||
├─ dist/houseplan-card.js # build (rollup+terser), ~290 KB, plans embedded
|
||||
├─ dist/ # entry + manifest + content-hashed JS chunks
|
||||
├─ demo/golden/ # deterministic HP-QA-01 matrix, capture/verify/accept
|
||||
├─ demo/performance/ # large-house budgets and same-runner comparison
|
||||
├─ scripts/release-*.mjs # exact-SHA publication contract and local orchestrator
|
||||
@@ -54,10 +57,16 @@ houseplan-card/
|
||||
└─ docs/ # this documentation
|
||||
```
|
||||
|
||||
Rollup embeds a source fingerprint in the bundle. Performance and golden-image
|
||||
tooling compares it with the current `src/` tree before recording results, so a
|
||||
committed demo snapshot from an older source revision cannot produce a false
|
||||
baseline.
|
||||
Rollup embeds one source fingerprint in the eager entry and both lazy runtimes.
|
||||
`dist/houseplan-assets.json` records the import graph, sizes and SHA-256 of every
|
||||
generated asset. View loads only the initial graph; Plan, Devices and Background
|
||||
share one editor runtime loaded on first intent. An empty installation loads a
|
||||
separate onboarding dialog chunk, so creating the first space does not require
|
||||
the editor asset; saving it then continues into Plan as before. The backend keeps the public
|
||||
entry URL stable and serves only manifest-listed JS basenames below
|
||||
`/houseplan_files/houseplan-assets/`. Performance, golden and smoke tooling
|
||||
verify the manifest and every asset before recording results, so a stale or
|
||||
partially copied tree cannot produce a false baseline.
|
||||
|
||||
Prerelease publication has one fail-closed contract shared by the local command
|
||||
and the manual GitHub workflow. The tag version must match all six shipped
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- View now starts without downloading the three editors; their shared runtime
|
||||
is loaded on first use and protected by one retry and a build-version check.
|
||||
Release and test tooling now verify the complete multi-file frontend asset
|
||||
tree rather than one JavaScript file
|
||||
([#337](https://github.com/Matysh/houseplan-card/issues/337)).
|
||||
|
||||
- The wall-junction checks are honest at the boundaries: false refusals on
|
||||
microscopic coordinate noise are gone, very long walls no longer crash the
|
||||
check, and an internal check error refuses the write instead of waving it
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Режим просмотра теперь запускается без загрузки трёх редакторов: их общий
|
||||
runtime подгружается при первом обращении и защищён повторной попыткой и
|
||||
проверкой версии сборки. Релизные и тестовые инструменты проверяют всё дерево
|
||||
frontend-ассетов, а не один JavaScript-файл
|
||||
([#337](https://github.com/Matysh/houseplan-card/issues/337)).
|
||||
|
||||
- Проверки стыков стен стали честными на границах: ложные отказы на
|
||||
микроскопических погрешностях координат устранены, очень длинные стены не
|
||||
роняют проверку, а внутренняя ошибка проверки больше не пропускает запись
|
||||
|
||||
@@ -102,10 +102,16 @@ git config core.untrackedCache true
|
||||
- IMPORTANT (audit lesson): the rollup typescript plugin reports a syntax error as a WARNING and still
|
||||
builds the bundle — a truncated file can "pass". That is why the build starts with `tsc --noEmit`,
|
||||
which fails on such errors. Always build with `npm run build`, never bare `rollup -c`.
|
||||
- Before committing a frontend source change, run `npm run bundle:sync`: `npm run build`
|
||||
writes `dist/houseplan-card.js` only, while the release contract also requires the
|
||||
committed `custom_components/houseplan/frontend/houseplan-card.js` snapshot to be
|
||||
byte-identical. The demo copy is refreshed by the same command but remains untracked.
|
||||
- Before committing a frontend source change, run `npm run bundle:sync`. Rollup writes
|
||||
`dist/houseplan-card.js`, `dist/houseplan-assets.json` and content-hashed chunks under
|
||||
`dist/houseplan-assets/`; the command synchronizes that complete tree to the committed
|
||||
integration snapshot and the untracked demo copy, then verifies every manifest hash.
|
||||
`node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend` is the
|
||||
read-only parity check used by CI and release automation.
|
||||
- The first-space/import dialog is a separate `houseplan-onboarding-runtime-*`
|
||||
chunk. Do not fold it into `houseplan-editor-runtime-*`: empty-install
|
||||
onboarding is a View prerequisite, while a configured View must request
|
||||
neither lazy runtime until the corresponding user intent.
|
||||
|
||||
## Maintenance diagnostics
|
||||
|
||||
@@ -172,9 +178,9 @@ commands and the explicit review workflow are documented in
|
||||
|
||||
```bash
|
||||
cd /tmp/hpc && npm ci # once
|
||||
npx rollup -c # → dist/houseplan-card.js
|
||||
node --check dist/houseplan-card.js
|
||||
cp dist/houseplan-card.js custom_components/houseplan/frontend/
|
||||
npm run bundle:sync # build + entry/manifest/chunks → integration + demo
|
||||
npm run bundle:budget # initial View graph must stay <= 256000 B gzip
|
||||
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
|
||||
```
|
||||
|
||||
## Deployment to the dacha (ha.jbstudio.pro)
|
||||
@@ -184,7 +190,9 @@ cp dist/houseplan-card.js custom_components/houseplan/frontend/
|
||||
- **The HA config root is `/mnt/data/supervisor/homeassistant`** — in this SSH
|
||||
environment `/config` does not exist; a deploy aimed at `/config/...` fails
|
||||
with "No such file or directory".
|
||||
- JS: `scp -P 22222 -i <key> dist/houseplan-card.js root@ha.jbstudio.pro:/mnt/data/supervisor/homeassistant/custom_components/houseplan/frontend/`
|
||||
- Frontend: copy the complete `custom_components/houseplan/frontend/` tree.
|
||||
Copying only `houseplan-card.js` is unsupported: the entry imports hashed chunks and
|
||||
validates its editor runtime against the build fingerprint.
|
||||
- Cache busting: `sed` the `?v=` version in `.storage/lovelace_resources`, then restart HA.
|
||||
- **The `frontend/` subfolder is not optional.** `__init__.py` registers
|
||||
`Path(__file__).parent / "frontend" / "houseplan-card.js"` as the static path.
|
||||
|
||||
@@ -3255,6 +3255,26 @@ require hands on real hardware — they remain for the human pass.
|
||||
Popover and forced portal fallback have the same no-layout-shift contract
|
||||
[auto: `smoke_help_affordance`].
|
||||
|
||||
## Lazy editor runtime and frontend asset tree (#337)
|
||||
|
||||
- [ ] A cold configured View reaches a complete interactive frame without any
|
||||
request for `houseplan-editor-runtime-*.js`. The first Plan/Devices/
|
||||
Background intent requests it once; later editor switches do not repeat
|
||||
the request [auto: `smoke_lazy_editor_chunk`].
|
||||
- [ ] Two failed requests, or a runtime with a different build fingerprint,
|
||||
leave mode, camera and plan in View and show the localized refresh advice
|
||||
[auto: `editor-runtime-loader.test`, `smoke_lazy_editor_chunk`].
|
||||
- [ ] An empty installation requests the dedicated onboarding chunk, displays
|
||||
the first-space dialog and still has no editor request. Saving a drawn
|
||||
first space requests the editor once and continues into Plan; async
|
||||
`getConfigElement()` still returns `houseplan-card-editor`
|
||||
[auto: `smoke_lazy_editor_chunk`].
|
||||
- [ ] `bundle:budget` follows transitive static imports and keeps initial View
|
||||
at or below 256000 B gzip. Bundle sync, demo freshness, CI artifacts and
|
||||
release zip validation fail when any manifest-listed asset is missing or
|
||||
its SHA-256 differs [auto: `bundle-assets.test`, `bundle-freshness.test`,
|
||||
release-contract tests].
|
||||
|
||||
## Hiding layers: decor, openings, zero-thickness walls (docs/UX-MODES.md)
|
||||
|
||||
- [ ] **Room names have one literal off state (#203)**: disable «Показывать
|
||||
|
||||
@@ -101,7 +101,14 @@ on-disk path, not the JavaScript URL served by Home Assistant.
|
||||
|
||||
Copy the release folder to `config/custom_components/houseplan`, restart Home
|
||||
Assistant, add the integration, then add the resource above only if Lovelace
|
||||
resources are YAML-managed.
|
||||
resources are YAML-managed. Always copy the complete integration folder: the
|
||||
stable resource URL remains one file, but that entry loads internal
|
||||
content-hashed modules. A lone `houseplan-card.js` is not a supported install.
|
||||
|
||||
The ordinary View does not download editor code. The first opening of Plan,
|
||||
Device or Background may therefore take a brief moment. If that internal module
|
||||
cannot be loaded after one retry, the plan stays in View and asks you to refresh
|
||||
the page; no half-open editor is kept.
|
||||
|
||||
### Permissions
|
||||
|
||||
|
||||
@@ -101,6 +101,15 @@ resources:
|
||||
3. Добавьте интеграцию **House Plan**.
|
||||
4. Если ресурсы Lovelace управляются YAML, добавьте ресурс из примера выше.
|
||||
|
||||
Копируйте папку интеграции целиком: стабильный URL ресурса по-прежнему один,
|
||||
но entry-файл загружает внутренние модули с хешированными именами. Установка
|
||||
одного `houseplan-card.js` не поддерживается.
|
||||
|
||||
В обычном просмотре код редакторов не загружается. Поэтому первое открытие
|
||||
«Плана», «Устройств» или «Подложки» может занять короткое время. Если внутренний
|
||||
модуль не загрузился и после одной повторной попытки, план остаётся в просмотре
|
||||
и предлагает обновить страницу; наполовину открытого редактора не остаётся.
|
||||
|
||||
### Права пользователей
|
||||
|
||||
| Настройка интеграции | Просмотр | Управление устройствами | Редактирование планов и загрузка файлов |
|
||||
|
||||
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 345 KiB |
|
Before Width: | Height: | Size: 334 KiB After Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 319 KiB After Width: | Height: | Size: 320 KiB |
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 319 KiB |
|
Before Width: | Height: | Size: 296 KiB After Width: | Height: | Size: 297 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 143 KiB |
@@ -2,8 +2,8 @@
|
||||
"version": 1,
|
||||
"fixture": "synthetic-only",
|
||||
"chromium": "151.0.7922.34",
|
||||
"sourceFingerprint": "fd5a7383c969b1a8e03fc334c745d90a1d0f2c5c8df5434b18a57474712e83c5",
|
||||
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
|
||||
"sourceFingerprint": "5f56dab65ab3ec45e84eb724fd5b13a4b4d3d94413f86c529b7b66702282e2af",
|
||||
"captureScriptSha256": "23e17620d030b52ea1da5187d4f638f029478ad4f1c56bb64b07dd635397972a",
|
||||
"command": "npm run build && node demo/docs/capture.mjs",
|
||||
"scenarios": {
|
||||
"view-desktop": {
|
||||
@@ -14,8 +14,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "fd5a7383c969b1a8e03fc334c745d90a1d0f2c5c8df5434b18a57474712e83c5",
|
||||
"imageSha256": "0ca982495a7749907c48ebbddfd089c54385a4347f79f64e94af2c5a8d5c03eb"
|
||||
"sourceSha256": "5f56dab65ab3ec45e84eb724fd5b13a4b4d3d94413f86c529b7b66702282e2af",
|
||||
"imageSha256": "9190bd86a07b0cb0502019d76d3286c0542181e1b4a4f862077d9cefa120613c"
|
||||
},
|
||||
"view-touch": {
|
||||
"file": "02-view-touch.png",
|
||||
@@ -25,8 +25,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "fd5a7383c969b1a8e03fc334c745d90a1d0f2c5c8df5434b18a57474712e83c5",
|
||||
"imageSha256": "1c8e28ff34736b936ba7c9ec047d1da27aaf6fbb01411155f2d5c05282c02c41"
|
||||
"sourceSha256": "5f56dab65ab3ec45e84eb724fd5b13a4b4d3d94413f86c529b7b66702282e2af",
|
||||
"imageSha256": "cfa2f77eb68df69ae9fc9b51ffe2f14df01f7b74444fd2b038cadc1e9dfee22b"
|
||||
},
|
||||
"space-create": {
|
||||
"file": "03-space-create.png",
|
||||
@@ -36,8 +36,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "fd5a7383c969b1a8e03fc334c745d90a1d0f2c5c8df5434b18a57474712e83c5",
|
||||
"imageSha256": "a55b463055a565892ca139014e314a8f63fb5301c87fff2e48d7e69210fe9b6c"
|
||||
"sourceSha256": "5f56dab65ab3ec45e84eb724fd5b13a4b4d3d94413f86c529b7b66702282e2af",
|
||||
"imageSha256": "a86d4211af24923d5048e9094d0d35f9b28129394d00cd72582a8f8c2dd7ff75"
|
||||
},
|
||||
"room-contour-close": {
|
||||
"file": "04-room-contour-close.png",
|
||||
@@ -47,8 +47,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "fd5a7383c969b1a8e03fc334c745d90a1d0f2c5c8df5434b18a57474712e83c5",
|
||||
"imageSha256": "dfad9a27371cfa397a3fd8dce7005f42d24649743f5322105ee894067d3a9f16"
|
||||
"sourceSha256": "5f56dab65ab3ec45e84eb724fd5b13a4b4d3d94413f86c529b7b66702282e2af",
|
||||
"imageSha256": "c7effd4ccd19bcaff9138458583368aaab488dcb6fc7ea5ab766383cb5377ac4"
|
||||
},
|
||||
"plan-context-tray": {
|
||||
"file": "05-plan-context-tray.png",
|
||||
@@ -58,8 +58,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "fd5a7383c969b1a8e03fc334c745d90a1d0f2c5c8df5434b18a57474712e83c5",
|
||||
"imageSha256": "b75f348c632296da5c2fc142d53df613f757e1516acac315be50f5b4557729cc"
|
||||
"sourceSha256": "5f56dab65ab3ec45e84eb724fd5b13a4b4d3d94413f86c529b7b66702282e2af",
|
||||
"imageSha256": "af1194f073f00b03af02b949a072cffb21bfd29995aa65a067eaaaec74ef8490"
|
||||
},
|
||||
"device-editor": {
|
||||
"file": "06-device-editor.png",
|
||||
@@ -69,8 +69,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "fd5a7383c969b1a8e03fc334c745d90a1d0f2c5c8df5434b18a57474712e83c5",
|
||||
"imageSha256": "6b9cd281450e7782cff292f11efc58d227bedd0df86afc3be987feb2edec9dc3"
|
||||
"sourceSha256": "5f56dab65ab3ec45e84eb724fd5b13a4b4d3d94413f86c529b7b66702282e2af",
|
||||
"imageSha256": "36ed21b66920ff2de9e3346cf6f27c67e64a9f81692599b9c70d8373f5eb7241"
|
||||
},
|
||||
"device-display-preview": {
|
||||
"file": "06-device-display-preview.png",
|
||||
@@ -80,8 +80,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "fd5a7383c969b1a8e03fc334c745d90a1d0f2c5c8df5434b18a57474712e83c5",
|
||||
"imageSha256": "6f628abe6b89d7c4b352e6d73ac0b126526f6fdba5927bb774c5b884e484ca19"
|
||||
"sourceSha256": "5f56dab65ab3ec45e84eb724fd5b13a4b4d3d94413f86c529b7b66702282e2af",
|
||||
"imageSha256": "7151c96dc048381f7a7070ecb85a82a890b2f4c703628f14fa472eb8dacbf3dc"
|
||||
},
|
||||
"background-editor": {
|
||||
"file": "07-background-editor.png",
|
||||
@@ -91,8 +91,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "fd5a7383c969b1a8e03fc334c745d90a1d0f2c5c8df5434b18a57474712e83c5",
|
||||
"imageSha256": "a1d1a905f1410f73231d2ea5d1dfbd8ae79c2fe8b0a79cff87ebb977ac7a3103"
|
||||
"sourceSha256": "5f56dab65ab3ec45e84eb724fd5b13a4b4d3d94413f86c529b7b66702282e2af",
|
||||
"imageSha256": "9f08f3f3711b100d68fb1a05af269e43e47d8f8d12c0cbe4f9ab557138722989"
|
||||
},
|
||||
"room-card": {
|
||||
"file": "08-room-card.png",
|
||||
@@ -102,8 +102,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "fd5a7383c969b1a8e03fc334c745d90a1d0f2c5c8df5434b18a57474712e83c5",
|
||||
"imageSha256": "48a5685039a5e0de2a28190d857b193c86e2e63606e3c7a8c40fc9d2b190da4e"
|
||||
"sourceSha256": "5f56dab65ab3ec45e84eb724fd5b13a4b4d3d94413f86c529b7b66702282e2af",
|
||||
"imageSha256": "66d8b0a509909822ff6b891a483bad9d2c72b20f08e287355883aa5205c6cee0"
|
||||
},
|
||||
"device-info": {
|
||||
"file": "09-device-info.png",
|
||||
@@ -113,8 +113,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "fd5a7383c969b1a8e03fc334c745d90a1d0f2c5c8df5434b18a57474712e83c5",
|
||||
"imageSha256": "29a342811aaedaebc54ba337792d60cb454a7cf4c3063b210f6c4bafe0a4d87b"
|
||||
"sourceSha256": "5f56dab65ab3ec45e84eb724fd5b13a4b4d3d94413f86c529b7b66702282e2af",
|
||||
"imageSha256": "f4d3bda00a2fce54f408b1f20e03b5c590ba4b168fd214a28aafb1692b1f3124"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
# CODE-REVIEW-337-r1
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/337
|
||||
- Материал: `git diff origin/dev...HEAD` на `6d338b78a0794491639edee9171662cdd260c42b`
|
||||
(ветка `issue/337-lazy-editor-chunk`, HEAD detached), 99 файлов, +33197/-26570.
|
||||
- Этап: код-ревью, заход r1, блокирующих циклов израсходовано 0/4 до этого разбора.
|
||||
- **Вердикт: красный.**
|
||||
|
||||
## Скоуп
|
||||
|
||||
ТЗ (`docs/specs/337-lazy-editor-chunk.md`, принято зелёным на SPEC-REVIEW-337-r1)
|
||||
требует: (1) View не должен загружать код трёх редакторов до намерения открыть
|
||||
редактор; (2) редакторы, kiosk-контролы, onboarding и Lovelace GUI config editor
|
||||
должны продолжать работать **без изменения наблюдаемого поведения**; (3) при
|
||||
ошибке загрузки редактора View остаётся рабочим и показывает новое сообщение;
|
||||
(4) multi-asset build/раздача/CI/HACS-контракт. Диапазон правки: `src/houseplan-card.ts`
|
||||
похудел на ~11.6 тыс. строк, весь этот код перенесён в новый
|
||||
`src/houseplan-editor-runtime.ts` (13 016 строк) + `src/houseplan-onboarding-runtime.ts`
|
||||
(843 строки) + `src/editor-runtime-loader.ts` (72 строки); плюс полностью новый
|
||||
multi-asset build/раздача (`scripts/bundle-*.mjs`, `frontend_assets.py`,
|
||||
`frontend_asset_manifest.py`), CSS-минификатор и обновлённая документация/changelog.
|
||||
Это перенос практически всего некогда-монолитного класса через новую границу
|
||||
`host ↔ typed port ↔ lazy runtime` — диапазон дельты сопоставим с исходной
|
||||
задачей целиком, разбор ведётся полностью.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Дешёвые гейты подтверждены зелёным Validate на этом же SHA
|
||||
(https://github.com/Matysh/houseplan-card/actions/runs/33137282955) **частично** —
|
||||
см. ниже отдельное расследование, почему доверять этому прогону в части
|
||||
smoke/golden нельзя. Я перепрогнал самостоятельно:
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| typecheck | `npx tsc --noEmit` (через `npm run build`) | pass |
|
||||
| unit | `npm test` | 1438 тестов, 1437 pass, 1 skip, 0 fail |
|
||||
| build + budget | `npm run build && npm run bundle:sync && npm run bundle:budget` | initial View 255 385 B ≤ 256 000 B (запас 615 B); lazy editor 131 765 B |
|
||||
| docs fingerprint | `node scripts/check-docs.mjs` | pass (7 файлов, 10 внешних ссылок) |
|
||||
| **browser smokes — ВСЕ 195** | `for f in demo/smoke_*.mjs; do node "$f"; done` (после `bundle:sync`) | **178 pass / 17 FAIL** |
|
||||
| golden | `npm run golden:verify` | **127 passed / 4 different** |
|
||||
|
||||
Почему прогнан весь набор smoke, а не выборка: diff перемещает весь бывший
|
||||
монолит в новый файл, поэтому `node scripts/smoke-select.mjs --base origin/dev --head HEAD`
|
||||
называет **175 из 195** смоков «прямым совпадением» (символы на изменённых
|
||||
строках) — порог «широкого» символа превышен на два порядка. Это ровно случай
|
||||
«дельта не локальна» из инструкции разбора: выборка не имела бы смысла,
|
||||
проверен весь набор.
|
||||
|
||||
**Почему нельзя было принять зелёный Validate за доказательство smoke/golden.**
|
||||
Разобрал историю прогонов ветки (`gh run list --branch issue/337-lazy-editor-chunk`):
|
||||
|
||||
1. Коммит `876e709c` («test: enter lazy editor in resize smoke» — тот же коммит,
|
||||
что сейчас на одну позицию перед HEAD) получил **свой собственный** прогон
|
||||
Validate (`33136940969`) **до** финального docs-коммита. В нём **фронтенд,
|
||||
HACS, hassfest, backend, perf выполнились и прошли**, но **все три шарда
|
||||
smoke упали**, **golden упал** (4 разных кадра), и **preflight (docs/провенанс)
|
||||
тоже упал**.
|
||||
2. Следующий и последний коммит `6d338b78` («docs: refresh screenshots after dev
|
||||
rebase») меняет только `docs/images/06-device-editor.png` и
|
||||
`docs/images/screenshots.json`. Job `changes` в `validate.yml` при push на
|
||||
ветку задачи считает diff **инкрементально** — `git diff` между предыдущим и
|
||||
новым tip пуша, а не `merge-base(dev)..HEAD`. Поэтому для этого пуша
|
||||
`frontend=false`, и jobs `frontend`/`smoke`/`golden`/`hacs`/`hassfest`/`backend`
|
||||
**пропущены** (`skipped`, не «reused по маркеру» — маркер smoke/golden явно
|
||||
«Cache not found» в логе job «Переиспользование»). Итоговый зелёный статус
|
||||
прогона `33137282955` относится только к docs/provenance/process-gate этого
|
||||
инкремента и никогда не перепроверял smoke/golden после
|
||||
`876e709c`.
|
||||
3. Следствие: **ни один зелёный прогон CI на этой ветке не подтверждает, что
|
||||
smoke/golden проходят на финальном дереве.** Последний прогон, где они
|
||||
реально исполнялись, был красным. Хендофф-комментарий автора («Целевые
|
||||
browser smokes... green», «Точный CI:...— green: (переиспользованы зелёные
|
||||
...markers)») интерпретирует переиспользование/пропуск как подтверждение,
|
||||
но по факту это не так — маркер smoke/golden для точного дерева `876e709c`
|
||||
не «success», а «Cache not found», и последующий пуш этот маркер не создал,
|
||||
потому что fingerprint jobs совпадает только внутри `reuse`, а `changes`
|
||||
для пути smoke/golden использует другой, инкрементальный механизм.
|
||||
4. Мой независимый прогон на `HEAD=6d338b78` **воспроизводит содержательно те
|
||||
же классы падений**, что были красными в `876e709c` (kiosk, warm-remount,
|
||||
device-inbox, resize-preflight, golden device-editor/dialogs) — то есть
|
||||
ничего из перечисленного не было исправлено между `876e709c` и `6d338b78`,
|
||||
несмотря на заявление «Готово к автоматическому S7 code review».
|
||||
|
||||
## Находки
|
||||
|
||||
### High (блокируют)
|
||||
|
||||
**H1. Warm-remount коммитит editor mode в обход loader-гейта — падения на
|
||||
уже существующих smoke.**
|
||||
`src/houseplan-card.ts:3164-3206`, `_warmAdoptViewport()`, строка 3180:
|
||||
```ts
|
||||
this._adoptMode(vp.mode !== 'view' && this._canEdit && !config.kiosk ? vp.mode : 'view');
|
||||
```
|
||||
Это прямой вызов `_adoptMode()`, минуя `_requestMode()` — единственное место,
|
||||
которое перед сменой режима вызывает `_ensureEditorRuntime()`
|
||||
(`src/houseplan-card.ts:890-898`). ТЗ §6.2.1 требует: «Mode, editor chrome и
|
||||
editor camera не коммитятся до успешной установки runtime». При warm-remount
|
||||
(HA пересоздаёт элемент карточки на том же месте — `docs/WARM-REMOUNT.md`)
|
||||
новый экземпляр получает собственный `_editorRuntimeLoader`/`_editorRuntime`
|
||||
(поля инстанса, не модуля), но `_warmAdoptViewport` восстанавливает
|
||||
предыдущий `mode` немедленно и синхронно, до того как что-либо вызвало
|
||||
`_ensureEditorRuntime()` на новом инстансе.
|
||||
|
||||
**Воспроизведение:** три существующих (не изменённых этой задачей) smoke
|
||||
детерминированно падают на этом:
|
||||
- `demo/smoke_preloader_lifecycle.mjs` — `page.evaluate: Error: Houseplan
|
||||
editor runtime is not loaded`, стек `_renderMarkupDefs → render → update`;
|
||||
- `demo/smoke_warm_dialogs.mjs` — 8 идентичных необработанных исключений с тем
|
||||
же стеком в процессе штатного сценария «тёплого» возврата к диалогу;
|
||||
- `demo/smoke_warm_owners.mjs` — `aOwnersDraftRestored`/`bDraftOpenBefore`/
|
||||
`bDraftSurvivedDoubleRemount` — все три `expected true, got false` (черновик
|
||||
не восстанавливается после повторного remount).
|
||||
|
||||
Тест умеет падать: ни один из трёх не является новым или переписанным этой
|
||||
задачей (не входят в diff), то есть до правки они проходили, а сейчас —
|
||||
красные на HEAD.
|
||||
|
||||
**H2. Kiosk-контролы, явно названные в принятом ТЗ eager (§6.3: «kiosk
|
||||
controls остаются eager, если они нужны View сами по себе»), зависят от
|
||||
ленивого runtime и не работают в первом View.**
|
||||
`src/houseplan-card.ts:10599-10600`:
|
||||
```ts
|
||||
private _saveKioskScale(patch: Partial<{ icon: number; font: number }>): void {
|
||||
return this._editorRuntimeOrThrow()._saveKioskScale(patch);
|
||||
}
|
||||
```
|
||||
Диалог, из которого этот метод вызывается, открывается напрямую из View по
|
||||
3-секундному long-press на пустой сцене в kiosk-режиме —
|
||||
`src/houseplan-card.ts:5980-5997` (`_stagePointerDown`, `this._kioskDialog =
|
||||
true`) — без единого вызова `_ensureEditorRuntime()` на этом пути. Рендер
|
||||
диалога отдельно защищён (`this._editorRuntime ? this._renderKioskDialog() :
|
||||
nothing`, строка 11289), поэтому на «холодном» View (никто ещё не открывал
|
||||
редактор) long-press на планшете/панели **молча не открывает попап** размера
|
||||
значков/текста — ключевая функция kiosk/планшетной персоны, для которой View
|
||||
и есть продукт (`docs/SCOPE.md`), просто перестаёт быть доступна до первого
|
||||
входа администратора в редактор где-то ещё.
|
||||
|
||||
**Воспроизведение:** `demo/smoke_kiosk.mjs` (существующий, не тронут этой
|
||||
задачей) — `page.evaluate: Error: Houseplan editor runtime is not loaded` при
|
||||
вызове `_saveKioskScale`, воспроизводится детерминированно.
|
||||
|
||||
**H3. Безопасность resize «fail-closed» сломана: принудительный отказ
|
||||
preflight-проверки всё равно приводит к реальному, отличному от исходного,
|
||||
коммиту геометрии.**
|
||||
`demo/smoke_room_resize.mjs` (существующий) подменяет
|
||||
`card._checkSpacePhysicalGeometry = () => ({ ok: false, status: 'failed' })`
|
||||
и тянет край комнаты во время активного (мокнутого) отказа. Ожидание по
|
||||
контракту #199/#277: ноль записей, комната остаётся на исходной геометрии.
|
||||
Факт:
|
||||
```
|
||||
safe_resize.preflight_visible_reason: expected true, got false
|
||||
safe_resize.preflight_reason_once: expected 1, got 0
|
||||
safe_resize.preflight_no_commit: expected [[0.1,0.1],[0.4,...]], got [[0.1,0.1],[0.5333...,...]]
|
||||
safe_resize.preflight_zero_write: expected 0, got 1
|
||||
safe_resize.commit_preflight_no_commit: expected 0.4, got 0.5
|
||||
safe_resize.commit_preflight_zero_write: expected 0, got 1
|
||||
```
|
||||
Комната реально переместилась на позицию под курсором и в истории геометрии
|
||||
появилась запись — то есть подмена `card._checkSpacePhysicalGeometry` (метод
|
||||
на HOST, `src/houseplan-card.ts:9955`, форвардящий в
|
||||
`_editorRuntimeOrThrow()._checkSpacePhysicalGeometry`) не перехватывает
|
||||
реальный вызов внутри перенесённого resize-контроллера. Тот же класс
|
||||
поломки — на независимом safety-контуре `_checkOptimizeGeometry`
|
||||
(`demo/smoke_optimize_geometry_preflight.mjs`, комментарий в файле явно
|
||||
ссылается на #199 «production bundle must fail closed before the Optimize WS
|
||||
write»): подмена того же вида, `forceRed`-обёртка, даёт **9 упавших проверок
|
||||
из 9** — preflight в проде для «Оптимизировать план» тоже не подтверждён
|
||||
рабочим.
|
||||
|
||||
Это не вопрос тестируемости в узком смысле — на `preflight_no_commit`/
|
||||
`commit_preflight_no_commit` наблюдается **реальный commit геометрии,
|
||||
отличной от исходной**, когда по контракту не должно быть commit вообще.
|
||||
Учитывая, что это ровно тот класс дефектов, ради которого заведены #199, #253,
|
||||
#258, #259, #277, #291 (записи толщины/геометрии, zero-write гарантии) —
|
||||
серьёзность максимальная.
|
||||
|
||||
**H4. Стрелочная навигация по вкладкам инвентаря устройств падает с
|
||||
необработанным исключением, сама функция не работает.**
|
||||
`demo/smoke_device_inbox.mjs` (существующий) — `ArrowRight` в открытом
|
||||
инвентаре устройств:
|
||||
```
|
||||
EXC TypeError: Cannot read properties of undefined (reading '_deviceInbox')
|
||||
at fm._deviceInboxTabKey (...houseplan-editor-runtime-pmkXpWgM.js:929:37170)
|
||||
```
|
||||
`src/houseplan-editor-runtime.ts:7398-7407`, тело метода читает
|
||||
`this.host._deviceInbox` — на живом дереве `this` внутри обработчика,
|
||||
привязанного как `@keydown=${this._deviceInboxTabKey}`
|
||||
(`src/houseplan-editor-runtime.ts:11374`), оказывается не инстансом runtime
|
||||
(`this.host` undefined). Результат: `arrowChangesTab: expected true, got
|
||||
false`, плюс необработанное исключение внутри карточки — функция полностью
|
||||
неработоспособна, а не просто «отличается от ожидания».
|
||||
|
||||
**H5. Визуальная порча вторичного тулбара Device editor + 3 неподтверждённых
|
||||
golden-дельты по диалогам устройств.**
|
||||
`npm run golden:verify` на HEAD: **4 из 131 кадра отличаются** (не 0, как
|
||||
требует AC3 «без golden delta», и не согласовано отдельным решением владельца,
|
||||
как требует §12 «принятие эталонов только через `golden:accept --reviewed`»):
|
||||
- `geometry-devices-editor-dark` — подписи кнопок вторичного тулбара Device
|
||||
editor визуально **накладываются друг на друга** (скриншот diff:
|
||||
`artifacts/golden/diff/geometry-devices-editor-dark.png`) — реальная порча
|
||||
layout, а не смещение на пиксель;
|
||||
- `device-dialog-mobile-ru`, `toggle-entity-dialog-mobile-ru`,
|
||||
`device-ripple-color-popover-mobile-ru` — одинаковый паттерн: узкая полоса
|
||||
различий по правому/нижнему краю диалога на мобильной ширине (изменился
|
||||
размер/переполнение диалога).
|
||||
|
||||
Все четыре сценария относятся к Device editor/device-диалогам — подсистеме,
|
||||
чей код перенесён этой задачей в `houseplan-editor-runtime.ts`. Ни один из
|
||||
четырёх не упомянут в хендоффе автора и не принят через
|
||||
`golden:accept -- --reviewed`.
|
||||
|
||||
### Medium (в скоупе, чинится в этой же задаче)
|
||||
|
||||
**M1. Два существующих smoke ожидают, что `houseplan-card-editor` уже
|
||||
зарегистрирован на странице, но ТЗ сделало регистрацию асинхронной (AC11), а
|
||||
фикстуры не обновлены.**
|
||||
`demo/smoke_fixed_floor.mjs:74` и `demo/smoke_orphan_space_references.mjs:6`
|
||||
делают `document.createElement('houseplan-card-editor')` напрямую, без
|
||||
предварительного `await HouseplanCard.getConfigElement()` (или `import
|
||||
'./editor'`). До задачи `./editor` импортировался статически, поэтому элемент
|
||||
был зарегистрирован при загрузке бандла; после задачи — нет. Обе фикстуры
|
||||
падают с `TypeError: editor.setConfig is not a function`. План автотестов ТЗ
|
||||
(§14) требует, чтобы существующий набор проходил; это два конкретных, легко
|
||||
чинимых места (добавить `await customElements.whenDefined(...)` после явного
|
||||
триггера лениво загрузки, либо dynamic import перед `createElement`).
|
||||
|
||||
**M2. Смок-инструментовка чтения версии сборки не адаптирована к
|
||||
multi-asset дереву.**
|
||||
`demo/smoke_general_settings.mjs:60-65` ищет строку консольного баннера
|
||||
`HOUSEPLAN-CARD %c v...` в `demo/srv/assets/houseplan-card.js` (entry-файл).
|
||||
После разделения баннер переехал в общий чанк
|
||||
`houseplan-assets/houseplan-card-<hash>.js` — `grep` подтверждает: строки в
|
||||
entry нет, есть только в чанке. `BUNDLE_VERSION` получается `undefined`,
|
||||
`bundleVersionFound` красный, второе сравнение сравнивает с заведомо неверной
|
||||
строкой `"Houseplan Card vundefined"`. Само отображение в диалоге "About"
|
||||
похоже корректно (`v1.68.1`, видно из вывода теста) — это регресс
|
||||
инструментовки теста под новое дерево ассетов, а не видимого поведения, но
|
||||
именно такую регрессию должен был поймать и починить сам этот таск (AC8/§14).
|
||||
|
||||
**M3. Ещё 9 существующих smoke красные на HEAD, не разобраны до корня в этом
|
||||
документе — логи сохранены, требуют триажа автором.**
|
||||
`smoke_edit_walk`, `smoke_ha_controls`, `smoke_help_affordance`,
|
||||
`smoke_nav_persist`, `smoke_opening_entity_search`, `smoke_partition_openings`,
|
||||
`smoke_grid_scale_invariance` — детерминированно красные при отдельном
|
||||
запуске на HEAD (полные логи: `for f in ...; do node "$f"; done`, доступны по
|
||||
запросу/в артефакте ревью-агента). Показательные симптомы: необработанные
|
||||
исключения внутри карточки (`opening_entity_search`, ещё один случай кроме
|
||||
H4), сравнение пикселей не сходится при скрытой сетке
|
||||
(`grid_scale_invariance`), функциональные регрессии в конкретных диалогах
|
||||
(`ha_controls`: выбор «ведущей» сущности при нескольких источниках;
|
||||
`partition_openings`: прямой drag больше не останавливается на границе
|
||||
одинакового джамба; `help_affordance`: второй Escape перестал закрывать
|
||||
диалог; `nav_persist`: устаревший формат навигации не переписывается при
|
||||
следующем переходе). Не считаю доказанным, что все девять сводятся к H1–H4 —
|
||||
не проверял по коду каждый до конца; это отдельная работа автора, но
|
||||
достаточно списка «что именно красное» и «как воспроизвести» (`node
|
||||
demo/smoke_<name>.mjs` после `npm run bundle:sync`), чтобы задача не
|
||||
возвращалась в S7 до того, как каждый пункт либо зафиксирован тестом,
|
||||
либо явно объяснён как заведомо не связанный с #337 регресс из ребейза (в
|
||||
последнем случае — с доказательством: тот же smoke красный и на чистом
|
||||
`origin/dev` до задачи).
|
||||
|
||||
Итого по census: **17 из 195** существующих browser-smoke красные на
|
||||
`git rev-parse HEAD` = `6d338b78`; **4 из 131** golden-кадров отличаются.
|
||||
AC3 («View parity... без golden delta») и AC4 («editor parity... существующие
|
||||
smokes без изменения ожидаемого DOM/данных») формально заявлены выполненными
|
||||
в хендоффе автора, но не выполнены фактически — это не единичный дефект, а
|
||||
систематический разрыв между заявленным прогоном и действительным
|
||||
состоянием дерева (см. раздел «Как проверялось» о причине несовпадения).
|
||||
|
||||
### Low
|
||||
|
||||
Нет отдельных Low-находок сверх перечисленного — все наблюдения либо попали в
|
||||
Medium (в скоуп, чинится здесь), либо в High.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **Бюджет (AC1).** `npm run bundle:budget` на HEAD: initial View 255 385 B ≤
|
||||
256 000 B, lazy editor 131 765 B — арифметически верно, посчитано моим
|
||||
собственным прогоном, не переписано со слов автора. Запас 615 B крайне
|
||||
узкий (сам автор называет это риском) — любой последующий eager-код без
|
||||
учёта бюджета сломает AC1 на первой же правке; отдельной находкой не делаю,
|
||||
так как это явный, зафиксированный риск, а не скрытый факт.
|
||||
- **Импорт-граница (AC2, unit-уровень).** `npm test` — 1438/1438 применимых
|
||||
(1437 pass, 1 skip, 0 fail): в этот прогон входят unit/manifest-тесты
|
||||
import-графа и bundle-assets (`test/bundle-assets.test.mjs`,
|
||||
`test/editor-runtime-loader.test.mjs`, `test/bundle-freshness.test.mjs`) —
|
||||
на уровне статического графа импортов ленивая граница подтверждена. Именно
|
||||
поэтому дефекты H1–H5 не unit-уровня — они интеграционные/browser-уровня, то
|
||||
есть ровно там, где единственная защита это smoke/golden, а не тип-чек.
|
||||
- **Backend asset route (AC7).** `tests_backend/test_frontend_assets.py`
|
||||
существует и по заявлению автора зелёный (3 passed); полный HA-harness в
|
||||
этой песочнице недоступен (нет `homeassistant`), поэтому **не перепрогонял**
|
||||
— доверяю авторскому запуску только для этого файла, так как он pure-Python
|
||||
и не завязан на HA.
|
||||
- **Docs/трейлеры.** `node scripts/check-docs.mjs` — pass. Коммит
|
||||
`c1aaddc7` (`User-Visible: yes`) правит оба changelog в одном коммите —
|
||||
требование соблюдено. Остальные коммиты `User-Visible: no`, все несут
|
||||
`Issue: #337`.
|
||||
- **Демо-стенд синхронен.** `npm run bundle:sync` — dist/integration/demo
|
||||
деревья по 6 файлов, побайтово совпадают (проверено самим скриптом при
|
||||
моём прогоне).
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- **Полный HA-harness backend** (`python -m pytest tests_backend -q` с
|
||||
установленным `homeassistant`) — недоступен в этой песочнице; доверяю
|
||||
авторскому заявлению только для чистого pure-Python подмножества,
|
||||
относящегося к asset route.
|
||||
- **Не довёл до корня 9 из 17 падающих smoke** (список в M3) — это
|
||||
зафиксировано как Medium с логами и командой воспроизведения, а не
|
||||
замолчано.
|
||||
- **Ручное тестирование в реальном Home Assistant** — не выполнялось (не
|
||||
требуется циклом ревью; недоступно из этой среды).
|
||||
- **Performance-профили** — не запускал: ни один AC #337 явно не называет
|
||||
perf-бюджет кадра, только gzip-бюджет (AC1, проверен).
|
||||
- **`node scripts/model-invariants.mjs`** — не запускал: diff не трогает
|
||||
геометрические записи/`layout`/`marker.space`/`open_spans` в смысле их
|
||||
формата хранения (перенос кода, не модели); H3 — поведенческий регресс в
|
||||
runtime-проверке, а не в структуре записи, поэтому инвариант модели здесь не
|
||||
тот инструмент, которым он обнаруживается — обнаружен smoke'ом.
|
||||
- **Полное построчное соответствие каждого пункта §6–§13 спецификации
|
||||
каждой строке кода** — не сделано построчно; проверка велась от
|
||||
найденных красных smoke/golden к коду, а не от текста ТЗ построчно вниз, так
|
||||
как объём диффа (33k+/26k-) делает построчную сверку нецелесообразной при
|
||||
уже найденных блокирующих находках. Даже без учёта H1–H5 количество
|
||||
красных существующих smoke (17) само по себе уже является достаточным
|
||||
основанием для красного вердикта независимо от их индивидуального
|
||||
разбора до конца.
|
||||
|
||||
## Итог
|
||||
|
||||
Пять High-находок делают вердикт красным: код не проходит собственный план
|
||||
автотестов задачи (AC3, AC4), нарушает явное продуктовое решение принятого ТЗ
|
||||
(§6.3 kiosk eager), ломает независимо проверяемую safety-гарантию
|
||||
(fail-closed resize/optimize preflight, H3) и содержит минимум одну прямую
|
||||
runtime-ошибку в рабочей функции (H4). Отдельная системная проблема процесса,
|
||||
не входящая в оценку кода, но существенная для дальнейшей работы над этой
|
||||
веткой: зелёный Validate на финальном SHA возник из-за того, что
|
||||
последний коммит — docs-only и не ретриггерит smoke/golden при инкрементальном
|
||||
diff'е пуша, а не потому что эти гейты были перепройдены после последнего
|
||||
красного прогона (`876e709c`). Пока это не учитывается, «зелёный CI» на
|
||||
последнем коммите задачи с docs-хвостом не является доказательством того, что
|
||||
smoke/golden проходят.
|
||||
@@ -0,0 +1,248 @@
|
||||
# CODE-REVIEW-337-r2
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/337
|
||||
- Материал: `git diff 6d338b78..HEAD` (r1 SHA `6d338b78a0794491639edee9171662cdd260c42b` →
|
||||
HEAD `2beafc06398c16d1cad80fddbe77e6a40f111b9a`), ветка `issue/337-lazy-editor-chunk`.
|
||||
4 коммита, 32 файла (без учёта сгенерированных `dist/*`/`custom_components/houseplan/frontend/*`,
|
||||
которые зеркалят исходники): `src/houseplan-card.ts` (+79/-13), `src/houseplan-editor-runtime.ts`
|
||||
(+55/-55, преимущественно механическая правка), `scripts/mutation-gate.mjs`, два unit-теста,
|
||||
11 browser-smoke фикстур, документ CODE-REVIEW-337-r1, обновлённый `docs/images/screenshots.json`.
|
||||
- Этап: код-ревью, заход r2, блокирующих циклов израсходовано 1/4 до этого разбора.
|
||||
- **Вердикт: красный.**
|
||||
|
||||
## Скоуп разбора
|
||||
|
||||
Предыдущий вердикт (CODE-REVIEW-337-r1, SHA `6d338b78`) — красный, 5 High + 3 Medium.
|
||||
Дельта этого раунда — точечные правки по каждой находке, а не переработка задачи:
|
||||
два изменённых src-файла, обновлённые smoke-фикстуры под новый async-контракт,
|
||||
одна правка mutation-gate (стал ссылаться на актуальный файл стилей после переноса
|
||||
CSS), плюс сам документ r1 и пересборка bandle-артефактов. Это не ребейз на ушедший
|
||||
`dev` (branch и так уже стоит на `508945c0` = `origin/dev`), не смена контракта
|
||||
поведения и не новая подсистема — разбор веду по дельте, а не с нуля, но
|
||||
дополнительно перепрогоняю **весь** browser-smoke census и `golden:verify`, а не
|
||||
только смоки, названные автором: H4-фикс (`_deviceInboxTabKey`/`@click=${this._x}`
|
||||
→ `@click=${() => this._x()}`) механически переписал практически КАЖДЫЙ
|
||||
обработчик события в `houseplan-editor-runtime.ts` (не только вкладки инвентаря
|
||||
устройств) — это затрагивает общий рендер-путь всех редакторских диалогов, а не
|
||||
только те четыре сценария, что назвал автор.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Дешёвые гейты (перепрогнаны самостоятельно, а не приняты со слов автора):
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| typecheck | `npx tsc --noEmit` | pass (5.6s) |
|
||||
| unit | `npm test` | 1439 total, 1438 pass, 1 skip, 0 fail — совпадает с заявлением автора |
|
||||
| build+sync+budget | `npm run build && npm run bundle:sync && npm run bundle:budget` | initial View 255 778 B ≤ 256 000 B (запас 222 B — **уже, чем 615 B на r1**); lazy editor 131 785 B; `bundle:sync` не оставил расхождений в дереве (только биты прав доступа 755→644 на двух артефактах из-за локального `tsc`, отменено `git checkout --`, содержимого не касалось) |
|
||||
| docs fingerprint | `node scripts/check-docs.mjs` (diff трогает `src/**`) | pass (7 файлов, 10 внешних ссылок) |
|
||||
| mutation, changed-range | `node scripts/mutation-gate.mjs --check --changed=6d338b78..HEAD` | 58/58 применимых guard'ов поймали подмену |
|
||||
|
||||
Тяжёлые гейты — по дельте и по решению ревьюера, не выборочно на веру:
|
||||
|
||||
- **Полный browser-smoke census** (195 файлов, `for f in demo/smoke_*.mjs; do
|
||||
node "$f"; done` после `bundle:sync`) — **195/195 pass**, включая
|
||||
`smoke_warm_dialogs` (единственный прогон в составе census). Это ожидаемо и
|
||||
НЕ противоречит находке H6 ниже: точечный прогон того же файла 7 раз подряд
|
||||
дал 5 отказов из 7 (≈71% красных) — при однократном прогоне в общем census
|
||||
благополучный исход (≈29% по моей выборке) не редкость. Единичный зелёный
|
||||
census — статистический шум для гонки такой частоты, а не доказательство
|
||||
исправности; полагаться на однократный прогон здесь означало бы повторить
|
||||
саму ошибку хендоффа автора.
|
||||
- **`npm run golden:verify`** — 127 passed / **4 different** — те же четыре
|
||||
кадра, что в r1: `geometry-devices-editor-dark`, `device-dialog-mobile-ru`,
|
||||
`toggle-entity-dialog-mobile-ru`, `device-ripple-color-popover-mobile-ru`.
|
||||
- **Независимая проверка авторского заявления по H5** (не принято на веру: автор
|
||||
утверждает «pre-existing stale baseline, не регресс #337» — это фактическое
|
||||
утверждение о состоянии `dev`, которое легко проверить и легко сфальсифицировать).
|
||||
Поднял `git worktree add /tmp/dev-check origin/dev --detach` (чистый `508945c0`,
|
||||
ancestor этой ветки), `npm ci && npm run build && npm run bundle:sync`, затем
|
||||
`node demo/golden/run.mjs --mode=capture --scenario=<id>` (verify требует полную
|
||||
матрицу, capture позволяет точечный diff) для всех четырёх кадров:
|
||||
**все четыре «different» воспроизводятся на чистом `dev` без единого изменения
|
||||
из #337.** Открыл сами diff-PNG: `geometry-devices-editor-dark` — актуальный
|
||||
рендер показывает два раздельных, не наложенных друг на друга элемента
|
||||
тулбара («Devices», «Icon rules»); диффовое наложение возникает потому что
|
||||
**эталон** старше и содержит три кнопки («Add», «Hidden and disabled»,
|
||||
«Icon rules») — устаревший baseline `dev`, а не порча layout веткой. Три
|
||||
mobile-ru кадра показывают одинаковую узкую полосу по правому/нижнему краю
|
||||
диалога и на чистом `dev` — тоже baseline drift, не регресс. **H5 закрыт**:
|
||||
находка воспроизводится на `dev` и не относится к #337; это отдельный,
|
||||
предсуществующий дефект золотых эталонов — завёл отдельный issue
|
||||
([#346](https://github.com/Matysh/houseplan-card/issues/346)).
|
||||
- **Целевые smoke, привязанные к находкам r1**, каждый прогнан минимум по
|
||||
разу, некоторые — многократно при подозрении на нестабильность (ниже):
|
||||
`smoke_preloader_lifecycle`, `smoke_warm_owners`, `smoke_kiosk`,
|
||||
`smoke_room_resize`, `smoke_optimize_geometry_preflight`,
|
||||
`smoke_device_inbox`, `smoke_fixed_floor`, `smoke_orphan_space_references`,
|
||||
`smoke_general_settings`, `smoke_ha_controls`, `smoke_nav_persist`,
|
||||
`smoke_partition_openings`, `smoke_grid_scale_invariance`,
|
||||
`smoke_edit_walk`, `smoke_help_affordance`, `smoke_opening_entity_search`.
|
||||
- **`smoke_warm_dialogs` — прогнан 7 раз подряд после `bundle:sync`, а не один
|
||||
раз.** Причина повторов: первый запуск дал `bViewBitExact` FAIL на кадре,
|
||||
близком, но не идентичном сохранённой камере — при плавающей точке одного
|
||||
разового отказа недостаточно, чтобы отличить гонку от единичной аномалии
|
||||
окружения. Результат: **5 из 7 запусков красные**, см. находку H6 ниже. Это
|
||||
прямо противоречит заявлению автора «warm_dialogs green» в хендоффе.
|
||||
- **model-invariants** — не запускал: дельта не меняет ни один формат хранения
|
||||
геометрии (`layout`, `marker.space`, `open_spans`, ключи толщины); H3-фикс
|
||||
меняет только то, ЧЕРЕЗ КАКОЙ объект (`this` vs `this.host`) вызывается уже
|
||||
существующая preflight-проверка, не сами данные. Не тот инструмент для этой
|
||||
дельты — то же обоснование, что в r1.
|
||||
- **Backend/pytest** — не тронуты этой дельтой (`custom_components/**/*.py` не
|
||||
входит в diff `6d338b78..HEAD`), не перепрогонял.
|
||||
|
||||
## Закрытие раунда r1
|
||||
|
||||
| Находка r1 | Чем закрыта | Где видно |
|
||||
|---|---|---|
|
||||
| **H1** (warm-remount коммитит mode в обход `_ensureEditorRuntime()`) | `_warmAdoptViewport` больше не коммитит редакторский `mode` напрямую: адаптирует `'view'` синхронно, а восстановление `mode` уходит через `_requestMode(restoredMode, false, true)`, который проходит `_ensureEditorRuntime()`; новый параметр `adopt` в `_requestMode` атомарно вызывает `_adoptMode` + возобновляет отложенный `_warmReviveDialog` | `src/houseplan-card.ts:890-901` (сигнатура/adopt-ветка `_requestMode`), `:3190-3222` (`_warmAdoptViewport`), `:3358-3369` (`_warmReviveDialog` откладывает потребление диалога, если ждёт runtime) | **Частично** — крах устранён (`smoke_preloader_lifecycle`, `smoke_warm_owners` зелёные детерминированно), но см. новую находку **H6**: та же зона правки сломала бит-точность камеры |
|
||||
| **H2** (kiosk scale зависит от lazy runtime) | `_saveKioskScale`/`_renderKioskDialog` возвращены в eager-код хоста, больше не форвардятся в `_editorRuntimeOrThrow()`; вызов диалога больше не защищён условием `this._editorRuntime ?` | `src/houseplan-card.ts:10629-10662`, `:11350` (`${this._kioskDialog ? this._renderKioskDialog() : nothing}`) | `smoke_kiosk` зелёный (сам прогнал) |
|
||||
| **H3** (fail-closed resize/optimize preflight не перехватывается подменой) | Внутренние вызовы `_checkSpacePhysicalGeometry`/`_checkOptimizeGeometry` внутри runtime переведены на `this.host._checkSpacePhysicalGeometry(...)`/`this.host._checkOptimizeGeometry(...)` — то есть идут через тот же публичный метод хоста, который переопределяет смок; сами реализации переименованы в `..._Impl` и не вызываются напрямую нигде, кроме хоста | `src/houseplan-editor-runtime.ts:1117-1120` (интерфейс `HouseplanEditorHostPort` пополнен), `:1761, 2085, 2156, 2265, 2288, 3609, 3626, 8591, 8599, 8688, 8721` (все внутренние вызовы через `this.host.`), `src/houseplan-card.ts:9973/9984` (`_checkOptimizeGeometry`/`_checkSpacePhysicalGeometry` хоста форвардят в `..._Impl`) | `smoke_room_resize` и `smoke_optimize_geometry_preflight` зелёные (сам прогнал) |
|
||||
| **H4** (`_deviceInboxTabKey` падает — `this` не тот) | Метод стал полем-стрелкой (`public _deviceInboxTabKey = (event) => {...}`), а КАЖДЫЙ голый `@event=${this._method}` в файле переписан в `@event=${() => this._method(...)}` — то есть устранён весь класс бага, а не только один обработчик; закреплено регрессионным unit-тестом, который grep'ает файл на голые `@x=${this._y}` и требует пустой список | `src/houseplan-editor-runtime.ts:7401-7410` (метод), десятки мест по файлу (см. `git diff 6d338b78..HEAD -- src/houseplan-editor-runtime.ts`), `test/editor-runtime-loader.test.mjs:124-133` («lazy runtime event handlers keep the runtime receiver») | `smoke_device_inbox` зелёный (сам прогнал); тест зелёный в `npm test` |
|
||||
| **H5** (4 golden-дельты, включая порчу тулбара Device editor) | Не изменено ни строки, относящейся к golden — **не регресс #337**: independently подтверждено, что все 4 кадра дают «different» и на чистом `origin/dev` (`508945c0`) без единого коммита этой ветки, см. раздел «Как проверялось» | `git diff origin/dev...HEAD -- demo/golden/baselines/` пуст (нулевой diff эталонов); скриншот-diff `geometry-devices-editor-dark` на чистом `dev` показывает то же наложение старого 3-кнопочного baseline поверх нового 2-кнопочного актуального рендера | **Закрыт как не относящийся к #337**; отдельный issue [#346](https://github.com/Matysh/houseplan-card/issues/346), не блокирует эту задачу |
|
||||
| **M1** (`fixed_floor`/`orphan_space_references` создают `houseplan-card-editor` напрямую) | Обе фикстуры переведены на `await customElements.get('houseplan-card').getConfigElement()` | `demo/smoke_fixed_floor.mjs:75`, `demo/smoke_orphan_space_references.mjs:191` | Оба смока зелёные (сам прогнал) |
|
||||
| **M2** (`general_settings` ищет версию только в entry-файле) | Смок теперь читает `houseplan-assets.json`, конкатенирует все `.js`-файлы манифеста и ищет баннер в объединённой строке | `demo/smoke_general_settings.mjs:61-67` | Зелёный (сам прогнал) |
|
||||
| **M3** (9 нерасследованных красных смоков) | `ha_controls`, `nav_persist`, `partition_openings`, `grid_scale_invariance` явно адаптированы под async editor runtime (`_editorRuntime`, `await c._requestMode(...)`, `await c._ensureEditorRuntime()`) | `demo/smoke_ha_controls.mjs`, `demo/smoke_nav_persist.mjs`, `demo/smoke_partition_openings.mjs`, `demo/smoke_grid_scale_invariance.mjs` (везде в диффе `6d338b78..HEAD`) | Все четыре зелёные при точечном прогоне; `edit_walk`, `help_affordance`, `opening_entity_search` не изменялись в дельте — перепрогнаны отдельно, зелёные, входят в «унаследовано» ниже |
|
||||
|
||||
## Новые находки
|
||||
|
||||
### High (блокирует)
|
||||
|
||||
**H6. Warm-remount editor-камера теряет бит-точность — визуальный «прыжок»
|
||||
кадра, воспроизводится в большинстве прогонов.**
|
||||
|
||||
Контракт (комментарий самого смока, `demo/smoke_warm_dialogs.mjs:1-9`,
|
||||
`docs/WARM-REMOUNT.md`): при пересоздании элемента картой (Lovelace warm
|
||||
remount) камера редактора должна восстановиться **бит-в-бит — ни один кадр не
|
||||
отличается от прежнего**. Прогнал `node demo/smoke_warm_dialogs.mjs` **7 раз
|
||||
подряд** после `npm run bundle:sync`: **5 из 7 — FAIL** на `bViewBitExact`:
|
||||
|
||||
```
|
||||
FAILED (1):
|
||||
- bViewBitExact: expected true, got "кадр 20мс: zoom=3.4
|
||||
view=[256.7064599980745,247.64705882352942,346.587080003851,264.70588235294116]
|
||||
(ждали 3.4 / [254.1156927887434,247.64705882352942,351.7686144225132,264.70588235294116])"
|
||||
```
|
||||
|
||||
Во всех пяти отказов кадр приходит на 9–20мс после пересоздания, `zoom` и `y/h`
|
||||
точно совпадают с ожиданием, а `x`/`w` — нет (расхождение 2–6 логических
|
||||
единиц) — то есть не полный сброс камеры, а короткий одиночный кадр с
|
||||
пересчитанной шириной вида, прежде чем всё встаёт на место. Это ровно та зона,
|
||||
что переписал H1-фикс: `_warmAdoptViewport` (`src/houseplan-card.ts:3196-3222`)
|
||||
синхронно восстанавливает `_zoom`/`_view` в точные сохранённые числа ДО того,
|
||||
как `_mode` меняется на `'devices'`, но сам `_mode` первый кадр или два остаётся
|
||||
`'view'` (полноширинная сцена без редакторского тулбара), а асинхронный
|
||||
`_requestMode(restoredMode, false, true)` переключает `_mode` только после
|
||||
`_ensureEditorRuntime()`. Между этими двумя моментами существует окно, где
|
||||
камера уже стоит в «devices»-числах, но сцена ещё рендерится в View-раскладке
|
||||
(другая доступная ширина stage) либо наоборот — и какой-то путь размера/layout
|
||||
(вероятно, привязанный к `ResizeObserver`/первому измерению `_stageEl` для
|
||||
нового инстанса, `_lastValidStageSize` пуст на свежесозданном элементе)
|
||||
пересчитывает `x`/`w` под несовпадающую раскладку на один кадр.
|
||||
|
||||
**Смок умеет падать** — это не переписанный этой задачей файл, докстрока прямо
|
||||
говорит «ПАДАЕТ на сборке до DEV-B703-03» (задача, которая изначально ввела
|
||||
это требование), и я лично наблюдал детерминированный, воспроизводимый по
|
||||
большинству прогонов красный результат, а не разовую аномалию: 5/7. Полный
|
||||
census (195/195, единичный прогон каждого файла) этот файл не поймал —
|
||||
статистически ожидаемо при частоте отказа 71% и единственной попытке (см. раздел
|
||||
«Как проверялось»), а не признак исправности.
|
||||
|
||||
Это отдельная находка от H1 (не то же самое, что было в r1): в r1 весь смок падал
|
||||
с необработанным исключением ДО того, как код доходил до этой проверки —
|
||||
H1-фикс устранил исключение, но обнажил под ним ещё один, ранее не наблюдаемый
|
||||
дефект в том же коде. Прямо противоречит хендоффу автора («warm_dialogs...
|
||||
green», перечислено как pass в списке «targeted review smokes»). Учитывая
|
||||
majority-red частоту (71%) и то, что визуальный прыжок камеры при
|
||||
warm-remount — это ровно тот UX-дефект, ради предотвращения которого
|
||||
существует весь контракт DEV-B703-03/WARM-REMOUNT.md, серьёзность максимальная.
|
||||
|
||||
### Medium (вне скоупа #337 — отдельный issue)
|
||||
|
||||
**M4 (→ [#346](https://github.com/Matysh/houseplan-card/issues/346)). Golden-эталоны
|
||||
`geometry-devices-editor-dark`, `device-dialog-mobile-ru`,
|
||||
`toggle-entity-dialog-mobile-ru`, `device-ripple-color-popover-mobile-ru`
|
||||
устарели на `origin/dev` независимо от #337.** Воспроизведено на чистом
|
||||
`508945c0`: `node demo/golden/run.mjs --mode=capture --scenario=<id>` для
|
||||
каждого из четырёх даёт `different`; diff-PNG `geometry-devices-editor-dark`
|
||||
показывает наложение устаревшего 3-кнопочного тулбара эталона на актуальный
|
||||
2-кнопочный рендер (тулбар Device editor был упрощён каким-то более ранним,
|
||||
уже смёрженным в `dev` изменением, после которого эталон не переснимали); три
|
||||
mobile-ru кадра — устаревший размер диалога по правому/нижнему краю. Нужно
|
||||
принять новые эталоны через `npm run golden:accept -- --reviewed` по чистому
|
||||
`dev`, не через эту ветку. Issue заведён.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **Бюджет (AC1).** 255 778 B ≤ 256 000 B, посчитано лично. Запас упал с 615 B
|
||||
(r1) до 222 B — риск, названный ещё в r1, материализуется: правки H1/H4
|
||||
добавили код (новый параметр `_requestMode`, обёртки обработчиков), и запас
|
||||
почти исчерпан. Не блокирую этим, так как AC1 формально выполнен и риск уже
|
||||
зафиксирован, но следующая правка (в том числе исправление H6) обязана
|
||||
держать бюджет в уме.
|
||||
- **H2/H3/H4/M1/M2 фактически исправлены** — не только по диффу, но по
|
||||
собственному прогону соответствующих smoke, см. таблицу выше.
|
||||
- **H5 закрыт корректно и добросовестно** — авторское расследование не просто
|
||||
заявлено, а воспроизведено мной независимо на чистом `dev`.
|
||||
- **Мутационное покрытие затронутых guard'ов (changed-range)** — 58/58 ловят
|
||||
подмену; changed-range корректно расширился под новый файл
|
||||
`src/styles/plan.styles.ts`, где теперь реально лежит проверяемый CSS.
|
||||
- **Полный browser-smoke census** — 195/195 pass на единичном прогоне (не
|
||||
противоречит H6, см. выше).
|
||||
- **Trailers/changelog.** Все 4 коммита несут `Issue: #337`; все — `User-Visible:
|
||||
no` — корректно, т.к. пользовательское поведение (после исправления
|
||||
H1–H4) возвращено к состоянию ДО дефектов r1, changelog для #337 уже писался
|
||||
отдельным коммитом на предыдущем этапе (`c1aaddc7`, зафиксирован в r1).
|
||||
|
||||
## Унаследовано из r1
|
||||
|
||||
Принято без повторной проверки в этом раунде, дельта их не касается:
|
||||
|
||||
- **AC7 (backend asset route)** — `tests_backend/test_frontend_assets.py`,
|
||||
не входит в diff `6d338b78..HEAD`; вывод r1 (CODE-REVIEW-337-r1.md,
|
||||
SHA `6d338b78`, раздел «Что проверено и корректно») остаётся в силе.
|
||||
- **AC8 (bundle sync/freshness/HACS zip/release scripts)** — не входит в
|
||||
дельту; вывод r1 наследуется.
|
||||
- **AC9 (CSS-минификатор, adversarial fixtures)** — не тронут этой дельтой,
|
||||
кроме одной ссылки в `mutation-gate.mjs` на новый путь файла стилей, что не
|
||||
меняет сам минификатор; вывод r1 наследуется.
|
||||
- **AC10 (fingerprint mismatch)**, **AC12 (no model drift)**, **AC13 (докс не
|
||||
обещает single-file install)** — вне дельты, наследуются из r1 документа.
|
||||
- **Прочие смоки, не входящие в таблицу закрытия r1**, включая полный набор
|
||||
195 файлов — пройдены заново единичным census + точечными повторными
|
||||
прогонами там, где я подозревал гонку, а не приняты по r1 без проверки:
|
||||
дельта тронула общий рендер-путь всех редакторских диалогов (H4-правка),
|
||||
поэтому я не ограничился списком автора.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- **Полный HA-harness backend** (`python -m pytest tests_backend -q`) —
|
||||
недоступен в этой песочнице (нет `homeassistant`), дельта не трогает
|
||||
`custom_components/**/*.py`, поэтому и не требовался в этом раунде.
|
||||
- **`node scripts/model-invariants.mjs` / `npm run invariants`** — не
|
||||
запускал, дельта не меняет формат геометрической записи (обоснование выше).
|
||||
- **Perf-профили** — не запускал, не названы в AC и не затронуты дельтой.
|
||||
- **Ручное тестирование в реальном Home Assistant** — не выполнялось.
|
||||
- **Частоту гонки H6 в CI-окружении** (headless Linux CI vs моя песочница) —
|
||||
не измерял отдельно; предполагаю сравнимую или более высокую частоту (CI
|
||||
раннеры обычно медленнее/более вариативны по таймингу кадров, что скорее
|
||||
усилит гонку, чем скроет её), но это предположение, а не измерение.
|
||||
|
||||
## Итог
|
||||
|
||||
Три из пяти High r1 закрыты полностью (H2, H3, H4), H5 закрыт как посторонний
|
||||
(и это подтверждено, а не принято на слово), но **H1 закрыт лишь частично**:
|
||||
устранённый крах открыл дорогу новому, статистически частому (5 из 7 прогонов)
|
||||
дефекту в той же самой warm-remount/lazy-adopt зоне — H6, видимый прыжок
|
||||
камеры редактора при пересоздании карточки. Это блокирующая находка, вердикт
|
||||
остаётся красным. Заявление автора «warm_dialogs... green» не подтвердилось
|
||||
при независимом многократном прогоне — рекомендую на следующем заходе
|
||||
прогонять чувствительные к таймингу smoke (`smoke_warm_dialogs`,
|
||||
`smoke_warm_owners`, `smoke_preloader_lifecycle`) не менее 3–5 раз подряд
|
||||
перед хендоффом, а не один раз.
|
||||
|
||||
Отдельно: заведён новый issue [#346](https://github.com/Matysh/houseplan-card/issues/346)
|
||||
на предсуществующий (не из #337) дефект золотых эталонов Device
|
||||
editor/mobile-ru диалогов (M4) — он не блокирует #337 и не должен чиниться в
|
||||
этой ветке.
|
||||
@@ -0,0 +1,264 @@
|
||||
# CODE-REVIEW-337-r3
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/337
|
||||
- Ветка: `issue/337-lazy-editor-chunk`, ревьюемый SHA: `16c616be165604aca8eb9c7cdeab59024a828006`
|
||||
- Заход: r3 · блокирующих циклов израсходовано **2/4** до этого разбора
|
||||
- **Вердикт: красный.**
|
||||
|
||||
## Скоуп разбора
|
||||
|
||||
Предыдущий вердикт — CODE-REVIEW-337-r2, SHA ревью `2beafc06398c16d1cad80fddbe77e6a40f111b9a`
|
||||
(красный, High:1 — H6, Medium:0). Этот SHA и SHA r1 (`6d338b78`) в текущей истории
|
||||
отсутствуют: ветка была перебазирована. Проверил, на что именно:
|
||||
|
||||
```
|
||||
git log --oneline 59ae6b1 -3
|
||||
59ae6b10 ci: полная история без блобов там, где содержимое старых файлов не нужно
|
||||
80a664bc docs: рекомендовать blobless-клон вместо полного
|
||||
508945c0 fix: a 0° pair is the shared-wall model, not a duplicate — field revert of #331 §2.2
|
||||
```
|
||||
|
||||
`508945c0` — это тот же `dev`, на котором уже стояли и r1, и r2 (см. r2-документ:
|
||||
«branch и так уже стоит на `508945c0` = `origin/dev`»). Между r2 и этим заходом `dev`
|
||||
продвинулся всего на два коммита класса C/B (`docs`, `ci`, ни один не в `src/**` и не
|
||||
в `custom_components/**/*.py`) — не «ребейз на ушедший вперёд dev» в смысле §2.10/§7.2
|
||||
(другая подсистема, смена контракта), а техническая синхронизация без содержательного
|
||||
чужого кода. Полный разбор с нуля не требуется; веду по дельте.
|
||||
|
||||
**Материал дельты r2→r3** — единственный содержательный коммит:
|
||||
|
||||
```
|
||||
16c616be fix: preserve warm editor camera during lazy adoption (src/houseplan-card.ts +28/-4, плюс зеркальные dist/*)
|
||||
```
|
||||
|
||||
(Коммиты `8afc8636` — документ r2, `6b482f4c`/`946f67ac`/`6f9df14d` предшествуют r2 и
|
||||
уже разобраны в CODE-REVIEW-337-r2 — не входят в дельту этого раунда.)
|
||||
|
||||
Единственная находка предыдущего раунда — **H6** (потеря бит-точности камеры
|
||||
warm-remount при lazy-adopt). Разбор ограничен: (а) верификацией фикса H6, (б) всеми
|
||||
проверками, до которых дотягивается 15-файловый diff (`src/houseplan-card.ts` +
|
||||
зеркальные `dist/`/`custom_components/houseplan/frontend/`), (в) обязательным
|
||||
по AC13/§8 `check-docs` (диф трогает `src/**`).
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Дешёвые гейты — прогнаны лично на `16c616be`, не приняты со слов автора:
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| typecheck | `npx tsc --noEmit` | pass, exit 0 |
|
||||
| unit | `npm test` | 1440 total, **1439 pass, 1 skip, 0 fail** (у автора 1438/2 — расхождение на один conditional skip, см. ниже, не регрессия) |
|
||||
| build+sync+budget | `npm run build && npm run bundle:sync && npm run bundle:budget` | initial View **255 910 B** ≤ 256 000 B (запас **90 B**, у автора — то же число); lazy editor 131 779 B; три дерева (`dist`, `custom_components/houseplan/frontend`, `demo/srv/assets`) синхронны |
|
||||
| docs fingerprint (диф трогает `src/**`, AC13 прямо называет `check-docs` доказательством) | `node scripts/check-docs.mjs` | **FAIL, exit 1**: `ERROR screenshot source fingerprint is stale; run npm run build && node demo/docs/capture.mjs` — см. находку H7 |
|
||||
| mutation, changed-range | `node scripts/mutation-gate.mjs --check --changed=16c616be^..16c616be` | 30/30 применимых guard'ов ловят подмену |
|
||||
|
||||
Разница в skip-счётчике: у меня единственный skip — `issue 281 private exact fixture
|
||||
has no enabled zero-range handle # SKIP private #281 fixture is not present`, условный
|
||||
пропуск из-за отсутствующей приватной фикстуры в песочнице, к #337 не относится. Не
|
||||
исследовал, какой второй тест пропущен у автора на Windows — сумма (1440) совпадает,
|
||||
расхождение не в счёте тестов #337.
|
||||
|
||||
Целевые browser smokes (по дельте — файл трогает ровно зону `_requestMode` /
|
||||
`_refitView` / `_setMode` / disconnect-guard, разбор не «по названию», а по
|
||||
затронутым символам):
|
||||
|
||||
| Smoke | Зачем выбран | Результат |
|
||||
|---|---|---|
|
||||
| `smoke_warm_dialogs.mjs` ×7 подряд, свежий `bundle:sync` | тот самый смок, поймавший H6 (5/7 fail в r2) | **7/7 OK**, `bViewBitExact` не подводит ни разу |
|
||||
| `smoke_warm_owners.mjs` | та же warm-adopt зона (`_warmAdoptViewport`/`_requestMode(adopt)`) | OK |
|
||||
| `smoke_preloader_lifecycle.mjs` | та же зона, ранее ловил H1 | OK |
|
||||
| `smoke_kiosk.mjs` | ранее ловил H2, эта зона трогает общий `_requestMode` | OK |
|
||||
| `smoke_mode_transition.mjs` | напрямую использует `_modeTransitionBusy`, которым теперь делит guard `_refitView` | OK |
|
||||
| `smoke_resize_wall_thickness.mjs` | использует resize/refit во время editor-режима | OK |
|
||||
| `smoke_warm_remount.mjs` | смежный warm-remount контракт, слабая связь по `smoke-select` | OK |
|
||||
| `smoke_room_resize.mjs`, `smoke_optimize_geometry_preflight.mjs` | H3-зона (`this.host.` preflight), проверка на отсутствие регресса от нового `isConnected`/`_warmModeRequest` раннего return | OK |
|
||||
|
||||
`node scripts/smoke-select.mjs --base origin/dev --head HEAD` даёт 175/195 «прямое
|
||||
совпадение» — бесполезно как фильтр для этого раунда: `origin/dev` не содержит всей
|
||||
задачи #337 целиком, поэтому инструмент показывает список для ВСЕЙ ветки, а не для
|
||||
дельты r2→r3. Использовал его для контекста (список выше уже включает `smoke_warm_dialogs`,
|
||||
`smoke_mode_transition` и `smoke_warm_remount` из числа тех, что он называет), но
|
||||
подбирал набор вручную по символам самого диффа, а не по выдаче инструмента.
|
||||
|
||||
Полный census 195 smoke-файлов и `golden:verify` **не перезапускал**: он уже
|
||||
пройден дважды (195/195 в r1, 195/195 в r2) на неизменной по этому раунду части
|
||||
дерева, а этот раунд — один файл в одной узкой зоне таймингов, для которой census
|
||||
уже доказанно нечувствителен (сам census не поймал H6 ни разу, только точечный
|
||||
7×-прогон). Полный набор — предрелизный гейт (§8), не гейт ревью при такой дельте.
|
||||
|
||||
`model-invariants` — не запускал: diff не меняет ни один формат геометрической
|
||||
записи (`layout`, `marker.space`, `open_spans`, ключи толщины) — правка только
|
||||
про `_view`/`_zoom` кэш камеры и таймер refit. Backend/pytest — не тронут этой
|
||||
дельтой, не перепрогонял.
|
||||
|
||||
## Находки
|
||||
|
||||
### High (блокирует)
|
||||
|
||||
**H7. `check-docs` красный на ревьюемом SHA — стало устаревшим ровно из-за
|
||||
этого коммита, AC13 не доказан.**
|
||||
|
||||
`node scripts/check-docs.mjs` → `ERROR screenshot source fingerprint is stale`
|
||||
(exit 1). Причина механическая и не спорная: `scripts/source-fingerprint.mjs`
|
||||
хэширует весь `src/**`; `16c616be` меняет `src/houseplan-card.ts` (единственный
|
||||
src-файл в этом коммите) и не обновляет `sourceFingerprint` в
|
||||
`docs/images/screenshots.json`, поэтому хэши расходятся. На SHA r2
|
||||
(`2beafc06`) `check-docs` был зелёным (сам документ r2: «pass (7 файлов, 10
|
||||
внешних ссылок)») — регрессия введена именно этим, последним коммитом
|
||||
раунда, а не унаследована.
|
||||
|
||||
Это прямо предусмотренный в задаче случай, а не мелочь: AC13 спецификации
|
||||
(`docs/specs/337-lazy-editor-chunk.md:346-348`) называет `check-docs` **прямым
|
||||
доказательством** этого критерия приёмки, а §8 `PROCESS.md` называет его
|
||||
«реальным блокером» именно за то, что отпечаток покрывает весь `src/**` — «любая
|
||||
правка фронтенда делает его устаревшим… выбирать тут нечего». Цена пропуска
|
||||
уже была измерена дважды на этой же кодовой базе: «скриншоты не пересняли в
|
||||
#230 и #234, и `dev` стоял с красным job `docs`, пока это не нашли при
|
||||
следующей задаче (#237)». Слияние `16c616be` в `dev` в текущем виде
|
||||
воспроизводит ровно этот сценарий.
|
||||
|
||||
Автор в хендоффе смешал эту находку с H5/#346: «известный stale screenshot
|
||||
fingerprint; H5 уже независимо подтверждён ревьюером и вынесен в #346» — но
|
||||
H5/#346 про **golden-эталоны** (`demo/golden/baselines/**`, устаревший baseline
|
||||
тулбара Device editor и mobile-ru диалогов, не связан с #337 и воспроизводится
|
||||
на чистом `dev`), а это — про **fingerprint скриншотов документации**
|
||||
(`docs/images/screenshots.json`, `docs-section` в `README`/`USER-GUIDE`),
|
||||
совершенно другой гейт с другим владением. Ссылка на #346 не закрывает эту
|
||||
находку: #346 не затрагивает `sourceFingerprint`, а причина здесь — собственный
|
||||
код-коммит этой ветки, а не предсуществующее состояние `dev`.
|
||||
|
||||
Сам автор уже дважды в этой же задаче правильно закрывал этот же гейт без
|
||||
замены единого PNG («fingerprint обновлён без замены PNG, потому что
|
||||
наблюдаемого UI-изменения нет» — хендофф после H1–H4). Здесь этот шаг просто
|
||||
пропущен для последнего коммита.
|
||||
|
||||
**Воспроизведение:** `git checkout 16c616be -- .` (или просто на HEAD), затем
|
||||
`npm run build && node scripts/check-docs.mjs` → exit 1 с текстом выше.
|
||||
**Исправление в скоупе задачи**: прогнать `Docs screenshots` (workflow_dispatch)
|
||||
и принять новый fingerprint через `npm run docs:accept -- --reviewed
|
||||
--from=<артефакт>` (замены PNG не требуется, если рендер не изменился —
|
||||
ровно то, что уже делалось между r1 и r2 этой же задачи).
|
||||
|
||||
### Medium
|
||||
|
||||
Нет находок этого уровня в дельте r2→r3.
|
||||
|
||||
### Low
|
||||
|
||||
Нет.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **Сама механика фикса H6 логически закрывает найденную гонку.** Разобрал
|
||||
правку построчно (`src/houseplan-card.ts:803-925, 2549, 5880, 6662`):
|
||||
- новое поле `_warmModeRequest` выставляется только на `adopt`-пути
|
||||
`_requestMode` (`:894-901`) и гейтит `_refitView` (`:5880`: `if
|
||||
(this._modeTransitionBusy || this._warmModeRequest) return;`) — то есть
|
||||
ResizeObserver-триггерный пересчёт `x`/`w` камеры (источник H6) подавлен
|
||||
на всё время окна между синхронным восстановлением `_view` и асинхронным
|
||||
переключением `_mode` через `_requestMode(restoredMode, false, true)`;
|
||||
- после `updateComplete` и двух `requestAnimationFrame` (реальная
|
||||
расстановка раскладки после переключения режима) код берёт фактический
|
||||
`stage.clientWidth/Height` как новый `_lastValidStageSize` и снимает
|
||||
guard (`:918-924`) — следующий настоящий resize сравнивается с верной
|
||||
базой, а не с устаревшей;
|
||||
- guard снимается на всех путях выхода: неудача `_ensureEditorRuntime()`
|
||||
(`:903`), опережающий запрос или дисконнект во время `await`
|
||||
(`:906-907`, добавленная проверка `!this.isConnected`), обычная смена
|
||||
режима пользователем через `_setMode` (`:6662`), и
|
||||
`disconnectedCallback` (`:2549`) — то есть защита не может «залипнуть».
|
||||
- `_warmAdoptViewport`/`adopt=true` вызывается только на **свежесозданном**
|
||||
экземпляре карточки при warm-remount (`:3110, 3243`), где `_refitRaf`/
|
||||
`_pendingRefitSize` заведомо в начальном состоянии — отмена «на всякий
|
||||
случай» на `:899-900` не теряет ничьих легитимных запросов.
|
||||
- **Эмпирически гонка не воспроизводится.** `smoke_warm_dialogs.mjs` — тот же
|
||||
файл и тот же сценарий, что дал 5 отказов из 7 в r2 — **7/7 OK** на этом
|
||||
SHA после `bundle:sync`. Это выполняет дисциплину «тест умеет падать»: он
|
||||
падал большинством прогонов на предыдущей ревизии кода и на новой этого не
|
||||
делает ни разу — не совпадение единичного зелёного прогона.
|
||||
- **Соседние сценарии той же зоны не пострадали**: `warm_owners`,
|
||||
`preloader_lifecycle` (H1-зона), `kiosk` (H2-зона), `room_resize`,
|
||||
`optimize_geometry_preflight` (H3-зона), `mode_transition`,
|
||||
`resize_wall_thickness`, `warm_remount` — все зелёные лично.
|
||||
- **Бюджет (AC1)** — 255 910 B ≤ 256 000 B, посчитано лично. Запас **90 B**,
|
||||
сократился с 222 B (r2) — тенденция, названная риском уже в r1/r2,
|
||||
продолжается и здесь и стоит держать в уме на любой следующей правке;
|
||||
формально AC1 выполнен, не блокирую этим отдельно.
|
||||
- **Mutation gate по коммиту** — 30/30 применимых guard'ов на
|
||||
`16c616be^..16c616be` ловят подмену; изменённая зона осталась под
|
||||
контролем существующих guard'ов, новых guard'ов эта точечная правка не
|
||||
требовала.
|
||||
- **Трейлеры.** `Issue: #337`, `User-Visible: no` — корректно: пользовательский
|
||||
changelog для #337 уже зафиксирован отдельным коммитом ранее (r1), сама
|
||||
правка не добавляет нового видимого поведения, а завершает контракт до
|
||||
релиза (задача ещё не в `dev`, User-Visible относится к тому, что увидит
|
||||
пользователь после выпуска).
|
||||
- **Одно число — один источник.** Диф не вводит и не дублирует ни одной
|
||||
видимой пользователю величины: `_view`/`_zoom`/`_lastValidStageSize` — это
|
||||
внутреннее состояние камеры рендера, не выводится текстом/подписью нигде
|
||||
в UI. Правило `test/single-source-numbers.test.mjs` не затронуто и
|
||||
проходит в общем `npm test`.
|
||||
|
||||
## Закрытие раунда r2
|
||||
|
||||
| Находка r2 | Чем закрыта | Где видно |
|
||||
|---|---|---|
|
||||
| **H6** (потеря бит-точности камеры warm-remount, 5/7 fail на `smoke_warm_dialogs`) | `_refitView` подавлен на время адаптации новым `_warmModeRequest`-guard'ом; после двух `rAF` после `updateComplete` берётся фактический размер сцены как новая база для рефита — устраняет саму гонку резайза во время переключения режима | `src/houseplan-card.ts:806, 894-925, 2549, 5880, 6662`; эмпирически — `smoke_warm_dialogs.mjs` 7/7 OK (прогнал лично) против 5/7 FAIL на предыдущей ревизии |
|
||||
|
||||
## Унаследовано из r2
|
||||
|
||||
Принято без повторной проверки в этом раунде — дельта (`16c616be`, один
|
||||
src-файл + зеркальные `dist/`) их не касается:
|
||||
|
||||
- **AC1 (initial budget)** — пересчитан лично в этом же раунде (см. таблицу
|
||||
гейтов), не просто унаследован: 255 910 B ≤ 256 000 B.
|
||||
- **AC2 (lazy boundary), AC5 (loader atomicity), AC6 (failure сохраняет View)** —
|
||||
вне дельты; вывод CODE-REVIEW-337-r2.md (SHA `2beafc06`, раздел «Закрытие
|
||||
раунда r1» + «Что проверено и корректно») остаётся в силе.
|
||||
- **AC7 (asset security), AC8 (полнота distribution), AC9 (CSS-минификатор)** —
|
||||
не тронуты этой дельтой; вывод r2 (наследующий вывод r1, SHA `6d338b78`)
|
||||
остаётся в силе.
|
||||
- **AC10 (fingerprint mismatch runtime — версии не смешиваются), AC11
|
||||
(onboarding/async config editor), AC12 (no model drift)** — вне дельты,
|
||||
наследуются из r2/r1.
|
||||
- **H2, H3, H4, M1–M3 (r1)** и **H5/M4 → #346 (golden baselines, вне скоупа
|
||||
#337)** — закрыты и подтверждены независимым прогоном ещё в r2
|
||||
(CODE-REVIEW-337-r2.md, SHA `2beafc06`); эта дельта их зону не трогает.
|
||||
- **Полный browser-smoke census (195/195) и `golden:verify` (127/131, 4
|
||||
«different» = #346)** — пройдены дважды (r1 SHA `6d338b78`, r2 SHA
|
||||
`2beafc06`) на неизменной этим раундом части дерева; не перезапускал
|
||||
целиком, см. «Как проверялось».
|
||||
|
||||
Отдельно от AC-цепочки: **AC13 (docs) не наследуется** — именно на нём найдена
|
||||
новая находка H7 этого раунда (было зелёным на SHA r2, стало красным на SHA r3).
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- **Полный HA-harness backend** (`python -m pytest tests_backend -q` с
|
||||
установленным `homeassistant`) — недоступен в этой песочнице, дельта не
|
||||
трогает `custom_components/**/*.py`.
|
||||
- **`node scripts/model-invariants.mjs`** — не запускал, дельта не меняет
|
||||
формат геометрической записи (см. «Как проверялось»).
|
||||
- **Полный browser-smoke census (195 файлов) и `golden:verify`** — не
|
||||
перезапускал в этом раунде; обоснование выше и в разделе «Унаследовано».
|
||||
- **Perf-профили** — не запускал, не названы в AC и не затронуты этой
|
||||
дельтой.
|
||||
- **Частоту гонки H6 в headless Linux CI** отдельно от своей песочницы — не
|
||||
измерял; 7/7 подряд в своём окружении расцениваю как достаточное
|
||||
доказательство закрытия конкретно найденной гонки (тот же порог, что
|
||||
задавал сам r2-документ), но абсолютную частоту в CI не оцениваю числом.
|
||||
- **Ручное тестирование в реальном Home Assistant** — не выполнялось (в
|
||||
процессе нет фазы ручного тестирования, код-ревью отвечает за AC вместо
|
||||
него).
|
||||
|
||||
## Итог
|
||||
|
||||
Фикс H6 логически и эмпирически закрывает единственную блокирующую находку
|
||||
r2: разобран по коду, зона правки атомарна и не расширяет скоуп, гонка не
|
||||
воспроизводится ни разу за 7 прогонов там, где раньше падала в 5 из 7.
|
||||
Однако тот же самый коммит красит обязательный по AC13 гейт `check-docs` —
|
||||
не унаследованная, а новая, введённая этим раундом находка (H7), с уже
|
||||
дважды на этой задаче отработанным способом закрытия. Вердикт остаётся
|
||||
красным по формальному правилу «High блокирует», при том что содержательная
|
||||
причина возврата — узкая: один пропущенный шаг (пересъёмка/приёмка
|
||||
fingerprint), а не дефект самого фикса камеры.
|
||||
@@ -0,0 +1,216 @@
|
||||
# CODE-REVIEW-337-r4
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/337
|
||||
- Ветка: `issue/337-lazy-editor-chunk`, ревьюемый SHA: `0bd5570ae56b878dabe5c440840f5a3c90c523a0`
|
||||
- Заход: r4 · блокирующих циклов израсходовано **3/4** до этого разбора
|
||||
- **Вердикт: зелёный.**
|
||||
|
||||
## Скоуп разбора
|
||||
|
||||
Предыдущий вердикт — CODE-REVIEW-337-r3, SHA ревью `16c616be165604aca8eb9c7cdeab59024a828006`
|
||||
(красный, High:1 — H7, Medium:0). Этот SHA в текущей истории отсутствует: ветка
|
||||
была перебазирована. Проверил, на что именно:
|
||||
|
||||
```
|
||||
git log --oneline 59ae6b1..ff6e53a4
|
||||
ff6e53a4 docs: review document for #333
|
||||
```
|
||||
|
||||
`dev` продвинулся ровно на **один** коммит между базой r3 (`59ae6b1`) и текущей
|
||||
базой (`ff6e53a4` = текущий `origin/dev`) — класс C, docs-only ревью-документ
|
||||
чужого issue (#333), не в `src/**`, не в `custom_components/**/*.py`. Это не
|
||||
«ребейз на ушедший вперёд dev» в смысле §2.10/§7.2 (другая подсистема, смена
|
||||
контракта), а техническая синхронизация без содержательного чужого кода.
|
||||
Полный разбор с нуля не требуется; веду по дельте.
|
||||
|
||||
**Материал дельты r3→r4:**
|
||||
|
||||
```
|
||||
33a0110e fix: preserve warm editor camera during lazy adoption (тот же код, что 16c616be — рехеширован ребейзом; сверил построчно, см. ниже)
|
||||
d30cc799 docs: review document for #337 (публикация документа r3, не продукт)
|
||||
0bd5570a docs: accept lazy editor screenshot fingerprint (H7-фикс: docs/images/screenshots.json + 06-device-editor.png)
|
||||
```
|
||||
|
||||
Проверил тождественность рехешированного `33a0110e` содержимому, разобранному в
|
||||
r3, а не поверил ребейзу на слово: прочитал `src/houseplan-card.ts:803-925,
|
||||
2549, 5880, 6662` на текущем HEAD и сверил построчно с описанием фикса в
|
||||
CODE-REVIEW-337-r3.md — `_warmModeRequest`, гейт `_refitView`, снятие guard'а
|
||||
после `updateComplete` + два `requestAnimationFrame`, снятие на всех путях
|
||||
выхода — код идентичен тому, что r3 уже разобрал и принял. Единственный
|
||||
содержательный новый коммит этого раунда — `0bd5570a` (закрытие H7).
|
||||
|
||||
Единственная находка предыдущего раунда — **H7** (`check-docs` красный на
|
||||
последнем коммите r3, AC13 не доказан). Разбор ограничен: (а) верификацией
|
||||
закрытия H7, (б) подтверждением, что ребейз не изменил семантику уже принятого
|
||||
кода, (в) самостоятельным перепрогоном дешёвых гейтов на итоговом SHA, раз
|
||||
готового зелёного Validate-прогона с реально исполненными job'ами `frontend`/
|
||||
`smoke`/`golden` на этом конкретном SHA нет (см. ниже).
|
||||
|
||||
## Как проверялось
|
||||
|
||||
### Дешёвые гейты — прогнаны лично на `0bd5570a`
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| typecheck | `npx tsc --noEmit` | pass, exit 0 |
|
||||
| unit | `npm test` | 1440 total, **1439 pass, 1 skip, 0 fail** |
|
||||
| build | `npm run build` | pass, `dist` пересобран за 13.8s |
|
||||
| bundle sync | `npm run bundle:sync` | pass; после пересборки `git status` — 0 диффов в содержимом (только file-mode 755→644 в двух файлах от локальной пересборки, откатил `git checkout --`); три дерева (`dist`, `custom_components/houseplan/frontend`, `demo/srv/assets`) синхронны и побайтово совпадают с закоммиченными |
|
||||
| bundle budget | `npm run bundle:budget` | initial View **255 910 B** ≤ 256 000 B (запас 90 B, то же число, что в r3/хендоффе); lazy editor 131 779 B |
|
||||
| docs fingerprint (H7) | `node scripts/check-docs.mjs` | **pass**: «Documentation checks passed (7 files, 10 external links)» |
|
||||
|
||||
Пересборка бандла на моей машине оказалась побайтово идентична закоммиченному
|
||||
дереву — это независимое подтверждение, что `bundle:sync`/`bundle:budget` не
|
||||
разошлись со временем автора, а не просто повтор его чисел.
|
||||
|
||||
### Почему проверял `check-docs` и билд лично, а не принял чужой зелёный Validate как есть
|
||||
|
||||
В задаче процитирован зелёный `Validate` на `0bd5570a`
|
||||
(https://github.com/Matysh/houseplan-card/actions/runs/33144989734) как повод
|
||||
не гонять `tsc`/`test`/`build` самому. Проверил сам прогон, потому что в r1
|
||||
этой же задачи уже был прецедент: зелёный Validate на docs-only коммите не
|
||||
означает, что `frontend`/`smoke`/`golden` реально исполнялись (§1, находка r1).
|
||||
Разобрал этот конкретный прогон:
|
||||
|
||||
```
|
||||
gh run view 33144989734 --json jobs -q '.jobs[] | .name + " => " + .conclusion'
|
||||
```
|
||||
|
||||
Job «Классификация изменённых файлов» вычислил инкрементальный diff между
|
||||
`before`-SHA пуша (форс-пуш после ребейза, старый tip недостижим локально) и
|
||||
`0bd5570a`: три файла — `docs/images/06-device-editor.png`,
|
||||
`docs/images/screenshots.json`, `docs/reviews/SPEC-REVIEW-333-r1.md` (последний
|
||||
— спутник ребейза на `ff6e53a4`, тоже docs). `frontend=false`, поэтому job
|
||||
«Фронтенд: типы, юниты, мутанты, синхрон бандла» (где живут `tsc`/`test`/
|
||||
`build`) в этом конкретном прогоне **skipped**, а не green по содержанию — сам
|
||||
Validate зелёный только потому, что docs-гейты (которые реально исполнялись)
|
||||
прошли. Отдельно проверил job «Переиспользование: это дерево уже проверено»
|
||||
(`scripts/gate-reuse.mjs`): `golden` и `backend` дали `Cache not found` (не
|
||||
были waived кэшем), но всё равно оказались `skipped` в графе — их пропустила
|
||||
та же path-классификация `frontend=false`, а не механизм переиспользования.
|
||||
|
||||
Вывод не «Validate врёт»: срез diff между force-push before/after корректно
|
||||
показал, что после ребейза содержимое `dist`/`src` не изменилось относительно
|
||||
уже прошедшего полный `Validate` состояния (та же логика, что уже описана в
|
||||
самом r1-документе про докс-коммиты). Но раз я не могу подтвердить это архивной
|
||||
реконструкцией истории (старые SHA недостижимы после форс-пуша), надёжнее было
|
||||
перепрогнать `tsc`/`test`/`build`/`bundle:*` самому на итоговом дереве — они
|
||||
дешёвые (минуты) — чем полагаться на цепочку рассуждений о том, что должно было
|
||||
быть проверено раньше. Результат подтвердил: числа и статусы совпадают с тем,
|
||||
что репортили автор и r3.
|
||||
|
||||
### Что не прогонял и почему
|
||||
|
||||
- **Полный browser-smoke census (195 файлов) и `golden:verify`** — не
|
||||
перезапускал. Дельта r3→r4 (`0bd5570a`) касается исключительно
|
||||
`docs/images/**`; ни один файл `src/**`, `demo/**`, `test/**` не изменился.
|
||||
Census и golden уже пройдены дважды на продуктовом дереве (r1: 195/195 и
|
||||
127/131 golden; r2: 195/195; targeted smokes зоны H6 — многократно в r2/r3) и
|
||||
не могут быть чувствительны к правке `screenshots.json`/PNG документации —
|
||||
они не читают эти файлы. `smoke-select` не гонял: применять его к диффу, не
|
||||
содержащему ни одного `src`/`demo` файла, не даёт сигнала.
|
||||
- **`node scripts/model-invariants.mjs`** — не запускал. Дельта не меняет
|
||||
формат геометрической записи (`layout`, `marker.space`, `open_spans`, ключи
|
||||
толщины); последний src-коммит, трогавший геометрию/камеру (`33a0110e`),
|
||||
уже прогонялся через `npm test` (модельные инварианты входят в общий набор)
|
||||
и мной, и r3 — без изменений с тех пор.
|
||||
- **`node scripts/mutation-gate.mjs`** — не запускал повторно. Дельта не
|
||||
трогает `src/**`; r3 уже прогнал `--changed` для единственного src-коммита
|
||||
этой цепочки (30/30).
|
||||
- **`python -m pytest tests_backend -q`** — не запускал. Диапазон дельты не
|
||||
трогает `custom_components/**/*.py`.
|
||||
- **Perf-профили** — не запускал, не названы в AC и не затронуты этой дельтой.
|
||||
- **Ручное тестирование в реальном Home Assistant** — не выполнялось (фазы
|
||||
ручного тестирования в процессе нет).
|
||||
|
||||
## Находки
|
||||
|
||||
Находок этого раунда нет — High/Medium/Low: 0/0/0.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **H7 закрыт.** `node scripts/check-docs.mjs` зелёный на `0bd5570a`: «pass (7
|
||||
files, 10 external links)», ровно та же форма, что r1 фиксировал как норму.
|
||||
Причина закрытия видна в самом коммите: `sourceFingerprint` в
|
||||
`docs/images/screenshots.json` обновлён с `d1ea5e9d…` на `5f56dab6…` —
|
||||
значение, полученное реальной пересъёмкой (`sourceSha256` во всех 10
|
||||
сценариях синхронно обновлён на то же значение), а не скопировано вручную.
|
||||
Единственный изменившийся `imageSha256` — `06-device-editor.png`
|
||||
(`a01bad41…` → `36ed21b6…`), все остальные 9 `imageSha256` не тронуты —
|
||||
соответствует утверждению автора и r3, что рендер изменился только на 1 байт
|
||||
PNG-кодирования и только у одного кадра (r3 сам это независимо
|
||||
воспроизвёл ещё до принятия).
|
||||
- **Ребейз не изменил семантику.** `dev` продвинулся на один класс-C коммит
|
||||
(docs-review чужого issue), не в `src/**`. Локальная пересборка (`npm run
|
||||
build && npm run bundle:sync`) дала 0 диффов содержимого относительно
|
||||
закоммиченного дерева — рехешированные ассеты `33a0110e` соответствуют
|
||||
ровно той сборке, что уже стояла до ребейза.
|
||||
- **Код фикса H6 (унаследован из r3, но перечитан для очистки от риска «ребейз
|
||||
= другой код»)** — построчно совпадает с описанием r3: `_warmModeRequest`
|
||||
выставляется только на `adopt`-пути (`src/houseplan-card.ts:894-901`), гейтит
|
||||
`_refitView` (`:5880`), снимается после `updateComplete` + двух `rAF`
|
||||
(`:918-924`) и на всех путях выхода (`:903, 906-907, 2549, 6662`). Изменений
|
||||
в этой логике между `16c616be` и `33a0110e` нет.
|
||||
- **Бюджет (AC1)** — пересчитан лично: 255 910 B ≤ 256 000 B, то же число, что
|
||||
в r3 и в хендоффе — запас 90 B не изменился и не ухудшился этим раундом.
|
||||
- **Трейлеры.** Оба коммита раунда (`33a0110e`, `0bd5570a`) несут `Issue: #337`
|
||||
и `User-Visible: no` — корректно: ни один не добавляет нового наблюдаемого
|
||||
пользователем поведения (H6 — внутреннее исправление тайминга камеры,
|
||||
H7-фикс — служебный docs-артефакт), changelog для #337 уже закрыт в r1.
|
||||
- **Одно число — один источник.** Дельта этого раунда не вводит и не дублирует
|
||||
ни одной пользовательской величины: правка ограничена внутренним
|
||||
fingerprint/хешами и байтами PNG документации, не выводимыми в UI как текст.
|
||||
`test/single-source-numbers.test.mjs` проходит в общем `npm test`.
|
||||
|
||||
## Закрытие раунда r3
|
||||
|
||||
| Находка r3 | Чем закрыта | Где это видно |
|
||||
|---|---|---|
|
||||
| **H7** (`check-docs` красный на последнем src-коммите r3, AC13 не доказан) | Канонический прогон `Docs screenshots` (workflow_dispatch) на ветке задачи, Linux-артефакт принят через `npm run docs:accept -- --reviewed --from=...`; `sourceFingerprint`/`sourceSha256` синхронно обновлены во всех 10 сценариях, изменился ровно 1 `imageSha256` | Коммит `0bd5570a` (`docs/images/screenshots.json`, `docs/images/06-device-editor.png`); `node scripts/check-docs.mjs` → pass лично на `0bd5570a` |
|
||||
|
||||
## Унаследовано из r3 (и транзитивно из r1/r2)
|
||||
|
||||
Принято без повторной проверки в этом раунде — дельта (`33a0110e`/рехеш +
|
||||
`0bd5570a`, docs-only) их не касается:
|
||||
|
||||
- **AC1 (initial budget)** — пересчитан лично в этом раунде (см. таблицу
|
||||
гейтов), не просто унаследован.
|
||||
- **AC2 (lazy boundary), AC5 (loader atomicity), AC6 (failure сохраняет
|
||||
View)** — вне дельты; вывод CODE-REVIEW-337-r2.md (SHA `2beafc06`) остаётся в
|
||||
силе.
|
||||
- **AC3/AC4 (View и editor parity), включая всю зону H1–H4 из r1 (warm-remount,
|
||||
kiosk, resize/optimize preflight, device inbox)** — закрыты и подтверждены
|
||||
независимым прогоном в r2 (CODE-REVIEW-337-r2.md, SHA `2beafc06`); эта дельта
|
||||
их зону не трогает.
|
||||
- **AC7 (asset security), AC8 (полнота distribution), AC9 (CSS-минификатор)** —
|
||||
не тронуты этой дельтой; вывод r1/r2 (CODE-REVIEW-337-r1.md, SHA `6d338b78`)
|
||||
остаётся в силе.
|
||||
- **AC10 (fingerprint mismatch), AC11 (onboarding/async config editor), AC12
|
||||
(no model drift)** — вне дельты, наследуются из r1/r2.
|
||||
- **H6 (потеря бит-точности камеры warm-remount)** — закрыт и подтверждён в r3
|
||||
(CODE-REVIEW-337-r3.md, SHA `16c616be`, `smoke_warm_dialogs` 7/7 против 5/7
|
||||
fail); код идентичен на текущем SHA (см. «Что проверено и корректно» выше —
|
||||
перечитан заново, не просто принят на слово).
|
||||
- **H5/#346 (устаревшие golden-эталоны Device editor/mobile-ru)** — не
|
||||
относится к #337, независимо воспроизведён на чистом `dev` в r2, заведён
|
||||
отдельным issue #346 (проверил: issue открыт, `S1-new`, `bug`, `P3`).
|
||||
- **Полный browser-smoke census (195/195) и `golden:verify` (127/131, 4
|
||||
«different» = #346)** — пройдены в r1/r2 на неизменной этим и предыдущим
|
||||
(r3) раундами части дерева; не перезапускал, см. «Что не прогонял».
|
||||
|
||||
Отдельно от AC-цепочки: **AC13 (docs) не наследуется** — на нём в r3 была
|
||||
найдена находка H7, в этом раунде она закрыта заново проверенным гейтом, а не
|
||||
принята со слов автора.
|
||||
|
||||
## Итог
|
||||
|
||||
Единственная блокирующая находка предыдущего раунда (H7 — устаревший
|
||||
screenshot fingerprint) закрыта корректно: канонический Linux-прогон,
|
||||
воспроизводимый дважды с одинаковым хешем, обновил fingerprint и ровно один
|
||||
байтово отличающийся кадр, без замены остальных девяти. Ребейз между r3 и r4
|
||||
не изменил продуктовый код — на `dev` прилетел один нерелевантный docs-коммит
|
||||
другого issue, и локальная пересборка дала 0 диффов с закоммиченным деревом.
|
||||
Дешёвые гейты (`tsc`, `npm test`, `build+sync+budget`, `check-docs`)
|
||||
перепрогнаны лично на итоговом SHA `0bd5570a`, а не приняты по цепочке
|
||||
рассуждений о докс-коммитах — все зелёные, числа совпадают с отчётами автора и
|
||||
r3. Новых находок нет. Вердикт: зелёный.
|
||||
@@ -0,0 +1,140 @@
|
||||
# SPEC-REVIEW-337-r1
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/337
|
||||
- ТЗ: `docs/specs/337-lazy-editor-chunk.md`, commit `6e7ab6b7` (ветка `issue/337-lazy-editor-chunk`, HEAD detached)
|
||||
- Трек: полный (обоснование в аналитике: производительность + несколько
|
||||
поверхностей/public asset contract — критерий §5 назван, `small` корректно отклонён)
|
||||
- Этап: spec-review, заход r1, блокирующих циклов израсходовано 0/4
|
||||
- Вердикт: **зелёный**
|
||||
|
||||
## Скоуп
|
||||
|
||||
Задача переносит View/editor границу frontend в реальную модульную границу
|
||||
(`src/editors/runtime/`), делает multi-asset build/раздачу/CI/HACS-контракт и
|
||||
безопасно минифицирует статические CSS-литералы, чтобы initial View graph
|
||||
уложился в ≤256 000 B gzip. Данные/layout/геометрия/UX/touch policy не
|
||||
меняются, только: (а) до входа в редактор код редакторов не грузится, (б) при
|
||||
редкой ошибке загрузки чанка показывается новое локализованное сообщение, а
|
||||
View остаётся рабочим.
|
||||
|
||||
Проверка по `docs/SCOPE.md`: работа не добавляет функциональность — она
|
||||
инфраструктурно защищает J1/J6 (быстрый рабочий View для kiosk/tablet-персон,
|
||||
которые являются «the product» по SCOPE) и не задевает ни один пункт
|
||||
out-of-scope. Отдельный вопрос владельцу не нужен: видимое поведение не
|
||||
меняется, кроме двух явно описанных добавлений (индикатор ожидания, сообщение
|
||||
об ошибке), и автор прямо заявил в комментарии, что продуктовых вопросов нет —
|
||||
это соответствует действительности, разночтений о персоне/сценарии в ТЗ я не
|
||||
нашёл.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Ревью документа на этом этапе не имеет кода для гейтов (typecheck/test/build
|
||||
неприменимы — commit `6e7ab6b7` меняет только `docs/specs/337-lazy-editor-chunk.md`).
|
||||
Вместо этого каждое фактическое утверждение ТЗ, которое можно спутать с
|
||||
непроверенной догадкой, сверено с текущим репозиторием на `HEAD`:
|
||||
|
||||
| Утверждение ТЗ | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| `dist/houseplan-card.js` 1 353 147 B raw / 378 335 B gzip, `src/houseplan-card.ts` ≈1052 KiB, View+3 редактора в одном классе | `wc -c src/houseplan-card.ts` → 1 071 305 B (22 809 строк) | согласуется с заявленным порядком величины |
|
||||
| `custom_components/houseplan/__init__.py` раздаёт один точный файл `StaticPathConfig(FRONTEND_URL, ...)` без auth | прочитан файл целиком | подтверждено; новый asset-route не вводит новый класс уязвимости — паритет с текущим |
|
||||
| GUI config editor (`houseplan-card-editor`) сейчас eager | `grep "import './editor'"` в `houseplan-card.ts:190` — статический импорт | подтверждено, AC11 закрывает реальный источник веса |
|
||||
| Существующий `mode-transition` CSS-класс переиспользуется под loading surface | найден в `plan.styles.ts`/`houseplan-card.ts`, уже используется как de-emphasis coordinate | правдоподобно, а конкретная реализация прямо отнесена к §18 «принято предположительно» |
|
||||
| «Старый браузер, способный исполнять текущий ESM bundle, способен исполнять native dynamic import» (§12) | `tsconfig.json` target `es2021`, без даунлевелинга; в коде 811 использований `?.` и 110 `??` — уже требуется ES2020+ движок | **верно**: минимальный движок, способный распарсить нынешний бандл, уже новее движков без `import()` (Chrome 63/FF 67/Safari 11.1) — вычислено, не принято на веру |
|
||||
| Ручная установка сегодня — это копирование папки `custom_components/houseplan`, а не одного JS (README.md:109-112, USER-GUIDE.ru.md:97-102) | прочитаны оба файла | подтверждено — multi-asset дерево не ломает существующий документированный manual-install путь; отдельный top-level `houseplan-card.js` GitHub-asset никогда не был этим путём |
|
||||
| `docs/specs/README.md`/PROCESS.md §7.1 обязательные разделы | построчная сверка ТЗ §1–§18 | все присутствуют по содержанию (см. ниже) |
|
||||
| Трейлеры коммита `6e7ab6b7` | `git log -1 --format=%B` | `Issue: #337` / `User-Visible: no`, корректно — ТЗ не является пользовательским изменением |
|
||||
| #34 (направление декомпозиции, на которое ссылается §7.1) | `gh issue view 34` | #34 сам в `S3-spec`, ещё не утверждён; его текущий целевой tree (`editors/plan/`, `editors/devices/`, `editors/render/`, …) и «безопасный порядок» (render-слои → диалоги → контроллеры, маленькими слайсами) не совпадают дословно с `editors/runtime/` из #337 |
|
||||
|
||||
Дополнительно сверены обязательные разделы ТЗ (`PROCESS.md` §7.1): сценарий (§1)
|
||||
· что человек видит (§2) · проблема (§3) · скоуп/не-скоуп (§4–§5) · контракт
|
||||
поведения и UX (§6) · архитектурный контракт (§7) · модель данных и миграция
|
||||
(§12) · i18n (§11) · AC1…AC13 с доказательством (§13) · план автотестов (§14) ·
|
||||
риски (§15) · откат (§16) · release-артефакты (§17) · блок «принято
|
||||
предположительно» (§18). Все присутствуют по существу, не только по названию.
|
||||
|
||||
## Находки
|
||||
|
||||
Ничего в статусе High или Medium-в-скоупе, требующего возврата автору. Три
|
||||
наблюдения Low, ревьюер снимает их с записью (правка необязательна, вносить
|
||||
можно по желанию автора в этом же цикле без отдельного возврата):
|
||||
|
||||
1. **`houseplan-space-card` не упомянута в границе runtime.** У неё есть
|
||||
собственный GUI config editor (`src/space-editor.ts`, статически
|
||||
импортированный в `space-card.ts:39`, 93 строки/3.3 KB raw). ТЗ §7.1/§6.2.6
|
||||
описывает границу и AC11 только для `houseplan-card`. Снято: вклад в gzip
|
||||
пренебрежимо мал (на два порядка меньше бюджета), «Обе карточки в одном
|
||||
файле» и обе регистрируются надёжно — п.6.1.1 это покрывает; не наблюдается
|
||||
риска для AC1/AC2. Если авторы решат вынести и его — не помешает, но не
|
||||
является требованием этого ТЗ.
|
||||
2. **Число/размер editor-чанков не ограничено.** Формально ТЗ можно закрыть
|
||||
набором очень мелких чанков, что било бы по числу HTTP-запросов при первом
|
||||
входе в редактор, хотя раздел «Риски» уже фиксирует намерение «переносить
|
||||
законченными typed slices». Снято: нет продуктового контракта на задержку
|
||||
входа в редактор кроме «не выглядит зависшим» (150 ms порог, §15), а именно
|
||||
этим и ограничен риск.
|
||||
3. **Имя `src/editors/runtime/` разойдётся с целевым деревом #34** (`editors/plan/`,
|
||||
`editors/devices/`, `editors/decor/`, `render/`, `dialogs/` — сам #34 ещё в
|
||||
`S3-spec`, не утверждён). Это два разных среза: #34 — про читаемость и
|
||||
единственный источник состояния, #337 — про то, что статически не
|
||||
импортируется в entry; частичное совпадение целей не значит, что нужно
|
||||
ждать #34. Снято: §18 прямо относит имена внутренних модулей и форму typed
|
||||
host port к «принято предположительно, можно менять свободно» — реальной
|
||||
привязки к продукту нет, а сведение выполнимости и проверяемости ТЗ не
|
||||
страдает.
|
||||
|
||||
Ни одна из трёх не задевает выполнимость или проверяемость ни одного AC1…AC13,
|
||||
поэтому они не поднимаются выше Low и не создают жёлтый вердикт.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- Числовой бюджет непротиворечив: «250 KiB» (§4.2) и «256 000 B» (issue,
|
||||
AC1/§13) — один и тот же порог (250×1024=256000), никакого двойного
|
||||
источника значения нет.
|
||||
- Каждый AC1…AC13 имеет явный, разный и проверяемый способ доказательства
|
||||
(unit/manifest-тест/Playwright-smoke/backend-тест/golden/статический гейт);
|
||||
ни один не сформулирован как «проверено ревьюером на глаз».
|
||||
- Не-цели (§5) корректно исключают геометрию, config/layout, `polyclip-ts`,
|
||||
lazy i18n, backend API/SVG-вычисления и automatic idle-prefetch —
|
||||
согласуется с `docs/SCOPE.md` (никакой новый функционал сверх заявленной
|
||||
работы) и не противоречит `docs/CONFIG-COMPATIBILITY.md` (миграции нет,
|
||||
контракт не сформулирован — не нужен, раз stored data не меняется).
|
||||
`docs/WALL-THICKNESS.md`/`CANVAS.md` не затронуты (геометрия explicitly
|
||||
вне скоупа) — глубокая сверка с ними не требовалась.
|
||||
- §6.4 (failure/retry/fingerprint) и §10 (fingerprint/cache) описывают ровно
|
||||
одну попытку retry, запрет auto-hard-reload и явную обработку mismatch —
|
||||
внутренне непротиворечиво и без недосказанных веток.
|
||||
- Формулировки пользовательских сообщений (§6.2.3, §6.4.2) — новый текст, а не
|
||||
изобретённая замена существующей терминологии `USER-GUIDE.ru.md`: там нет
|
||||
готовой фразы под этот сценарий (grep пуст), так что это не нарушение
|
||||
правила «UI не говорит на языке разработчика».
|
||||
- Блок «принято предположительно» (§18) корректно отделяет технические решения
|
||||
(имена модулей, формат manifest, реализация delayed-surface, механизм
|
||||
инвалидации manifest, gzip-параметры сборки, разбиение на коммиты) от того,
|
||||
что зафиксировано как продуктовое решение и менять нельзя (URL ресурса,
|
||||
отсутствие auto-reload, сохранение рабочего View при ошибке, отсутствие
|
||||
prefetch) — ровно то разделение, которого требует PROCESS.md §7.1.
|
||||
- Трек и метка (`S4-spec-review`, полный трек) корректны для задачи со
|
||||
сложностью 9/10 и влиянием на public asset contract.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Гейты `npx tsc --noEmit` / `npm test` / `npm run build` / `bundle:sync` /
|
||||
`check-docs.mjs` / инварианты геометрии — **не запускал**: на этом этапе нет
|
||||
продуктового кода, диапазон коммита r1 — один файл в `docs/specs/`. Они
|
||||
становятся обязательными на этапе код-ревью (§2.7), когда появится реализация.
|
||||
- Достижимость бюджета ≤256 000 B самой декомпозицией — это инженерный риск,
|
||||
который ТЗ само называет негарантированным («если модульная декомпозиция не
|
||||
доводит граф до бюджета, задача не считается выполненной») и оставляет
|
||||
измеримым на этапе реализации; ревью ТЗ не может предсказать фактический
|
||||
результат code-splitting, только то, что критерий сформулирован проверяемо.
|
||||
- Полное содержание `docs/ARCHITECTURE.md`, `docs/DEVELOPMENT.md`,
|
||||
`docs/TOUCH-SUPPORT.md` не перечитывалось построчно — сверены только
|
||||
фрагменты, релевantные конкретным утверждениям ТЗ (раздел «Как
|
||||
проверялось»); геометрия/touch/config не в скоупе задачи, поэтому глубокого
|
||||
разбора WALL-THICKNESS.md/CANVAS.md/CONFIG-COMPATIBILITY.md не требовалось.
|
||||
|
||||
## Итог
|
||||
|
||||
ТЗ выполнимо и проверяемо, разделы полны, продуктовых вопросов владельцу нет и
|
||||
не появилось при ревью, скоуп соответствует `docs/SCOPE.md`. Три Low-находки
|
||||
сняты с записью выше. Статус — «Готово к разработке».
|
||||
@@ -0,0 +1,419 @@
|
||||
# ТЗ #337 — ленивый editor-runtime и multi-asset frontend
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/337
|
||||
- Тип: техдолг / производительность
|
||||
- Приоритет: P2
|
||||
- Трек: полный — задача влияет на производительность и меняет несколько
|
||||
поверхностей и release-asset contract, поэтому не проходит критерии `small`
|
||||
из `PROCESS.md` §5
|
||||
- Связанные задачи: #34 — направление декомпозиции frontend; #62 — единый
|
||||
eager-реестр двух текущих языков, lazy i18n в этой задаче отсутствует
|
||||
|
||||
## 1. Сценарий
|
||||
|
||||
Основная персона — жилец или гость (`docs/SCOPE.md`), который открывает
|
||||
дашборд Home Assistant на телефоне, планшете или настенной панели, чтобы быстро
|
||||
увидеть дом и управлять устройствами в View. Он не должен загружать код трёх
|
||||
администраторских редакторов, пока сам не попросил открыть редактор.
|
||||
|
||||
Вторичная персона — владелец/администратор. При первом входе в Plan, Devices
|
||||
или Background после открытия дашборда он допускает короткое ожидание загрузки,
|
||||
но не потерю View, текущего пространства, масштаба или незавершённого клика.
|
||||
|
||||
## 2. Что человек увидит до и после
|
||||
|
||||
До изменения каждый вход на дашборд загружает весь редактор; после изменения
|
||||
обычный View становится рабочим после заметно меньшей загрузки, а код редактора
|
||||
подгружается только перед первым фактическим входом в него, без изменения
|
||||
интерфейса и поведения редакторов.
|
||||
|
||||
При редкой ошибке загрузки редактора View остаётся рабочим, а пользователь
|
||||
видит локализованное сообщение с просьбой обновить страницу вместо зависшей или
|
||||
наполовину открытой панели.
|
||||
|
||||
## 3. Подтверждённое исходное состояние
|
||||
|
||||
Замеры выполнены на `origin/dev` `eff786f`, версия 1.68.1:
|
||||
|
||||
- `dist/houseplan-card.js` — 1 353 147 B raw / 378 335 B gzip;
|
||||
- `src/houseplan-card.ts` даёт около 1 052 KiB кода до terser и содержит View,
|
||||
editor state machines, команды, панели и диалоги в одном классе;
|
||||
- backend раздаёт только точный файл
|
||||
`/houseplan_files/houseplan-card.js`; относительный chunk сейчас получит 404;
|
||||
- bundle sync, freshness, demo, CI и release scripts считают артефактом один
|
||||
JS-файл;
|
||||
- консервативная минификация всех текущих статических `css\`\``-литералов
|
||||
уменьшает bundle примерно на 32 016 B raw, но только на 2 114 B gzip. Она не
|
||||
заменяет реальное code splitting;
|
||||
- #62 намеренно сохранила `en` и `ru` синхронными/eager. Утверждение старого
|
||||
описания #337 о lazy dictionaries неверно и не является предпосылкой ТЗ.
|
||||
|
||||
## 4. Цели
|
||||
|
||||
1. Не загружать runtime Plan/Devices/Background и editor-only dialogs в первом
|
||||
рабочем View.
|
||||
2. Ограничить сумму gzip initial View graph величиной **250 KiB**.
|
||||
3. Сохранить наблюдаемое поведение View, переходов, редакторов, конфигурации и
|
||||
Home Assistant card editor.
|
||||
4. Сделать несколько frontend-файлов полноценным проверяемым артефактом HACS,
|
||||
backend, demo, CI и release automation.
|
||||
5. Безопасно минифицировать статические Lit CSS templates без визуальной
|
||||
разницы.
|
||||
|
||||
## 5. Не-цели
|
||||
|
||||
- не менять stored config/layout и не добавлять миграцию;
|
||||
- не менять UX, состав инструментов, DOM-контракт, pointer thresholds,
|
||||
Undo/Redo, touch policy или геометрию;
|
||||
- не заменять и не упрощать `polyclip-ts`;
|
||||
- не делать lazy loading текущих i18n dictionaries;
|
||||
- не оптимизировать backend API или SVG/Glow вычисления, если они нужны View;
|
||||
- не считать успехом маленький entry, который тут же синхронно или автоматически
|
||||
скачивает тот же код до готовности View;
|
||||
- не делать автоматический idle-prefetch editor runtime: он вернул бы сетевой
|
||||
расход всем пользователям, которые редактор не открывают.
|
||||
|
||||
## 6. Пользовательский контракт
|
||||
|
||||
### 6.1 Первый View
|
||||
|
||||
1. `houseplan-card` и `houseplan-space-card` регистрируются так же надёжно, как
|
||||
до изменения; HA не показывает промежуточное `Custom element doesn't exist`.
|
||||
2. View считается готовым только когда видны план и устройства и доступны
|
||||
обычные View-действия. Все JS-файлы, обязательные до этого момента, входят в
|
||||
initial View graph и budget §13.
|
||||
3. Ни один editor-only asset не запрашивается до намерения пользователя открыть
|
||||
редактор или editor-only диалог. Hover без клика и простои страницы не
|
||||
считаются намерением.
|
||||
4. Пустая новая установка сохраняет текущий onboarding: диалог создания первого
|
||||
пространства не может зависеть от editor runtime.
|
||||
|
||||
### 6.2 Вход в редактор
|
||||
|
||||
1. Нажатие Plan, Devices или Background сначала запускает единственный shared
|
||||
loader. Mode, editor chrome и editor camera не коммитятся до успешной
|
||||
установки runtime.
|
||||
2. Повторные/конкурентные клики используют один Promise; runtime не скачивается
|
||||
и не устанавливается дважды.
|
||||
3. Пока загрузка занимает меньше 150 ms, отдельная плашка не появляется. После
|
||||
150 ms поверх неизменившегося View показывается неблокирующий существующий
|
||||
transition surface с текстом «Загружаем редактор…» / “Loading editor…”.
|
||||
4. После успеха выполняется обычный переход текущего `mode-transition`; первая
|
||||
editor-панель не появляется в полуготовом состоянии. Пространство, View zoom
|
||||
snapshot и selection contracts сохраняются.
|
||||
5. После первой успешной загрузки все три редактора и editor-only dialogs
|
||||
используют уже установленный runtime без новой сети.
|
||||
6. Lovelace GUI editor загружается тем же lazy graph через async
|
||||
`getConfigElement()`. Создание config editor не должно заставлять View заранее
|
||||
загружать editor runtime.
|
||||
|
||||
### 6.3 Editor-only dialogs из View
|
||||
|
||||
К editor-only относятся формы, изменяющие plan/device/decor data и не нужные для
|
||||
обычного просмотра. Если такая форма вызывается из доступной в View цепочки
|
||||
(например, Edit из device info), loader завершается до открытия формы. Info,
|
||||
more-info, подтверждение действия, статусы проёмов, kiosk controls и onboarding
|
||||
остаются eager, если они нужны View сами по себе.
|
||||
|
||||
Точный список экспортов фиксирует `editor-runtime-manifest.ts`; новый editor-only
|
||||
диалог должен добавляться туда, а не импортироваться из View entry напрямую.
|
||||
|
||||
### 6.4 Ошибка загрузки и обновление во время открытой вкладки
|
||||
|
||||
1. Первый сетевой/parse/fingerprint failure повторяет import один раз с
|
||||
versioned cache-buster текущей карточки. До retry View остаётся рабочим.
|
||||
2. После повторной ошибки mode остаётся `view`, editor session не создаётся,
|
||||
toolbar/camera/selection не меняются. Показывается локализованное сообщение:
|
||||
«Не удалось загрузить редактор. Обновите страницу и повторите попытку.» /
|
||||
“Could not load the editor. Refresh the page and try again.”
|
||||
3. Runtime сообщает build fingerprint до установки. Несовпадение entry/runtime
|
||||
рассматривается как тот же failure; код разных версий не смешивается.
|
||||
4. Автоматический hard reload запрещён: он может прервать другое действие на
|
||||
HA-дашборде. Пользователь сам решает, когда обновить страницу.
|
||||
5. Ошибка editor asset никогда не скрывает план и не ломает View-действия.
|
||||
|
||||
## 7. Архитектурный контракт frontend
|
||||
|
||||
### 7.1 Граница runtime
|
||||
|
||||
Создаётся `src/editors/runtime/` с двумя явными сторонами:
|
||||
|
||||
- eager `editor-loader.ts`: маленькая state machine `idle → loading → ready |
|
||||
failed`, dedupe, retry и fingerprint handshake;
|
||||
- lazy `houseplan-editor-runtime.ts`: composition root редакторов;
|
||||
- `editor-host-port.ts`: typed минимальный порт к snapshot, командами, save,
|
||||
translation, requestUpdate и mode transition владельца карточки;
|
||||
- editor-specific render/controller modules, вынесенные из
|
||||
`houseplan-card.ts` законченными ответственностями.
|
||||
|
||||
Runtime не получает `HouseplanCard` через `any`, не патчит prototype и не читает
|
||||
произвольные private fields. Он работает через typed host port и собственный
|
||||
state. Число `any` в `src/` не увеличивается. Pure geometry authorities остаются
|
||||
в текущих модулях и передаются runtime как обычные imports; второй модели
|
||||
геометрии не создаётся.
|
||||
|
||||
Изменение является крупным independently releasable slice направления #34:
|
||||
root сохраняет HA lifecycle, View projection/render, server revisions, common
|
||||
dialogs, navigation и visual continuity; runtime получает editor gestures,
|
||||
tool state, editor chrome/secondary tray, editor-only drafts/dialogs и команды.
|
||||
|
||||
### 7.2 Import graph
|
||||
|
||||
- eager entry не имеет static import из `src/editors/runtime/houseplan-editor-runtime.ts`
|
||||
или editor-only descendants;
|
||||
- единственное runtime-ребро — `import()` внутри loader;
|
||||
- общие модули, реально нужные View и editor, остаются eager либо становятся
|
||||
shared chunk, который входит в initial budget;
|
||||
- модуль не признаётся editor-only только по имени: Rollup manifest и тест
|
||||
import graph являются источником истины;
|
||||
- `polyclip-ts` сохраняется там, куда его помещают реальные потребители.
|
||||
|
||||
## 8. Multi-asset build и раздача
|
||||
|
||||
### 8.1 Выход Rollup
|
||||
|
||||
Rollup пишет дерево:
|
||||
|
||||
```text
|
||||
dist/
|
||||
houseplan-card.js
|
||||
houseplan-assets/
|
||||
<rollup chunks>.js
|
||||
houseplan-assets.json
|
||||
```
|
||||
|
||||
Имена chunk содержат content hash. Manifest детерминированно содержит:
|
||||
|
||||
- source fingerprint;
|
||||
- entry filename;
|
||||
- для каждого asset: relative path, SHA-256, raw bytes, gzip bytes;
|
||||
- static imports и dynamic imports;
|
||||
- рассчитанные `initialViewGzipBytes` и `lazyEditorGzipBytes`.
|
||||
|
||||
Порядок записей и gzip settings фиксированы, поэтому Windows/Linux build одного
|
||||
SHA создаёт побайтово одинаковые committed snapshots.
|
||||
|
||||
### 8.2 Backend route
|
||||
|
||||
Точный публичный URL `/houseplan_files/houseplan-card.js` остаётся без изменений.
|
||||
Chunks раздаются публичным read-only view по
|
||||
`/houseplan_files/houseplan-assets/{filename}`.
|
||||
|
||||
View:
|
||||
|
||||
- принимает только один basename без `/`, `\\`, `..` и percent-decoded обходов;
|
||||
- раздаёт только `.js`, присутствующий в текущем `houseplan-assets.json`;
|
||||
- разрешает real path только внутри `frontend/houseplan-assets/`;
|
||||
- возвращает 404 для отсутствующего/неразрешённого asset;
|
||||
- не требует auth, потому что Lovelace ESM assets должны загружаться до карточки;
|
||||
- перечитывает/инвалидирует manifest после обновления integration, чтобы config
|
||||
entry reload не оставлял старый allowlist.
|
||||
|
||||
Plans и marker files не возвращаются в public static path; существующий signed
|
||||
content API не меняется.
|
||||
|
||||
### 8.3 Копии и HACS
|
||||
|
||||
`bundle:sync` атомарно синхронизирует **всё дерево**, а не один файл:
|
||||
|
||||
1. `dist/` — build output;
|
||||
2. `custom_components/houseplan/frontend/` — committed release snapshot;
|
||||
3. `demo/srv/assets/` — materialized untracked test copy.
|
||||
|
||||
Перед копированием target asset directory очищается только по списку старого
|
||||
manifest, с path containment check; посторонние файлы не удаляются. После копии
|
||||
каждый hash сверяется с manifest.
|
||||
|
||||
`houseplan.zip` включает entry, manifest и все chunks. Отдельный GitHub asset
|
||||
`houseplan-card.js` сохраняется как диагностический entry-файл, но каноническая
|
||||
установка остаётся целым `houseplan.zip`; документация не обещает, что один
|
||||
скачанный entry без integration assets является самостоятельной установкой.
|
||||
Release verification проверяет полный manifest внутри zip.
|
||||
|
||||
## 9. CSS minification
|
||||
|
||||
Build plugin обрабатывает только static Lit `css\`\`` templates без `${…}`:
|
||||
|
||||
- удаляет CSS comments вне строк;
|
||||
- схлопывает ASCII whitespace вне строк;
|
||||
- удаляет пробелы вокруг безопасной пунктуации, не меняя значения custom
|
||||
properties, strings, escapes, `url()`, `calc()`, media/container queries и
|
||||
descendant combinators;
|
||||
- fail-closed сообщает файл/позицию при interpolation или незакрытой
|
||||
строке/comment вместо частичной порчи CSS;
|
||||
- порядок templates и rules не меняется.
|
||||
|
||||
Минификация не имеет отдельного продуктового budget: ожидаемая gzip-экономия
|
||||
мала и учитывается в общем initial View graph.
|
||||
|
||||
## 10. Fingerprint и cache contract
|
||||
|
||||
1. Source fingerprint продолжает покрывать `src/`, Rollup, TypeScript и lockfile;
|
||||
manifest несёт то же значение.
|
||||
2. Entry публикует fingerprint как сейчас. Lazy runtime экспортирует тот же
|
||||
fingerprint; loader сверяет его до `ready`.
|
||||
3. Demo freshness проверяет entry и manifest, затем SHA-256 каждого asset.
|
||||
4. Content-hashed filename предотвращает выдачу нового кода из старого browser
|
||||
cache. Entry version query продолжает меняться вместе с release version.
|
||||
5. Старый открытый entry после обновления может запросить удалённый hash; это
|
||||
штатный failure §6.4, а не основание хранить бесконечно старые chunks.
|
||||
|
||||
## 11. I18n и документация
|
||||
|
||||
Добавляются четыре ключа (RU/EN): loading editor, load failed, refresh advice и
|
||||
доступное имя busy-state. Остальные подписи не меняются.
|
||||
|
||||
Обновляются:
|
||||
|
||||
- `docs/ARCHITECTURE.md` — multi-asset graph, runtime boundary и public routes;
|
||||
- `docs/DEVELOPMENT.md` — build/sync/deploy всего дерева, проверка manifest;
|
||||
- `docs/USER-GUIDE.ru.md` и README RU/EN — прежний resource URL сохраняется,
|
||||
но frontend состоит из entry и внутренних assets; ручное копирование одного
|
||||
JS не является поддерживаемой установкой;
|
||||
- `docs/TESTING.md`/релевантный testing guide — сценарий failure и network gate;
|
||||
- оба changelog — изменение пользовательски заметно по скорости и редкому
|
||||
сообщению ошибки.
|
||||
|
||||
## 12. Совместимость и миграция
|
||||
|
||||
Stored data не меняется; миграции нет. Старый браузер, способный исполнять
|
||||
текущий ESM bundle, способен исполнять native dynamic import. При отсутствии
|
||||
assets обновлённый entry сохраняет View и выдаёт §6.4.
|
||||
|
||||
Обновление integration требует полный HACS zip и обычный restart/reload HA.
|
||||
Новый backend с новым frontend является штатной парой; mixed-version runtime
|
||||
блокируется fingerprint handshake.
|
||||
|
||||
## 13. Критерии приёмки
|
||||
|
||||
**AC1. Честный initial budget.** На production build сумма gzip entry и всех
|
||||
транзитивных static imports до рабочего первого View ≤ 256 000 B; dynamic editor
|
||||
assets не учитываются только если browser smoke подтверждает, что они не
|
||||
запрошены до editor intent. **Доказательство:** `bundle-budget` unit + CI command
|
||||
с manifest + Playwright network smoke.
|
||||
|
||||
**AC2. Реальная lazy boundary.** Initial import graph не содержит editor runtime,
|
||||
editor toolbar/gesture/dialog modules и GUI config editor; первый вход в любой
|
||||
из трёх редакторов загружает один deduplicated editor graph. **Доказательство:**
|
||||
manifest/import-graph unit и Playwright network smoke.
|
||||
|
||||
**AC3. View parity.** Configured View, kiosk, touch View, device actions,
|
||||
Glow/sun/vacuum, openings, room hover, space switching and visual continuity
|
||||
проходят без golden delta. **Доказательство:** связанные smokes + полный golden
|
||||
verify на pre-release gate; на code-review — selected smokes из registry.
|
||||
|
||||
**AC4. Editor parity.** Plan, Devices и Background открываются после cold load;
|
||||
основные select/draw/save/undo flows и editor-to-editor transitions проходят
|
||||
существующие smokes без изменения ожидаемого DOM/данных. **Доказательство:**
|
||||
selected editor smokes и unit tests controller/loader.
|
||||
|
||||
**AC5. Loader atomicity.** Двойной клик/конкурентные запросы выполняют один
|
||||
import/install; mode и camera меняются только после ready. **Доказательство:**
|
||||
unit с controllable Promise + browser smoke.
|
||||
|
||||
**AC6. Failure сохраняет View.** Первый 404/parse/fingerprint failure retry-ится
|
||||
один раз; второй оставляет mode=view, план интерактивным и показывает
|
||||
локализованное сообщение. **Доказательство:** unit loader state machine +
|
||||
Playwright route abort/mismatch scenarios RU/EN.
|
||||
|
||||
**AC7. Asset security.** Backend отдаёт только manifest-listed JS из asset root и
|
||||
отказывает traversal, encoded traversal, nested path, unknown extension и stale
|
||||
asset. **Доказательство:** HA backend tests.
|
||||
|
||||
**AC8. Полнота distribution.** Build/sync/zip/release verification падают при
|
||||
удалении или подмене любого manifest asset; dist, integration и demo после sync
|
||||
побайтово совпадают по manifest. **Доказательство:** Node release-contract,
|
||||
bundle-sync и freshness tests + zip test.
|
||||
|
||||
**AC9. CSS без семантической порчи.** Plugin корректно сохраняет strings,
|
||||
escapes, URLs, custom properties, calc/media/container и combinators, умеет
|
||||
падать на interpolation/malformed input; computed styles выбранных компонентов
|
||||
и golden images не меняются. **Доказательство:** unit с adversarial fixtures +
|
||||
computed-style smoke + golden verify.
|
||||
|
||||
**AC10. Fingerprint mismatch не смешивает версии.** Runtime с другим fingerprint
|
||||
не устанавливается и проходит failure contract. **Доказательство:** unit +
|
||||
browser injected mismatch.
|
||||
|
||||
**AC11. Onboarding и GUI editor.** Empty-config onboarding работает без editor
|
||||
asset; async `getConfigElement()` загружает editor graph и возвращает прежний
|
||||
custom element/config contract. **Доказательство:** browser smokes обоих путей.
|
||||
|
||||
**AC12. Без model drift.** No-op config/layout roundtrip и backend validation не
|
||||
изменяют данные; `any` count в `src/` не растёт; второй geometry authority не
|
||||
появляется. **Доказательство:** existing roundtrip/schema tests + static gate.
|
||||
|
||||
**AC13. Документация не обещает single-file install.** Resource URL остаётся
|
||||
прежним, а install/deploy docs требуют целое asset tree. **Доказательство:**
|
||||
`check-docs` и ревью текста.
|
||||
|
||||
## 14. План автотестов и гейты реализации
|
||||
|
||||
На каждом implementation slice:
|
||||
|
||||
```text
|
||||
npm run typecheck
|
||||
npm test
|
||||
npm run build
|
||||
npm run bundle:sync
|
||||
node scripts/check-docs.mjs
|
||||
node scripts/smoke-select.mjs --base origin/dev --head HEAD
|
||||
<все smokes, выбранные registry и перечисленные AC>
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Перед code review дополнительно:
|
||||
|
||||
- backend pure tests и полный HA subset в WSL/CI для frontend asset view;
|
||||
- new network/failure/editor cold-load smoke;
|
||||
- `npm run inventory`;
|
||||
- hashes полного asset tree;
|
||||
- `npm run golden:verify` как диагностический локальный прогон; канонический
|
||||
full golden/performance остаётся pre-beta CI по `PROCESS.md`.
|
||||
|
||||
Mutation guards обязаны уметь сломать: dynamic boundary, retry cap, fingerprint
|
||||
check, manifest allowlist, omitted zip chunk, CSS string/comment handling и
|
||||
initial budget.
|
||||
|
||||
## 15. Риски и меры
|
||||
|
||||
| Риск | Мера |
|
||||
|---|---|
|
||||
| Big-bang перенос editor state ломает жесты | переносить законченными typed slices; одинаковые smokes до/после каждого slice; без новой geometry model |
|
||||
| Chunk доступен в demo, но 404 в HA | production backend route + HA harness test; smoke использует production URL layout |
|
||||
| Entry мал, но eager shared chunk возвращает вес | budget следует transitive static graph из manifest и подтверждается network trace |
|
||||
| Update смешивает версии | content hash + source fingerprint handshake; отказ до install |
|
||||
| Сломан ручной deploy | sync/deploy docs и script работают с tree, не с одним `scp` |
|
||||
| CSS whitespace меняет selector/value | token-aware conservative transform, adversarial tests, computed style + golden |
|
||||
| Release zip неполон | manifest-driven zip validation и публичный asset verification |
|
||||
| First editor click выглядит зависшим | 150 ms delayed loading surface, View остаётся на месте |
|
||||
|
||||
## 16. Откат
|
||||
|
||||
Откат выполняется одним release commit к предыдущему monolithic Rollup output,
|
||||
точному static route и single-file bundle tooling. Stored config/layout не
|
||||
менялись, поэтому data rollback не нужен. Backend не удаляет старый entry URL;
|
||||
после отката новые chunk routes становятся неиспользуемыми.
|
||||
|
||||
## 17. Release-артефакты
|
||||
|
||||
- production entry + manifest + chunk tree в `dist/` и integration snapshot;
|
||||
- обновлённый `houseplan.zip` с полным tree;
|
||||
- прежний top-level GitHub `houseplan-card.js` entry asset;
|
||||
- RU/EN changelog со ссылкой на #337;
|
||||
- architecture/development/user docs;
|
||||
- новые unit/backend/browser tests; golden baselines не меняются без отдельного
|
||||
reviewed acceptance.
|
||||
|
||||
## 18. Принято предположительно, можно менять без владельца
|
||||
|
||||
- имена внутренних modules/chunks и форма typed host port;
|
||||
- формат manifest, если он остаётся детерминированным и доказывает все AC;
|
||||
- точная реализация delayed loading surface поверх существующей transition UI;
|
||||
- механизм перечитывания manifest backend view;
|
||||
- gzip implementation и fixed compression level в budget script;
|
||||
- разбиение editor extraction на внутренние commits/slices.
|
||||
|
||||
Нельзя менять без продуктового решения владельца: прежний resource URL,
|
||||
отсутствие автоматического hard reload, сохранение рабочего View при failure и
|
||||
отсутствие заранее загружаемого editor prefetch.
|
||||
@@ -11,6 +11,7 @@
|
||||
"test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs",
|
||||
"benchmark:wall-model": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node scripts/benchmark-wall-segment-model.mjs",
|
||||
"bundle:sync": "npm run build && node scripts/bundle-sync.mjs",
|
||||
"bundle:budget": "node scripts/bundle-budget.mjs",
|
||||
"invariants": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node scripts/model-invariants.mjs",
|
||||
"docs:accept": "node scripts/docs-accept.mjs",
|
||||
"smokes:select": "node scripts/smoke-select.mjs",
|
||||
|
||||
@@ -3,13 +3,22 @@ import json from '@rollup/plugin-json';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import terser from '@rollup/plugin-terser';
|
||||
import { sourceFingerprint } from './scripts/source-fingerprint.mjs';
|
||||
import { cssTemplateMinifier } from './scripts/css-template-minifier.mjs';
|
||||
import {
|
||||
buildFingerprintPlugin,
|
||||
bundleManifestPlugin,
|
||||
cleanBundleOutputPlugin,
|
||||
editorRuntimeRetryUrlPlugin,
|
||||
} from './scripts/bundle-manifest.mjs';
|
||||
|
||||
const SOURCE_FINGERPRINT = sourceFingerprint();
|
||||
|
||||
export default {
|
||||
input: 'src/houseplan-card.ts',
|
||||
output: {
|
||||
file: 'dist/houseplan-card.js',
|
||||
dir: 'dist',
|
||||
entryFileNames: 'houseplan-card.js',
|
||||
chunkFileNames: 'houseplan-assets/[name]-[hash].js',
|
||||
format: 'es',
|
||||
sourcemap: false,
|
||||
// Tooling reads this before recording screenshots/performance. A committed
|
||||
@@ -17,5 +26,15 @@ export default {
|
||||
// plausible-looking but invalid baseline.
|
||||
intro: `globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__=${JSON.stringify(SOURCE_FINGERPRINT)};`,
|
||||
},
|
||||
plugins: [resolve(), json(), typescript(), terser({ format: { comments: false } })],
|
||||
plugins: [
|
||||
cleanBundleOutputPlugin(),
|
||||
buildFingerprintPlugin(SOURCE_FINGERPRINT),
|
||||
cssTemplateMinifier(),
|
||||
resolve(),
|
||||
json(),
|
||||
typescript({ compilerOptions: { outDir: 'dist/.ts' } }),
|
||||
terser({ format: { comments: false } }),
|
||||
editorRuntimeRetryUrlPlugin(),
|
||||
bundleManifestPlugin(SOURCE_FINGERPRINT),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
export const INITIAL_VIEW_GZIP_BUDGET = 256_000;
|
||||
|
||||
export function assertBundleBudget(manifest, budget = INITIAL_VIEW_GZIP_BUDGET) {
|
||||
if (manifest?.schema !== 1 || !Array.isArray(manifest.files)) {
|
||||
throw new Error('invalid houseplan-assets.json');
|
||||
}
|
||||
if (!manifest.lazyEditorFiles?.length) {
|
||||
throw new Error('bundle has no lazy editor graph');
|
||||
}
|
||||
if (manifest.initialViewFiles.some((path) => manifest.lazyEditorFiles.includes(path))) {
|
||||
throw new Error('initial View graph overlaps lazy editor graph');
|
||||
}
|
||||
if (manifest.initialViewGzipBytes > budget) {
|
||||
throw new Error(
|
||||
`initial View graph ${manifest.initialViewGzipBytes} B gzip exceeds ${budget} B budget`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
initialViewGzipBytes: manifest.initialViewGzipBytes,
|
||||
lazyEditorGzipBytes: manifest.lazyEditorGzipBytes,
|
||||
};
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||||
try {
|
||||
const manifest = JSON.parse(readFileSync(resolve('dist/houseplan-assets.json'), 'utf8'));
|
||||
const result = assertBundleBudget(manifest);
|
||||
console.log(`initial View: ${result.initialViewGzipBytes} B gzip`);
|
||||
console.log(`lazy editor: ${result.lazyEditorGzipBytes} B gzip`);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { dirname, posix, relative, resolve } from 'node:path';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
|
||||
const BUILD_FINGERPRINT_TOKEN = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
|
||||
const EDITOR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_EDITOR_RETRY_ASSET__';
|
||||
const ONBOARDING_RETRY_ASSET_TOKEN = '__HOUSEPLAN_ONBOARDING_RETRY_ASSET__';
|
||||
|
||||
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
const reachable = (entry, byPath, edge) => {
|
||||
const seen = new Set();
|
||||
const visit = (path) => {
|
||||
if (seen.has(path)) return;
|
||||
const file = byPath.get(path);
|
||||
if (!file) throw new Error(`bundle manifest references missing asset: ${path}`);
|
||||
seen.add(path);
|
||||
for (const child of file[edge] || []) visit(child);
|
||||
};
|
||||
visit(entry);
|
||||
return seen;
|
||||
};
|
||||
|
||||
export function buildBundleManifest(bundle, fingerprint) {
|
||||
const files = Object.values(bundle)
|
||||
.filter((item) => item.type === 'chunk')
|
||||
.map((chunk) => {
|
||||
const contents = Buffer.from(chunk.code, 'utf8');
|
||||
return {
|
||||
path: chunk.fileName.replaceAll('\\', '/'),
|
||||
sha256: sha256(contents),
|
||||
rawBytes: contents.length,
|
||||
gzipBytes: gzipSync(contents, { level: 9 }).length,
|
||||
isEntry: chunk.isEntry,
|
||||
imports: [...chunk.imports].sort(),
|
||||
dynamicImports: [...chunk.dynamicImports].sort(),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.path.localeCompare(right.path));
|
||||
const entry = files.find((file) => file.isEntry)?.path;
|
||||
if (!entry) throw new Error('bundle manifest has no entry chunk');
|
||||
const byPath = new Map(files.map((file) => [file.path, file]));
|
||||
const initial = reachable(entry, byPath, 'imports');
|
||||
const dynamicRoots = [...initial]
|
||||
.flatMap((path) => byPath.get(path)?.dynamicImports || []);
|
||||
const lazy = new Set();
|
||||
for (const root of dynamicRoots) {
|
||||
for (const path of reachable(root, byPath, 'imports')) if (!initial.has(path)) lazy.add(path);
|
||||
}
|
||||
const onboardingRoots = dynamicRoots.filter((path) => path.includes('houseplan-onboarding-runtime-'));
|
||||
const editorRoots = dynamicRoots.filter((path) => !onboardingRoots.includes(path));
|
||||
const graphFrom = (roots) => {
|
||||
const graph = new Set();
|
||||
for (const root of roots) {
|
||||
for (const path of reachable(root, byPath, 'imports')) if (!initial.has(path)) graph.add(path);
|
||||
}
|
||||
return graph;
|
||||
};
|
||||
const lazyEditor = graphFrom(editorRoots);
|
||||
const lazyOnboarding = graphFrom(onboardingRoots);
|
||||
const sum = (paths) => [...paths]
|
||||
.reduce((total, path) => total + (byPath.get(path)?.gzipBytes || 0), 0);
|
||||
return {
|
||||
schema: 1,
|
||||
fingerprint,
|
||||
entry,
|
||||
initialViewFiles: [...initial].sort(),
|
||||
initialViewGzipBytes: sum(initial),
|
||||
lazyFiles: [...lazy].sort(),
|
||||
lazyGzipBytes: sum(lazy),
|
||||
lazyEditorFiles: [...lazyEditor].sort(),
|
||||
lazyEditorGzipBytes: sum(lazyEditor),
|
||||
lazyOnboardingFiles: [...lazyOnboarding].sort(),
|
||||
lazyOnboardingGzipBytes: sum(lazyOnboarding),
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
export function bundleManifestPlugin(fingerprint) {
|
||||
return {
|
||||
name: 'houseplan-bundle-manifest',
|
||||
generateBundle(_options, bundle) {
|
||||
const manifest = buildBundleManifest(bundle, fingerprint);
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: 'houseplan-assets.json',
|
||||
source: `${JSON.stringify(manifest, null, 2)}\n`,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Embed one immutable build id in both sides of the lazy handshake. */
|
||||
export function buildFingerprintPlugin(fingerprint) {
|
||||
return {
|
||||
name: 'houseplan-build-fingerprint',
|
||||
transform(code, id) {
|
||||
if (!id.replaceAll('\\', '/').includes('/src/')
|
||||
|| !code.includes(BUILD_FINGERPRINT_TOKEN)) return null;
|
||||
return {
|
||||
code: code.replaceAll(BUILD_FINGERPRINT_TOKEN, fingerprint),
|
||||
map: null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the second import attempt at the exact content-hashed editor chunk.
|
||||
* Rollup owns that filename, so the URL is injected only after chunk naming;
|
||||
* the first import remains a normal Rollup edge for graph accounting.
|
||||
*/
|
||||
export function editorRuntimeRetryUrlPlugin() {
|
||||
return {
|
||||
name: 'houseplan-editor-retry-url',
|
||||
generateBundle(_options, bundle) {
|
||||
const chunks = Object.values(bundle).filter((item) => item.type === 'chunk');
|
||||
const editor = chunks.find((chunk) => Object.keys(chunk.modules)
|
||||
.some((id) => id.replaceAll('\\', '/').endsWith('/src/houseplan-editor-runtime.ts')));
|
||||
const onboarding = chunks.find((chunk) => Object.keys(chunk.modules)
|
||||
.some((id) => id.replaceAll('\\', '/').endsWith('/src/houseplan-onboarding-runtime.ts')));
|
||||
if (!editor) throw new Error('editor runtime chunk was not emitted');
|
||||
if (!onboarding) throw new Error('onboarding runtime chunk was not emitted');
|
||||
let editorReplacements = 0;
|
||||
let onboardingReplacements = 0;
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.code.includes(EDITOR_RETRY_ASSET_TOKEN)) {
|
||||
let asset = posix.relative(posix.dirname(chunk.fileName), editor.fileName);
|
||||
if (!asset.startsWith('.')) asset = `./${asset}`;
|
||||
editorReplacements += chunk.code.split(EDITOR_RETRY_ASSET_TOKEN).length - 1;
|
||||
chunk.code = chunk.code.replaceAll(EDITOR_RETRY_ASSET_TOKEN, asset);
|
||||
}
|
||||
if (chunk.code.includes(ONBOARDING_RETRY_ASSET_TOKEN)) {
|
||||
let asset = posix.relative(posix.dirname(chunk.fileName), onboarding.fileName);
|
||||
if (!asset.startsWith('.')) asset = `./${asset}`;
|
||||
onboardingReplacements += chunk.code.split(ONBOARDING_RETRY_ASSET_TOKEN).length - 1;
|
||||
chunk.code = chunk.code.replaceAll(ONBOARDING_RETRY_ASSET_TOKEN, asset);
|
||||
}
|
||||
}
|
||||
if (editorReplacements !== 1 || onboardingReplacements !== 1) {
|
||||
throw new Error('lazy retry URL placeholder counts are '
|
||||
+ `${editorReplacements}/${onboardingReplacements}, expected 1/1`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Remove only files named by the previous generated manifest. */
|
||||
export function cleanBundleOutputPlugin(outputRoot = 'dist') {
|
||||
return {
|
||||
name: 'houseplan-clean-bundle-output',
|
||||
buildStart() {
|
||||
const root = resolve(outputRoot);
|
||||
const manifestPath = resolve(root, 'houseplan-assets.json');
|
||||
if (!existsSync(manifestPath)) return;
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||
for (const file of manifest?.files || []) {
|
||||
const path = resolve(root, String(file.path || ''));
|
||||
const rel = relative(root, path);
|
||||
if (!rel || rel.startsWith('..') || rel.includes(':')) {
|
||||
throw new Error(`previous bundle path escapes output root: ${file.path}`);
|
||||
}
|
||||
rmSync(path, { force: true });
|
||||
}
|
||||
rmSync(manifestPath, { force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -9,24 +9,81 @@
|
||||
* туда» жило шестью разными `cp` в воркфлоу и трижды в документации; когда
|
||||
* копию забывали, смок врал согласованно (#236).
|
||||
*/
|
||||
import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
copyFileSync, existsSync, mkdirSync, readFileSync, rmSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SOURCE = resolve(ROOT, 'dist/houseplan-card.js');
|
||||
const SOURCE_ROOT = resolve(ROOT, 'dist');
|
||||
const MANIFEST_NAME = 'houseplan-assets.json';
|
||||
const TARGETS = [
|
||||
'custom_components/houseplan/frontend/houseplan-card.js',
|
||||
'demo/srv/assets/houseplan-card.js',
|
||||
'custom_components/houseplan/frontend',
|
||||
'demo/srv/assets',
|
||||
];
|
||||
|
||||
if (!existsSync(SOURCE)) {
|
||||
console.error('dist/houseplan-card.js не найден: сначала `npm run build`');
|
||||
const manifestPath = resolve(SOURCE_ROOT, MANIFEST_NAME);
|
||||
if (!existsSync(manifestPath)) {
|
||||
console.error(`dist/${MANIFEST_NAME} не найден: сначала \`npm run build\``);
|
||||
process.exit(1);
|
||||
}
|
||||
for (const target of TARGETS) {
|
||||
const path = resolve(ROOT, target);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
copyFileSync(SOURCE, path);
|
||||
console.log(`бандл → ${target}`);
|
||||
|
||||
const readManifest = (path) => {
|
||||
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
||||
if (parsed?.schema !== 1 || !Array.isArray(parsed.files) || !parsed.entry) {
|
||||
throw new Error(`${path}: invalid House Plan bundle manifest`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
const sourceManifest = readManifest(manifestPath);
|
||||
const managedFiles = [MANIFEST_NAME, ...sourceManifest.files.map((file) => file.path)];
|
||||
const sha256 = (path) => createHash('sha256').update(readFileSync(path)).digest('hex');
|
||||
const contained = (root, name) => {
|
||||
const path = resolve(root, name);
|
||||
const rel = relative(root, path);
|
||||
if (!rel || rel.startsWith('..') || rel.includes(':')) {
|
||||
throw new Error(`bundle path escapes target root: ${name}`);
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
for (const file of sourceManifest.files) {
|
||||
const path = contained(SOURCE_ROOT, file.path);
|
||||
if (!existsSync(path)) throw new Error(`manifest asset is missing: ${file.path}`);
|
||||
if (sha256(path) !== file.sha256) throw new Error(`manifest hash mismatch: ${file.path}`);
|
||||
}
|
||||
|
||||
for (const target of TARGETS) {
|
||||
const targetRoot = resolve(ROOT, target);
|
||||
const oldManifestPath = resolve(targetRoot, MANIFEST_NAME);
|
||||
let old = null;
|
||||
if (existsSync(oldManifestPath)) {
|
||||
old = readManifest(oldManifestPath);
|
||||
}
|
||||
// Content-hashed dependencies first, stable entry second, manifest last.
|
||||
// At every observable point the current manifest therefore names a complete
|
||||
// tree; an interrupted copy cannot publish an allowlist for absent chunks.
|
||||
const payload = sourceManifest.files
|
||||
.map((file) => file.path)
|
||||
.sort((left, right) => (left === sourceManifest.entry ? 1 : 0)
|
||||
- (right === sourceManifest.entry ? 1 : 0) || left.localeCompare(right));
|
||||
for (const name of payload) {
|
||||
const source = contained(SOURCE_ROOT, name);
|
||||
const destination = contained(targetRoot, name);
|
||||
mkdirSync(dirname(destination), { recursive: true });
|
||||
copyFileSync(source, destination);
|
||||
}
|
||||
copyFileSync(manifestPath, oldManifestPath);
|
||||
for (const file of old?.files || []) {
|
||||
if (!managedFiles.includes(file.path)) rmSync(contained(targetRoot, file.path), { force: true });
|
||||
}
|
||||
const copied = readManifest(resolve(targetRoot, MANIFEST_NAME));
|
||||
for (const file of copied.files) {
|
||||
if (sha256(contained(targetRoot, file.path)) !== file.sha256) {
|
||||
throw new Error(`${target}/${file.path}: copied hash mismatch`);
|
||||
}
|
||||
}
|
||||
console.log(`бандл-дерево → ${target}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const BUNDLE_MANIFEST = 'houseplan-assets.json';
|
||||
|
||||
export const sha256Bytes = (contents) => createHash('sha256').update(contents).digest('hex');
|
||||
|
||||
export function containedBundlePath(root, name) {
|
||||
if (typeof name !== 'string' || !name || name.includes('\\')) {
|
||||
throw new Error(`invalid bundle path: ${String(name)}`);
|
||||
}
|
||||
const path = resolve(root, name);
|
||||
const rel = relative(resolve(root), path);
|
||||
if (!rel || rel.startsWith('..') || rel.includes(':')) {
|
||||
throw new Error(`bundle path escapes root: ${name}`);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function readBundleManifest(root) {
|
||||
const path = resolve(root, BUNDLE_MANIFEST);
|
||||
if (!existsSync(path)) throw new Error(`${path}: bundle manifest is missing`);
|
||||
const manifest = JSON.parse(readFileSync(path, 'utf8'));
|
||||
if (manifest?.schema !== 1 || typeof manifest.entry !== 'string'
|
||||
|| typeof manifest.fingerprint !== 'string' || !Array.isArray(manifest.files)) {
|
||||
throw new Error(`${path}: invalid House Plan bundle manifest`);
|
||||
}
|
||||
const names = manifest.files.map((file) => file?.path);
|
||||
if (new Set(names).size !== names.length || !names.includes(manifest.entry)) {
|
||||
throw new Error(`${path}: duplicate assets or missing entry`);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function verifyBundleTree(root) {
|
||||
const manifest = readBundleManifest(root);
|
||||
for (const file of manifest.files) {
|
||||
if (typeof file?.path !== 'string' || !file.path.endsWith('.js')
|
||||
|| !/^[a-zA-Z0-9._/-]+$/.test(file.path)) {
|
||||
throw new Error(`invalid manifest asset path: ${String(file?.path)}`);
|
||||
}
|
||||
const path = containedBundlePath(root, file.path);
|
||||
if (!existsSync(path)) throw new Error(`manifest asset is missing: ${file.path}`);
|
||||
const actual = sha256Bytes(readFileSync(path));
|
||||
if (actual !== file.sha256) {
|
||||
throw new Error(`manifest hash mismatch: ${file.path} (${actual} != ${file.sha256})`);
|
||||
}
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function compareBundleTrees(sourceRoot, targetRoot) {
|
||||
const source = verifyBundleTree(sourceRoot);
|
||||
const target = verifyBundleTree(targetRoot);
|
||||
if (JSON.stringify(target) !== JSON.stringify(source)) {
|
||||
throw new Error('bundle manifests differ');
|
||||
}
|
||||
for (const file of source.files) {
|
||||
const left = readFileSync(containedBundlePath(sourceRoot, file.path));
|
||||
const right = readFileSync(containedBundlePath(targetRoot, file.path));
|
||||
if (!left.equals(right)) throw new Error(`bundle asset differs: ${file.path}`);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
const invokedDirectly = process.argv[1]
|
||||
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
||||
if (invokedDirectly) {
|
||||
try {
|
||||
const [source, target] = process.argv.slice(2);
|
||||
if (!source) throw new Error('usage: node scripts/bundle-tree.mjs <root> [matching-root]');
|
||||
const manifest = target
|
||||
? compareBundleTrees(resolve(source), resolve(target))
|
||||
: verifyBundleTree(resolve(source));
|
||||
console.log(`verified ${manifest.files.length} bundle assets`);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -24,15 +24,24 @@ const requireWindow = (errors, source, index, pattern, label, before = 650) => {
|
||||
|
||||
export function checkCoordinateWriteBarriers(root = defaultRoot) {
|
||||
const errors = [];
|
||||
const frontend = readFileSync(resolve(root, 'src/houseplan-card.ts'), 'utf8');
|
||||
// The editor implementation is a lazy chunk, but the persisted-coordinate
|
||||
// inventory remains one logical frontend. Normalize the typed host port so
|
||||
// the existing boundary windows keep describing the production call sites.
|
||||
const card = readFileSync(resolve(root, 'src/houseplan-card.ts'), 'utf8');
|
||||
const editor = readFileSync(resolve(root, 'src/houseplan-editor-runtime.ts'), 'utf8')
|
||||
.replaceAll('this.host.', 'this.');
|
||||
const frontend = `${card}\n${editor}`;
|
||||
|
||||
const configWrites = occurrences(frontend, "type: 'houseplan/config/set'");
|
||||
if (configWrites.length !== 1) errors.push(`frontend config writer inventory: ${configWrites.length}`);
|
||||
for (const index of configWrites) requireWindow(
|
||||
errors, frontend, index,
|
||||
/const candidate = canonicalizeConfigGeometry\(this\._serverCfg\);[\s\S]*config: candidate/,
|
||||
'frontend config/set', 3600,
|
||||
/const canonicalCandidate = canonicalizeConfigGeometry\(candidate\);[\s\S]*config: canonicalCandidate/,
|
||||
'frontend config/set', 600,
|
||||
);
|
||||
if (occurrences(frontend, '._sendConfigCandidate(candidate)').length !== 2) {
|
||||
errors.push('frontend canonical config transport callers changed');
|
||||
}
|
||||
|
||||
const positionWrites = occurrences(frontend, "type: 'houseplan/layout/update'");
|
||||
if (positionWrites.length !== 2) errors.push(`frontend position writer inventory: ${positionWrites.length}`);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Conservative minifier for static Lit css`` templates.
|
||||
*
|
||||
* This deliberately is not a general CSS optimiser: it removes comments and
|
||||
* redundant whitespace while preserving strings, escapes and descendant
|
||||
* combinators. Interpolated templates fail closed because rewriting across a
|
||||
* JavaScript expression would make the build depend on runtime values.
|
||||
*/
|
||||
const isWhitespace = (char) => /[\t\n\f\r ]/.test(char);
|
||||
const TIGHT_BEFORE = new Set(['{', '}', ':', ';', ',', '>', ')']);
|
||||
const TIGHT_AFTER = new Set(['{', '}', ':', ';', ',', '>', '(']);
|
||||
|
||||
export function minifyCssText(source, label = '<css>') {
|
||||
let out = '';
|
||||
let quote = '';
|
||||
let pendingSpace = false;
|
||||
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
const char = source[i];
|
||||
const next = source[i + 1];
|
||||
|
||||
if (quote) {
|
||||
out += char;
|
||||
if (char === '\\') {
|
||||
if (i + 1 >= source.length) throw new Error(`${label}: dangling CSS escape`);
|
||||
out += source[++i];
|
||||
} else if (char === quote) quote = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
if (pendingSpace && out && !TIGHT_AFTER.has(out.at(-1))) out += ' ';
|
||||
pendingSpace = false;
|
||||
quote = char;
|
||||
out += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '/' && next === '*') {
|
||||
const end = source.indexOf('*/', i + 2);
|
||||
if (end < 0) throw new Error(`${label}: unclosed CSS comment`);
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isWhitespace(char)) {
|
||||
pendingSpace = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pendingSpace && out && !TIGHT_AFTER.has(out.at(-1)) && !TIGHT_BEFORE.has(char)) {
|
||||
out += ' ';
|
||||
}
|
||||
pendingSpace = false;
|
||||
if (TIGHT_BEFORE.has(char) && out.endsWith(' ')) out = out.slice(0, -1);
|
||||
out += char;
|
||||
}
|
||||
|
||||
if (quote) throw new Error(`${label}: unclosed CSS string`);
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
export function minifyStaticCssTemplates(code, id = '<module>') {
|
||||
let cursor = 0;
|
||||
let output = '';
|
||||
let changed = false;
|
||||
while (true) {
|
||||
const start = code.indexOf('css`', cursor);
|
||||
if (start < 0) break;
|
||||
let end = start + 4;
|
||||
let escaped = false;
|
||||
for (; end < code.length; end += 1) {
|
||||
const char = code[end];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (char === '$' && code[end + 1] === '{') {
|
||||
throw new Error(`${id}:${end}: interpolated css template is not supported`);
|
||||
}
|
||||
if (char === '`') break;
|
||||
}
|
||||
if (end >= code.length) throw new Error(`${id}:${start}: unclosed css template`);
|
||||
const css = code.slice(start + 4, end);
|
||||
output += code.slice(cursor, start);
|
||||
output += `css\`${minifyCssText(css, `${id}:${start}`)}\``;
|
||||
cursor = end + 1;
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return null;
|
||||
return output + code.slice(cursor);
|
||||
}
|
||||
|
||||
export function cssTemplateMinifier() {
|
||||
return {
|
||||
name: 'houseplan-css-template-minifier',
|
||||
transform(code, id) {
|
||||
if (!id.endsWith('.ts') || !code.includes('css`')) return null;
|
||||
const transformed = minifyStaticCssTemplates(code, id);
|
||||
return transformed == null ? null : { code: transformed, map: null };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -37,10 +37,96 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
const occurrenceCount = (source, needle) => source.split(needle).length - 1;
|
||||
|
||||
/**
|
||||
* Map a logical pre-split card anchor onto the lazy editor source.
|
||||
*
|
||||
* Keeping the mutation definitions in their original logical form makes the
|
||||
* registry readable and preserves the history of each regression. At runtime
|
||||
* we relocate only anchors that actually moved into the typed editor host.
|
||||
* The mapping is deliberately strict: an ambiguous or missing anchor still
|
||||
* fails the cheap mutation applicability gate.
|
||||
*/
|
||||
function relocateEditorPatch(patch, cardSource, editorSource) {
|
||||
if (patch.file !== 'src/houseplan-card.ts'
|
||||
|| occurrenceCount(cardSource, patch.find) === 1) return patch;
|
||||
if (occurrenceCount(editorSource, patch.find) === 1) {
|
||||
return { ...patch, file: 'src/houseplan-editor-runtime.ts' };
|
||||
}
|
||||
|
||||
const normalized = editorSource.replaceAll('this.host.', 'this.');
|
||||
if (occurrenceCount(normalized, patch.find) !== 1) return patch;
|
||||
|
||||
const normalizedStart = normalized.indexOf(patch.find);
|
||||
let rawIndex = 0;
|
||||
let normalizedIndex = 0;
|
||||
while (normalizedIndex < normalizedStart) {
|
||||
if (editorSource.startsWith('this.host.', rawIndex)) {
|
||||
rawIndex += 'this.host.'.length;
|
||||
normalizedIndex += 'this.'.length;
|
||||
} else {
|
||||
rawIndex += 1;
|
||||
normalizedIndex += 1;
|
||||
}
|
||||
}
|
||||
const rawStart = rawIndex;
|
||||
const hostFlags = [];
|
||||
let findIndex = 0;
|
||||
while (findIndex < patch.find.length) {
|
||||
if (patch.find.startsWith('this.', findIndex)) {
|
||||
hostFlags.push(editorSource.startsWith('this.host.', rawIndex));
|
||||
}
|
||||
if (editorSource.startsWith('this.host.', rawIndex)) {
|
||||
rawIndex += 'this.host.'.length;
|
||||
findIndex += 'this.'.length;
|
||||
} else {
|
||||
rawIndex += 1;
|
||||
findIndex += 1;
|
||||
}
|
||||
}
|
||||
const rawFind = editorSource.slice(rawStart, rawIndex);
|
||||
let replacementThis = 0;
|
||||
const rawReplace = patch.replace.replaceAll('this.', () => {
|
||||
const host = hostFlags[replacementThis++] === true;
|
||||
return host ? 'this.host.' : 'this.';
|
||||
});
|
||||
return {
|
||||
...patch,
|
||||
file: 'src/houseplan-editor-runtime.ts',
|
||||
find: rawFind,
|
||||
replace: rawReplace,
|
||||
};
|
||||
}
|
||||
|
||||
// --- реестр ---------------------------------------------------------------
|
||||
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
|
||||
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
|
||||
export const MUTANTS = [
|
||||
const MUTANT_DEFINITIONS = [
|
||||
{
|
||||
id: 'editor-runtime-fingerprint-handshake',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test test/editor-runtime-loader.test.mjs',
|
||||
because: 'a mixed entry/editor build must fail closed instead of installing incompatible code (#337)',
|
||||
patches: [{
|
||||
file: 'src/editor-runtime-loader.ts',
|
||||
find: ' if (module.fingerprint !== this.options.expectedFingerprint) {',
|
||||
replace: ' if (false && module.fingerprint !== this.options.expectedFingerprint) {',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'editor-runtime-one-retry',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test test/editor-runtime-loader.test.mjs',
|
||||
because: 'a transient chunk failure gets exactly one bounded retry (#337)',
|
||||
patches: [{
|
||||
file: 'src/editor-runtime-loader.ts',
|
||||
find: ' for (const attempt of [0, 1] as const) {',
|
||||
replace: ' for (const attempt of [0] as const) {',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'wallthick-hover-floor-back',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
@@ -1527,9 +1613,9 @@ export const MUTANTS = [
|
||||
guard: 'npm run bundle:sync && node demo/smoke_near_axis_optimize.mjs',
|
||||
because: 'opening the Optimize preview or cancelling it must never persist a lossy repair (#290)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ' private _openAlignDialog = (): void => this._previewAlignDialog(false);',
|
||||
replace: ' private _openAlignDialog = (): void => {\n'
|
||||
file: 'src/houseplan-editor-runtime.ts',
|
||||
find: 'public _openAlignDialog = (): void => this._previewAlignDialog(false);',
|
||||
replace: 'public _openAlignDialog = (): void => {\n'
|
||||
+ ' this._previewAlignDialog(false);\n'
|
||||
+ ' void this._runAlignToGrid();\n'
|
||||
+ ' };',
|
||||
@@ -3244,9 +3330,9 @@ export const MUTANTS = [
|
||||
because: 'Plan architecture ink is visual chrome; leaving the wall outline at raw SVG units '
|
||||
+ 'makes it five times thinner on a 1 cm grid (#239)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ' stroke="${stroke}" stroke-width="${gridVisualUnits(0.6, this._cellCm)}"',
|
||||
replace: ' stroke="${stroke}" stroke-width="0.6"',
|
||||
file: 'src/styles/plan.styles.ts',
|
||||
find: ' stroke-width: calc(0.6px * var(--hp-cell-visual-scale, 1));',
|
||||
replace: ' stroke-width: 0.6px;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
@@ -3432,8 +3518,16 @@ export const MUTANTS = [
|
||||
},
|
||||
];
|
||||
|
||||
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
|
||||
const mutationEditorSource = readFileSync(join(repoRoot, 'src/houseplan-editor-runtime.ts'), 'utf8');
|
||||
export const MUTANTS = MUTANT_DEFINITIONS.map((mutant) => ({
|
||||
...mutant,
|
||||
patches: mutant.patches.map((patch) => relocateEditorPatch(
|
||||
patch, mutationCardSource, mutationEditorSource,
|
||||
)),
|
||||
}));
|
||||
|
||||
// --- механика ---------------------------------------------------------------
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
export function applyPatches(root, patches) {
|
||||
for (const patch of patches) {
|
||||
@@ -3493,7 +3587,8 @@ export function guardNeedsTestBuild(guard) {
|
||||
* Нужен ли гварду СОБРАННЫЙ бандл (#332).
|
||||
*
|
||||
* Бандл читают только браузерные проверки: смоки и golden-сцены грузят
|
||||
* `demo/srv/assets/houseplan-card.js`, и мутант обязан попасть в него, иначе
|
||||
* дерево `demo/srv/assets/`, и мутант обязан попасть в entry и lazy chunks,
|
||||
* иначе
|
||||
* guard проверяет чистый код. Юнит- и бэкенд-гварды бандл не открывают ни в
|
||||
* каком виде (проверено по реестру и по исходникам тестов: dist/** читается
|
||||
* только как git-чекаут, который в worktree и так есть). Rollup-сборка —
|
||||
@@ -3543,7 +3638,10 @@ function buildBundle(dir) {
|
||||
if (built.status !== 0) {
|
||||
throw new Error(`сборка мутанта упала:\n${(built.stderr || built.stdout).slice(-2000)}`);
|
||||
}
|
||||
cpSync(join(dir, 'dist', 'houseplan-card.js'), join(dir, 'demo', 'srv', 'assets', 'houseplan-card.js'));
|
||||
const synced = sh('node scripts/bundle-sync.mjs', dir);
|
||||
if (synced.status !== 0) {
|
||||
throw new Error(`дерево бандла мутанта не синхронизировалось:\n${(synced.stderr || synced.stdout).slice(-2000)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function runMutant(mutant) {
|
||||
|
||||
@@ -252,17 +252,33 @@ if (invokedDirectly) {
|
||||
// Копия стенда (`demo/srv/assets`) больше не коммитится (#255): её собирает
|
||||
// `npm run bundle:sync` перед браузерными прогонами. В релиз входят ровно
|
||||
// две копии — артефакт сборки и та, что ставит HACS.
|
||||
const names = [
|
||||
'dist/houseplan-card.js',
|
||||
'custom_components/houseplan/frontend/houseplan-card.js',
|
||||
];
|
||||
// Hash Git blobs, not checkout bytes. On Windows, Git can expose CRLF in
|
||||
// the worktree while the exact tagged blobs and Linux release checkout use
|
||||
// LF. The release must be bound to the immutable commit representation.
|
||||
const hashes = names.map((name) => [name, sha256Bytes(committedFile(sha, name))]);
|
||||
if (new Set(hashes.map(([, hash]) => hash)).size !== 1)
|
||||
throw new Error(`Committed bundle snapshots differ: ${hashes.map(([name, hash]) => `${name}=${hash}`).join(', ')}`);
|
||||
return hashes[0][1];
|
||||
const distManifestBytes = committedFile(sha, 'dist/houseplan-assets.json');
|
||||
const frontendManifestBytes = committedFile(
|
||||
sha, 'custom_components/houseplan/frontend/houseplan-assets.json',
|
||||
);
|
||||
if (!distManifestBytes.equals(frontendManifestBytes)) {
|
||||
throw new Error('Committed bundle manifests differ');
|
||||
}
|
||||
const manifest = JSON.parse(distManifestBytes.toString('utf8'));
|
||||
if (manifest?.schema !== 1 || !Array.isArray(manifest.files)
|
||||
|| manifest.entry !== 'houseplan-card.js') {
|
||||
throw new Error('Committed bundle manifest is invalid');
|
||||
}
|
||||
for (const file of manifest.files) {
|
||||
if (typeof file?.path !== 'string' || !file.path.endsWith('.js')
|
||||
|| file.path.includes('..') || file.path.includes('\\')) {
|
||||
throw new Error(`Committed bundle asset path is invalid: ${String(file?.path)}`);
|
||||
}
|
||||
const dist = committedFile(sha, `dist/${file.path}`);
|
||||
const frontend = committedFile(sha, `custom_components/houseplan/frontend/${file.path}`);
|
||||
if (!dist.equals(frontend)) throw new Error(`Committed bundle asset differs: ${file.path}`);
|
||||
const actual = sha256Bytes(dist);
|
||||
if (actual !== file.sha256) {
|
||||
throw new Error(`Committed bundle asset hash mismatch: ${file.path}`);
|
||||
}
|
||||
}
|
||||
const entry = manifest.files.find((file) => file.path === manifest.entry);
|
||||
return { manifest, entrySha256: entry.sha256 };
|
||||
};
|
||||
|
||||
const materializeCommittedBundle = (sha, expectedSha256, artifactsDir) => {
|
||||
@@ -275,18 +291,31 @@ if (invokedDirectly) {
|
||||
return bundlePath;
|
||||
};
|
||||
|
||||
const verifyZipContents = (zipPath, version, bundleSha256) => {
|
||||
const entries = readZipEntries(zipPath, ['manifest.json', 'frontend/houseplan-card.js']);
|
||||
const verifyZipContents = (zipPath, version, bundleSnapshot) => {
|
||||
const header = readZipEntries(zipPath, [
|
||||
'manifest.json', 'frontend/houseplan-assets.json',
|
||||
]);
|
||||
const frontendManifest = JSON.parse(header.get('frontend/houseplan-assets.json').toString('utf8'));
|
||||
if (JSON.stringify(frontendManifest) !== JSON.stringify(bundleSnapshot.manifest)) {
|
||||
throw new Error('houseplan.zip frontend manifest differs from committed bundle');
|
||||
}
|
||||
const required = [
|
||||
'manifest.json', 'frontend/houseplan-assets.json',
|
||||
...bundleSnapshot.manifest.files.map((file) => `frontend/${file.path}`),
|
||||
];
|
||||
const entries = readZipEntries(zipPath, required);
|
||||
const manifest = JSON.parse(entries.get('manifest.json').toString('utf8'));
|
||||
if (manifest.version !== version)
|
||||
throw new Error(`houseplan.zip manifest version ${manifest.version} != ${version}`);
|
||||
const bundledHash = createHash('sha256')
|
||||
.update(entries.get('frontend/houseplan-card.js')).digest('hex');
|
||||
if (bundledHash !== bundleSha256)
|
||||
throw new Error(`houseplan.zip frontend hash ${bundledHash} != committed bundle ${bundleSha256}`);
|
||||
for (const file of bundleSnapshot.manifest.files) {
|
||||
const bundledHash = sha256Bytes(entries.get(`frontend/${file.path}`));
|
||||
if (bundledHash !== file.sha256) {
|
||||
throw new Error(`houseplan.zip frontend hash mismatch: ${file.path}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const buildZip = (sha, version, bundleSha256, artifactsDir) => {
|
||||
const buildZip = (sha, version, bundleSnapshot, artifactsDir) => {
|
||||
const zipPath = resolve(artifactsDir, 'houseplan.zip');
|
||||
run('git', [
|
||||
// `git archive` on Windows otherwise applies local core.autocrlf and
|
||||
@@ -294,11 +323,11 @@ if (invokedDirectly) {
|
||||
'-c', 'core.autocrlf=false', 'archive', '--format=zip', `--output=${zipPath}`,
|
||||
`${sha}:custom_components/houseplan`,
|
||||
]);
|
||||
verifyZipContents(zipPath, version, bundleSha256);
|
||||
verifyZipContents(zipPath, version, bundleSnapshot);
|
||||
return zipPath;
|
||||
};
|
||||
|
||||
const verifyRemoteAssetContents = (version, bundleSha256) => {
|
||||
const verifyRemoteAssetContents = (version, bundleSnapshot) => {
|
||||
const download = mkdtempSync(resolve(tmpdir(), 'houseplan-release-check-'));
|
||||
try {
|
||||
run('gh', [
|
||||
@@ -308,9 +337,9 @@ if (invokedDirectly) {
|
||||
try {
|
||||
const cardPath = resolve(download, 'houseplan-card.js');
|
||||
const cardHash = sha256Path(cardPath);
|
||||
if (cardHash !== bundleSha256)
|
||||
throw new Error(`Published houseplan-card.js hash ${cardHash} != candidate ${bundleSha256}`);
|
||||
verifyZipContents(resolve(download, 'houseplan.zip'), version, bundleSha256);
|
||||
if (cardHash !== bundleSnapshot.entrySha256)
|
||||
throw new Error(`Published houseplan-card.js hash ${cardHash} != candidate ${bundleSnapshot.entrySha256}`);
|
||||
verifyZipContents(resolve(download, 'houseplan.zip'), version, bundleSnapshot);
|
||||
} catch (error) {
|
||||
throw new ReleaseAssetContentError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
@@ -466,7 +495,8 @@ if (invokedDirectly) {
|
||||
const sha = run('git', ['rev-parse', 'HEAD']).stdout;
|
||||
const remoteBranch = run('git', ['rev-parse', `origin/${branch}`]).stdout;
|
||||
if (sha !== remoteBranch) throw new Error(`HEAD ${sha} is not synchronized with origin/${branch} ${remoteBranch}`);
|
||||
const bundleSha256 = assertBundleSnapshots(sha);
|
||||
const bundleSnapshot = assertBundleSnapshots(sha);
|
||||
const bundleSha256 = bundleSnapshot.entrySha256;
|
||||
const validateRuns = assertGreenValidate(sha);
|
||||
validateIssues();
|
||||
const existingTag = remoteTag();
|
||||
@@ -519,7 +549,7 @@ if (invokedDirectly) {
|
||||
// A matching name and non-zero size are insufficient: bind both
|
||||
// downloadable assets to this exact candidate before closing issues.
|
||||
try {
|
||||
verifyRemoteAssetContents(contract.version, bundleSha256);
|
||||
verifyRemoteAssetContents(contract.version, bundleSnapshot);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ReleaseAssetContentError)) throw error;
|
||||
console.log(`Published release needs stale-asset recovery: ${error.message}`);
|
||||
@@ -534,7 +564,7 @@ if (invokedDirectly) {
|
||||
}
|
||||
}
|
||||
|
||||
const zipPath = buildZip(sha, contract.version, bundleSha256, artifactsDir);
|
||||
const zipPath = buildZip(sha, contract.version, bundleSnapshot, artifactsDir);
|
||||
if (!existingTag.exists) {
|
||||
const local = localTag();
|
||||
if (local.exists && (!local.annotated || local.commit !== sha)) {
|
||||
@@ -577,7 +607,7 @@ if (invokedDirectly) {
|
||||
const finalTag = remoteTag();
|
||||
if (!finalTag.exists || finalTag.commit !== sha)
|
||||
throw new Error(`Published tag ${tag} no longer resolves to exact SHA ${sha}`);
|
||||
verifyRemoteAssetContents(contract.version, bundleSha256);
|
||||
verifyRemoteAssetContents(contract.version, bundleSnapshot);
|
||||
verifyHacsDiscovery();
|
||||
finishIssues(published.url);
|
||||
console.log(`Published and content-verified: ${published.url}`);
|
||||
|
||||
@@ -34,6 +34,16 @@ export const SMOKE_LINKS = [
|
||||
+ 'fail-closed candidate) through the rendered card, never naming the key-quantisation '
|
||||
+ 'internals that decide which nodes are one node (#331 §2.1)',
|
||||
},
|
||||
{
|
||||
symbols: [
|
||||
'EditorRuntimeLoader', 'EditorRuntimeLoaderState', 'EditorRuntimeModule',
|
||||
'EDITOR_RUNTIME_FINGERPRINT', 'createHouseplanEditorRuntime',
|
||||
],
|
||||
smokes: ['smoke_lazy_editor_chunk.mjs'],
|
||||
because: 'the production-bundle scenario proves that the content-hashed editor chunk stays '
|
||||
+ 'off the View network path, is shared by all three editors, retries once, and refuses an '
|
||||
+ 'incompatible build before editor state is installed (#337)',
|
||||
},
|
||||
{
|
||||
symbols: [
|
||||
'placeResizeAreaLabel', 'resizeInwardNormal', 'resizeMeasuredEdges',
|
||||
|
||||
@@ -18,6 +18,8 @@ const BUILD_INPUTS = [
|
||||
'rollup.config.mjs',
|
||||
'tsconfig.json',
|
||||
'scripts/source-fingerprint.mjs',
|
||||
'scripts/css-template-minifier.mjs',
|
||||
'scripts/bundle-manifest.mjs',
|
||||
];
|
||||
|
||||
const fingerprintFiles = (root) => {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env node
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readZipEntries } from './release-prerelease.mjs';
|
||||
import { readBundleManifest, sha256Bytes } from './bundle-tree.mjs';
|
||||
|
||||
export function verifyHouseplanZip(zipPath, bundleRoot, expectedVersion = '') {
|
||||
const expected = readBundleManifest(bundleRoot);
|
||||
const header = readZipEntries(zipPath, [
|
||||
'manifest.json', 'frontend/houseplan-assets.json',
|
||||
]);
|
||||
const integration = JSON.parse(header.get('manifest.json').toString('utf8'));
|
||||
if (expectedVersion && integration.version !== expectedVersion) {
|
||||
throw new Error(`houseplan.zip manifest version ${integration.version} != ${expectedVersion}`);
|
||||
}
|
||||
const bundled = JSON.parse(header.get('frontend/houseplan-assets.json').toString('utf8'));
|
||||
if (JSON.stringify(bundled) !== JSON.stringify(expected)) {
|
||||
throw new Error('houseplan.zip frontend manifest differs from source tree');
|
||||
}
|
||||
const entries = readZipEntries(zipPath, expected.files.map((file) => `frontend/${file.path}`));
|
||||
for (const file of expected.files) {
|
||||
if (sha256Bytes(entries.get(`frontend/${file.path}`)) !== file.sha256) {
|
||||
throw new Error(`houseplan.zip frontend hash mismatch: ${file.path}`);
|
||||
}
|
||||
}
|
||||
return { version: integration.version, files: expected.files.length };
|
||||
}
|
||||
|
||||
const invokedDirectly = process.argv[1]
|
||||
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
||||
if (invokedDirectly) {
|
||||
try {
|
||||
const [zip, root, version = ''] = process.argv.slice(2);
|
||||
if (!zip || !root) {
|
||||
throw new Error('usage: node scripts/verify-houseplan-zip.mjs <zip> <bundle-root> [version]');
|
||||
}
|
||||
const result = verifyHouseplanZip(resolve(zip), resolve(root), version);
|
||||
console.log(`verified houseplan.zip ${result.version}: ${result.files} frontend assets`);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { polygonArea } from './logic';
|
||||
|
||||
/** View-safe area helpers kept separate from the lazy resize implementation. */
|
||||
export function areaM2(poly: number[][], gridPitch: number, cellCm: number): number {
|
||||
const cmPerUnit = cellCm / gridPitch;
|
||||
return (polygonArea(poly) * cmPerUnit * cmPerUnit) / 1e4;
|
||||
}
|
||||
|
||||
export function formatArea(m2: number, imperial: boolean): string {
|
||||
if (imperial) return `${Math.round(m2 * 10.7639)} ft²`;
|
||||
return `${(Math.round(m2 * 10) / 10).toFixed(1)} m²`;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export type EditorRuntimeLoaderState = 'idle' | 'loading' | 'ready' | 'failed';
|
||||
|
||||
export interface EditorRuntimeModule<Runtime> {
|
||||
readonly fingerprint: string;
|
||||
create(): Runtime;
|
||||
}
|
||||
|
||||
export interface EditorRuntimeLoaderOptions<Runtime> {
|
||||
readonly expectedFingerprint: string;
|
||||
readonly load: (attempt: 0 | 1) => Promise<EditorRuntimeModule<Runtime>>;
|
||||
readonly install: (runtime: Runtime) => void;
|
||||
readonly stateChanged?: (state: EditorRuntimeLoaderState) => void;
|
||||
readonly failed?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One atomic lazy-runtime boundary shared by all editor entry points.
|
||||
*
|
||||
* A failed module is retried exactly once. Construction happens before
|
||||
* `install`, so a parse, fingerprint or constructor failure cannot leave a
|
||||
* half-installed editor attached to the View card.
|
||||
*/
|
||||
export class EditorRuntimeLoader<Runtime> {
|
||||
private _state: EditorRuntimeLoaderState = 'idle';
|
||||
private _inFlight: Promise<boolean> | null = null;
|
||||
|
||||
public constructor(private readonly options: EditorRuntimeLoaderOptions<Runtime>) {}
|
||||
|
||||
public get state(): EditorRuntimeLoaderState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
public ensure(): Promise<boolean> {
|
||||
if (this._state === 'ready') return Promise.resolve(true);
|
||||
if (this._state === 'failed') return Promise.resolve(false);
|
||||
if (this._inFlight) return this._inFlight;
|
||||
this._setState('loading');
|
||||
this._inFlight = this._loadWithRetry().finally(() => {
|
||||
this._inFlight = null;
|
||||
});
|
||||
return this._inFlight;
|
||||
}
|
||||
|
||||
private async _loadWithRetry(): Promise<boolean> {
|
||||
let lastError: unknown = new Error('Editor runtime did not load');
|
||||
for (const attempt of [0, 1] as const) {
|
||||
try {
|
||||
const module = await this.options.load(attempt);
|
||||
if (module.fingerprint !== this.options.expectedFingerprint) {
|
||||
throw new Error(
|
||||
`Editor runtime fingerprint mismatch: expected ${this.options.expectedFingerprint}, got ${module.fingerprint}`,
|
||||
);
|
||||
}
|
||||
const runtime = module.create();
|
||||
this.options.install(runtime);
|
||||
this._setState('ready');
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
this._setState('failed');
|
||||
this.options.failed?.(lastError);
|
||||
return false;
|
||||
}
|
||||
|
||||
private _setState(state: EditorRuntimeLoaderState): void {
|
||||
if (state === this._state) return;
|
||||
this._state = state;
|
||||
this.options.stateChanged?.(state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,843 @@
|
||||
import { html, nothing, type TemplateResult } from 'lit';
|
||||
|
||||
import {
|
||||
DEFAULT_CUSTOM_FILL,
|
||||
DEFAULT_ROOM_COLOR,
|
||||
DEFAULT_ROOM_OPACITY,
|
||||
DEFAULT_TEMP_MAX,
|
||||
DEFAULT_TEMP_MIN,
|
||||
SPACE_FILL_UI_MODES,
|
||||
customFillOf,
|
||||
spaceDisplayOf,
|
||||
stageBgOf,
|
||||
} from './logic';
|
||||
import { bgModeOf, northDegOf } from './sun';
|
||||
import {
|
||||
createEmptySpaceConfig,
|
||||
initialSpaceDisplayDraft,
|
||||
switchSpacePlanSource,
|
||||
touchSpaceDisplay,
|
||||
} from './space-dialog';
|
||||
import { collectSpaceMarkerDependencies } from './space-deletion';
|
||||
import {
|
||||
gridCellFieldToCm,
|
||||
gridCellFieldValue,
|
||||
newSpaceCellCm,
|
||||
} from './grid-scale';
|
||||
import { zeroWallStyleOf } from './zero-walls';
|
||||
import type { HouseplanEditorHostPort } from './houseplan-editor-runtime';
|
||||
|
||||
const BUILD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
|
||||
const CELL_CM_MIN = 0.1;
|
||||
const CELL_CM_MAX = 1000;
|
||||
|
||||
const strictNumber = (value: string): number | null => {
|
||||
if (!value.trim()) return null;
|
||||
const parsed = Number(value.replace(',', '.'));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Space creation/import is part of empty-install onboarding, not an editor.
|
||||
* It therefore has its own lazy boundary: normal View downloads neither this
|
||||
* module nor the editor, while a new empty installation can create its first
|
||||
* space without ever requesting the editor chunk.
|
||||
*/
|
||||
export const ONBOARDING_RUNTIME_FINGERPRINT = BUILD_FINGERPRINT;
|
||||
|
||||
export class HouseplanOnboardingRuntime {
|
||||
public constructor(public readonly host: HouseplanEditorHostPort) {}
|
||||
|
||||
public _openSpaceDialog(mode: 'edit' | 'create', spaceId?: string): void {
|
||||
if (!this.host._serverStorage || !this.host._serverCfg) {
|
||||
this.host._showToast(this.host._t('toast.integration_missing'));
|
||||
return;
|
||||
}
|
||||
if (mode === 'edit') {
|
||||
const sp = this.host._serverCfg.spaces.find((space) => space.id === spaceId);
|
||||
if (!sp) return;
|
||||
const disp = spaceDisplayOf(sp);
|
||||
const storedCustom = sp.settings?.custom_fill && typeof sp.settings.custom_fill === 'object'
|
||||
? customFillOf(sp.settings.custom_fill) : null;
|
||||
const dialogCustom = disp.fill === 'none'
|
||||
? { ...(storedCustom || DEFAULT_CUSTOM_FILL), a: 0 }
|
||||
: storedCustom;
|
||||
this.host._spaceDialog = {
|
||||
mode, spaceId, title: sp.title, planUrl: sp.plan_url || null, planFile: null,
|
||||
source: sp.plan_url ? 'file' : 'draw',
|
||||
showBorders: disp.showBorders, showNames: disp.showNames,
|
||||
zeroWallStyle: zeroWallStyleOf(sp),
|
||||
displayTouched: true,
|
||||
hideDecor: disp.hideDecor, hideOpenings: disp.hideOpenings,
|
||||
roomColor: disp.color, roomOpacity: disp.opacity,
|
||||
fillMode: disp.fill === 'none' ? 'custom' : disp.fill,
|
||||
customFill: dialogCustom,
|
||||
glowEnabled: disp.glow,
|
||||
bgColor: disp.bgColor,
|
||||
bgMode: sp.settings?.bg_mode === 'static' || sp.settings?.bg_mode === 'daynight'
|
||||
? sp.settings.bg_mode : null,
|
||||
northDeg: northDegOf({}, sp.settings),
|
||||
sunRays: typeof sp.settings?.sun_rays === 'boolean' ? sp.settings.sun_rays : null,
|
||||
tempMin: disp.tempMin, tempMax: disp.tempMax,
|
||||
showLqi: disp.showLqi ?? this.host._config?.show_signal ?? true,
|
||||
cardFontScale: disp.cardFontScale,
|
||||
labelTemp: disp.labelTemp, labelHum: disp.labelHum,
|
||||
labelLqi: disp.labelLqi, labelLight: disp.labelLight,
|
||||
cellCm: Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5,
|
||||
cellCmInput: gridCellFieldValue(
|
||||
Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5, this.host._imperial,
|
||||
),
|
||||
cellCmTouched: false,
|
||||
busy: false,
|
||||
};
|
||||
return;
|
||||
}
|
||||
const cellCm = newSpaceCellCm(this.host._imperial);
|
||||
this.host._spaceDialog = {
|
||||
mode, title: '', planUrl: null, planFile: null,
|
||||
...initialSpaceDisplayDraft(),
|
||||
hideDecor: false, hideOpenings: false, zeroWallStyle: 'dashed',
|
||||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'custom',
|
||||
customFill: { ...DEFAULT_CUSTOM_FILL, a: 0 },
|
||||
glowEnabled: true,
|
||||
bgColor: null,
|
||||
bgMode: 'daynight', northDeg: null, sunRays: null,
|
||||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||||
showLqi: this.host._config?.show_signal ?? true,
|
||||
cardFontScale: 1,
|
||||
labelTemp: false, labelHum: false, labelLqi: false, labelLight: false,
|
||||
cellCm,
|
||||
cellCmInput: gridCellFieldValue(cellCm, this.host._imperial),
|
||||
cellCmTouched: false,
|
||||
busy: false,
|
||||
};
|
||||
}
|
||||
|
||||
public async _pickPlanFile(ev: Event): Promise<void> {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file || !this.host._spaceDialog) return;
|
||||
const extMap: Record<string, string> = {
|
||||
'image/svg+xml': 'svg', 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp',
|
||||
};
|
||||
const ext = extMap[file.type] || (file.name.toLowerCase().endsWith('.svg') ? 'svg' : '');
|
||||
if (!ext) {
|
||||
this.host._showToast(this.host._t('toast.plan_formats'));
|
||||
return;
|
||||
}
|
||||
const buf = new Uint8Array(await file.arrayBuffer());
|
||||
let bin = '';
|
||||
for (let i = 0; i < buf.length; i += 32768) bin += String.fromCharCode(...buf.subarray(i, i + 32768));
|
||||
const b64 = btoa(bin);
|
||||
const url = URL.createObjectURL(file);
|
||||
const aspect = await new Promise<number>((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image.naturalWidth && image.naturalHeight
|
||||
? image.naturalWidth / image.naturalHeight : 1.414);
|
||||
image.onerror = () => resolve(1.414);
|
||||
image.src = url;
|
||||
});
|
||||
URL.revokeObjectURL(url);
|
||||
this.host._spaceDialog = {
|
||||
...this.host._spaceDialog, planFile: { ext, b64, aspect, name: file.name },
|
||||
};
|
||||
}
|
||||
|
||||
public _toggleServerPlans = async (): Promise<void> => {
|
||||
const dialog = this.host._spaceDialog;
|
||||
if (!dialog) return;
|
||||
if (dialog.pickSaved) {
|
||||
this.host._spaceDialog = { ...dialog, pickSaved: false };
|
||||
return;
|
||||
}
|
||||
this.host._spaceDialog = { ...dialog, pickSaved: true, savedBusy: true };
|
||||
try {
|
||||
const response: { plans?: NonNullable<typeof dialog.saved> } = await this.host.hass.callWS({
|
||||
type: 'houseplan/plans/list',
|
||||
});
|
||||
const current = this.host._spaceDialog;
|
||||
if (current) this.host._spaceDialog = {
|
||||
...current, saved: response?.plans || [], savedBusy: false,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const current = this.host._spaceDialog;
|
||||
if (current) this.host._spaceDialog = { ...current, saved: [], savedBusy: false };
|
||||
this.host._showToast(this.host._t('toast.plans_list_failed', {
|
||||
err: this.host._errText(error),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
public _useServerPlan(url: string): void {
|
||||
const dialog = this.host._spaceDialog;
|
||||
if (!dialog) return;
|
||||
this.host._spaceDialog = {
|
||||
...dialog, planUrl: url, planFile: null, pickSaved: false, savedAspect: undefined,
|
||||
};
|
||||
this.host._aspectJob = this._readPlanAspect(url);
|
||||
}
|
||||
|
||||
public async _readPlanAspect(url: string): Promise<number> {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const src = this.host._display(url);
|
||||
if (src) {
|
||||
const ratio = await new Promise<number>((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image.naturalWidth && image.naturalHeight
|
||||
? image.naturalWidth / image.naturalHeight : 0);
|
||||
image.onerror = () => resolve(0);
|
||||
image.src = src;
|
||||
});
|
||||
const current = this.host._spaceDialog;
|
||||
if (current && current.planUrl === url && Number.isFinite(ratio) && ratio > 0) {
|
||||
this.host._spaceDialog = { ...current, savedAspect: ratio };
|
||||
return ratio;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
if (this.host._spaceDialog?.planUrl !== url) return 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public async _deleteServerPlan(name: string): Promise<void> {
|
||||
if (!confirm(this.host._t('confirm.delete_plan', { name }))) return;
|
||||
try {
|
||||
await this.host.hass.callWS({ type: 'houseplan/plans/delete', name });
|
||||
const dialog = this.host._spaceDialog;
|
||||
if (dialog?.saved) this.host._spaceDialog = {
|
||||
...dialog, saved: dialog.saved.filter((plan) => plan.name !== name),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
this.host._showToast(this.host._t('toast.plan_delete_failed', {
|
||||
err: this.host._errText(error),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
public _renderServerPlans(
|
||||
dialog: NonNullable<HouseplanEditorHostPort['_spaceDialog']>,
|
||||
): TemplateResult {
|
||||
if (dialog.savedBusy) return html`<div class="savedplans muted">${this.host._t('space.loading')}</div>`;
|
||||
const list = dialog.saved || [];
|
||||
if (!list.length) return html`<div class="savedplans muted">${this.host._t('space.no_saved')}</div>`;
|
||||
const kb = (size: number) => (size >= 1048576
|
||||
? `${(size / 1048576).toFixed(1)} MB` : `${Math.round(size / 1024)} KB`);
|
||||
return html`<div class="savedplans">
|
||||
${list.map((plan) => html`
|
||||
<div class="savedplan ${plan.url === dialog.planUrl ? 'cur' : ''}">
|
||||
<img src=${this.host._display(plan.url)} alt="" loading="lazy" decoding="async" />
|
||||
<div class="savedmeta">
|
||||
<b>${plan.name}</b>
|
||||
<span class="muted">${kb(plan.size)}${plan.used_by.length
|
||||
? ` · ${this.host._t('space.used_by', { list: plan.used_by.join(', ') })}`
|
||||
: ''}</span>
|
||||
</div>
|
||||
<button class="btn ghost" @click=${() => this._useServerPlan(plan.url)}
|
||||
?disabled=${plan.url === dialog.planUrl}>${this.host._t('btn.use')}</button>
|
||||
<button class="btn ghost danger"
|
||||
title=${plan.used_by.length || plan.url === dialog.planUrl
|
||||
? this.host._t('space.in_use') : this.host._t('btn.delete')}
|
||||
?disabled=${plan.used_by.length > 0 || plan.url === dialog.planUrl}
|
||||
@click=${() => this._deleteServerPlan(plan.name)}>
|
||||
<ha-icon icon="mdi:trash-can-outline"></ha-icon>
|
||||
</button>
|
||||
</div>`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private async _saveConfigNow(): Promise<void> {
|
||||
this.host._cfgEpoch++;
|
||||
try {
|
||||
await this.host._writeConfig();
|
||||
} catch (error: unknown) {
|
||||
const failure = error as { physicalGeometryRolledBack?: boolean; code?: string };
|
||||
if (failure?.physicalGeometryRolledBack) await this.host._reloadRejectedPhysicalWrite();
|
||||
else if (failure?.code === 'conflict') await this.host._reloadConfigOnly();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async _saveSpaceDialog(): Promise<void> {
|
||||
const dialog = this.host._spaceDialog;
|
||||
if (!dialog || dialog.busy || !dialog.title.trim()) return;
|
||||
if (dialog.source === 'file' && !dialog.planFile && !dialog.planUrl) {
|
||||
this.host._showToast(this.host._t('toast.plan_required'));
|
||||
return;
|
||||
}
|
||||
const wasFirst = dialog.mode === 'create' && (this.host._serverCfg?.spaces.length || 0) === 0;
|
||||
this.host._spaceDialog = { ...dialog, busy: true };
|
||||
try {
|
||||
const spaceId = dialog.mode === 'create'
|
||||
? `s${Date.now().toString(36)}` : dialog.spaceId!;
|
||||
let uploaded: { url: string; aspect: number } | null = null;
|
||||
if (dialog.source === 'file' && dialog.planFile) {
|
||||
const response: { url: string } = await this.host.hass.callWS({
|
||||
type: 'houseplan/plan/set',
|
||||
space_id: spaceId,
|
||||
ext: dialog.planFile.ext,
|
||||
data: dialog.planFile.b64,
|
||||
});
|
||||
uploaded = { url: response.url, aspect: dialog.planFile.aspect };
|
||||
}
|
||||
let pickedAspect: number | null = dialog.savedAspect || null;
|
||||
if (!uploaded && dialog.source === 'file' && dialog.planUrl
|
||||
&& !pickedAspect && this.host._aspectJob) {
|
||||
pickedAspect = (await this.host._aspectJob) || null;
|
||||
}
|
||||
|
||||
const config = this.host._serverCfg!;
|
||||
let space = config.spaces.find((candidate) => candidate.id === spaceId);
|
||||
if (dialog.mode === 'create') {
|
||||
space = createEmptySpaceConfig(spaceId, dialog.title.trim());
|
||||
config.spaces.push(space);
|
||||
} else {
|
||||
if (!space) throw new Error(`space ${spaceId} is gone from the config`);
|
||||
space.title = dialog.title.trim();
|
||||
}
|
||||
if (!space) throw new Error(`space ${spaceId} is unavailable`);
|
||||
if (uploaded) {
|
||||
space.plan_url = uploaded.url;
|
||||
space.plan_aspect = uploaded.aspect;
|
||||
} else if (dialog.source === 'file' && dialog.planUrl && dialog.planUrl !== space.plan_url) {
|
||||
space.plan_url = dialog.planUrl;
|
||||
space.plan_aspect = pickedAspect;
|
||||
}
|
||||
if (dialog.source === 'draw') {
|
||||
space.plan_url = null;
|
||||
space.plan_aspect = null;
|
||||
delete space.plan_x;
|
||||
delete space.plan_y;
|
||||
delete space.plan_scale;
|
||||
delete space.plan_scale_x;
|
||||
delete space.plan_scale_y;
|
||||
delete space.plan_angle;
|
||||
}
|
||||
space.settings = {
|
||||
...(space.settings || {}),
|
||||
show_borders: dialog.showBorders,
|
||||
show_names: dialog.showNames,
|
||||
hide_decor: dialog.hideDecor || undefined,
|
||||
hide_openings: dialog.hideOpenings || undefined,
|
||||
room_color: dialog.roomColor,
|
||||
room_opacity: dialog.roomOpacity,
|
||||
bg_color: dialog.bgColor || undefined,
|
||||
bg_mode: dialog.bgMode || undefined,
|
||||
north_deg: dialog.northDeg ?? undefined,
|
||||
sun_rays: dialog.sunRays ?? undefined,
|
||||
fill_mode: dialog.fillMode,
|
||||
custom_fill: dialog.customFill || undefined,
|
||||
glow_enabled: dialog.glowEnabled,
|
||||
temp_min: Number.isFinite(dialog.tempMin)
|
||||
? Math.min(dialog.tempMin, dialog.tempMax) : DEFAULT_TEMP_MIN,
|
||||
temp_max: Number.isFinite(dialog.tempMax)
|
||||
? Math.max(dialog.tempMin, dialog.tempMax) : DEFAULT_TEMP_MAX,
|
||||
show_lqi: dialog.showLqi,
|
||||
card_font_scale: dialog.cardFontScale !== 1 ? dialog.cardFontScale : undefined,
|
||||
label_temp: dialog.labelTemp,
|
||||
label_hum: dialog.labelHum,
|
||||
label_lqi: dialog.labelLqi,
|
||||
label_light: dialog.labelLight,
|
||||
};
|
||||
space.zero_wall_style = dialog.zeroWallStyle;
|
||||
space.cell_cm = Number.isFinite(dialog.cellCm) && dialog.cellCm > 0
|
||||
? Math.max(CELL_CM_MIN, Math.min(CELL_CM_MAX, dialog.cellCm)) : 5;
|
||||
await this._saveConfigNow();
|
||||
this.host._spaceDialog = null;
|
||||
if (dialog.mode === 'create') this.host._commitSpace(space.id);
|
||||
this.host._regSignature = '';
|
||||
this.host._maybeRebuildDevices();
|
||||
if (this.host._importQueue.length) {
|
||||
this._openNextImport();
|
||||
} else if (wasFirst || this.host._importTotal > 0) {
|
||||
const wasImport = this.host._importTotal > 0;
|
||||
this.host._importTotal = 0;
|
||||
this.host._commitSpace(this.host._serverCfg!.spaces[0]?.id || this.host._space);
|
||||
await this.host._requestMode('plan');
|
||||
this.host._tool = 'draw';
|
||||
this.host._path = [];
|
||||
this.host._cursorPt = null;
|
||||
this.host._primeDrawWallField();
|
||||
this.host._showToast(this.host._t(wasFirst && !wasImport
|
||||
? 'toast.space_added_onboard' : 'import.done'));
|
||||
} else {
|
||||
this.host._showToast(this.host._t(dialog.mode === 'create'
|
||||
? 'toast.space_added' : 'toast.space_saved'));
|
||||
if (dialog.mode === 'create') {
|
||||
if (this.host._mode !== 'plan') await this.host._requestMode('plan');
|
||||
else {
|
||||
this.host._tool = 'draw';
|
||||
this.host._path = [];
|
||||
this.host._cursorPt = null;
|
||||
this.host._primeDrawWallField();
|
||||
this.host._saveNav();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const failure = error as { code?: string };
|
||||
if (failure?.code !== 'conflict') await this.host._reloadConfigOnly(true);
|
||||
if (this.host._spaceDialog) this.host._spaceDialog = {
|
||||
...this.host._spaceDialog, busy: false,
|
||||
};
|
||||
this.host._showToast(this.host._t('toast.error', { err: this.host._errText(error) }));
|
||||
}
|
||||
}
|
||||
|
||||
public async _deleteSpace(): Promise<void> {
|
||||
const dialog = this.host._spaceDialog;
|
||||
if (!dialog || dialog.mode !== 'edit') return;
|
||||
const serverConfig = this.host._serverCfg;
|
||||
if (!serverConfig) return;
|
||||
const space = serverConfig.spaces.find((candidate) => candidate.id === dialog.spaceId);
|
||||
if (!space) return;
|
||||
const dependencies = collectSpaceMarkerDependencies(
|
||||
serverConfig, this.host._layout || {}, dialog.spaceId || '',
|
||||
);
|
||||
const deletingLastSpace = serverConfig.spaces.length === 1
|
||||
&& serverConfig.spaces[0]?.id === dialog.spaceId;
|
||||
if (dependencies.count && !deletingLastSpace) {
|
||||
this.host._spaceDialog = { ...dialog, deleteBlockers: dependencies.count };
|
||||
return;
|
||||
}
|
||||
if (!confirm(this.host._t('confirm.delete_space', { title: space.title }))) return;
|
||||
this.host._spaceDialog = { ...dialog, deleteBlockers: 0, busy: true };
|
||||
try {
|
||||
if (this.host._saveConfigDebounced.pending()) this.host._saveConfigDebounced.flush();
|
||||
if (this.host._persistLayout.pending()) this.host._persistLayout.flush();
|
||||
await this.host._writeChain;
|
||||
const response: { config_rev?: number; layout_rev?: number } = await this.host.hass.callWS({
|
||||
type: 'houseplan/space/delete',
|
||||
space_id: dialog.spaceId,
|
||||
expected_config_rev: this.host._cfgRev,
|
||||
expected_layout_rev: this.host._layoutRev,
|
||||
});
|
||||
const [configResponse, layoutResponse] = await Promise.all([
|
||||
this.host.hass.callWS({ type: 'houseplan/config/get' }),
|
||||
this.host.hass.callWS({ type: 'houseplan/layout/get' }),
|
||||
]);
|
||||
this.host._adoptStructuralResponses(configResponse, layoutResponse);
|
||||
this.host._cfgRev = response?.config_rev ?? this.host._cfgRev;
|
||||
this.host._layoutRev = response?.layout_rev ?? this.host._layoutRev;
|
||||
this.host._spaceDialog = null;
|
||||
if (this.host._space === dialog.spaceId) {
|
||||
this.host._commitSpace(this.host._serverCfg!.spaces[0]?.id || '');
|
||||
}
|
||||
this.host._regSignature = '';
|
||||
this.host._maybeRebuildDevices();
|
||||
this.host._showToast(this.host._t('toast.space_deleted'));
|
||||
} catch (error: unknown) {
|
||||
const failure = error as { code?: string };
|
||||
if (failure?.code === 'conflict' || failure?.code === 'space_in_use') {
|
||||
await Promise.all([
|
||||
this.host._reloadConfigOnly(true), this.host._reloadLayoutOnly(),
|
||||
]);
|
||||
}
|
||||
const refreshedConfig = this.host._serverCfg;
|
||||
if (this.host._spaceDialog && refreshedConfig) {
|
||||
const refreshed = collectSpaceMarkerDependencies(
|
||||
refreshedConfig, this.host._layout || {}, dialog.spaceId || '',
|
||||
);
|
||||
const stillLastSpace = refreshedConfig.spaces.length === 1
|
||||
&& refreshedConfig.spaces[0]?.id === dialog.spaceId;
|
||||
this.host._spaceDialog = {
|
||||
...this.host._spaceDialog,
|
||||
busy: false,
|
||||
deleteBlockers: stillLastSpace ? 0 : refreshed.count,
|
||||
};
|
||||
}
|
||||
this.host._showToast(this.host._t('toast.delete_failed', {
|
||||
err: this.host._errText(error),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
public _startImport(): void {
|
||||
const dialog = this.host._importDialog;
|
||||
if (!dialog) return;
|
||||
const titles = dialog.floors.filter((floor) => floor.checked).map((floor) => floor.name);
|
||||
this.host._importDialog = null;
|
||||
if (!titles.length) {
|
||||
this._openSpaceDialog('create');
|
||||
return;
|
||||
}
|
||||
this.host._importQueue = titles;
|
||||
this.host._importTotal = titles.length;
|
||||
this._openNextImport();
|
||||
}
|
||||
|
||||
public _openNextImport(): void {
|
||||
const title = this.host._importQueue.shift();
|
||||
if (title === undefined) return;
|
||||
const cellCm = newSpaceCellCm(this.host._imperial);
|
||||
this.host._spaceDialog = {
|
||||
mode: 'create', title, planUrl: null, planFile: null,
|
||||
...initialSpaceDisplayDraft(),
|
||||
hideDecor: false, hideOpenings: false, zeroWallStyle: 'dashed',
|
||||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'custom',
|
||||
customFill: null,
|
||||
glowEnabled: true,
|
||||
bgColor: null,
|
||||
bgMode: 'daynight', northDeg: null, sunRays: null,
|
||||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||||
showLqi: this.host._config?.show_signal ?? true,
|
||||
cardFontScale: 1,
|
||||
labelTemp: false, labelHum: false, labelLqi: false, labelLight: false,
|
||||
cellCm,
|
||||
cellCmInput: gridCellFieldValue(cellCm, this.host._imperial),
|
||||
cellCmTouched: false,
|
||||
busy: false,
|
||||
};
|
||||
}
|
||||
|
||||
public _skipImport(): void {
|
||||
this.host._spaceDialog = null;
|
||||
if (this.host._importQueue.length) this._openNextImport();
|
||||
else if (this.host._importTotal > 0 && this.host._model.length) {
|
||||
this.host._importTotal = 0;
|
||||
this.host._commitSpace(this.host._serverCfg!.spaces[0]?.id || this.host._space);
|
||||
void this.host._requestMode('plan').then(() => {
|
||||
this.host._showToast(this.host._t('import.done'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public _renderImportDialog(): TemplateResult {
|
||||
const dialog = this.host._importDialog!;
|
||||
const selected = dialog.floors.filter((floor) => floor.checked).length;
|
||||
return html`<hp-dialog .hass=${this.host.hass} .title=${this.host._t('import.title')}
|
||||
icon="mdi:home-floor-1" @hp-close=${() => (this.host._importDialog = null)}>
|
||||
<div class="body">
|
||||
<div class="rhint">${this.host._t('import.hint')}</div>
|
||||
${dialog.floors.map((floor, index) => html`<label class="floorrow">
|
||||
<input type="checkbox" .checked=${floor.checked}
|
||||
@change=${(event: Event) => {
|
||||
const floors = [...dialog.floors];
|
||||
floors[index] = {
|
||||
...floor, checked: (event.target as HTMLInputElement).checked,
|
||||
};
|
||||
this.host._importDialog = { floors };
|
||||
}} />
|
||||
<span>${floor.name}</span>
|
||||
${floor.level != null ? html`<span class="floorlvl">L${floor.level}</span>` : nothing}
|
||||
</label>`)}
|
||||
</div>
|
||||
<div class="row" slot="footer">
|
||||
<button class="btn ghost" @click=${() => {
|
||||
this.host._importDialog = null;
|
||||
this._openSpaceDialog('create');
|
||||
}}>${this.host._t('import.manual')}</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn on" @click=${() => this._startImport()} ?disabled=${!selected}>
|
||||
<ha-icon icon="mdi:import"></ha-icon>${this.host._t('import.start', { n: selected })}
|
||||
</button>
|
||||
</div>
|
||||
</hp-dialog>`;
|
||||
}
|
||||
|
||||
private _boolInput(value: boolean, change: (checked: boolean) => void): TemplateResult {
|
||||
return html`<input type="checkbox" .checked=${value}
|
||||
@change=${(event: Event) => change((event.target as HTMLInputElement).checked)} />`;
|
||||
}
|
||||
|
||||
private _rangeInput(
|
||||
min: number, max: number, step: number, value: number, change: (next: number) => void,
|
||||
): TemplateResult {
|
||||
return html`<input type="range" min=${min} max=${max} step=${step} .value=${String(value)}
|
||||
@input=${(event: Event) => change(Number((event.target as HTMLInputElement).value))} />`;
|
||||
}
|
||||
|
||||
public _renderSpaceDialog(): TemplateResult {
|
||||
return this._renderSpaceDialogBody(this.host._spaceDialog!);
|
||||
}
|
||||
|
||||
private _renderSpaceDialogBody(
|
||||
dialog: NonNullable<HouseplanEditorHostPort['_spaceDialog']>,
|
||||
): TemplateResult {
|
||||
const progress = this.host._importTotal > 0 && dialog.mode === 'create'
|
||||
? this.host._t('import.progress', {
|
||||
i: this.host._importTotal - this.host._importQueue.length,
|
||||
n: this.host._importTotal,
|
||||
}) : '';
|
||||
const close = () => {
|
||||
this.host._spaceDialog = null;
|
||||
this.host._importQueue = [];
|
||||
this.host._importTotal = 0;
|
||||
};
|
||||
return html`<hp-dialog .hass=${this.host.hass}
|
||||
.title=${`${dialog.mode === 'create'
|
||||
? this.host._t('space.new') : this.host._t('space.header')}${progress ? ` · ${progress}` : ''}`}
|
||||
icon="mdi:floor-plan" wide @hp-close=${close}>
|
||||
<div class="body">
|
||||
<label>${this.host._t('space.title_label')}</label>
|
||||
<input class="namein" type="text" placeholder=${this.host._t('space.title_ph')}
|
||||
.value=${dialog.title}
|
||||
@input=${(event: Event) => (this.host._spaceDialog = {
|
||||
...dialog, title: (event.target as HTMLInputElement).value,
|
||||
})} />
|
||||
<label>${this.host._t('space.plan_label')}</label>
|
||||
<label class="srcrow">
|
||||
<input type="radio" name="plansrc" .checked=${dialog.source === 'file'}
|
||||
@change=${() => (this.host._spaceDialog = switchSpacePlanSource(dialog, 'file'))} />
|
||||
<span>${this.host._t('space.source_file')}</span>
|
||||
</label>
|
||||
${dialog.source === 'file' ? html`<div class="planrow">
|
||||
${dialog.planFile
|
||||
? html`<span class="planname">${dialog.planFile.name}</span>`
|
||||
: dialog.planUrl
|
||||
? html`<img class="planprev" src=${this.host._display(dialog.planUrl)}
|
||||
alt=${this.host._t('space.plan_alt')} />`
|
||||
: html`<span class="planname muted">${this.host._t('space.no_plan')}</span>`}
|
||||
<span class="fileupload">
|
||||
<button class="btn filebtn" type="button" @click=${(event: Event) =>
|
||||
((event.currentTarget as HTMLElement).nextElementSibling as HTMLInputElement | null)?.click()}>
|
||||
<ha-icon icon="mdi:upload"></ha-icon>${dialog.planUrl || dialog.planFile
|
||||
? this.host._t('btn.replace') : this.host._t('btn.upload')}
|
||||
</button>
|
||||
<input type="file" hidden
|
||||
accept=".svg,.png,.jpg,.jpeg,.webp,image/svg+xml,image/png,image/jpeg,image/webp"
|
||||
@change=${(event: Event) => this._pickPlanFile(event)} />
|
||||
</span>
|
||||
<button class="btn ghost" @click=${this._toggleServerPlans}
|
||||
title=${this.host._t('space.pick_saved_hint')}>
|
||||
<ha-icon icon="mdi:folder-image"></ha-icon>${this.host._t('space.pick_saved')}
|
||||
</button>
|
||||
</div>
|
||||
${dialog.pickSaved ? this._renderServerPlans(dialog) : nothing}` : nothing}
|
||||
<label class="srcrow">
|
||||
<input type="radio" name="plansrc" .checked=${dialog.source === 'draw'}
|
||||
@change=${() => (this.host._spaceDialog = switchSpacePlanSource(dialog, 'draw'))} />
|
||||
<span>${this.host._t('space.source_draw')}</span>
|
||||
</label>
|
||||
|
||||
<label>${this.host._t('space.scale_label')}</label>
|
||||
<div class="colorrow">
|
||||
<input class="namein tempin" type="number"
|
||||
min=${gridCellFieldValue(CELL_CM_MIN, this.host._imperial)}
|
||||
max=${gridCellFieldValue(CELL_CM_MAX, this.host._imperial)}
|
||||
step="0.1"
|
||||
.value=${dialog.cellCmInput ?? gridCellFieldValue(dialog.cellCm, this.host._imperial)}
|
||||
@input=${(event: Event) => {
|
||||
const raw = (event.target as HTMLInputElement).value;
|
||||
const parsed = strictNumber(raw);
|
||||
const canonical = parsed == null ? null
|
||||
: gridCellFieldToCm(parsed, this.host._imperial);
|
||||
this.host._spaceDialog = {
|
||||
...dialog,
|
||||
cellCmInput: raw,
|
||||
cellCmTouched: true,
|
||||
cellCm: canonical != null && canonical > 0
|
||||
? Math.max(CELL_CM_MIN, Math.min(CELL_CM_MAX, canonical)) : dialog.cellCm,
|
||||
};
|
||||
}} />
|
||||
<span class="opl">${this.host._t(this.host._imperial
|
||||
? 'space.scale_unit_imperial' : 'space.scale_unit')}</span>
|
||||
</div>
|
||||
|
||||
<label class="dispsection">${this.host._t('space.display_section')}</label>
|
||||
<label class="srcrow">
|
||||
${this._boolInput(dialog.showBorders, (value) => {
|
||||
this.host._spaceDialog = touchSpaceDisplay(dialog, 'showBorders', value);
|
||||
})}
|
||||
<span>${this.host._t('space.show_borders')}</span>
|
||||
</label>
|
||||
<label>${this.host._t('space.zero_wall_style')}</label>
|
||||
<select class="areasel" @change=${(event: Event) => {
|
||||
const value = (event.target as HTMLSelectElement).value;
|
||||
this.host._spaceDialog = {
|
||||
...dialog, zeroWallStyle: value === 'solid' ? 'solid' : 'dashed',
|
||||
};
|
||||
}}>
|
||||
<option value="dashed" ?selected=${dialog.zeroWallStyle === 'dashed'}>
|
||||
${this.host._t('space.zero_wall_dashed')}
|
||||
</option>
|
||||
<option value="solid" ?selected=${dialog.zeroWallStyle === 'solid'}>
|
||||
${this.host._t('space.zero_wall_solid')}
|
||||
</option>
|
||||
</select>
|
||||
<div class="rhint">${this.host._t('space.zero_wall_help')}</div>
|
||||
<label class="srcrow">
|
||||
${this._boolInput(dialog.showNames, (value) => {
|
||||
this.host._spaceDialog = touchSpaceDisplay(dialog, 'showNames', value);
|
||||
})}
|
||||
<span>${this.host._t('space.show_names')}</span>
|
||||
</label>
|
||||
<label class="srcrow">
|
||||
${this._boolInput(dialog.showLqi, (value) => {
|
||||
this.host._spaceDialog = { ...dialog, showLqi: value };
|
||||
})}
|
||||
<span>${this.host._t('space.show_lqi')}</span>
|
||||
</label>
|
||||
<label class="srcrow">
|
||||
${this._boolInput(dialog.hideDecor, (value) => {
|
||||
this.host._spaceDialog = { ...dialog, hideDecor: value };
|
||||
})}
|
||||
<span>${this.host._t('space.hide_decor')}</span>
|
||||
</label>
|
||||
<div class="rhint">${this.host._t('space.hide_decor_tip')}</div>
|
||||
<label class="srcrow">
|
||||
${this._boolInput(dialog.hideOpenings, (value) => {
|
||||
this.host._spaceDialog = { ...dialog, hideOpenings: value };
|
||||
})}
|
||||
<span>${this.host._t('space.hide_openings')}</span>
|
||||
</label>
|
||||
<div class="rhint">${this.host._t('space.hide_openings_tip')}</div>
|
||||
<label class="dispsection">${this.host._t('space.roomcard_section')}</label>
|
||||
${([
|
||||
['labelTemp', 'space.label_temp'], ['labelHum', 'space.label_hum'],
|
||||
['labelLqi', 'space.label_lqi'], ['labelLight', 'space.label_light'],
|
||||
] as const).map(([field, key]) => html`<label class="srcrow">
|
||||
${this._boolInput(dialog[field], (value) => {
|
||||
this.host._spaceDialog = { ...dialog, [field]: value };
|
||||
})}
|
||||
<span>${this.host._t(key)}</span>
|
||||
</label>`)}
|
||||
<label>${this.host._t('space.card_font')}</label>
|
||||
<div class="colorrow gsrow">
|
||||
${this._rangeInput(50, 300, 5, Math.round(dialog.cardFontScale * 100), (value) => {
|
||||
this.host._spaceDialog = { ...dialog, cardFontScale: value / 100 };
|
||||
})}
|
||||
<span class="opv">${Math.round(dialog.cardFontScale * 100)}%</span>
|
||||
</div>
|
||||
${this.host._renderCardPreview(dialog.cardFontScale, 1, 1)}
|
||||
<div class="colorrow">
|
||||
<hp-color-opacity .label=${this.host._t('space.room_color')}
|
||||
.opacityLabel=${this.host._t('space.opacity')}
|
||||
.pickerLabels=${this.host._colorPickerLabels}
|
||||
.color=${dialog.roomColor} .opacity=${dialog.roomOpacity} .showOpacity=${true}
|
||||
@hp-color-opacity-change=${(event: CustomEvent<{ color: string; opacity: number }>) => {
|
||||
this.host._spaceDialog = {
|
||||
...dialog, roomColor: event.detail.color, roomOpacity: event.detail.opacity,
|
||||
};
|
||||
}}></hp-color-opacity>
|
||||
</div>
|
||||
<label>${this.host._t('space.bg_mode')}</label>
|
||||
<select class="areasel" @change=${(event: Event) => {
|
||||
const value = (event.target as HTMLSelectElement).value;
|
||||
this.host._spaceDialog = {
|
||||
...dialog,
|
||||
bgMode: value === 'static' || value === 'daynight' ? value : null,
|
||||
};
|
||||
}}>
|
||||
<option value="" ?selected=${dialog.bgMode === null}>${this.host._t('space.sun_inherit')}</option>
|
||||
<option value="static" ?selected=${dialog.bgMode === 'static'}>${this.host._t('gs.bg_static')}</option>
|
||||
<option value="daynight" ?selected=${dialog.bgMode === 'daynight'}>${this.host._t('gs.bg_daynight')}</option>
|
||||
</select>
|
||||
${(dialog.bgMode ?? bgModeOf(this.host._settings, {})) === 'static' ? html`
|
||||
<div class="colorrow">
|
||||
<hp-color-opacity .label=${this.host._t('space.bg_color')}
|
||||
.pickerLabels=${this.host._colorPickerLabels}
|
||||
.color=${dialog.bgColor || stageBgOf(this.host._settings, { bgColor: null })
|
||||
|| this.host._stageBgHex()}
|
||||
.opacity=${1} .showOpacity=${false}
|
||||
@hp-color-opacity-change=${(event: CustomEvent<{ color: string }>) => {
|
||||
this.host._spaceDialog = { ...dialog, bgColor: event.detail.color };
|
||||
}}></hp-color-opacity>
|
||||
${dialog.bgColor ? html`<button class="btn ghost" @click=${() => {
|
||||
this.host._spaceDialog = { ...dialog, bgColor: null };
|
||||
}}>${this.host._t('space.bg_inherit')}</button>`
|
||||
: html`<span class="opl">${this.host._t('space.bg_inherited')}</span>`}
|
||||
</div>` : nothing}
|
||||
<label>${this.host._t('space.north')}</label>
|
||||
<div class="colorrow">
|
||||
<input class="namein tempin" type="number" min="0" max="359" step="1"
|
||||
placeholder=${this.host._t('space.sun_inherit')}
|
||||
.value=${dialog.northDeg === null ? '' : String(dialog.northDeg)}
|
||||
@input=${(event: Event) => {
|
||||
const raw = (event.target as HTMLInputElement).value.trim();
|
||||
const value = raw === '' ? null : Math.round(Number(raw));
|
||||
this.host._spaceDialog = {
|
||||
...dialog,
|
||||
northDeg: value !== null && Number.isFinite(value)
|
||||
? Math.min(359, Math.max(0, value)) : null,
|
||||
};
|
||||
}} />
|
||||
<span class="opl">${dialog.northDeg === null
|
||||
? this.host._t('space.north_inherited', {
|
||||
v: northDegOf(this.host._settings, {}) === null
|
||||
? '—' : `${northDegOf(this.host._settings, {})}°`,
|
||||
}) : '°'}</span>
|
||||
</div>
|
||||
<label>${this.host._t('space.sun_rays')}</label>
|
||||
<select class="areasel" @change=${(event: Event) => {
|
||||
const value = (event.target as HTMLSelectElement).value;
|
||||
this.host._spaceDialog = {
|
||||
...dialog, sunRays: value === '' ? null : value === '1',
|
||||
};
|
||||
}}>
|
||||
<option value="" ?selected=${dialog.sunRays === null}>${this.host._t('space.sun_inherit')}</option>
|
||||
<option value="1" ?selected=${dialog.sunRays === true}>${this.host._t('space.sun_on')}</option>
|
||||
<option value="0" ?selected=${dialog.sunRays === false}>${this.host._t('space.sun_off')}</option>
|
||||
</select>
|
||||
<label>${this.host._t('space.fill_label')}</label>
|
||||
${SPACE_FILL_UI_MODES.map((value) => [value, `fill.${value}`] as const).map(
|
||||
([value, key]) => html`<label class="srcrow">
|
||||
<input type="radio" name="fillmode" .checked=${dialog.fillMode === value}
|
||||
@change=${() => (this.host._spaceDialog = { ...dialog, fillMode: value })} />
|
||||
<span>${this.host._t(key)}</span>
|
||||
${value === 'temp' && dialog.fillMode === 'temp' ? html`<span class="temprange">
|
||||
<input class="namein tempin" type="number" step="0.5" .value=${String(dialog.tempMin)}
|
||||
@input=${(event: Event) => {
|
||||
const parsed = strictNumber((event.target as HTMLInputElement).value);
|
||||
if (parsed != null) this.host._spaceDialog = { ...dialog, tempMin: parsed };
|
||||
}} />
|
||||
–
|
||||
<input class="namein tempin" type="number" step="0.5" .value=${String(dialog.tempMax)}
|
||||
@input=${(event: Event) => {
|
||||
const parsed = strictNumber((event.target as HTMLInputElement).value);
|
||||
if (parsed != null) this.host._spaceDialog = { ...dialog, tempMax: parsed };
|
||||
}} /> °C
|
||||
</span>` : nothing}
|
||||
</label>
|
||||
${value === 'custom' && dialog.fillMode === 'custom' ? html`
|
||||
<div class="colorrow gsrow">
|
||||
<span class="gsl">${this.host._t('space.custom_fill')}</span>
|
||||
<hp-color-opacity .label=${this.host._t('space.custom_fill')}
|
||||
.opacityLabel=${this.host._t('space.opacity')}
|
||||
.pickerLabels=${this.host._colorPickerLabels}
|
||||
.color=${(dialog.customFill || DEFAULT_CUSTOM_FILL).c}
|
||||
.opacity=${(dialog.customFill || DEFAULT_CUSTOM_FILL).a}
|
||||
@hp-color-opacity-change=${(event: CustomEvent<{ color: string; opacity: number }>) => {
|
||||
this.host._spaceDialog = {
|
||||
...dialog, customFill: { c: event.detail.color, a: event.detail.opacity },
|
||||
};
|
||||
}}></hp-color-opacity>
|
||||
${dialog.customFill ? html`<button class="btn ghost" type="button"
|
||||
@click=${() => (this.host._spaceDialog = { ...dialog, customFill: null })}>
|
||||
${this.host._t('btn.reset')}</button>` : nothing}
|
||||
</div>` : nothing}`,
|
||||
)}
|
||||
<label class="srcrow">
|
||||
${this._boolInput(dialog.glowEnabled, (checked) => {
|
||||
this.host._spaceDialog = { ...dialog, glowEnabled: checked };
|
||||
})}
|
||||
<span>${this.host._t('space.glow_enabled')}</span>
|
||||
</label>
|
||||
${dialog.deleteBlockers ? html`<div class="backuperror" role="alert">
|
||||
${this.host._t('space.delete_blocked', { n: String(dialog.deleteBlockers) })}
|
||||
</div>` : nothing}
|
||||
</div>
|
||||
<div class="row dialog-action-footer" slot="footer">
|
||||
${dialog.mode === 'edit' ? html`<div class="dialog-action-group dialog-action-danger">
|
||||
<button class="btn danger" @click=${() => this._deleteSpace()} ?disabled=${dialog.busy}>
|
||||
<ha-icon icon="mdi:delete-outline"></ha-icon>${this.host._t('btn.delete')}
|
||||
</button>
|
||||
</div>` : nothing}
|
||||
<div class="dialog-action-group dialog-action-commit">
|
||||
${this.host._importTotal > 0 && dialog.mode === 'create'
|
||||
? html`<button class="btn ghost" @click=${() => this._skipImport()}>
|
||||
${this.host._t('btn.skip')}</button>` : nothing}
|
||||
<button class="btn ghost" @click=${close}>${this.host._t('btn.cancel')}</button>
|
||||
<button class="btn on" @click=${() => this._saveSpaceDialog()}
|
||||
?disabled=${!dialog.title.trim()
|
||||
|| (dialog.source === 'file' && !(dialog.planFile || dialog.planUrl)) || dialog.busy}
|
||||
title=${dialog.source === 'file' && !(dialog.planFile || dialog.planUrl)
|
||||
? this.host._t('title.need_plan') : ''}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>${dialog.busy ? '…' : this.host._t('btn.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</hp-dialog>`;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@
|
||||
"editor.open_group": "Tool group: {group}",
|
||||
"editor.group_active": "{group} — active: {item}",
|
||||
"editor.disabled_action": "{action} is unavailable: {reason}",
|
||||
"editor.loading": "Loading editor…",
|
||||
"editor.loading_aria": "The plan editor is loading",
|
||||
"editor.load_failed": "Could not load the editor.",
|
||||
"editor.refresh_advice": "Refresh the page and try again.",
|
||||
"color_picker.title": "Color picker",
|
||||
"color_picker.hue": "Hue",
|
||||
"color_picker.saturation": "Saturation",
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
"editor.open_group": "Группа инструментов: {group}",
|
||||
"editor.group_active": "{group} — активно: {item}",
|
||||
"editor.disabled_action": "{action} недоступно: {reason}",
|
||||
"editor.loading": "Загружаем редактор…",
|
||||
"editor.loading_aria": "Редактор плана загружается",
|
||||
"editor.load_failed": "Не удалось загрузить редактор.",
|
||||
"editor.refresh_advice": "Обновите страницу и повторите попытку.",
|
||||
"color_picker.title": "Выбор цвета",
|
||||
"color_picker.hue": "Оттенок",
|
||||
"color_picker.saturation": "Насыщенность",
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
polygonArea, segmentsProperlyCross, polyContainsPoly, roomsOverlap,
|
||||
} from './logic';
|
||||
import { classifyNearAxisSegment } from './near-axis';
|
||||
export { areaM2, formatArea } from './area-format';
|
||||
|
||||
/** Minimal room dimension in centimetres (owner: «мин. габарит ~30 см»). */
|
||||
export const MIN_ROOM_CM = 30;
|
||||
@@ -1347,17 +1348,3 @@ export function clampRoomScale(
|
||||
}
|
||||
return good;
|
||||
}
|
||||
|
||||
// ---------------- live numbers ----------------
|
||||
|
||||
/** Room area in m² from render units via the grid scale. */
|
||||
export function areaM2(poly: number[][], gridPitch: number, cellCm: number): number {
|
||||
const cmPerUnit = cellCm / gridPitch;
|
||||
return (polygonArea(poly) * cmPerUnit * cmPerUnit) / 1e4;
|
||||
}
|
||||
|
||||
/** "12.4 m²" or "133 ft²" per the HA unit system. */
|
||||
export function formatArea(m2: number, imperial: boolean): string {
|
||||
if (imperial) return `${Math.round(m2 * 10.7639)} ft²`;
|
||||
return `${(Math.round(m2 * 10) / 10).toFixed(1)} m²`;
|
||||
}
|
||||
|
||||
@@ -114,9 +114,41 @@ export const dialogsStyles = css`
|
||||
--mdc-icon-size: 44px;
|
||||
color: var(--hp-accent);
|
||||
}
|
||||
.editorloading {
|
||||
position: absolute;
|
||||
z-index: 74;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-5);
|
||||
border: 1px solid color-mix(in srgb, var(--hp-accent) 45%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb,
|
||||
var(--ha-card-background, var(--card-background-color, #111)) 92%, transparent);
|
||||
color: var(--primary-text-color);
|
||||
box-shadow: 0 6px 22px rgb(0 0 0 / 18%);
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
animation: editor-loading-in 0.15s ease both;
|
||||
}
|
||||
.editorloading ha-icon {
|
||||
--mdc-icon-size: 22px;
|
||||
color: var(--hp-accent);
|
||||
animation: editor-loading-spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes editor-loading-in {
|
||||
from { opacity: 0; transform: translate(-50%, calc(-50% + 4px)); }
|
||||
to { opacity: 1; transform: translate(-50%, -50%); }
|
||||
}
|
||||
@keyframes editor-loading-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.recoveryoverlay {
|
||||
.recoveryoverlay,
|
||||
.editorloading,
|
||||
.editorloading ha-icon {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.oplock {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Compatibility projection for a session token written by the old toolbar. */
|
||||
export function normalizeUnifiedWallTool(value: unknown): unknown {
|
||||
return value === 'partition' ? 'draw' : value;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildBundleManifest, buildFingerprintPlugin, editorRuntimeRetryUrlPlugin,
|
||||
} from '../scripts/bundle-manifest.mjs';
|
||||
import { assertBundleBudget } from '../scripts/bundle-budget.mjs';
|
||||
import { compareBundleTrees, sha256Bytes, verifyBundleTree } from '../scripts/bundle-tree.mjs';
|
||||
import {
|
||||
minifyCssText, minifyStaticCssTemplates,
|
||||
} from '../scripts/css-template-minifier.mjs';
|
||||
|
||||
test('CSS template minifier preserves semantic whitespace, strings and functions', () => {
|
||||
const css = `
|
||||
/* owner note */
|
||||
.a .b, .c > .d { --pair: 1 2; width: calc(100% - 2px); }
|
||||
.quoted { content: "a b /* text */"; background: url("a b.png"); }
|
||||
.joined/**/.state { color: red; }
|
||||
`;
|
||||
assert.equal(
|
||||
minifyCssText(css),
|
||||
'.a .b,.c>.d{--pair:1 2;width:calc(100% - 2px);}.quoted{content:"a b /* text */";background:url("a b.png");}.joined.state{color:red;}',
|
||||
);
|
||||
assert.equal(
|
||||
minifyStaticCssTemplates('const s = css` .a { content: "\\`"; } `;', 'fixture.ts'),
|
||||
'const s = css`.a{content:"\\`";}`;',
|
||||
);
|
||||
});
|
||||
|
||||
test('CSS template minifier fails closed on interpolation and malformed input', () => {
|
||||
assert.throws(
|
||||
() => minifyStaticCssTemplates('const s = css`color:${value}`;', 'fixture.ts'),
|
||||
/interpolated css template/,
|
||||
);
|
||||
assert.throws(() => minifyCssText('.a{/* nope'), /unclosed CSS comment/);
|
||||
assert.throws(() => minifyCssText('.a{content:"nope}'), /unclosed CSS string/);
|
||||
});
|
||||
|
||||
test('bundle manifest separates static initial graph from dynamic editor graph', () => {
|
||||
const manifest = buildBundleManifest({
|
||||
'houseplan-card.js': {
|
||||
type: 'chunk', fileName: 'houseplan-card.js', code: 'entry', isEntry: true,
|
||||
imports: ['shared.js'], dynamicImports: [
|
||||
'houseplan-assets/editor.js', 'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
|
||||
],
|
||||
},
|
||||
'shared.js': {
|
||||
type: 'chunk', fileName: 'shared.js', code: 'shared', isEntry: false,
|
||||
imports: [], dynamicImports: [],
|
||||
},
|
||||
'houseplan-assets/editor.js': {
|
||||
type: 'chunk', fileName: 'houseplan-assets/editor.js', code: 'editor', isEntry: false,
|
||||
imports: ['shared.js'], dynamicImports: [],
|
||||
},
|
||||
'houseplan-assets/houseplan-onboarding-runtime-HASH.js': {
|
||||
type: 'chunk', fileName: 'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
|
||||
code: 'onboarding', isEntry: false,
|
||||
imports: ['shared.js'], dynamicImports: [],
|
||||
},
|
||||
}, 'fingerprint');
|
||||
assert.deepEqual(manifest.initialViewFiles, ['houseplan-card.js', 'shared.js']);
|
||||
assert.deepEqual(manifest.lazyEditorFiles, ['houseplan-assets/editor.js']);
|
||||
assert.deepEqual(manifest.lazyOnboardingFiles, [
|
||||
'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
|
||||
]);
|
||||
assert.deepEqual(manifest.lazyFiles, [
|
||||
'houseplan-assets/editor.js', 'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
|
||||
]);
|
||||
assert.doesNotThrow(() => assertBundleBudget(manifest, 1_000_000));
|
||||
assert.throws(() => assertBundleBudget(manifest, 1), /exceeds/);
|
||||
});
|
||||
|
||||
test('build fingerprint is embedded for Windows and POSIX source ids', () => {
|
||||
const plugin = buildFingerprintPlugin('exact-build');
|
||||
for (const id of ['C:\\repo\\src\\houseplan-card.ts', '/repo/src/houseplan-editor-runtime.ts']) {
|
||||
const transformed = plugin.transform(
|
||||
'export const fingerprint = "__HOUSEPLAN_SOURCE_FINGERPRINT__";', id,
|
||||
);
|
||||
assert.match(transformed.code, /"exact-build"/);
|
||||
}
|
||||
});
|
||||
|
||||
test('retry URL points at the content-hashed runtime chunk after naming', () => {
|
||||
const plugin = editorRuntimeRetryUrlPlugin();
|
||||
const bundle = {
|
||||
'houseplan-assets/houseplan-card.js': {
|
||||
type: 'chunk', fileName: 'houseplan-assets/houseplan-card.js',
|
||||
code: 'new URL("__HOUSEPLAN_EDITOR_RETRY_ASSET__", import.meta.url);'
|
||||
+ 'new URL("__HOUSEPLAN_ONBOARDING_RETRY_ASSET__", import.meta.url)', modules: {},
|
||||
},
|
||||
'houseplan-assets/houseplan-editor-runtime-HASH.js': {
|
||||
type: 'chunk', fileName: 'houseplan-assets/houseplan-editor-runtime-HASH.js', code: '',
|
||||
modules: { '/repo/src/houseplan-editor-runtime.ts': {} },
|
||||
},
|
||||
'houseplan-assets/houseplan-onboarding-runtime-HASH.js': {
|
||||
type: 'chunk', fileName: 'houseplan-assets/houseplan-onboarding-runtime-HASH.js', code: '',
|
||||
modules: { '/repo/src/houseplan-onboarding-runtime.ts': {} },
|
||||
},
|
||||
};
|
||||
plugin.generateBundle({}, bundle);
|
||||
assert.equal(
|
||||
bundle['houseplan-assets/houseplan-card.js'].code,
|
||||
'new URL("./houseplan-editor-runtime-HASH.js", import.meta.url);'
|
||||
+ 'new URL("./houseplan-onboarding-runtime-HASH.js", import.meta.url)',
|
||||
);
|
||||
});
|
||||
|
||||
test('bundle tree verification fails for a missing or tampered manifest asset', () => {
|
||||
const temp = mkdtempSync(join(tmpdir(), 'houseplan-bundle-tree-'));
|
||||
const source = join(temp, 'source');
|
||||
const target = join(temp, 'target');
|
||||
try {
|
||||
for (const root of [source, target]) {
|
||||
mkdirSync(join(root, 'houseplan-assets'), { recursive: true });
|
||||
writeFileSync(join(root, 'houseplan-card.js'), 'entry');
|
||||
writeFileSync(join(root, 'houseplan-assets', 'editor-hash.js'), 'editor');
|
||||
const files = ['houseplan-card.js', 'houseplan-assets/editor-hash.js'].map((path) => ({
|
||||
path, sha256: sha256Bytes(readFileSync(join(root, path))),
|
||||
}));
|
||||
writeFileSync(join(root, 'houseplan-assets.json'), `${JSON.stringify({
|
||||
schema: 1, fingerprint: 'fixture', entry: 'houseplan-card.js', files,
|
||||
})}\n`);
|
||||
}
|
||||
assert.doesNotThrow(() => compareBundleTrees(source, target));
|
||||
writeFileSync(join(target, 'houseplan-assets', 'editor-hash.js'), 'tampered');
|
||||
assert.throws(() => verifyBundleTree(target), /manifest hash mismatch/);
|
||||
rmSync(join(target, 'houseplan-assets', 'editor-hash.js'));
|
||||
assert.throws(() => verifyBundleTree(target), /manifest asset is missing/);
|
||||
} finally {
|
||||
rmSync(temp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
@@ -44,6 +45,35 @@ test('bundle freshness rejects a missing or stale fingerprint', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('bundle freshness verifies every manifest-listed demo asset', async () => {
|
||||
const root = fixtureRoot();
|
||||
try {
|
||||
const expected = sourceFingerprint(root);
|
||||
const assets = resolve(root, 'demo/srv/assets');
|
||||
mkdirSync(resolve(assets, 'houseplan-assets'), { recursive: true });
|
||||
const files = [
|
||||
['houseplan-card.js', 'entry'],
|
||||
['houseplan-assets/editor-HASH.js', 'editor'],
|
||||
];
|
||||
for (const [name, contents] of files) writeFileSync(resolve(assets, name), contents, 'utf8');
|
||||
writeFileSync(resolve(assets, 'houseplan-assets.json'), JSON.stringify({
|
||||
schema: 1,
|
||||
fingerprint: expected,
|
||||
files: files.map(([path, contents]) => ({
|
||||
path,
|
||||
sha256: createHash('sha256').update(contents).digest('hex'),
|
||||
})),
|
||||
}), 'utf8');
|
||||
const page = { evaluate: async () => expected };
|
||||
assert.equal(await assertFreshDemoBundle(page, root), expected);
|
||||
|
||||
writeFileSync(resolve(assets, 'houseplan-assets/editor-HASH.js'), 'tampered', 'utf8');
|
||||
await assert.rejects(assertFreshDemoBundle(page, root), /hash mismatch/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('bundle freshness uses the target tree fingerprint contract', async () => {
|
||||
const root = fixtureRoot();
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
hexToRgb, hsvToHex, hsvToRgb, normalizeHexColor, normalizeHue, rgbToHex, rgbToHsv,
|
||||
} from '../test-build/color-picker.js';
|
||||
import { readAllStylesSource } from './styles-source.mjs';
|
||||
import { readHouseplanProductionSource } from './houseplan-source.mjs';
|
||||
|
||||
test('hex drafts accept only three or six digits and normalize on commit', () => {
|
||||
assert.equal(normalizeHexColor('#AbC'), '#aabbcc');
|
||||
@@ -40,7 +41,7 @@ test('HSV helpers wrap hue, clamp finite dimensions and keep grayscale achromati
|
||||
|
||||
test('the shared component keeps its API and contains no nested native color picker', () => {
|
||||
const component = readFileSync(new URL('../src/hp-color-opacity.ts', import.meta.url), 'utf8');
|
||||
const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const card = readHouseplanProductionSource();
|
||||
const sourceFiles = (directory) => readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const target = new URL(entry.name + (entry.isDirectory() ? '/' : ''), directory);
|
||||
return entry.isDirectory() ? sourceFiles(target) : entry.name.endsWith('.ts') ? [target] : [];
|
||||
@@ -80,7 +81,7 @@ test('the hue range exposes one cyclic spectrum without restyling other ranges',
|
||||
});
|
||||
|
||||
test('activity color and ripple size keep independent readable rows', () => {
|
||||
const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const card = readHouseplanProductionSource();
|
||||
const styles = readAllStylesSource();
|
||||
const start = card.indexOf("d.display === 'icon_ripple'");
|
||||
const end = card.indexOf("marker.activity_alarm_note", start);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { readHouseplanProductionSource } from './houseplan-source.mjs';
|
||||
|
||||
import {
|
||||
COORDINATE_DECIMALS,
|
||||
@@ -110,11 +111,24 @@ test('one position changes only x/y and preserves future metadata (#224)', () =>
|
||||
});
|
||||
|
||||
test('frontend write paths adopt canonical candidates before persistence (#224)', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const source = readHouseplanProductionSource();
|
||||
const eagerCardSource = readFileSync(
|
||||
new URL('../src/houseplan-card.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /enqueueSerializedWrite\(this\._writeChain, async \(\) =>/);
|
||||
assert.match(
|
||||
source,
|
||||
/const candidate = canonicalizeConfigGeometry\(this\._serverCfg\);[\s\S]*config: candidate/,
|
||||
/const candidate = canonicalizeConfigGeometry\(this\._serverCfg\);/,
|
||||
);
|
||||
assert.match(
|
||||
eagerCardSource,
|
||||
/const candidate = canonicalizeConfigGeometry\(this\._serverCfg\);/,
|
||||
'the eager View writer keeps the same canonicalization barrier as the lazy editor writer',
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const canonicalCandidate = canonicalizeConfigGeometry\(candidate\);[\s\S]*config: canonicalCandidate/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
|
||||
@@ -3,11 +3,14 @@ import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { readAllStylesSource } from './styles-source.mjs';
|
||||
import { readHouseplanProductionSource } from './houseplan-source.mjs';
|
||||
|
||||
// #266: styles.ts is being split into surface files — style greps must
|
||||
// see the whole sheet, other sources keep the direct read.
|
||||
const source = (name) => name === 'styles.ts'
|
||||
? readAllStylesSource()
|
||||
: name === 'houseplan-card.ts'
|
||||
? readHouseplanProductionSource()
|
||||
: readFileSync(new URL(`../src/${name}`, import.meta.url), 'utf8');
|
||||
|
||||
test('issue 213 resolves the effective base before the face without a late visual factor', () => {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { EditorRuntimeLoader } from '../test-build/editor-runtime-loader.js';
|
||||
|
||||
const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; });
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
test('editor runtime loader deduplicates concurrent intent and installs atomically', async () => {
|
||||
const pending = deferred();
|
||||
const states = [];
|
||||
const installed = [];
|
||||
let loads = 0;
|
||||
const loader = new EditorRuntimeLoader({
|
||||
expectedFingerprint: 'same',
|
||||
load: async () => { loads++; return pending.promise; },
|
||||
install: (runtime) => installed.push(runtime),
|
||||
stateChanged: (state) => states.push(state),
|
||||
});
|
||||
|
||||
const first = loader.ensure();
|
||||
const second = loader.ensure();
|
||||
assert.equal(loads, 1);
|
||||
assert.equal(loader.state, 'loading');
|
||||
assert.equal(installed.length, 0);
|
||||
|
||||
pending.resolve({ fingerprint: 'same', create: () => ({ ready: true }) });
|
||||
assert.deepEqual(await Promise.all([first, second]), [true, true]);
|
||||
assert.equal(loads, 1);
|
||||
assert.deepEqual(installed, [{ ready: true }]);
|
||||
assert.deepEqual(states, ['loading', 'ready']);
|
||||
});
|
||||
|
||||
test('editor runtime loader retries the first failure exactly once', async () => {
|
||||
const attempts = [];
|
||||
const loader = new EditorRuntimeLoader({
|
||||
expectedFingerprint: 'same',
|
||||
load: async (attempt) => {
|
||||
attempts.push(attempt);
|
||||
if (attempt === 0) throw new Error('404');
|
||||
return { fingerprint: 'same', create: () => 'runtime' };
|
||||
},
|
||||
install: () => {},
|
||||
});
|
||||
|
||||
assert.equal(await loader.ensure(), true);
|
||||
assert.deepEqual(attempts, [0, 1]);
|
||||
assert.equal(await loader.ensure(), true);
|
||||
assert.deepEqual(attempts, [0, 1], 'a ready loader never imports again');
|
||||
});
|
||||
|
||||
test('fingerprint mismatch fails closed after one retry and remains terminal', async () => {
|
||||
const attempts = [];
|
||||
const installed = [];
|
||||
const failures = [];
|
||||
const loader = new EditorRuntimeLoader({
|
||||
expectedFingerprint: 'entry',
|
||||
load: async (attempt) => {
|
||||
attempts.push(attempt);
|
||||
return { fingerprint: 'other', create: () => 'mixed-runtime' };
|
||||
},
|
||||
install: (runtime) => installed.push(runtime),
|
||||
failed: (error) => failures.push(String(error)),
|
||||
});
|
||||
|
||||
assert.equal(await loader.ensure(), false);
|
||||
assert.deepEqual(attempts, [0, 1]);
|
||||
assert.deepEqual(installed, []);
|
||||
assert.equal(loader.state, 'failed');
|
||||
assert.match(failures[0], /fingerprint mismatch/);
|
||||
|
||||
assert.equal(await loader.ensure(), false);
|
||||
assert.deepEqual(attempts, [0, 1], 'terminal failure requires a page refresh');
|
||||
});
|
||||
|
||||
test('lazy runtime boundary keeps the eager entry independent and does not grow any usage', () => {
|
||||
const card = readFileSync(join(repoRoot, 'src', 'houseplan-card.ts'), 'utf8');
|
||||
assert.doesNotMatch(
|
||||
card,
|
||||
/^import(?!\s+type\b)[^;]*houseplan-editor-runtime[^;]*;/m,
|
||||
'the eager card must not statically import the editor runtime',
|
||||
);
|
||||
const sources = [];
|
||||
const visit = (directory) => {
|
||||
for (const name of readdirSync(directory)) {
|
||||
const path = join(directory, name);
|
||||
if (statSync(path).isDirectory()) visit(path);
|
||||
else if (/\.ts$/.test(name)) sources.push(readFileSync(path, 'utf8'));
|
||||
}
|
||||
};
|
||||
visit(join(repoRoot, 'src'));
|
||||
const anyCount = sources.reduce(
|
||||
(total, source) => total + (source.match(/\bany\b/g)?.length || 0), 0,
|
||||
);
|
||||
assert.ok(anyCount <= 1123, `src any count grew from 1123 to ${anyCount}`);
|
||||
});
|
||||
|
||||
test('source type references stay portable across build hosts', () => {
|
||||
const sources = [];
|
||||
const visit = (directory) => {
|
||||
for (const name of readdirSync(directory)) {
|
||||
const path = join(directory, name);
|
||||
if (statSync(path).isDirectory()) visit(path);
|
||||
else if (/\.ts$/.test(name)) sources.push(readFileSync(path, 'utf8'));
|
||||
}
|
||||
};
|
||||
visit(join(repoRoot, 'src'));
|
||||
assert.doesNotMatch(
|
||||
sources.join('\n'),
|
||||
/import\(["'][A-Za-z]:[\\/]/,
|
||||
'type imports must not capture an absolute Windows workspace path',
|
||||
);
|
||||
});
|
||||
|
||||
test('lazy runtime event handlers keep the runtime receiver', () => {
|
||||
const runtime = readFileSync(join(repoRoot, 'src', 'houseplan-editor-runtime.ts'), 'utf8');
|
||||
const bareHandlers = [...runtime.matchAll(/@[a-zA-Z-]+=\$\{this\.(_[A-Za-z0-9]+)\}/g)]
|
||||
.map((match) => match[1]);
|
||||
assert.deepEqual(
|
||||
bareHandlers,
|
||||
[],
|
||||
`Lit binds bare listeners to the host element, not the lazy runtime: ${bareHandlers.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('documentation capture materializes the complete bundle tree', () => {
|
||||
const capture = readFileSync(join(repoRoot, 'demo', 'docs', 'capture.mjs'), 'utf8');
|
||||
assert.match(capture, /import ['"]\.\.\/\.\.\/scripts\/bundle-sync\.mjs['"]/);
|
||||
assert.doesNotMatch(capture, /copyFileSync\(BUNDLE,\s*DEMO_BUNDLE\)/);
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readHouseplanProductionSource } from './houseplan-source.mjs';
|
||||
|
||||
const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const card = readHouseplanProductionSource();
|
||||
const editor = readFileSync(new URL('../src/editor.ts', import.meta.url), 'utf8');
|
||||
|
||||
test('active-space writes go through the fixed-floor mutation boundary', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS, OPENING_SYMBOL_EXISTING_GOLDEN_IMPACT,
|
||||
} from '../demo/golden/matrix.mjs';
|
||||
import { fixtureWallKey } from '../demo/fixtures/visual-matrix.mjs';
|
||||
import { readHouseplanProductionSource } from './houseplan-source.mjs';
|
||||
|
||||
test('golden matrix has stable unique ids and bounded comparison thresholds', () => {
|
||||
assert.equal(Number.isInteger(GOLDEN_MATRIX_VERSION) && GOLDEN_MATRIX_VERSION > 0, true);
|
||||
@@ -739,7 +740,7 @@ test('golden overrides fail closed on misspelled fixture references', () => {
|
||||
});
|
||||
|
||||
test('a light source paints exactly one region: the floor it can see', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const source = readHouseplanProductionSource();
|
||||
// One region per source, and it is the visibility polygon clipped to floor.
|
||||
assert.match(source, /visibilityPolygon\(\[pos\.x, pos\.y\], R, occluders/);
|
||||
assert.match(source,
|
||||
@@ -772,7 +773,7 @@ test('a light source paints exactly one region: the floor it can see', () => {
|
||||
});
|
||||
|
||||
test('all destructive editor dialogs use the medium shell and shared responsive footer groups', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const source = readHouseplanProductionSource();
|
||||
for (const method of ['_renderOpeningDialog', '_renderPhysicalDialog', '_renderSpaceDialog']) {
|
||||
const start = source.indexOf(`private ${method}`);
|
||||
assert.notEqual(start, -1, method);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
|
||||
/**
|
||||
* Source-contract tests historically inspected one monolithic card file.
|
||||
* Editor implementations are now lazy, so expose the same logical production
|
||||
* surface with the implementation before its typed host stubs.
|
||||
*/
|
||||
export function readHouseplanProductionSource() {
|
||||
const runtime = readFileSync(
|
||||
new URL('../src/houseplan-editor-runtime.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const parse = (name, source) => ts.createSourceFile(
|
||||
name, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS,
|
||||
);
|
||||
const runtimeFile = parse('houseplan-editor-runtime.ts', runtime);
|
||||
const cardFile = parse('houseplan-card.ts', card);
|
||||
const classOf = (file, name) => file.statements.find(
|
||||
(statement) => ts.isClassDeclaration(statement) && statement.name?.text === name,
|
||||
);
|
||||
const runtimeClass = classOf(runtimeFile, 'HouseplanEditorRuntime');
|
||||
const cardClass = classOf(cardFile, 'HouseplanCard');
|
||||
if (!runtimeClass || !cardClass) throw new Error('Houseplan source classes are missing');
|
||||
const nameOf = (member, file) => member.name?.getText(file);
|
||||
const implementations = new Map(runtimeClass.members
|
||||
.map((member) => [nameOf(member, runtimeFile), member])
|
||||
.filter(([name]) => name));
|
||||
const replacements = [];
|
||||
for (const member of cardClass.members) {
|
||||
const name = nameOf(member, cardFile);
|
||||
const implementation = implementations.get(name);
|
||||
if (!implementation) continue;
|
||||
const baseText = card.slice(member.getStart(cardFile), member.end);
|
||||
if (!baseText.includes('_editorRuntime')) continue;
|
||||
const visibility = /^(private|protected|public)\b/.exec(baseText)?.[1] || 'private';
|
||||
const implementationText = runtime
|
||||
.slice(implementation.getStart(runtimeFile), implementation.end)
|
||||
.replace(/^public\b/, visibility)
|
||||
.replaceAll('this.host.', 'this.');
|
||||
replacements.push({
|
||||
from: member.getStart(cardFile),
|
||||
to: member.end,
|
||||
text: implementationText,
|
||||
});
|
||||
}
|
||||
let reconstructed = card;
|
||||
for (const replacement of replacements.sort((a, b) => b.from - a.from)) {
|
||||
reconstructed = reconstructed.slice(0, replacement.from)
|
||||
+ replacement.text
|
||||
+ reconstructed.slice(replacement.to);
|
||||
}
|
||||
return reconstructed;
|
||||
}
|
||||
@@ -12,13 +12,14 @@ import {
|
||||
resolveLanguageCode,
|
||||
} from '../test-build/i18n/registry.js';
|
||||
import { langOf } from '../test-build/i18n.js';
|
||||
import { readHouseplanProductionSource } from './houseplan-source.mjs';
|
||||
|
||||
const dictionaries = new Map(
|
||||
LANGUAGE_REGISTRY.map(({ code, dictionary }) => [code, dictionary]),
|
||||
);
|
||||
const en = dictionaries.get('en');
|
||||
const ru = dictionaries.get('ru');
|
||||
const cardSource = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const cardSource = readHouseplanProductionSource();
|
||||
|
||||
test('i18n: registry codes and English fallback are valid', () => {
|
||||
const codes = LANGUAGE_REGISTRY.map(({ code }) => code);
|
||||
|
||||