mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
@@ -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
|
||||
|
||||
|
||||
+4
-2
@@ -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": "f111431804c4c42c6e35286f29720d089bc8b77ffc364a6517f7a3510fcb11c8",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-Cs2QHIcW.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 255385,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/editor-BC5jpMNv.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DUuA4F8c.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Bj55plb1.js",
|
||||
"houseplan-assets/space-deletion-CJ8Co_o8.js"
|
||||
],
|
||||
"lazyGzipBytes": 138414,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/editor-BC5jpMNv.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DUuA4F8c.js",
|
||||
"houseplan-assets/space-deletion-CJ8Co_o8.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 131768,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Bj55plb1.js",
|
||||
"houseplan-assets/space-deletion-CJ8Co_o8.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 7200,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/editor-BC5jpMNv.js",
|
||||
"sha256": "f10e45b5a98ab0efc6ceb3c4e08acc4f57a9cb5f99d178b723077a2cb011e05b",
|
||||
"rawBytes": 3699,
|
||||
"gzipBytes": 1534,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Cs2QHIcW.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-Cs2QHIcW.js",
|
||||
"sha256": "caa02ba0654e191f72d9b7c9b139c078bb6edb84df4b3e2ec263be492009ecd2",
|
||||
"rawBytes": 908483,
|
||||
"gzipBytes": 255201,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/editor-BC5jpMNv.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DUuA4F8c.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Bj55plb1.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-DUuA4F8c.js",
|
||||
"sha256": "0e053b445f87c6c7fdb2b681863e148976ab703221f4988d93f80ca825bd4051",
|
||||
"rawBytes": 504648,
|
||||
"gzipBytes": 129680,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Cs2QHIcW.js",
|
||||
"houseplan-assets/space-deletion-CJ8Co_o8.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-Bj55plb1.js",
|
||||
"sha256": "32b8812031403d65f909ca236f431a3eeefc9bde9a07499ee123925331f75982",
|
||||
"rawBytes": 26356,
|
||||
"gzipBytes": 6646,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Cs2QHIcW.js",
|
||||
"houseplan-assets/space-deletion-CJ8Co_o8.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/space-deletion-CJ8Co_o8.js",
|
||||
"sha256": "59ab0af663f25f859d7bbe3a8893cd00757a40117ca4b9e08d6846f0b2dfd3fc",
|
||||
"rawBytes": 911,
|
||||
"gzipBytes": 554,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "53623e0fa5ef93003fc02a66b9932d847c8c68b5345233f9ed6c61f1e470d86a",
|
||||
"rawBytes": 190,
|
||||
"gzipBytes": 184,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Cs2QHIcW.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f111431804c4c42c6e35286f29720d089bc8b77ffc364a6517f7a3510fcb11c8";import{c as e,l as o,t,d2 as s,A as a,b as l}from"./houseplan-card-Cs2QHIcW.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);
|
||||
File diff suppressed because one or more lines are too long
+2572
File diff suppressed because one or more lines are too long
+217
File diff suppressed because one or more lines are too long
@@ -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__="f111431804c4c42c6e35286f29720d089bc8b77ffc364a6517f7a3510fcb11c8";export{t as a,e as c,o as i,r as s,s as t};
|
||||
File diff suppressed because one or more lines are too long
@@ -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'}.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+13
-2
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
Vendored
+98
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "f111431804c4c42c6e35286f29720d089bc8b77ffc364a6517f7a3510fcb11c8",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-Cs2QHIcW.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 255385,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/editor-BC5jpMNv.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DUuA4F8c.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Bj55plb1.js",
|
||||
"houseplan-assets/space-deletion-CJ8Co_o8.js"
|
||||
],
|
||||
"lazyGzipBytes": 138414,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/editor-BC5jpMNv.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DUuA4F8c.js",
|
||||
"houseplan-assets/space-deletion-CJ8Co_o8.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 131768,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Bj55plb1.js",
|
||||
"houseplan-assets/space-deletion-CJ8Co_o8.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 7200,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/editor-BC5jpMNv.js",
|
||||
"sha256": "f10e45b5a98ab0efc6ceb3c4e08acc4f57a9cb5f99d178b723077a2cb011e05b",
|
||||
"rawBytes": 3699,
|
||||
"gzipBytes": 1534,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Cs2QHIcW.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-Cs2QHIcW.js",
|
||||
"sha256": "caa02ba0654e191f72d9b7c9b139c078bb6edb84df4b3e2ec263be492009ecd2",
|
||||
"rawBytes": 908483,
|
||||
"gzipBytes": 255201,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/editor-BC5jpMNv.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DUuA4F8c.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Bj55plb1.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-DUuA4F8c.js",
|
||||
"sha256": "0e053b445f87c6c7fdb2b681863e148976ab703221f4988d93f80ca825bd4051",
|
||||
"rawBytes": 504648,
|
||||
"gzipBytes": 129680,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Cs2QHIcW.js",
|
||||
"houseplan-assets/space-deletion-CJ8Co_o8.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-Bj55plb1.js",
|
||||
"sha256": "32b8812031403d65f909ca236f431a3eeefc9bde9a07499ee123925331f75982",
|
||||
"rawBytes": 26356,
|
||||
"gzipBytes": 6646,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Cs2QHIcW.js",
|
||||
"houseplan-assets/space-deletion-CJ8Co_o8.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/space-deletion-CJ8Co_o8.js",
|
||||
"sha256": "59ab0af663f25f859d7bbe3a8893cd00757a40117ca4b9e08d6846f0b2dfd3fc",
|
||||
"rawBytes": 911,
|
||||
"gzipBytes": 554,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "53623e0fa5ef93003fc02a66b9932d847c8c68b5345233f9ed6c61f1e470d86a",
|
||||
"rawBytes": 190,
|
||||
"gzipBytes": 184,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Cs2QHIcW.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
]
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f111431804c4c42c6e35286f29720d089bc8b77ffc364a6517f7a3510fcb11c8";import{c as e,l as o,t,d2 as s,A as a,b as l}from"./houseplan-card-Cs2QHIcW.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);
|
||||
+5221
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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__="f111431804c4c42c6e35286f29720d089bc8b77ffc364a6517f7a3510fcb11c8";export{t as a,e as c,o as i,r as s,s as t};
|
||||
Vendored
+1
-7763
File diff suppressed because one or more lines are too long
+15
-6
@@ -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)).
|
||||
|
||||
- Проверки стыков стен стали честными на границах: ложные отказы на
|
||||
микроскопических погрешностях координат устранены, очень длинные стены не
|
||||
роняют проверку, а внутренняя ошибка проверки больше не пропускает запись
|
||||
|
||||
+16
-8
@@ -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 «Показывать
|
||||
|
||||
+8
-1
@@ -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` не поддерживается.
|
||||
|
||||
В обычном просмотре код редакторов не загружается. Поэтому первое открытие
|
||||
«Плана», «Устройств» или «Подложки» может занять короткое время. Если внутренний
|
||||
модуль не загрузился и после одной повторной попытки, план остаётся в просмотре
|
||||
и предлагает обновить страницу; наполовину открытого редактора не остаётся.
|
||||
|
||||
### Права пользователей
|
||||
|
||||
| Настройка интеграции | Просмотр | Управление устройствами | Редактирование планов и загрузка файлов |
|
||||
|
||||
@@ -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",
|
||||
|
||||
+21
-2
@@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
+69
-12
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
+105
-7
@@ -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'
|
||||
+ ' };',
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+718
-10794
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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": "Насыщенность",
|
||||
|
||||
+1
-14
@@ -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,15 @@ 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();
|
||||
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(
|
||||
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,105 @@
|
||||
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}`);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
+2
-1
@@ -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);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
cmToUnits, newViolations,
|
||||
} from '../test-build/junction-limits.js';
|
||||
import { GRID_STEP_N } from '../test-build/space-geometry.js';
|
||||
import { readHouseplanProductionSource } from './houseplan-source.mjs';
|
||||
|
||||
// #329, решения владельца 2026-08-27. Пороги абсолютные: 15°, 6 стен,
|
||||
// max(20 см, толщина), 5 см, 25 см². cell_cm на них не влияет.
|
||||
@@ -383,9 +384,7 @@ test('#330 AC4: кэш baseline инвалидируется по конфиг-
|
||||
// приёмом, каким #293 пинит обвязку смока. Поведенческая половина AC4
|
||||
// (N move → N+1 вычислений в реальном жесте) живёт в
|
||||
// demo/smoke_junction_limits.mjs (resizeBaselineCachedPerGesture).
|
||||
const source = readFileSync(
|
||||
new URL('../src/houseplan-card.ts', import.meta.url), 'utf8',
|
||||
);
|
||||
const source = readHouseplanProductionSource();
|
||||
const method = source.slice(
|
||||
source.indexOf('private _junctionLimitsIntroduced('),
|
||||
source.indexOf('private _commitPhysicalGeometry('),
|
||||
|
||||
@@ -3,9 +3,10 @@ import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
import { readHouseplanProductionSource } from './houseplan-source.mjs';
|
||||
|
||||
const srcRoot = fileURLToPath(new URL('../src/', import.meta.url));
|
||||
const source = readFileSync(join(srcRoot, 'houseplan-card.ts'), 'utf8');
|
||||
const source = readHouseplanProductionSource();
|
||||
const walkTypescript = (directory) => readdirSync(directory, { withFileTypes: true })
|
||||
.flatMap((entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
@@ -27,7 +28,7 @@ test('native selects explicitly project selection unless they are command menus'
|
||||
|
||||
// This is a command menu, not persisted state: it intentionally resets to
|
||||
// its empty first row after inserting an HA variable into decorative text.
|
||||
assert.deepEqual(unsafe, ['houseplan-card.ts:decor-live-attribute']);
|
||||
assert.deepEqual(unsafe, ['houseplan-editor-runtime.ts:decor-live-attribute']);
|
||||
});
|
||||
|
||||
test('persisted dynamic marker selects project their selected option explicitly', () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { optimizePlans } from '../test-build/plan-optimizer.js';
|
||||
import { MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES } from '../test-build/wall-thickness.js';
|
||||
import { nearAxisProfile } from '../scripts/model-invariants.mjs';
|
||||
import { readHouseplanProductionSource } from './houseplan-source.mjs';
|
||||
|
||||
const fixture = JSON.parse(readFileSync(
|
||||
new URL('./fixtures/279-near-orthogonal-junction.json', import.meta.url), 'utf8',
|
||||
@@ -21,7 +22,7 @@ const sourceOf = (file) => readFileSync(new URL(`../src/${file}`, import.meta.ur
|
||||
|
||||
test('#290 authoring, Optimize and renderer use one near-axis threshold source', () => {
|
||||
const renderer = sourceOf('wall-thickness.ts');
|
||||
const authoring = sourceOf('houseplan-card.ts');
|
||||
const authoring = readHouseplanProductionSource();
|
||||
const optimizer = sourceOf('plan-optimizer.ts');
|
||||
|
||||
assert.match(renderer, /import \{ NEAR_AXIS_MAX_DEGREES \} from '\.\/near-axis';/);
|
||||
|
||||
@@ -2,8 +2,9 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readAllStylesSource } from './styles-source.mjs';
|
||||
import { readHouseplanProductionSource } from './houseplan-source.mjs';
|
||||
|
||||
const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const card = readHouseplanProductionSource();
|
||||
const styles = readAllStylesSource();
|
||||
const staticRender = readFileSync(new URL('../src/space-render.ts', import.meta.url), 'utf8');
|
||||
const backend = readFileSync(new URL('../custom_components/houseplan/websocket_api.py', import.meta.url), 'utf8');
|
||||
|
||||
@@ -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 source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const source = readHouseplanProductionSource();
|
||||
|
||||
const methodBody = (name) => {
|
||||
const start = source.search(new RegExp(`(?:private|protected)\\s+(?:get\\s+|async\\s+)?${name}(?:\\s*=)?(?:\\(|:)`));
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
GLOW_CARD_CONTRACT,
|
||||
LARGE_HOUSE_CARD_CONTRACT,
|
||||
} from '../demo/performance/card-contract.mjs';
|
||||
import { readHouseplanProductionSource } from './houseplan-source.mjs';
|
||||
|
||||
const directCardMembers = (relativePath) => {
|
||||
const source = readFileSync(new URL(`../${relativePath}`, import.meta.url), 'utf8');
|
||||
@@ -38,10 +39,10 @@ test('Glow benchmark declares every private card member it consumes', () => {
|
||||
});
|
||||
|
||||
test('performance contracts reference real production members', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const source = readHouseplanProductionSource();
|
||||
for (const contract of [LARGE_HOUSE_CARD_CONTRACT, GLOW_CARD_CONTRACT]) {
|
||||
for (const name of declaredMembers(contract)) {
|
||||
assert.match(source, new RegExp(`\\b(?:private\\s+(?:get\\s+)?|get\\s+)${name}\\b`),
|
||||
assert.match(source, new RegExp(`\\b(?:private\\s+(?:declare\\s+|get\\s+)?|get\\s+)${name}\\b`),
|
||||
`${contract.label} declares missing production member ${name}`);
|
||||
}
|
||||
}
|
||||
@@ -66,7 +67,7 @@ test('contract accepts recent optional fields only when their runtime type is va
|
||||
});
|
||||
|
||||
test('view render resolves structural wall cuts once per frame, not once per room', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const source = readHouseplanProductionSource();
|
||||
const start = source.indexOf('const allZeroCuts = this._openCuts();');
|
||||
const end = source.indexOf('${this._renderOpeningTunnelFills(space, roomFills)}', start);
|
||||
assert.ok(start >= 0 && end > start, 'view room-render block is present');
|
||||
@@ -87,7 +88,7 @@ test('view render resolves structural wall cuts once per frame, not once per roo
|
||||
});
|
||||
|
||||
test('room inner faces are structurally cached and shared by both fill layers', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const source = readHouseplanProductionSource();
|
||||
const helperStart = source.indexOf('private _innerRoomContour(');
|
||||
const helperEnd = source.indexOf('\n /**', helperStart);
|
||||
assert.ok(helperStart >= 0 && helperEnd > helperStart, 'inner-contour cache helper is present');
|
||||
@@ -113,7 +114,7 @@ test('room inner faces are structurally cached and shared by both fill layers',
|
||||
});
|
||||
|
||||
test('wall and light geometry reuse bounded caches before structural work', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const source = readHouseplanProductionSource();
|
||||
|
||||
const unionStart = source.indexOf('private _wallUnionGeometry()');
|
||||
const unionEnd = source.indexOf('\n /** Thick-wall spans', unionStart);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
createRenderDeviceSnapshot, presentationSnapshotKey, renderDeviceSnapshotPositions,
|
||||
} from '../test-build/render-device-snapshot.js';
|
||||
import { readHouseplanProductionSource } from './houseplan-source.mjs';
|
||||
|
||||
test('snapshot positions skip resolution until a renderable plan exists', () => {
|
||||
const devices = [{ id: 'one' }, { id: 'two' }];
|
||||
@@ -71,7 +72,7 @@ const methodBody = (source, name) => {
|
||||
};
|
||||
|
||||
test('atomic plan render paths do not bypass RenderDeviceSnapshot with this.hass', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const source = readHouseplanProductionSource();
|
||||
for (const name of [
|
||||
'_roomLqi', '_resolvedRoomFills', '_sunNow', '_renderSunRays', '_renderGlowLayer',
|
||||
'_renderVacuums', '_renderVacFit', '_renderDevice', '_roomTemp', '_roomHum', '_openingAmt',
|
||||
@@ -82,7 +83,7 @@ test('atomic plan render paths do not bypass RenderDeviceSnapshot with this.hass
|
||||
});
|
||||
|
||||
test('the card gates snapshot positions on the render model', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const source = readHouseplanProductionSource();
|
||||
const capture = methodBody(source, '_captureRenderDeviceSnapshot');
|
||||
assert.match(
|
||||
capture,
|
||||
@@ -97,7 +98,7 @@ test('the card gates snapshot positions on the render model', () => {
|
||||
});
|
||||
|
||||
test('opening references use their own availability policy without weakening plan tombstones', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const source = readHouseplanProductionSource();
|
||||
for (const name of [
|
||||
'_contactCandidates', '_lockCandidates', '_openingAmt', '_renderOpenings',
|
||||
'_renderOpeningLocks', '_renderOpeningInfoCard', '_lockAction',
|
||||
@@ -125,7 +126,7 @@ test('opening references use their own availability policy without weakening pla
|
||||
});
|
||||
|
||||
test('lock actuation remains guarded inside the one sanctioned opening-card method', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const source = readHouseplanProductionSource();
|
||||
const action = methodBody(source, '_lockAction');
|
||||
const guardAt = action.indexOf('_openingEntityAvailable(entityId)');
|
||||
const confirmAt = action.indexOf('confirm(');
|
||||
@@ -136,7 +137,7 @@ test('lock actuation remains guarded inside the one sanctioned opening-card meth
|
||||
});
|
||||
|
||||
test('marker delete/re-add and opening save remain separate config transactions', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const source = readHouseplanProductionSource();
|
||||
const saveOpening = methodBody(source, '_saveOpening');
|
||||
const saveMarker = methodBody(source, '_saveMarker');
|
||||
assert.match(saveOpening, /sp\.openings/);
|
||||
|
||||
@@ -2,10 +2,12 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { readAllStylesSource } from './styles-source.mjs';
|
||||
import { readHouseplanProductionSource } from './houseplan-source.mjs';
|
||||
|
||||
const card = fs.readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const card = readHouseplanProductionSource();
|
||||
const resize = fs.readFileSync(new URL('../src/resize.ts', import.meta.url), 'utf8');
|
||||
const controller = fs.readFileSync(new URL('../src/resize-controller.ts', import.meta.url), 'utf8');
|
||||
const editorRuntime = fs.readFileSync(new URL('../src/houseplan-editor-runtime.ts', import.meta.url), 'utf8');
|
||||
const wallInvariant = fs.readFileSync(new URL('../src/wall-record-preservation.ts', import.meta.url), 'utf8');
|
||||
const invariantCli = fs.readFileSync(new URL('../scripts/model-invariants.mjs', import.meta.url), 'utf8');
|
||||
const styles = readAllStylesSource();
|
||||
@@ -99,7 +101,7 @@ test('#264 Resize controller is the sole mutable gesture owner', () => {
|
||||
for (const mirror of ['_rszSel', '_rszDrag', '_rszPreview', '_rszLive', '_rszEligibilityCache']) {
|
||||
assert.equal(card.includes(`private ${mirror}`), false, `${mirror} remains a root state mirror`);
|
||||
}
|
||||
assert.match(card, /new ResizeController</);
|
||||
assert.match(editorRuntime, /new ResizeController</);
|
||||
assert.match(controller, /class ResizeController/);
|
||||
assert.doesNotMatch(controller, /LitElement|PointerEvent|requestUpdate|_serverCfg|_writeConfig/);
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@ const UNIT_FORMATTING = new RegExp(
|
||||
*/
|
||||
const FORMATTERS = [
|
||||
{ file: 'src/logic.ts', anchor: 'export function formatLength(', lines: 12 },
|
||||
{ file: 'src/resize.ts', anchor: 'export function formatArea(', lines: 8 },
|
||||
{ file: 'src/area-format.ts', anchor: 'export function formatArea(', lines: 8 },
|
||||
];
|
||||
|
||||
const sourceFiles = (root) => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '../test-build/wall-segment-model.js';
|
||||
import { wallKey } from '../test-build/wall-thickness.js';
|
||||
import { GRID_STEP_N } from '../test-build/space-geometry.js';
|
||||
import { readHouseplanProductionSource } from './houseplan-source.mjs';
|
||||
|
||||
const rectangle = (id, x1 = 0, y1 = 0, x2 = 1, y2 = 1) => ({
|
||||
id,
|
||||
@@ -377,7 +378,7 @@ test('initial migration resolves a reserved deterministic id with the documented
|
||||
});
|
||||
|
||||
test('every frontend structural transaction crosses the wall identity barrier', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const source = readHouseplanProductionSource();
|
||||
const optimizer = readFileSync(new URL('../src/plan-optimizer.ts', import.meta.url), 'utf8');
|
||||
const methodSource = (name) => {
|
||||
const start = source.indexOf(`private ${name}(`);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { spaceModels } from '../test-build/space-geometry.js';
|
||||
import { wallBodiesGeometry, wallBodiesUnionPath } from '../test-build/wall-thickness.js';
|
||||
import { checkPhysicalGeometry } from '../scripts/model-invariants.mjs';
|
||||
import { readHouseplanProductionSource } from './houseplan-source.mjs';
|
||||
|
||||
const fixture = JSON.parse(readFileSync(
|
||||
new URL('./fixtures/278-wall-union-isolation.json', import.meta.url), 'utf8',
|
||||
@@ -98,7 +99,7 @@ test('#278 physical fingerprint ignores decor but covers every strict writer fie
|
||||
});
|
||||
|
||||
test('#278 production source routes physical writers through one barrier and decor around it', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const source = readHouseplanProductionSource();
|
||||
for (const historyKey of [
|
||||
'draft_segment', 'wall_chain_finish', 'column_add', 'physical_edit', 'physical_delete',
|
||||
'physical_move', 'resize_room', 'wall_thickness',
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Pure path/manifest guards for public frontend chunks (#337)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
_PATH = Path(__file__).parents[1] / "custom_components" / "houseplan" / "frontend_asset_manifest.py"
|
||||
_SPEC = importlib.util.spec_from_file_location("hp_frontend_asset_manifest", _PATH)
|
||||
assert _SPEC and _SPEC.loader
|
||||
_MODULE = importlib.util.module_from_spec(_SPEC)
|
||||
_SPEC.loader.exec_module(_MODULE)
|
||||
resolve_frontend_asset = _MODULE.resolve_frontend_asset
|
||||
|
||||
|
||||
def _root(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "frontend"
|
||||
assets = root / "houseplan-assets"
|
||||
assets.mkdir(parents=True)
|
||||
(assets / "editor-abc.js").write_text("export {};", encoding="utf-8")
|
||||
(assets / "stale.js").write_text("export {};", encoding="utf-8")
|
||||
(root / "houseplan-assets.json").write_text(json.dumps({
|
||||
"schema": 1,
|
||||
"files": [{"path": "houseplan-assets/editor-abc.js"}],
|
||||
}), encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
def test_frontend_asset_requires_current_manifest_entry(tmp_path: Path) -> None:
|
||||
root = _root(tmp_path)
|
||||
assert resolve_frontend_asset(root, "editor-abc.js") == (
|
||||
root / "houseplan-assets" / "editor-abc.js"
|
||||
).resolve()
|
||||
assert resolve_frontend_asset(root, "stale.js") is None
|
||||
assert resolve_frontend_asset(root, "missing.js") is None
|
||||
assert resolve_frontend_asset(root, "houseplan-assets.json") is None
|
||||
|
||||
|
||||
def test_frontend_asset_refuses_traversal_and_malformed_manifest(tmp_path: Path) -> None:
|
||||
root = _root(tmp_path)
|
||||
for value in (
|
||||
"../editor-abc.js", "..\\editor-abc.js", "nested/editor-abc.js",
|
||||
"..%2feditor-abc.js", "editor-abc.css", "..", "",
|
||||
):
|
||||
assert resolve_frontend_asset(root, value) is None
|
||||
(root / "houseplan-assets.json").write_text("not json", encoding="utf-8")
|
||||
assert resolve_frontend_asset(root, "editor-abc.js") is None
|
||||
|
||||
|
||||
def test_frontend_asset_refuses_wrong_schema_and_symlink_escape(tmp_path: Path) -> None:
|
||||
root = _root(tmp_path)
|
||||
manifest = root / "houseplan-assets.json"
|
||||
manifest.write_text(json.dumps({
|
||||
"schema": 2, "files": [{"path": "houseplan-assets/editor-abc.js"}],
|
||||
}), encoding="utf-8")
|
||||
assert resolve_frontend_asset(root, "editor-abc.js") is None
|
||||
|
||||
outside = tmp_path / "outside.js"
|
||||
outside.write_text("outside", encoding="utf-8")
|
||||
link = root / "houseplan-assets" / "linked.js"
|
||||
try:
|
||||
link.symlink_to(outside)
|
||||
except OSError:
|
||||
return # Windows without Developer Mode; containment is covered elsewhere.
|
||||
manifest.write_text(json.dumps({
|
||||
"schema": 1, "files": [{"path": "houseplan-assets/linked.js"}],
|
||||
}), encoding="utf-8")
|
||||
assert resolve_frontend_asset(root, "linked.js") is None
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
"src/space-geometry.ts", "src/junction-limits.ts",
|
||||
"src/space-order.ts", "src/card-editor-validation.ts",
|
||||
"src/signing.ts", "src/initial-load.ts", "src/space-model-selection.ts", "src/space-dialog.ts",
|
||||
"src/visual-continuity.ts", "src/mode-transition.ts", "src/pointer-modality.ts",
|
||||
"src/visual-continuity.ts", "src/mode-transition.ts", "src/editor-runtime-loader.ts", "src/pointer-modality.ts",
|
||||
"src/render-device-snapshot.ts",
|
||||
"src/command-stack.ts",
|
||||
"src/coordinate-canonicalization.ts",
|
||||
|
||||
Reference in New Issue
Block a user