mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 21:58:56 +00:00
Stage 5 of #780. - Import summary: «Strips left unbound after import: {n}» from the backend `unbound_led_strips` count; «Optimize plans» reports strips passing through walls per space and edits none (AC16). - Linear field for long strips (ТЗ §13.2): pieces of at most the radius along the polyline, emitters thinned to r/4, each piece clipped to the visibility fans of its own emitters as separate clipPath children (no boolean pass per piece), one floor clip for the whole layer, no fan at all where nothing blocks within the radius; a grid index of body faces and boxed inside tests; unchanged fields skip re-diffing. 50×50 on the large house: first stable frame ~1.4 s, warm space ~1.1 s locally. - led-strips-v1 profile: demo/benchmark_led_strips.mjs with the derived large-house fixture (10×5, 50×50, none), absolute limits of the ТЗ table in demo/performance/budgets-led-strips.json, exact counters (zero recomputes on HA ticks/camera/colour, ≤50 cache entries, no growth over 20 cycles); added to the full performance workflow. - Bundle: LAZY_LED_GZIP_CEILING 10 KiB, LAZY_LED_EDITOR_GZIP_CEILING 11 KiB (measured + 10 %, rounded up); overlaps with the initial and editor graphs refused; the lazy editor graph stays inside its ceiling. - Smokes smoke_led_strip_draw/bind/glow, linked in smoke-links; 13 mutants in the registry (7 browser guards in the inventory); config field registry entry `spaces[].led_strips`. - Golden: five new scenes on the `golden-led` space of the visual fixture (`ledStrips` option, the designer's four strips on #868D94), matrix v71. - Docs: LIGHT, DEVICE-PRESENTATION, USER-GUIDE (en/ru), UX-MODES, ARCHITECTURE, ISOMETRIC, CONFIG-COMPATIBILITY, TOUCH-SUPPORT, demo/stand README, performance README; docs/design/led-strips with the unchanged designer archive, two paired frames and ACCEPTANCE.md; both changelogs. Issue: #780 User-Visible: yes
507 lines
26 KiB
JavaScript
507 lines
26 KiB
JavaScript
// #642: диалог «Оптимизировать планы» — исполнением через test-build, а не
|
||
// текстом монолита. Сюда же переехали 11 утверждений `i18n.test.mjs` о
|
||
// разметке и тосте диалога (AC3) и гварды восьми мутантов реестра (AC4).
|
||
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readFileSync } from 'node:fs';
|
||
import ts from 'typescript';
|
||
|
||
import {
|
||
OptimizePlansDialog, preflightDiagnostics, preflightVersionsDiffer,
|
||
} from '../test-build/optimize-plans-dialog.js';
|
||
import { formatLatticeShiftCm } from '../test-build/coordinate-canonicalization.js';
|
||
import { spacePhysicalGeometryFingerprint } from '../test-build/plan-geometry-preflight.js';
|
||
import { contentFingerprint } from '../test-build/visual-continuity.js';
|
||
|
||
const MODULE_SOURCE = readFileSync(new URL('../src/optimize-plans-dialog.ts', import.meta.url), 'utf8');
|
||
const BUILT_SOURCE = readFileSync(new URL('../test-build/optimize-plans-dialog.js', import.meta.url), 'utf8');
|
||
|
||
// ---------------------------------------------------------------- fixtures
|
||
|
||
const space = (id, title, x) => ({
|
||
id, title, view_box: [0, 0, 1, 1], cell_cm: 5,
|
||
rooms: [{ id: `${id}-room`, name: 'R', area: null, poly: [[0.1, 0.1], [x, 0.1], [x, 0.4], [0.1, 0.4]] }],
|
||
});
|
||
|
||
const report = (patch = {}) => ({
|
||
moved: 0, total: 0, rotated: 0, maxShift: 0, maxShiftCm: 0, maxSpace: '',
|
||
modelFrom: 10, modelTo: 10, migrated: 0, glowSpacesMigrated: 0, glowRoomsMigrated: 0,
|
||
canonicalized: 0, wallSegmentsMigrated: 0, roomDraftsMigrated: 0, roomDraftSegmentsMigrated: 0,
|
||
legacyZeroWallsMigrated: 0, wallsMerged: 0, spansMerged: 0, partitionsMerged: 0,
|
||
partitionsReconciled: 0, openingsRehosted: 0, wallsStraightened: 0, wallsStraightenSkipped: 0,
|
||
maxStraightenShiftCm: 0, maxStraightenSpace: '', coordsCanonicalized: 0,
|
||
latticeCoordinatesCanonicalized: 0, latticeCoordinatesFar: 0, latticeMaxShift: 0,
|
||
latticeMaxShiftCm: 0, latticeSpaces: [],
|
||
spaceRefsRemapped: 0, roomRefsRemapped: 0, positionsRemapped: 0, markersDetached: 0,
|
||
orphanRoomLabelsRemoved: 0, orphanDevicePositionsRemoved: 0, orphanGroupPositionsRemoved: 0,
|
||
liveMissingPositionsRemoved: 0, nestedRefsUnresolved: 0,
|
||
removedPositions: [], liveMissingPositions: [], unverifiedPositions: [],
|
||
...patch,
|
||
});
|
||
|
||
const okPreflight = (config) => ({
|
||
fingerprint: contentFingerprint(config), spaces: [], failures: [], ok: true,
|
||
});
|
||
|
||
const failedPreflight = (fingerprint = 'fp-1') => ({
|
||
fingerprint, spaces: [], ok: false,
|
||
failures: [
|
||
{ spaceId: 'a', displayName: 'Alpha', status: 'failed', reason: 'wall-exception', detail: 'Error' },
|
||
{ spaceId: 'b2', displayName: 'Second', status: 'failed', reason: 'floor-null' },
|
||
],
|
||
});
|
||
|
||
const dialogOf = (patch = {}) => {
|
||
const config = patch.config ?? { spaces: [space('a', 'Alpha', 0.5)], markers: [], settings: {} };
|
||
return {
|
||
report: report(), config, layout: {}, preflight: okPreflight(config),
|
||
cm: 0, where: '', changed: true, busy: false, removeLiveMissingPositions: false,
|
||
...patch,
|
||
};
|
||
};
|
||
|
||
/** Фейковый порт: ровно члены `OptimizePlansDialogPort`, каждый считает вызовы. */
|
||
function fakePort(overrides = {}) {
|
||
const calls = {
|
||
commit: [], checkGeometry: [], toasts: [], requestUpdate: 0, migration: [],
|
||
gesture: 0, reload: 0, referenceContext: [],
|
||
};
|
||
const state = {
|
||
dialog: null,
|
||
config: { spaces: [space('a', 'Alpha', 0.5)], markers: [], settings: {} },
|
||
integration: null,
|
||
};
|
||
const port = {
|
||
dialog: () => state.dialog,
|
||
setDialog: (next) => { state.dialog = next; },
|
||
t: (key, vars) => (vars ? `${key}${JSON.stringify(vars)}` : key),
|
||
hass: () => ({}),
|
||
config: () => state.config,
|
||
planReady: () => true,
|
||
layout: () => ({}),
|
||
integrationVersion: () => state.integration,
|
||
cardVersion: () => '9.9.9',
|
||
requestUpdate: () => { calls.requestUpdate += 1; },
|
||
showToast: (message) => { calls.toasts.push(message); },
|
||
errorText: (error) => String(error?.message ?? error),
|
||
checkGeometry: (config) => { calls.checkGeometry.push(config); return okPreflight(config); },
|
||
referenceContext: (removeLive) => { calls.referenceContext.push(removeLive); return { removeLiveMissingPositions: removeLive }; },
|
||
showMigrationBlocked: (error) => { calls.migration.push(error); },
|
||
clearGeometryGesture: () => { calls.gesture += 1; },
|
||
commit: async (config, layout) => { calls.commit.push([config, layout]); },
|
||
reloadAfterConflict: async () => { calls.reload += 1; },
|
||
// #780: strips through walls come from the lazy LED tool; none in this fixture.
|
||
ledNote: () => "",
|
||
...overrides,
|
||
};
|
||
return { port, calls, state, dialog: new OptimizePlansDialog(port) };
|
||
}
|
||
|
||
// ------------------------------------------------------ TemplateResult helpers
|
||
|
||
const isTemplate = (value) => !!value && typeof value === 'object'
|
||
&& Array.isArray(value.strings) && Array.isArray(value.values);
|
||
|
||
/** Сплющенная разметка: строки шаблона и значения, `nothing` — пусто. */
|
||
function flatten(value) {
|
||
if (value == null || value === false || typeof value === 'symbol' || typeof value === 'function') return '';
|
||
if (Array.isArray(value)) return value.map(flatten).join('');
|
||
if (isTemplate(value)) {
|
||
return value.strings.reduce((out, part, index) => (
|
||
out + part + (index < value.values.length ? flatten(value.values[index]) : '')
|
||
), '');
|
||
}
|
||
return String(value);
|
||
}
|
||
|
||
/** Обработчики `@event=${fn}` вместе с текстом перед ними — чтобы найти кнопку. */
|
||
function handlers(value, out = []) {
|
||
if (Array.isArray(value)) { value.forEach((item) => handlers(item, out)); return out; }
|
||
if (!isTemplate(value)) return out;
|
||
value.values.forEach((item, index) => {
|
||
const match = /@([\w-]+)=$/.exec(value.strings[index]);
|
||
// Текст от начала тега до обработчика: по нему кнопка и находится.
|
||
const before = value.strings.slice(0, index + 1).join('');
|
||
const tag = before.slice(before.lastIndexOf('<'));
|
||
if (match && typeof item === 'function') out.push({ event: match[1], before: tag, fn: item });
|
||
else handlers(item, out);
|
||
});
|
||
return out;
|
||
}
|
||
|
||
const handlerFor = (template, event, marker) => handlers(template)
|
||
.find((item) => item.event === event && (!marker || item.before.includes(marker)))?.fn;
|
||
|
||
/**
|
||
* `navigator` в Node — геттер без сеттера, `t.mock.property` его не
|
||
* восстанавливает. Подменяем дескриптор и возвращаем исходный сами.
|
||
*/
|
||
function withClipboard(t, clipboard) {
|
||
const original = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
|
||
Object.defineProperty(globalThis, 'navigator', { value: { clipboard }, configurable: true });
|
||
t.after(() => {
|
||
if (original) Object.defineProperty(globalThis, 'navigator', original);
|
||
else delete globalThis.navigator;
|
||
});
|
||
}
|
||
|
||
// ------------------------------------------------------------- pure helpers
|
||
|
||
test('#642 preflightDiagnostics: candidate hash, reason, detail, version, fixed clock', () => {
|
||
const candidate = { spaces: [space('a', 'Alpha', 0.6)] };
|
||
const now = new Date('2026-09-24T10:00:00.000Z');
|
||
const block = preflightDiagnostics(failedPreflight(), candidate, { cardVersion: '1.2.3', now });
|
||
assert.equal(block.kind, 'houseplan-optimize-preflight');
|
||
assert.equal(block.origin, 'runtime');
|
||
assert.equal(block.cardVersion, '1.2.3');
|
||
assert.equal(block.checkedAt, '2026-09-24T10:00:00.000Z');
|
||
assert.equal(block.preflightFingerprint, 'fp-1');
|
||
assert.deepEqual(block.failures.map((failure) => [failure.spaceId, failure.reason, failure.detail]), [
|
||
['a', 'wall-exception', 'Error'], ['b2', 'floor-null', null],
|
||
]);
|
||
assert.equal(block.failures[0].spaceGeometryFingerprint, spacePhysicalGeometryFingerprint(candidate.spaces[0]));
|
||
assert.equal(block.failures[1].spaceGeometryFingerprint, null, 'a space missing from the candidate has no hash');
|
||
// Смок передаёт кандидата не всегда; блок всё равно собирается.
|
||
assert.equal(preflightDiagnostics(failedPreflight(), undefined, { cardVersion: 'x', now }).failures[0]
|
||
.spaceGeometryFingerprint, null);
|
||
});
|
||
|
||
test('#642 preflightVersionsDiffer: only a known, different integration version advises an update', () => {
|
||
assert.equal(preflightVersionsDiffer(null, '1.0.0'), false);
|
||
assert.equal(preflightVersionsDiffer('', '1.0.0'), false);
|
||
assert.equal(preflightVersionsDiffer('1.0.0', '1.0.0'), false);
|
||
assert.equal(preflightVersionsDiffer('0.0.1-other', '1.0.0'), true);
|
||
});
|
||
|
||
// ------------------------------------------------------------------ preview
|
||
|
||
test('#642 preview: a failed migration reports once and opens nothing', () => {
|
||
const boom = new Error('wall model');
|
||
const { dialog, calls, state } = fakePort({ referenceContext: () => { throw boom; } });
|
||
dialog.preview(false);
|
||
assert.deepEqual(calls.migration, [boom]);
|
||
assert.equal(state.dialog, null);
|
||
});
|
||
|
||
test('#642 preview: nothing without a plan or a saved config', () => {
|
||
const noPlan = fakePort({ planReady: () => false });
|
||
noPlan.dialog.open();
|
||
assert.equal(noPlan.state.dialog, null);
|
||
const noConfig = fakePort();
|
||
noConfig.state.config = null;
|
||
noConfig.dialog.open();
|
||
assert.equal(noConfig.state.dialog, null);
|
||
assert.deepEqual(noConfig.calls.referenceContext, []);
|
||
});
|
||
|
||
test('#642 preview: cm is rounded UP, the space is named only when there are several', () => {
|
||
const two = fakePort();
|
||
two.state.config = { spaces: [space('a', 'Alpha', 0.5), space('b', 'Beta', 0.50337)], markers: [], settings: {} };
|
||
two.dialog.preview(false);
|
||
const d = two.state.dialog;
|
||
assert.equal(d.changed, true);
|
||
assert.equal(d.report.maxSpace, 'b');
|
||
assert.equal(d.cm, Math.ceil(d.report.maxShiftCm * 10) / 10);
|
||
assert.ok(d.cm >= d.report.maxShiftCm, 'the promise is never smaller than the deed');
|
||
assert.equal(d.where, 'Beta');
|
||
assert.equal(d.busy, false);
|
||
assert.equal(d.removeLiveMissingPositions, false);
|
||
assert.deepEqual(two.calls.checkGeometry, [d.config], 'a changed candidate is preflighted');
|
||
assert.deepEqual(two.calls.referenceContext, [false]);
|
||
|
||
const one = fakePort();
|
||
one.state.config = { spaces: [space('b', 'Beta', 0.50337)], markers: [], settings: {} };
|
||
one.dialog.preview(false);
|
||
assert.equal(one.state.dialog.where, '');
|
||
});
|
||
|
||
test('#642 toggleLivePositions re-previews with the flag flipped, only when live positions exist', () => {
|
||
const { dialog, state, calls } = fakePort();
|
||
state.dialog = dialogOf();
|
||
dialog.toggleLivePositions();
|
||
assert.deepEqual(calls.referenceContext, [], 'no live-missing positions — nothing to toggle');
|
||
state.dialog = dialogOf({ report: report({ liveMissingPositions: [{ id: 'm', name: 'Lamp', kind: 'device', spaceId: 'a' }] }) });
|
||
dialog.toggleLivePositions();
|
||
assert.deepEqual(calls.referenceContext, [true]);
|
||
assert.equal(state.dialog.removeLiveMissingPositions, true);
|
||
});
|
||
|
||
// --------------------------------------------------------------------- run
|
||
|
||
test('#642 open: preview never writes, and cancel closes without a write', async () => {
|
||
const { dialog, state, calls } = fakePort();
|
||
state.config = { spaces: [space('a', 'Alpha', 0.5), space('b', 'Beta', 0.50337)], markers: [], settings: {} };
|
||
dialog.open();
|
||
await Promise.resolve();
|
||
assert.ok(state.dialog?.changed && state.dialog.preflight?.ok, 'a writable preview is open');
|
||
assert.deepEqual(calls.commit, [], 'opening the preview must not persist anything');
|
||
handlerFor(dialog.render(), 'click', 'data-hp="dialog-cancel"')();
|
||
assert.equal(state.dialog, null);
|
||
assert.deepEqual(calls.commit, []);
|
||
});
|
||
|
||
test('#642 run: red preflight is a hard write barrier', async () => {
|
||
const { dialog, state, calls } = fakePort();
|
||
state.dialog = dialogOf({ preflight: failedPreflight() });
|
||
await dialog.run();
|
||
assert.deepEqual(calls.commit, []);
|
||
assert.equal(calls.gesture, 0);
|
||
state.dialog = dialogOf({ changed: false });
|
||
await dialog.run();
|
||
state.dialog = dialogOf({ busy: true });
|
||
await dialog.run();
|
||
assert.deepEqual(calls.commit, [], 'unchanged or busy dialogs do not write either');
|
||
});
|
||
|
||
test('#642 run: a stale fingerprint is rechecked, and a red recheck stops the write', async () => {
|
||
const red = failedPreflight('fresh');
|
||
const { dialog, state, calls } = fakePort({
|
||
checkGeometry: (config) => { calls.checkGeometry.push(config); return red; },
|
||
});
|
||
const warns = [];
|
||
const original = console.warn;
|
||
console.warn = (...args) => { warns.push(args); };
|
||
try {
|
||
state.dialog = dialogOf({ preflight: { ...okPreflight({}), fingerprint: 'stale' } });
|
||
const candidate = state.dialog.config;
|
||
await dialog.run();
|
||
assert.deepEqual(calls.checkGeometry, [candidate]);
|
||
assert.equal(state.dialog.preflight, red, 'the dialog now shows the fresh refusal');
|
||
assert.deepEqual(calls.commit, []);
|
||
assert.equal(warns.length, 1, 'the fresh refusal is dev-logged');
|
||
} finally {
|
||
console.warn = original;
|
||
}
|
||
});
|
||
|
||
test('#642 run: success writes the exact pair, closes, and toasts counts including lattice cleanup', async () => {
|
||
const seen = [];
|
||
const { dialog, state, calls } = fakePort({
|
||
commit: async (config, layout) => { seen.push(state.dialog?.busy); calls.commit.push([config, layout]); },
|
||
});
|
||
state.dialog = dialogOf({ report: report({ moved: 2, latticeCoordinatesCanonicalized: 7, orphanRoomLabelsRemoved: 1 }) });
|
||
const { config, layout } = state.dialog;
|
||
await dialog.run();
|
||
assert.equal(calls.gesture, 1);
|
||
assert.deepEqual(seen, [true], 'busy while the write is in flight');
|
||
assert.equal(calls.commit.length, 1);
|
||
assert.equal(calls.commit[0][0], config);
|
||
assert.equal(calls.commit[0][1], layout);
|
||
assert.deepEqual(calls.checkGeometry, [], 'an unchanged fingerprint is not rechecked');
|
||
assert.equal(state.dialog, null);
|
||
assert.deepEqual(calls.toasts, ['gs.align_done{"n":"2","m":"7","r":"1"}']);
|
||
});
|
||
|
||
test('#642 run: conflict reloads both stores; outdated client gets its own toast', async () => {
|
||
const conflict = fakePort({ commit: async () => { throw Object.assign(new Error('c'), { code: 'conflict' }); } });
|
||
conflict.state.dialog = dialogOf();
|
||
await conflict.dialog.run();
|
||
assert.equal(conflict.calls.reload, 1);
|
||
assert.equal(conflict.state.dialog.busy, false, 'the dialog stays open and usable');
|
||
assert.deepEqual(conflict.calls.toasts, ['toast.error{"err":"c"}']);
|
||
|
||
const outdated = fakePort({ commit: async () => { throw { code: 'wall_model_client_outdated' }; } });
|
||
outdated.state.dialog = dialogOf();
|
||
await outdated.dialog.run();
|
||
assert.equal(outdated.calls.reload, 0);
|
||
assert.deepEqual(outdated.calls.toasts, ['toast.wall_model_client_outdated']);
|
||
});
|
||
|
||
// -------------------------------------------------------------- diagnostics
|
||
|
||
test('#642 copyDiagnostics hashes the candidate, not the saved config', async (t) => {
|
||
const { dialog, state, calls } = fakePort();
|
||
const candidate = { spaces: [space('a', 'Alpha', 0.6)], markers: [], settings: {} };
|
||
state.config = { spaces: [space('a', 'Alpha', 0.5)], markers: [], settings: {} };
|
||
state.dialog = dialogOf({ config: candidate, preflight: failedPreflight() });
|
||
let copied = null;
|
||
withClipboard(t, { writeText: async (text) => { copied = text; } });
|
||
await dialog.copyDiagnostics();
|
||
const block = JSON.parse(copied);
|
||
assert.equal(block.failures[0].spaceGeometryFingerprint, spacePhysicalGeometryFingerprint(candidate.spaces[0]));
|
||
assert.notEqual(block.failures[0].spaceGeometryFingerprint, spacePhysicalGeometryFingerprint(state.config.spaces[0]));
|
||
assert.equal(block.cardVersion, '9.9.9');
|
||
assert.deepEqual(calls.toasts, ['gs.preflight_copied']);
|
||
assert.equal(dialog.clipboardFallback, null);
|
||
});
|
||
|
||
test('#642 copyDiagnostics: the inline fallback belongs to one dialog showing', async (t) => {
|
||
const { dialog, state, calls } = fakePort();
|
||
withClipboard(t, { writeText: async () => { throw new Error('denied'); } });
|
||
state.dialog = dialogOf({ preflight: failedPreflight() });
|
||
await dialog.copyDiagnostics();
|
||
assert.match(dialog.clipboardFallback, /houseplan-optimize-preflight/);
|
||
assert.equal(calls.requestUpdate, 1, 'the fallback is not @state — the module asks for the render');
|
||
assert.match(flatten(dialog.render()), /<pre[^>]*>\{\n {2}"kind": "houseplan-optimize-preflight"/);
|
||
// Закрытие настоящим обработчиком hp-close.
|
||
handlerFor(dialog.render(), 'hp-close')();
|
||
assert.equal(state.dialog, null);
|
||
assert.equal(dialog.clipboardFallback, null);
|
||
// Новый отказ — новый объект диалога — без JSON прошлого отказа.
|
||
state.dialog = dialogOf({ preflight: failedPreflight('fp-2') });
|
||
assert.equal(dialog.clipboardFallback, null);
|
||
assert.doesNotMatch(flatten(dialog.render()), /<pre/);
|
||
});
|
||
|
||
test('#642 copyDiagnostics does nothing for a green or missing preflight', async (t) => {
|
||
const { dialog, state, calls } = fakePort();
|
||
let writes = 0;
|
||
withClipboard(t, { writeText: async () => { writes += 1; } });
|
||
await dialog.copyDiagnostics();
|
||
state.dialog = dialogOf();
|
||
await dialog.copyDiagnostics();
|
||
assert.equal(writes, 0);
|
||
assert.deepEqual(calls.toasts, []);
|
||
});
|
||
|
||
test('#642 reportPreflightFailure logs one structured record per distinct fingerprint', () => {
|
||
const { dialog } = fakePort();
|
||
const warns = [];
|
||
const original = console.warn;
|
||
console.warn = (...args) => { warns.push(args); };
|
||
try {
|
||
dialog.reportPreflightFailure(failedPreflight('fp-1'), null);
|
||
dialog.reportPreflightFailure(failedPreflight('fp-1'), null);
|
||
dialog.reportPreflightFailure({ ...okPreflight({}), fingerprint: 'fp-ok' }, null);
|
||
assert.equal(warns.length, 1, 'dedup: the same fingerprint logs once, a green one never');
|
||
assert.equal(warns[0][0], '[houseplan] optimize preflight failed');
|
||
assert.equal(warns[0][1].kind, 'houseplan-optimize-preflight');
|
||
assert.equal(warns[0][1].failures[0].reason, 'wall-exception');
|
||
dialog.reportPreflightFailure(failedPreflight('fp-2'), null);
|
||
assert.equal(warns.length, 2);
|
||
} finally {
|
||
console.warn = original;
|
||
}
|
||
});
|
||
|
||
// ------------------------------------------------------------------ render
|
||
|
||
test('#642 render: changes — every counter, lattice lines and orphan report (i18n.test AC3)', () => {
|
||
const { dialog, state } = fakePort();
|
||
state.config = { spaces: [space('a', 'Alpha', 0.5), space('b', 'Beta', 0.5)], markers: [], settings: {} };
|
||
const r = report({
|
||
moved: 2, total: 9, coordsCanonicalized: 3, partitionsMerged: 5, migrated: 1, canonicalized: 4,
|
||
wallsMerged: 6, spansMerged: 8, partitionsReconciled: 2, openingsRehosted: 1,
|
||
legacyZeroWallsMigrated: 1, latticeCoordinatesCanonicalized: 4, latticeMaxShiftCm: 0.0123,
|
||
latticeSpaces: [{ space: 'Alpha', canonicalized: 4, far: 1 }],
|
||
orphanRoomLabelsRemoved: 1, orphanDevicePositionsRemoved: 2, orphanGroupPositionsRemoved: 0,
|
||
removedPositions: [{ id: 'dev1', kind: 'device', spaceId: 'gone' }],
|
||
wallsStraightened: 1, maxStraightenShiftCm: 1.23, maxStraightenSpace: 'b',
|
||
});
|
||
state.dialog = dialogOf({ report: r, cm: 1.5, where: 'Beta' });
|
||
const text = flatten(dialog.render());
|
||
assert.match(text, /gs\.align_count\{"n":"2","total":"9","cm":"1.5"\}/);
|
||
assert.match(text, /gs\.align_where\{"s":"Beta"\}/);
|
||
assert.match(text, /gs\.zero_walls_migrated\{"n":"1"\}/);
|
||
// p — шум координат, i — независимые стены (#229): счётчики показываются, а не копятся молча.
|
||
assert.match(text, /gs\.optimize_changes\{"m":"1","c":"4","p":"3","w":"6","s":"8","i":"5"\}/);
|
||
assert.match(text, /gs\.optimize_coincident_partitions\{"n":"2"\}/);
|
||
assert.match(text, /gs\.optimize_openings_rehosted\{"n":"1"\}/);
|
||
assert.ok(text.includes(`gs.optimize_lattice_summary{"n":"4","cm":"${formatLatticeShiftCm(0.0123)}"}`));
|
||
assert.match(text, /gs\.optimize_lattice_space\{"space":"Alpha","n":"4","far":"1"\}/);
|
||
assert.match(text, /gs\.optimize_walls_straightened\{"n":"1","cm":"1.3"\}/);
|
||
assert.match(text, /gs\.optimize_walls_straightened_where\{"s":"Beta"\}/);
|
||
assert.match(text, /gs\.optimize_orphans_removed\{"total":"3","rooms":"1","devices":"2","groups":"0"\}/);
|
||
assert.match(text, /<details class="optimize-details">/);
|
||
assert.match(text, /gs\.align_warn/);
|
||
assert.doesNotMatch(text, /gs\.optimize_reference_warning/);
|
||
assert.match(text, /data-hp="dialog-confirm"/);
|
||
});
|
||
|
||
test('#642 render: failed preflight names each reason and offers no Apply', () => {
|
||
const { dialog, state } = fakePort();
|
||
state.dialog = dialogOf({ preflight: failedPreflight() });
|
||
const text = flatten(dialog.render());
|
||
assert.match(text, /gs\.align_preflight_failed\{"spaces":"Alpha, Second","more":""\}/);
|
||
assert.match(text, /Alpha: gs\.preflight_reason_wall-exception/);
|
||
assert.match(text, /Second: gs\.preflight_reason_floor-null/);
|
||
assert.match(text, /gs\.preflight_copy/);
|
||
assert.doesNotMatch(text, /data-hp="dialog-confirm"/, 'the failure state is not a dismissible warning');
|
||
assert.doesNotMatch(text, /gs\.preflight_update_hint/);
|
||
state.integration = '0.0.1-other';
|
||
assert.match(flatten(dialog.render()), /gs\.preflight_update_hint/);
|
||
});
|
||
|
||
test('#642 render: nothing to do — plain «none» or «no automatic changes», never Apply', () => {
|
||
const { dialog, state } = fakePort();
|
||
state.dialog = dialogOf({ changed: false, preflight: null });
|
||
let text = flatten(dialog.render());
|
||
assert.match(text, /gs\.align_none/);
|
||
assert.doesNotMatch(text, /data-hp="dialog-confirm"/);
|
||
state.dialog = dialogOf({ changed: false, preflight: null, report: report({ nestedRefsUnresolved: 2 }) });
|
||
text = flatten(dialog.render());
|
||
assert.match(text, /gs\.optimize_no_automatic_changes/);
|
||
assert.match(text, /gs\.optimize_vacuum_warning\{"n":"2"\}/);
|
||
});
|
||
|
||
test('#642 render: live positions — names, toggle state and the selected hint', () => {
|
||
const { dialog, state, calls } = fakePort();
|
||
const live = ['A', 'B', 'C', 'D'].map((name, index) => ({ id: `m${index}`, name, kind: 'device', spaceId: 'a' }));
|
||
state.dialog = dialogOf({ report: report({ liveMissingPositions: live }) });
|
||
let text = flatten(dialog.render());
|
||
assert.ok(text.includes('gs.optimize_live_positions{"n":"4","names":"gs.optimize_live_names{\\"names\\":\\"A, B, C\\",\\"more\\":\\"gs.optimize_reference_more{\\\\\\"n\\\\\\":\\\\\\"1\\\\\\"}\\"}"}'));
|
||
assert.match(text, /aria-pressed=false/);
|
||
assert.match(text, /gs\.optimize_live_remove/);
|
||
assert.doesNotMatch(text, /gs\.optimize_live_selected/);
|
||
state.dialog = { ...state.dialog, removeLiveMissingPositions: true };
|
||
text = flatten(dialog.render());
|
||
assert.match(text, /gs\.optimize_live_positions_remove/);
|
||
assert.match(text, /aria-pressed=true/);
|
||
assert.match(text, /gs\.optimize_live_keep/);
|
||
assert.match(text, /gs\.optimize_live_selected/);
|
||
handlerFor(dialog.render(), 'click', 'optimize-cleanup')();
|
||
assert.deepEqual(calls.referenceContext, [false], 'the toggle re-previews with the flag flipped back');
|
||
});
|
||
|
||
test('#642 render: details list at most ten items, statuses by kind, and the remainder', () => {
|
||
const { dialog, state } = fakePort();
|
||
const removed = Array.from({ length: 8 }, (_, index) => ({ id: `r${index}`, kind: 'room_label', spaceId: 'x' }));
|
||
const unverified = Array.from({ length: 4 }, (_, index) => ({
|
||
id: `u${index}`, kind: index ? 'group' : 'mystery', spaceId: 'y', reason: 'registry_unavailable',
|
||
}));
|
||
state.dialog = dialogOf({ report: report({ removedPositions: removed, unverifiedPositions: unverified }) });
|
||
const text = flatten(dialog.render());
|
||
assert.equal(text.split('<li>').length - 1, 10);
|
||
assert.match(text, /gs\.optimize_detail_item\{"status":"gs\.optimize_detail_removed","kind":"gs\.optimize_detail_room_label","id":"r0","space":"x"\}/);
|
||
assert.match(text, /gs\.optimize_detail_item\{"status":"gs\.optimize_detail_unverified","kind":"gs\.optimize_detail_unknown","id":"u0","space":"y"\}/);
|
||
assert.match(text, /gs\.optimize_details_more\{"n":"2"\}/);
|
||
assert.match(text, /gs\.optimize_unverified\{"n":"4"\}/);
|
||
assert.match(text, /gs\.optimize_registry_limited/);
|
||
});
|
||
|
||
// ------------------------------------------------------------ AC2: the port
|
||
|
||
const parsedModule = ts.createSourceFile('optimize-plans-dialog.ts', MODULE_SOURCE, ts.ScriptTarget.Latest, true);
|
||
const portMembers = () => {
|
||
const port = parsedModule.statements.find((statement) => ts.isInterfaceDeclaration(statement)
|
||
&& statement.name.text === 'OptimizePlansDialogPort');
|
||
assert.ok(port, 'the module declares its own port');
|
||
return port.members.map((member) => member.name.getText(parsedModule)).sort();
|
||
};
|
||
|
||
test('#642 AC2: the dialog port is narrow and the module never reaches the editor host', () => {
|
||
// Импорты — и типовые тоже: `import type` стирается в сборке, поэтому
|
||
// разбирается исходник модуля, а собранный файл проверяется отдельно.
|
||
const specifiers = parsedModule.statements
|
||
.filter((statement) => ts.isImportDeclaration(statement))
|
||
.map((statement) => statement.moduleSpecifier.text);
|
||
const host = /\/houseplan-(?:card|editor-runtime)$/;
|
||
assert.deepEqual(specifiers.filter((specifier) => host.test(specifier)), []);
|
||
const identifiers = new Set();
|
||
const walk = (node) => {
|
||
if (ts.isIdentifier(node)) identifiers.add(node.text);
|
||
ts.forEachChild(node, walk);
|
||
};
|
||
walk(parsedModule);
|
||
assert.equal(identifiers.has('HouseplanEditorHostPort'), false, 'the wide editor port is not referenced');
|
||
assert.equal(identifiers.has('host'), false, 'no `host` field or parameter — only the narrow port');
|
||
assert.doesNotMatch(BUILT_SOURCE, /houseplan-(?:card|editor-runtime)\.js/);
|
||
|
||
const members = portMembers();
|
||
assert.ok(members.length <= 20, `port has ${members.length} members`);
|
||
const { port, dialog } = fakePort();
|
||
assert.ok(Object.keys(port).length <= 20);
|
||
assert.deepEqual(Object.keys(port).sort(), members, 'the fake port is exactly the declared port');
|
||
assert.equal('host' in dialog, false);
|
||
});
|