/** * Диалог «Оптимизировать планы» (Общие настройки → Сетка), #642. * * Первый вынос из монолита по образцу `live-*` и `RadarSetupController`: узкий * порт `OptimizePlansDialogPort` из 18 членов вместо `HouseplanEditorHostPort` * на 350, без делегатов-заглушек в карточке. Модуль импортирует только * редакторский рантайм, поэтому остаётся в его ленивом чанке; карточка берёт * отсюда только тип состояния. * * Сам диалог (`_alignDialog`) остаётся `@state` карточки: харнесс и golden * пишут его напрямую. Сюда переехали два куска состояния, которые карточке * не принадлежали: инлайн-фолбэк буфера обмена и дедуп dev-лога. */ import { html, nothing, type TemplateResult } from 'lit'; import { formatLatticeShiftCm } from './coordinate-canonicalization'; import type { DeviceLayout } from './device-position-history'; import type { I18nKey } from './i18n'; import { spacePhysicalGeometryFingerprint, type OptimizeGeometryPreflightResult, } from './plan-geometry-preflight'; import { optimizePlans, type OptimizeReport } from './plan-optimizer'; import type { SpaceReferenceRepairContext } from './space-reference-repair'; import type { ServerConfig } from './types'; import { contentFingerprint } from './visual-continuity'; /** Optimization preview plus the exact pair, so commit cannot differ from it. */ export type OptimizePlansDialogState = { report: OptimizeReport; config: any; layout: Record; preflight: OptimizeGeometryPreflightResult | null; /** the promised maximum, in centimetres, ALREADY rounded up (AUD-158B1-01) */ cm: number; /** the space that maximum belongs to, named only when there are several */ where: string; changed: boolean; busy: boolean; /** False by default; true only after the secondary preview action. */ removeLiveMissingPositions: boolean; }; /** * Всё, что диалогу нужно от редактора. Порт собирает рантайм в конструкторе; * других зависимостей у модуля нет. Рост порта выше 20 членов — сигнал, что * сюда протекает чужая подсистема (юнит `optimize-plans-dialog.test.mjs`). */ export interface OptimizePlansDialogPort { dialog(): OptimizePlansDialogState | null; setDialog(next: OptimizePlansDialogState | null): void; t(key: I18nKey, vars?: Record): string; hass(): unknown; config(): ServerConfig | null; /** the normalised plan exists — the preview has something to optimise */ planReady(): boolean; layout(): DeviceLayout | null | undefined; integrationVersion(): string | null; /** `displayVersion(CARD_VERSION)` of the runtime; no second copy here */ cardVersion(): string; requestUpdate(): void; showToast(message: string): void; errorText(error: unknown): string; checkGeometry(config: ServerConfig): OptimizeGeometryPreflightResult; referenceContext(removeLiveMissingPositions: boolean): SpaceReferenceRepairContext; showMigrationBlocked(error: unknown): void; clearGeometryGesture(): void; commit(config: ServerConfig, layout: DeviceLayout): Promise; /** a write conflict reloads both stores before the error toast */ reloadAfterConflict(): Promise; } export function preflightDiagnostics( preflight: OptimizeGeometryPreflightResult, candidate: ServerConfig | null, { cardVersion, now }: { cardVersion: string; now: Date }, ): object { // CODE-REVIEW-295-r1 M1: hash the CANDIDATE spaces the preflight judged, // not the already-saved config — a saved-config hash is exactly what a // space export would reproduce, and the block promises what the export // does not carry. const spacesById = new Map(((candidate as any)?.spaces || []) // any-ok: кандидат приходит из untyped optimizePlans, форма spaces не гарантирована .map((space) => [String(space?.id || ''), space])); return { kind: 'houseplan-optimize-preflight', origin: 'runtime', cardVersion, checkedAt: now.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, })), }; } export function preflightVersionsDiffer(integration: string | null, cardVersion: string): boolean { return typeof integration === 'string' && integration.length > 0 && integration !== cardVersion; } export class OptimizePlansDialog { /** * CODE-REVIEW-295-r1 M2: the inline clipboard fallback belongs to one * dialog showing. Keyed by the dialog object, a reopened dialog — always a * new object — can never display the previous refusal's JSON, and closing * needs no manual reset. */ private readonly fallbacks = new WeakMap(); /** #295: dev-log once per distinct failing preflight, not once per render. */ private reportedFingerprint: string | null = null; public constructor(private readonly port: OptimizePlansDialogPort) {} /** #295: diagnostics text shown inline when the clipboard is unavailable. */ public get clipboardFallback(): string | null { const dialog = this.port.dialog(); return dialog ? this.fallbacks.get(dialog) ?? null : null; } private diagnostics( preflight: OptimizeGeometryPreflightResult, candidate: ServerConfig | null, ): object { return preflightDiagnostics(preflight, candidate, { cardVersion: this.port.cardVersion(), now: new Date(), }); } public reportPreflightFailure( preflight: OptimizeGeometryPreflightResult, candidate: ServerConfig | null, ): void { if (preflight.ok || preflight.fingerprint === this.reportedFingerprint) return; this.reportedFingerprint = preflight.fingerprint; // eslint-disable-next-line no-console console.warn('[houseplan] optimize preflight failed', this.diagnostics(preflight, candidate)); } public async copyDiagnostics(): Promise { const dialog = this.port.dialog(); const preflight = dialog?.preflight; if (!dialog || !preflight || preflight.ok) return; const text = JSON.stringify( this.diagnostics(preflight, dialog.config ?? null), null, 2, ); try { await navigator.clipboard.writeText(text); if (this.fallbacks.delete(dialog)) this.port.requestUpdate(); this.port.showToast(this.port.t('gs.preflight_copied')); } catch { // Insecure context / embedded webview: surface the block inline so the // owner can select and copy it by hand. this.fallbacks.set(dialog, text); this.port.requestUpdate(); } } /** * Preview whole-plan maintenance. Nothing is written here: the pure run * produces both the report and the exact config/layout pair to commit. */ public preview(removeLiveMissingPositions: boolean): void { const config = this.port.config(); if (!this.port.planReady() || !config) return; const spaces = config.spaces || []; let r; try { r = optimizePlans( config, this.port.layout() || {}, this.port.referenceContext(removeLiveMissingPositions), ); } catch (error) { this.port.showMigrationBlocked(error); return; } const preflight = r.changed ? this.port.checkGeometry(r.config) : null; if (preflight) this.reportPreflightFailure(preflight, r.config); // 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 // a two-scale plan twentyfold (AUD-158B1-01) — and the last tenth is // rounded UP, so the promise can never be smaller than the deed. const cm = Math.ceil(r.report.maxShiftCm * 10) / 10; const sp = spaces.find((x) => x?.id != null && String(x.id) === r.report.maxSpace); const where = spaces.length > 1 && sp ? String(sp.title || sp.id) : ''; // A new dialog object also starts without an inline clipboard fallback. this.port.setDialog({ report: r.report, config: r.config, layout: r.layout, cm, where, preflight, changed: r.changed, busy: false, removeLiveMissingPositions, }); } public open(): void { this.preview(false); } public toggleLivePositions(): void { const dialog = this.port.dialog(); if (!dialog || dialog.busy || !dialog.report.liveMissingPositions.length) return; this.preview(!dialog.removeLiveMissingPositions); } /** * The backend persists an intent before either store changes, then keeps a * one-deep snapshot that remains undoable until the next plan edit. */ public async run(): Promise { let d = this.port.dialog(); if (!d || d.busy || !this.port.config() || !d.changed || !d.preflight?.ok) return; const fingerprint = contentFingerprint(d.config); if (d.preflight.fingerprint !== fingerprint) { const preflight = this.port.checkGeometry(d.config); this.reportPreflightFailure(preflight, d.config); d = { ...d, preflight }; this.port.setDialog(d); if (!preflight.ok) return; } this.port.clearGeometryGesture(); this.port.setDialog({ ...d, busy: true }); try { await this.port.commit(d.config, d.layout); this.port.setDialog(null); this.port.showToast(this.port.t('gs.align_done', { n: String(d.report.moved), m: String(d.report.migrated + d.report.canonicalized + d.report.coordsCanonicalized + d.report.latticeCoordinatesCanonicalized + d.report.wallsMerged + d.report.spansMerged + d.report.partitionsMerged + d.report.partitionsReconciled + d.report.openingsRehosted + d.report.wallsStraightened), r: String(d.report.spaceRefsRemapped + d.report.roomRefsRemapped + d.report.positionsRemapped + d.report.markersDetached + d.report.orphanRoomLabelsRemoved + d.report.orphanDevicePositionsRemoved + d.report.orphanGroupPositionsRemoved), })); } catch (e: any) { // any-ok: ответ WS HA не типизирован, code читается как у всех писателей конфига const current = this.port.dialog(); if (current) this.port.setDialog({ ...current, busy: false }); if (e?.code === 'wall_model_client_outdated') { this.port.showToast(this.port.t('toast.wall_model_client_outdated')); return; } if (e?.code === 'conflict') { await this.port.reloadAfterConflict(); } this.port.showToast(this.port.t('toast.error', { err: this.port.errorText(e) })); } } /** * The confirmation separates geometry movement from lossless maintenance, * and promises the one-deep undo before either store is changed. */ render(): TemplateResult { const d = this.port.dialog()!; const r = d.report; const failed = d.changed && !d.preflight?.ok; const failures = d.preflight?.failures || []; const visibleNames = failures.slice(0, 3).map((failure) => failure.displayName); const spaces = visibleNames.length ? visibleNames.join(', ') : this.port.t('gs.align_preflight_space', { n: '1' }); const remaining = Math.max(0, failures.length - visibleNames.length); const more = remaining ? this.port.t('gs.align_preflight_more', { n: String(remaining) }) : ''; const repaired = r.spaceRefsRemapped + r.roomRefsRemapped + r.positionsRemapped + r.markersDetached; const modelMaintenance = r.migrated + r.canonicalized + r.coordsCanonicalized + r.wallSegmentsMigrated + r.roomDraftsMigrated + r.roomDraftSegmentsMigrated + r.wallsMerged + r.spansMerged + r.partitionsMerged + r.partitionsReconciled + r.openingsRehosted; const gridWarning = r.moved + r.rotated + r.coordsCanonicalized + r.wallsStraightened; const straightenCm = Math.ceil(r.maxStraightenShiftCm * 10) / 10; const straightenSpace = (this.port.config()?.spaces || []).find( (space) => String(space?.id || '') === r.maxStraightenSpace, ); const straightenWhere = (this.port.config()?.spaces || []).length > 1 && straightenSpace ? String(straightenSpace.title || straightenSpace.id) : ''; const removed = r.orphanRoomLabelsRemoved + r.orphanDevicePositionsRemoved + r.orphanGroupPositionsRemoved; const liveNames = r.liveMissingPositions.map((item) => item.name).filter(Boolean); const visibleLiveNames = liveNames.slice(0, 3).join(', '); const remainingLiveNames = Math.max(0, liveNames.length - 3); const liveNamesText = visibleLiveNames ? this.port.t('gs.optimize_live_names', { names: visibleLiveNames, more: remainingLiveNames ? this.port.t('gs.optimize_reference_more', { n: String(remainingLiveNames) }) : '', }) : ''; const registryLimited = r.unverifiedPositions.some( (item) => item.reason === 'registry_unavailable', ); const detailStatus = (item: typeof r.removedPositions[number]): string => { if (r.removedPositions.some((removedItem) => removedItem.id === item.id)) { return this.port.t('gs.optimize_detail_removed'); } if (r.liveMissingPositions.some((liveItem) => liveItem.id === item.id)) { return this.port.t('gs.optimize_detail_live'); } return this.port.t('gs.optimize_detail_unverified'); }; const detailKind = (kind: typeof r.removedPositions[number]['kind']): string => this.port.t( kind === 'room_label' ? 'gs.optimize_detail_room_label' : kind === 'group' ? 'gs.optimize_detail_group' : kind === 'device' ? 'gs.optimize_detail_device' : 'gs.optimize_detail_unknown', ); const referenceDetails = [ ...r.removedPositions, ...r.liveMissingPositions.filter((item) => ( !r.removedPositions.some((removedItem) => removedItem.id === item.id) )), ...r.unverifiedPositions, ]; const visibleDetails = referenceDetails.slice(0, 10); const remainingDetails = Math.max(0, referenceDetails.length - visibleDetails.length); const fallback = this.clipboardFallback; return html` this.port.setDialog(null)}>
${failed ? html`

${this.port.t('gs.align_preflight_failed', { spaces, more })}

${failures.slice(0, 10).map((failure) => html`

${failure.displayName}: ${this.port.t(`gs.preflight_reason_${failure.reason}` as I18nKey)}

`)} ${failures.length > 10 ? html`

${this.port.t('gs.align_preflight_more', { n: String(failures.length - 10) })}

` : nothing}
${this.port.t('gs.align_preflight_hint')}
${preflightVersionsDiffer(this.port.integrationVersion(), this.port.cardVersion()) ? html`
${this.port.t('gs.preflight_update_hint')}
` : nothing}
${fallback ? html`
${this.port.t('gs.preflight_copy')}
${fallback}
` : nothing}` : !d.changed ? html`

${this.port.t( r.liveMissingPositions.length || r.unverifiedPositions.length || r.nestedRefsUnresolved ? 'gs.optimize_no_automatic_changes' : 'gs.align_none', )}

` : html` ${r.moved ? html`

${this.port.t('gs.align_count', { n: String(r.moved), total: String(r.total), cm: String(d.cm), })}

` : nothing} ${r.latticeCoordinatesCanonicalized ? html`

${this.port.t('gs.optimize_lattice_summary', { n: String(r.latticeCoordinatesCanonicalized), cm: formatLatticeShiftCm(r.latticeMaxShiftCm), })}

${r.latticeSpaces.map((space) => html`

${this.port.t( 'gs.optimize_lattice_space', { space: space.space, n: String(space.canonicalized), far: String(space.far), }, )}

`)} ` : nothing} ${d.where ? html`

${this.port.t('gs.align_where', { s: d.where })}

` : nothing} ${r.rotated ? html`

${this.port.t('gs.align_turned', { n: String(r.rotated) })}

` : nothing} ${r.wallSegmentsMigrated ? html`

${this.port.t( 'gs.wall_segments_migrated', { n: String(r.wallSegmentsMigrated) }, )}

` : nothing} ${r.roomDraftsMigrated || r.roomDraftSegmentsMigrated ? html`

${this.port.t('gs.room_drafts_migrated', { drafts: String(r.roomDraftsMigrated), segments: String(r.roomDraftSegmentsMigrated), })}

` : nothing} ${r.legacyZeroWallsMigrated ? html`

${this.port.t( 'gs.zero_walls_migrated', { n: String(r.legacyZeroWallsMigrated) }, )}

` : nothing} ${modelMaintenance ? html`

${this.port.t('gs.optimize_changes', { m: String(r.migrated), c: String(r.canonicalized), p: String(r.coordsCanonicalized), w: String(r.wallsMerged), s: String(r.spansMerged), i: String(r.partitionsMerged), })}

` : nothing} ${r.partitionsReconciled ? html`

${this.port.t( 'gs.optimize_coincident_partitions', { n: String(r.partitionsReconciled) }, )}

` : nothing} ${r.openingsRehosted ? html`

${this.port.t( 'gs.optimize_openings_rehosted', { n: String(r.openingsRehosted) }, )}

` : nothing} ${r.wallsStraightened ? html`

${this.port.t( 'gs.optimize_walls_straightened', { n: String(r.wallsStraightened), cm: String(straightenCm), }, )}

` : nothing} ${straightenWhere ? html`

${this.port.t( 'gs.optimize_walls_straightened_where', { s: straightenWhere }, )}

` : nothing} ${r.glowSpacesMigrated || r.glowRoomsMigrated ? html`

${this.port.t('gs.optimize_glow_migration', { spaces: String(r.glowSpacesMigrated), rooms: String(r.glowRoomsMigrated), })}

` : nothing} ${gridWarning ? html`
${this.port.t('gs.align_warn')}
` : nothing}`} ${!failed && r.wallsStraightenSkipped ? html`

${this.port.t( 'gs.optimize_walls_straighten_skipped', { n: String(r.wallsStraightenSkipped), }, )}

` : nothing} ${repaired ? html`

${this.port.t('gs.optimize_references', { spaces: String(r.spaceRefsRemapped), rooms: String(r.roomRefsRemapped), positions: String(r.positionsRemapped), detached: String(r.markersDetached), })}

` : nothing} ${removed ? html`

${this.port.t('gs.optimize_orphans_removed', { total: String(removed), rooms: String(r.orphanRoomLabelsRemoved), devices: String(r.orphanDevicePositionsRemoved), groups: String(r.orphanGroupPositionsRemoved), })}

` : nothing} ${r.liveMissingPositions.length ? html`

${this.port.t(d.removeLiveMissingPositions ? 'gs.optimize_live_positions_remove' : 'gs.optimize_live_positions', { n: String(r.liveMissingPositions.length), names: liveNamesText, })}

${d.removeLiveMissingPositions ? html`
${this.port.t('gs.optimize_live_selected')}
` : nothing}
` : nothing} ${r.unverifiedPositions.length ? html`` : nothing} ${r.nestedRefsUnresolved ? html`` : nothing} ${referenceDetails.length ? html`
${this.port.t('gs.optimize_details')}
    ${visibleDetails.map((item) => html`
  • ${this.port.t('gs.optimize_detail_item', { status: detailStatus(item), kind: detailKind(item.kind), id: item.id, space: item.spaceId, })}
  • `)}
${remainingDetails ? html`
${this.port.t('gs.optimize_details_more', { n: String(remainingDetails), })}
` : nothing}
` : nothing}
${!d.changed || !d.preflight?.ok ? nothing : html` `}
`; } }