mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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:
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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
Vendored
+141
-123
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
|
||||
- Флаг диалога привязки называется «Показывать сущности» — как в руководстве
|
||||
пользователя ([#269](https://github.com/Matysh/houseplan-card/issues/269)).
|
||||
- Отказ проверки геометрии теперь объясняет себя: диалог называет причину по
|
||||
каждому пространству, даёт скопировать диагностический блок одной кнопкой
|
||||
(с фолбэком прямо в диалоге, если буфер недоступен) и пишет одну
|
||||
структурированную запись в консоль разработчика. Совет «обновите House
|
||||
Plan» показывается только при реальном расхождении версий карточки и
|
||||
интеграции ([#295](https://github.com/Matysh/houseplan-card/issues/295)).
|
||||
|
||||
- Инструмент «Толщина» теперь берёт любую стену: отдельно стоящие перегородки
|
||||
и сегменты сохранённых черновиков выбираются и меняются так же, как контуры
|
||||
|
||||
@@ -1437,6 +1437,13 @@ show_signal: true
|
||||
|
||||
Кнопка **Общие настройки → Оптимизировать планы** запускает явное обслуживание. Сначала показывается точный предпросмотр; данные не меняются до подтверждения.
|
||||
|
||||
Если проверка геометрии отклонила результат, диалог называет причину по каждому
|
||||
пространству и предлагает **Скопировать диагностику** — блок с причинами,
|
||||
отпечатками и версией карточки для отчёта об ошибке (приложите его вместе с
|
||||
экспортом пространства; если буфер обмена недоступен, блок раскрывается прямо в
|
||||
диалоге). Планы при отказе не меняются. Совет обновить House Plan показывается
|
||||
только когда версия карточки действительно отличается от версии интеграции.
|
||||
|
||||
### Что делает оптимизация
|
||||
|
||||
| Операция | Что меняется |
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}.",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user