fix: a refused geometry preflight names its reason and hands over diagnostics (#295)

Диалог «Оптимизировать» при отказе перечисляет причину по каждому
пространству (7 значений OptimizeGeometryFailureReason получили RU/EN
строки), даёт «Скопировать диагностику» — JSON-блок с origin: runtime,
версией карточки, отпечатками и классами исключений (граница приватности
checkOptimizeGeometry; privacy-тесты дополнены позитивной проверкой) — и
пишет одну структурированную запись в dev-лог (дедупликация по fingerprint).
При недоступном clipboard блок раскрывается прямо в диалоге.

Совет «обновите House Plan» больше не безусловный: websocket
houseplan/config/get теперь возвращает integration_version (бэкенд-тест),
и подсказка показывается только при реальном расхождении с версией карточки;
старый бэкенд без поля — подсказки нет.

Три новых мутанта (потеря причины в диалоге, блок без reason, отключённый
dev-лог) — краснота каждого проверена исполнением; смок
smoke_preflight_diagnostics на dev падает.

Issue: #295
User-Visible: yes
This commit is contained in:
Codex
2026-08-26 10:28:29 +03:00
parent a5abfe8bd6
commit 1cdd4ed6de
16 changed files with 615 additions and 270 deletions
File diff suppressed because one or more lines are too long
@@ -24,6 +24,7 @@ from .const import (
CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
MAX_SIGN_PATHS,
PLANS_DIR, PLANS_URL,
VERSION,
)
from .coordinate_canonicalization import (
canonicalize_config_geometry,
@@ -1157,6 +1158,9 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
"can_write": may_write(hass, getattr(connection, "user", None)),
"can_optimize_undo": _optimizer_backup_is_current(data, layout_data),
"undo_kind": _undo_kind(data, layout_data),
# #295: the card compares this against its own version to decide
# whether the «update House Plan» preflight hint can actually help.
"integration_version": VERSION,
},
)
+9 -4
View File
@@ -75,11 +75,14 @@ const out = await page.evaluate(async () => {
'Could not safely verify the geometry of the following spaces: Alpha, beta, Space 3, and 1 more.',
);
result.englishFailureHasExactHint = englishText.includes(
'Plans were not changed. Update House Plan and try again. If the error persists, attach a space export to the bug report.',
'Plans were not changed. Copy the diagnostics with the button below and attach them to the bug report together with a space export.',
);
result.failureTitleIsEscapedAndFourthNameHidden =
// #295: every failure (up to 10) is listed with its reason; the injected
// title renders as inert text — lit escapes it, no element is created.
result.failureListsReasonsAndEscapesTitle =
!card.renderRoot.querySelector('#preflight-injection')
&& !englishText.includes('<img') && !englishText.includes('unsafe');
&& englishText.includes('Alpha: The wall body did not build (the union came back empty)')
&& englishText.includes('<img id="preflight-injection" src=x>');
result.failureRendersNoApply = !card.renderRoot.querySelector('hp-dialog .btn.on');
await card._runAlignToGrid(); await card.updateComplete;
result.redPreflightMakesZeroWrites = sent.length === 0;
@@ -139,7 +142,9 @@ const out = await page.evaluate(async () => {
result.russianFailureHasExactCopy = russianText.includes(
'Не удалось безопасно проверить геометрию следующих пространств: Alpha, beta, Пространство 3 и ещё 1.',
) && russianText.includes(
'Планы не изменены. Обновите House Plan и повторите. Если ошибка останется, приложите экспорт пространства к отчёту об ошибке.',
'Планы не изменены. Скопируйте диагностику кнопкой ниже и приложите её к отчёту об ошибке вместе с экспортом пространства.',
) && russianText.includes(
'Alpha: Тело стен не построилось (объединение вернуло пустоту)',
);
card.renderRoot.querySelector('hp-dialog')?.dispatchEvent(new CustomEvent(
'hp-close', { bubbles: true, composed: true },
+96
View File
@@ -0,0 +1,96 @@
/**
* Issue #295: a failed optimize preflight names the reason per space, offers
* a copyable diagnostics block (with an inline fallback when the clipboard is
* unavailable), logs one structured dev record, and only advises updating
* when the integration version actually differs.
*/
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 900 }, 1);
const out = await page.evaluate(async () => {
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const update = async () => { card.requestUpdate(); await card.updateComplete; };
card._serverCfg = { spaces: [{ id: 'bad', title: 'Bad space', view_box: [0, 0, 1, 1],
rooms: [{ id: 'r', name: 'R', area: null, poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.4], [0.1, 0.4]] }],
}], markers: [], settings: {} };
card._layout = {}; card._space = 'bad'; card._modelCache = null; card._frame = null;
card._cfgEpoch++; card._setMode('plan'); await update();
const result = {};
const warns = [];
const origWarn = console.warn;
console.warn = (...args) => { warns.push(args); origWarn(...args); };
// Failed preflight, one failure per reason path we care about
const preflight = {
fingerprint: 'fp-1',
spaces: [], ok: false,
failures: [
{ spaceId: 'bad', displayName: 'Bad space', status: 'failed', reason: 'wall-exception', detail: 'Error' },
{ spaceId: 'bad2', displayName: 'Second', status: 'failed', reason: 'floor-null' },
],
};
card._reportPreflightFailure(preflight);
card._reportPreflightFailure(preflight); // dedup: same fingerprint logs once
result.devLogOnce = warns.filter((w) => String(w[0]).includes('optimize preflight failed')).length === 1;
const logged = warns.find((w) => String(w[0]).includes('optimize preflight failed'))?.[1];
result.devLogShape = !!logged && logged.kind === 'houseplan-optimize-preflight'
&& logged.origin === 'runtime' && Array.isArray(logged.failures)
&& logged.failures[0].reason === 'wall-exception' && logged.failures[0].detail === 'Error'
&& typeof logged.preflightFingerprint === 'string' && typeof logged.checkedAt === 'string';
// Dialog: failed branch renders per-failure reasons
card._alignDialog = {
cm: 5, where: '', changed: true, busy: false, removeLiveMissingPositions: false,
preflight,
config: card._serverCfg, layout: {},
report: { moved: 0, total: 0, rotated: 0, removedDrafts: 0, migrated: 0, canonicalized: 0,
coordsCanonicalized: 0, latticeCoordinatesCanonicalized: 0, wallSegmentsMigrated: 0,
wallsMerged: 0, spansMerged: 0, partitionsMerged: 0, partitionsReconciled: 0,
openingsRehosted: 0, redundantDraftsRemoved: 0, wallsStraightened: 0,
maxStraightenShiftCm: 0, maxStraightenSpace: '', spaceRefsRemapped: 0, roomRefsRemapped: 0,
positionsRemapped: 0, markersDetached: 0, orphanRoomLabelsRemoved: 0,
orphanDevicePositionsRemoved: 0, orphanGroupPositionsRemoved: 0,
removedPositions: [], liveMissingPositions: [], unverifiedPositions: [],
nestedRefsUnresolved: 0 },
};
await update();
const dialogText = root().querySelector('hp-dialog')?.textContent || '';
result.reasonShown = dialogText.includes(card._t('gs.preflight_reason_wall-exception'))
&& dialogText.includes(card._t('gs.preflight_reason_floor-null'));
result.spaceNamed = dialogText.includes('Bad space') && dialogText.includes('Second');
result.copyButton = dialogText.includes(card._t('gs.preflight_copy'));
// Same versions -> no «update» advice; differing -> shown
result.noUpdateHintSameVersion = !dialogText.includes(card._t('gs.preflight_update_hint'));
card._haIntegrationVersion = '0.0.1-other'; await update();
const withHint = root().querySelector('hp-dialog')?.textContent || '';
result.updateHintOnDiff = withHint.includes(card._t('gs.preflight_update_hint'));
card._haIntegrationVersion = null; await update();
// Copy: clipboard success path
let copied = null;
const clipboard = { writeText: async (text) => { copied = text; } };
Object.defineProperty(navigator, 'clipboard', { value: clipboard, configurable: true });
await card._copyPreflightDiagnostics(); await update();
let block = null;
try { block = JSON.parse(copied); } catch { /* keep null */ }
result.copiedBlock = !!block && block.kind === 'houseplan-optimize-preflight'
&& block.origin === 'runtime' && block.failures.length === 2
&& block.failures[0].reason === 'wall-exception' && block.failures[1].detail === null;
// Copy: clipboard failure -> inline fallback with the same JSON
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: async () => { throw new Error('denied'); } }, configurable: true,
});
await card._copyPreflightDiagnostics(); await update();
const pre = root().querySelector('hp-dialog details pre');
result.inlineFallback = !!pre && pre.textContent.includes('houseplan-optimize-preflight');
console.warn = origWarn;
return result;
});
checkAll(out);
await finish(browser);
+141 -123
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -5,6 +5,12 @@
- The Russian binding dialog names its entities toggle exactly as the user
guide does: «Показывать сущности»
([#269](https://github.com/Matysh/houseplan-card/issues/269)).
- A refused geometry preflight now explains itself: the dialog names the
reason per space, offers a one-click copyable diagnostics block (with an
inline fallback when the clipboard is unavailable) and logs one structured
dev record. The «update House Plan» advice only appears when the card and
integration versions actually differ
([#295](https://github.com/Matysh/houseplan-card/issues/295)).
- The Thickness tool now serves every wall: standalone partitions and saved
draft segments can be picked and resized just like room contours, with the
+6
View File
@@ -10,6 +10,12 @@
- Флаг диалога привязки называется «Показывать сущности» — как в руководстве
пользователя ([#269](https://github.com/Matysh/houseplan-card/issues/269)).
- Отказ проверки геометрии теперь объясняет себя: диалог называет причину по
каждому пространству, даёт скопировать диагностический блок одной кнопкой
(с фолбэком прямо в диалоге, если буфер недоступен) и пишет одну
структурированную запись в консоль разработчика. Совет «обновите House
Plan» показывается только при реальном расхождении версий карточки и
интеграции ([#295](https://github.com/Matysh/houseplan-card/issues/295)).
- Инструмент «Толщина» теперь берёт любую стену: отдельно стоящие перегородки
и сегменты сохранённых черновиков выбираются и меняются так же, как контуры
+7
View File
@@ -1437,6 +1437,13 @@ show_signal: true
Кнопка **Общие настройки → Оптимизировать планы** запускает явное обслуживание. Сначала показывается точный предпросмотр; данные не меняются до подтверждения.
Если проверка геометрии отклонила результат, диалог называет причину по каждому
пространству и предлагает **Скопировать диагностику** — блок с причинами,
отпечатками и версией карточки для отчёта об ошибке (приложите его вместе с
экспортом пространства; если буфер обмена недоступен, блок раскрывается прямо в
диалоге). Планы при отказе не меняются. Совет обновить House Plan показывается
только когда версия карточки действительно отличается от версии интеграции.
### Что делает оптимизация
| Операция | Что меняется |
+11 -11
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "daf805126d889c54ca7ae63af86c016f442b148fedf12a570f650efb93245ceb",
"sourceFingerprint": "2bd6abd8fa9674748b19b3ae12d65e093d804d4d9e4f0294e3fbdd6773effaab",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,7 +14,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "daf805126d889c54ca7ae63af86c016f442b148fedf12a570f650efb93245ceb",
"sourceSha256": "2bd6abd8fa9674748b19b3ae12d65e093d804d4d9e4f0294e3fbdd6773effaab",
"imageSha256": "36223106c073f07d8cc3ecf8eaab37192ebb2687daba65c5c21047d0b7890de0"
},
"view-touch": {
@@ -25,7 +25,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "daf805126d889c54ca7ae63af86c016f442b148fedf12a570f650efb93245ceb",
"sourceSha256": "2bd6abd8fa9674748b19b3ae12d65e093d804d4d9e4f0294e3fbdd6773effaab",
"imageSha256": "41e3ba67f8db0e98f26f484293af83ef937c369ca5ca6a59a3350d8954c906f4"
},
"space-create": {
@@ -36,7 +36,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "daf805126d889c54ca7ae63af86c016f442b148fedf12a570f650efb93245ceb",
"sourceSha256": "2bd6abd8fa9674748b19b3ae12d65e093d804d4d9e4f0294e3fbdd6773effaab",
"imageSha256": "c33a7279165a4cec6fa6fadb6fd08cd967e082a17fe101ef442d27d36ae59b6b"
},
"room-contour-close": {
@@ -47,7 +47,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "daf805126d889c54ca7ae63af86c016f442b148fedf12a570f650efb93245ceb",
"sourceSha256": "2bd6abd8fa9674748b19b3ae12d65e093d804d4d9e4f0294e3fbdd6773effaab",
"imageSha256": "b4777162eae89e0d95801721330bcd74ba761624b82b3362069d7e2d38317e08"
},
"plan-context-tray": {
@@ -58,7 +58,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "daf805126d889c54ca7ae63af86c016f442b148fedf12a570f650efb93245ceb",
"sourceSha256": "2bd6abd8fa9674748b19b3ae12d65e093d804d4d9e4f0294e3fbdd6773effaab",
"imageSha256": "c0e28edf82f45ccc6df568d3023b681e9e4394262ad34c1605fe9eacdb57a390"
},
"device-editor": {
@@ -69,7 +69,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "daf805126d889c54ca7ae63af86c016f442b148fedf12a570f650efb93245ceb",
"sourceSha256": "2bd6abd8fa9674748b19b3ae12d65e093d804d4d9e4f0294e3fbdd6773effaab",
"imageSha256": "d0ffd31ce80bfde21ab75da356a5fc1af38246f2b301030880320620c228d89d"
},
"device-display-preview": {
@@ -80,7 +80,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "daf805126d889c54ca7ae63af86c016f442b148fedf12a570f650efb93245ceb",
"sourceSha256": "2bd6abd8fa9674748b19b3ae12d65e093d804d4d9e4f0294e3fbdd6773effaab",
"imageSha256": "2cdabae1f89c3286e4fac0ce30f757ee1690b707ab8a5488748b7cd420626160"
},
"background-editor": {
@@ -91,7 +91,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "daf805126d889c54ca7ae63af86c016f442b148fedf12a570f650efb93245ceb",
"sourceSha256": "2bd6abd8fa9674748b19b3ae12d65e093d804d4d9e4f0294e3fbdd6773effaab",
"imageSha256": "30147bb00a90eea7136b4cee30995f6e6a9217b5132f3e8d3ad7471413b1af8a"
},
"room-card": {
@@ -102,7 +102,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "daf805126d889c54ca7ae63af86c016f442b148fedf12a570f650efb93245ceb",
"sourceSha256": "2bd6abd8fa9674748b19b3ae12d65e093d804d4d9e4f0294e3fbdd6773effaab",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
@@ -113,7 +113,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "daf805126d889c54ca7ae63af86c016f442b148fedf12a570f650efb93245ceb",
"sourceSha256": "2bd6abd8fa9674748b19b3ae12d65e093d804d4d9e4f0294e3fbdd6773effaab",
"imageSha256": "dd492f53150b7149085daada5cce9eeae9bde9e7ea1d86679a54b3041f72f517"
}
}
+36
View File
@@ -2246,6 +2246,42 @@ export const MUTANTS = [
replace: ' const reach = self.length;',
}],
},
{
id: 'preflight-reason-lost-in-dialog',
// #295: диалог обязан называть причину отказа по каждому пространству.
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node demo/smoke_preflight_diagnostics.mjs',
because: 'отказ preflight без причины недиагностируем — ровно исходный дефект #295',
patches: [{
file: 'src/houseplan-card.ts',
find: " ${failure.displayName}: ${this._t(`gs.preflight_reason_${failure.reason}` as any)}",
replace: " ${failure.displayName}",
}],
},
{
id: 'preflight-diagnostics-without-reason',
// #295: копируемый блок без reason бесполезен для отчёта об ошибке.
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node demo/smoke_preflight_diagnostics.mjs',
because: 'диагностический блок обязан нести reason каждого отказа',
patches: [{
file: 'src/houseplan-card.ts',
find: " reason: failure.reason,",
replace: " reason: undefined,",
}],
},
{
id: 'preflight-dev-log-disabled',
// #295: dev-лог — второй канал диагностики, его потерю обязан ловить смок.
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node demo/smoke_preflight_diagnostics.mjs',
because: 'структурированная запись отказа в консоли — часть контракта диагностики #295',
patches: [{
file: 'src/houseplan-card.ts',
find: " console.warn('[houseplan] optimize preflight failed', this._preflightDiagnostics(preflight));",
replace: " void preflight;",
}],
},
{
id: 'visual-mitre-limit-back-to-4',
// #309: порог визуального среза возвращается к классическим 4·h — шип на
+96 -1
View File
@@ -1826,6 +1826,10 @@ class HouseplanCard extends LitElement {
private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null;
/** Optimization preview plus the exact pair, so commit cannot differ from it. */
/** #295: diagnostics text shown inline when the clipboard is unavailable. */
private _preflightClipboardFallback: string | null = null;
/** #295: integration version from houseplan/config/get; null on old backends. */
private _haIntegrationVersion: string | null = null;
private _alignDialog: {
report: OptimizeReport; config: any; layout: Record<string, any>;
preflight: OptimizeGeometryPreflightResult | null;
@@ -2300,6 +2304,7 @@ class HouseplanCard extends LitElement {
_rulesDialog: { state: true },
_settingsDialog: { state: true },
_alignDialog: { state: true },
_preflightClipboardFallback: { state: true },
_backupExportDialog: { state: true },
_backupImportDialog: { state: true },
_importDialog: { state: true },
@@ -3879,6 +3884,8 @@ class HouseplanCard extends LitElement {
}
this._canOptimizeUndo = !!(cfgResp?.can_optimize_undo || layResp?.can_optimize_undo);
this._haIntegrationVersion = typeof cfgResp?.integration_version === 'string'
? cfgResp.integration_version : this._haIntegrationVersion;
this._undoKind = (cfgResp?.undo_kind || layResp?.undo_kind || null) as any;
if (typeof cfgResp?.can_write === 'boolean') this._serverCanWrite = cfgResp.can_write;
if (configChanged) this._continuity.note('config-candidate', { configRev: this._cfgRev });
@@ -3933,6 +3940,8 @@ class HouseplanCard extends LitElement {
// absent can_write = older backend / demo stub → keep null (legacy admin fallback)
if (typeof cfgResp?.can_write === 'boolean') this._serverCanWrite = cfgResp.can_write;
this._canOptimizeUndo = !!(cfgResp?.can_optimize_undo || layResp?.can_optimize_undo);
this._haIntegrationVersion = typeof cfgResp?.integration_version === 'string'
? cfgResp.integration_version : this._haIntegrationVersion;
this._adoptStructuralResponses(cfgResp, layResp);
this._adoptInitialSpace(this._model, true);
this._resumePendingNavMode();
@@ -4544,6 +4553,8 @@ class HouseplanCard extends LitElement {
}
this._layoutRev = resp?.rev ?? this._layoutRev;
this._canOptimizeUndo = !!resp?.can_optimize_undo;
this._haIntegrationVersion = typeof resp?.integration_version === 'string'
? resp.integration_version : this._haIntegrationVersion;
this._undoKind = (resp?.undo_kind || null) as any;
this._cacheSnapshot();
this.requestUpdate();
@@ -15824,6 +15835,70 @@ class HouseplanCard extends LitElement {
* Preview whole-plan maintenance. Nothing is written here: the pure run
* produces both the report and the exact config/layout pair to commit.
*/
/**
* #295: one diagnostics payload for the clipboard, the inline fallback and
* the dev log. `origin: runtime` is the honest part of the contract — the
* refusal depends on live card state and a saved export may not reproduce
* it, so the payload carries what the export cannot.
*/
private _preflightDiagnostics(preflight: OptimizeGeometryPreflightResult): object {
const spacesById = new Map((this._serverCfg?.spaces || [])
.map((space: any) => [String(space?.id || ''), space]));
return {
kind: 'houseplan-optimize-preflight',
origin: 'runtime',
cardVersion: CARD_VERSION,
checkedAt: new Date().toISOString(),
preflightFingerprint: preflight.fingerprint,
failures: preflight.failures.map((failure) => ({
spaceId: failure.spaceId,
displayName: failure.displayName,
reason: failure.reason,
detail: failure.detail ?? null,
spaceGeometryFingerprint: spacesById.has(failure.spaceId)
? spacePhysicalGeometryFingerprint(spacesById.get(failure.spaceId))
: null,
})),
};
}
/** #295: dev-log once per distinct failing preflight, not once per render. */
private _reportedPreflightFingerprint: string | null = null;
private _reportPreflightFailure(preflight: OptimizeGeometryPreflightResult): void {
if (preflight.ok || preflight.fingerprint === this._reportedPreflightFingerprint) return;
this._reportedPreflightFingerprint = preflight.fingerprint;
// eslint-disable-next-line no-console
console.warn('[houseplan] optimize preflight failed', this._preflightDiagnostics(preflight));
}
/**
* #295: the «update House Plan» advice only helps when the frontend is
* actually stale. houseplan/config/get now reports the integration
* version; an old backend does not, and then the advice is simply not
* shown — a missing hint is better than a misleading one (the rc.1
* report where the owner had nothing newer to update to).
*/
private _preflightVersionsDiffer(): boolean {
const integration = this._haIntegrationVersion;
return typeof integration === 'string' && integration.length > 0
&& integration !== CARD_VERSION;
}
private async _copyPreflightDiagnostics(): Promise<void> {
const preflight = this._alignDialog?.preflight;
if (!preflight || preflight.ok) return;
const text = JSON.stringify(this._preflightDiagnostics(preflight), null, 2);
try {
await navigator.clipboard.writeText(text);
this._preflightClipboardFallback = null;
this._showToast(this._t('gs.preflight_copied'));
} catch {
// Insecure context / embedded webview: surface the block inline so the
// owner can select and copy it by hand.
this._preflightClipboardFallback = text;
}
}
private _checkOptimizeGeometry(config: ServerConfig): OptimizeGeometryPreflightResult {
return checkOptimizeGeometry(config, {
fallbackSpaceName: (index) => this._t('gs.align_preflight_space', {
@@ -15916,6 +15991,7 @@ class HouseplanCard extends LitElement {
return;
}
const preflight = r.changed ? this._checkOptimizeGeometry(r.config) : null;
if (preflight) this._reportPreflightFailure(preflight);
// The maximum geometry shift is an UPPER BOUND, not a sample. The run
// measured every element in the centimetres of ITS OWN space — converting
// one normalised maximum through the first space's `cell_cm` understated
@@ -15948,6 +16024,7 @@ class HouseplanCard extends LitElement {
const fingerprint = contentFingerprint(d.config);
if (d.preflight.fingerprint !== fingerprint) {
const preflight = this._checkOptimizeGeometry(d.config);
this._reportPreflightFailure(preflight);
d = { ...d, preflight };
this._alignDialog = d;
if (!preflight.ok) return;
@@ -17089,7 +17166,25 @@ class HouseplanCard extends LitElement {
${failed
? html`
<p class="alignmsg">${this._t('gs.align_preflight_failed', { spaces, more })}</p>
<div class="rhint">${this._t('gs.align_preflight_hint')}</div>`
${failures.slice(0, 10).map((failure) => html`<p class="alignmsg">
${failure.displayName}: ${this._t(`gs.preflight_reason_${failure.reason}` as any)}
</p>`)}
${failures.length > 10 ? html`<p class="alignmsg">
${this._t('gs.align_preflight_more', { n: String(failures.length - 10) })}
</p>` : nothing}
<div class="rhint">${this._t('gs.align_preflight_hint')}</div>
${this._preflightVersionsDiffer() ? html`
<div class="rhint">${this._t('gs.preflight_update_hint')}</div>` : nothing}
<div class="row">
<button class="btn ghost" @click=${() => this._copyPreflightDiagnostics()}>
<ha-icon icon="mdi:content-copy"></ha-icon>
${this._t('gs.preflight_copy')}
</button>
</div>
${this._preflightClipboardFallback ? html`<details open>
<summary>${this._t('gs.preflight_copy')}</summary>
<pre style="user-select:text;white-space:pre-wrap">${this._preflightClipboardFallback}</pre>
</details>` : nothing}`
: !d.changed
? html`<p class="alignmsg">${this._t(
r.liveMissingPositions.length || r.unverifiedPositions.length
+11 -1
View File
@@ -822,7 +822,17 @@
"gs.align_removed_drafts": "Invalid outlines collapsed by the grid and removed: {n}.",
"gs.optimize_redundant_drafts": "Saved wall chains hidden by solid room walls and removed: {n}.",
"gs.align_preflight_failed": "Could not safely verify the geometry of the following spaces: {spaces}{more}.",
"gs.align_preflight_hint": "Plans were not changed. Update House Plan and try again. If the error persists, attach a space export to the bug report.",
"gs.align_preflight_hint": "Plans were not changed. Copy the diagnostics with the button below and attach them to the bug report together with a space export.",
"gs.preflight_reason_prepare-exception": "Could not prepare the space geometry (exception while building the model)",
"gs.preflight_reason_wall-null": "The wall body did not build (the union came back empty)",
"gs.preflight_reason_wall-degraded-extra": "The wall body degraded with extra geometry",
"gs.preflight_reason_wall-failed-core": "The wall body core failed to assemble",
"gs.preflight_reason_wall-exception": "Wall construction threw an exception",
"gs.preflight_reason_floor-null": "The floor outline did not build",
"gs.preflight_reason_floor-exception": "Floor construction threw an exception",
"gs.preflight_copy": "Copy diagnostics",
"gs.preflight_copied": "Diagnostics copied",
"gs.preflight_update_hint": "The card and integration versions differ — update House Plan and retry.",
"gs.align_preflight_space": "Space {n}",
"gs.align_preflight_more": ", and {n} more",
"gs.optimize_changes": "Model migrations: {m}; spaces updated: {c}; noisy coordinate values removed: {p}; merged real-wall fragments: {w}; virtual fragments: {s}; independent walls: {i}.",
+11 -1
View File
@@ -822,7 +822,17 @@
"gs.align_removed_drafts": "Схлопнувшиеся на сетке некорректные контуры удалены: {n}.",
"gs.optimize_redundant_drafts": "Сохранённые цепочки стен, полностью скрытые стенами комнат, удалены: {n}.",
"gs.align_preflight_failed": "Не удалось безопасно проверить геометрию следующих пространств: {spaces}{more}.",
"gs.align_preflight_hint": "Планы не изменены. Обновите House Plan и повторите. Если ошибка останется, приложите экспорт пространства к отчёту об ошибке.",
"gs.align_preflight_hint": "Планы не изменены. Скопируйте диагностику кнопкой ниже и приложите её к отчёту об ошибке вместе с экспортом пространства.",
"gs.preflight_reason_prepare-exception": "Не удалось подготовить геометрию пространства (исключение при сборке модели)",
"gs.preflight_reason_wall-null": "Тело стен не построилось (объединение вернуло пустоту)",
"gs.preflight_reason_wall-degraded-extra": "Тело стен деградировало с лишней геометрией",
"gs.preflight_reason_wall-failed-core": "Ядро тела стен не собралось",
"gs.preflight_reason_wall-exception": "Построение стен упало с исключением",
"gs.preflight_reason_floor-null": "Контур пола не построился",
"gs.preflight_reason_floor-exception": "Построение пола упало с исключением",
"gs.preflight_copy": "Скопировать диагностику",
"gs.preflight_copied": "Диагностика скопирована",
"gs.preflight_update_hint": "Версии карточки и интеграции различаются — обновите House Plan и повторите.",
"gs.align_preflight_space": "Пространство {n}",
"gs.align_preflight_more": " и ещё {n}",
"gs.optimize_changes": "Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; виртуальных: {s}; независимых: {i}.",
+32 -6
View File
@@ -57,6 +57,17 @@ export interface SpacePhysicalGeometryInputs {
coordScale: number;
}
/**
* #295: a PRIVACY-SAFE marker of the caught exception. The result deliberately
* carries the error CLASS, never the message: messages can leak entity ids,
* file paths or user data, and the existing preflight contract (unit «null,
* exceptions and floor failure are bounded…») forbids them in the payload.
*/
export function preflightErrorDetail(error: unknown): string {
if (error instanceof Error) return error.name || 'Error';
return typeof error;
}
export type OptimizeGeometryFailureReason =
| 'prepare-exception'
| 'wall-null'
@@ -73,6 +84,12 @@ export interface OptimizeSpaceGeometryCheck {
displayName: string;
status: OptimizeSpaceGeometryStatus;
reason?: OptimizeGeometryFailureReason;
/**
* #295: the caught exception's CLASS (never the message — privacy contract)
* for the *-exception reasons. Non-exception reasons are self-describing
* and leave it unset. Diagnostic payloads carry it; user strings do not.
*/
detail?: string;
}
export interface OptimizeGeometryPreflightResult {
@@ -334,8 +351,11 @@ export function checkOptimizeGeometry(
const model = spaceModels({ ...config, spaces: [raw] } as ServerConfig)[0];
if (!model) throw new Error('missing space model');
input = prepare(raw, model);
} catch {
spaces.push({ ...identity, status: 'failed', reason: 'prepare-exception' });
} catch (error) {
spaces.push({
...identity, status: 'failed', reason: 'prepare-exception',
detail: preflightErrorDetail(error),
});
continue;
}
@@ -353,8 +373,11 @@ export function checkOptimizeGeometry(
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
input.physicalBodies,
);
} catch {
spaces.push({ ...identity, status: 'failed', reason: 'wall-exception' });
} catch (error) {
spaces.push({
...identity, status: 'failed', reason: 'wall-exception',
detail: preflightErrorDetail(error),
});
continue;
}
if (united == null) {
@@ -378,8 +401,11 @@ export function checkOptimizeGeometry(
input.space.rooms, input.walls, input.openCuts,
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
);
} catch {
spaces.push({ ...identity, status: 'failed', reason: 'floor-exception' });
} catch (error) {
spaces.push({
...identity, status: 'failed', reason: 'floor-exception',
detail: preflightErrorDetail(error),
});
continue;
}
if (floor == null) {
+3
View File
@@ -123,6 +123,9 @@ test('null, exceptions and floor failure are bounded while successful empty geom
const wallThrows = checkOptimizeGeometry(wallConfig, { wallPass: () => { throw new Error('secret'); } });
assert.equal(wallThrows.failures[0].reason, 'wall-exception');
assert.doesNotMatch(JSON.stringify(wallThrows), /secret/);
// #295: the detail field carries the error CLASS only — diagnosable without
// breaking the privacy contract above.
assert.equal(wallThrows.failures[0].detail, 'Error');
const successfulEmpty = checkOptimizeGeometry(wallConfig, {
wallPass: () => ({ geom: [], paperGeom: [], depthUnits: 0, openingIndex: null }),
+5
View File
@@ -154,6 +154,11 @@ async def test_issue_244_space_delete_is_authoritative_and_revision_guarded(
unchanged_layout = await client.receive_json()
assert unchanged_config["result"]["rev"] == config_set["result"]["rev"]
assert unchanged_layout["result"]["rev"] == layout_set["result"]["rev"]
# #295: the card compares this against its own version before showing the
# «update House Plan» preflight hint — the field must be the live
# integration VERSION, not the export-document snapshot.
from custom_components.houseplan.const import VERSION
assert unchanged_config["result"]["integration_version"] == VERSION
config["markers"] = [{
"id": "device", "binding": "virtual", "space": "f1", "removed": True,