",
replace: "
",
}],
},
{
id: 'marker-badge-position-forgets-touch',
guard: 'node demo/smoke_device_settings_form.mjs',
because: '#600 К1: сегмент стороны бейджа заменил select и обязан, как и он, ставить '
+ 'valueBadgeTouched — иначе выбор человека проигрывает проекции превью при следующем '
+ 'рендере и в конфиг не попадает',
patches: [{
file: 'src/editors/marker-dialog.ts',
find: " valueBadgePosition: position,\n valueBadgeTouched: true,",
replace: " valueBadgePosition: position,\n valueBadgeTouched: false,",
}],
},
{
id: 'form-kit-segment-breaks-words',
guard: 'node demo/smoke_dialog_segments_i18n.mjs',
because: '#600 ревью r1 M1: пять равных колонок сегмента с overflow-wrap: anywhere рвали '
+ '«Temperature» посреди слова, и ни один смок этого не видел — scrollWidth контейнера не '
+ 'рос. Колонка не сжимается ниже самого длинного слова, слова переносятся только по '
+ 'пробелам; вернуть прежние две строки — вернуть дефект в четырёх локалях',
patches: [{
file: 'src/styles/form-kit.styles.ts',
find: ' min-width: fit-content;\n min-height: 38px;\n flex: 1 1 0;',
replace: ' min-height: 38px;\n flex: 1 1 0;',
}, {
file: 'src/styles/form-kit.styles.ts',
find: ' text-align: center;\n overflow-wrap: normal;\n }',
replace: ' text-align: center;\n overflow-wrap: anywhere;\n }',
}],
},
{
id: 'form-kit-segment-drops-radio-semantics',
guard: 'node --test test/form-kit.test.mjs',
because: '#594 К7: сегментированный переключатель заменил радиосписок, и вся его '
+ 'доступность держится на том, что под капотом осталась радиогруппа. Подмена на '
+ 'чекбоксы выглядит идентично и молча ломает стрелки и объявление скринридером',
patches: [{
file: 'src/editors/form-kit.ts',
// #600: у радиокнопки сегмента появился value — якорь дописан.
find: ' [id, fill && fill.opacity > 0 ? fill : glowBase.byId.get(id) ?? null])))',
replace: "memoIsoLightFloorRooms(null, new Map([...roomFills.byId].map(([id, fill]) => [id, fill && fill.opacity > 0 ? fill : glowBase.byId.get(id) ?? null])), parseCssColor(getComputedStyle(this.renderRoot.querySelector('.hp-paper') ?? this).fill) ?? [255, 255, 255]).rooms",
}],
},
{
id: 'iso-first-frame-reveals-flat-during-lazy-load',
guard: 'node demo/smoke_iso_first_frame.mjs',
because: '#654 AC1/AC3: removing the pending class exposes the effective Flat projection '
+ 'while the 2.5D chunk is still loading, most visibly in kiosk mode after the ordinary '
+ 'boot window has elapsed',
patches: [{
file: 'src/houseplan-card.ts',
find: "${isoFirstFramePending ? ' hpiso-pending' : ''}",
replace: "${isoFirstFramePending ? '' : ''}",
}],
},
{
id: 'iso-light-floor-memo-never-reuses',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#654 light-floor memo" test/iso-stage6.test.mjs',
because: '#654 AC4: HA state churn that leaves paper, fills and room membership unchanged '
+ 'must reuse the classification Set instead of rebuilding it on every card render',
patches: [{
file: 'src/iso-materials.ts',
find: ' return previous?.key === key',
replace: " return previous?.key === '__never__'",
}],
},
{
id: 'iso-paper-resolved-per-floor',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#654|#739" test/iso-stage6.test.mjs',
because: '#739 AC1: the 2.5D paper under a backdrop is the theme card background, a function of '
+ 'the theme and the card mode; keyed by the floor again, every floor switch resolves the '
+ 'computed colour after commit and renders the card twice',
patches: [{
file: 'src/iso-first-frame.ts',
find: ' mode, themes?.darkMode ?? null, themes?.default_theme ?? null,\n',
replace: ' space, mode, themes?.darkMode ?? null, themes?.default_theme ?? null,\n',
}],
},
{
id: 'shutdown-skips-deferred-store-flush',
guard: 'node scripts/backend-test-guard.mjs '
+ 'home_assistant_stop_flushes_pending_virtual_light_and_trail '
+ 'tests_backend/test_ha_virtual_lights.py',
because: '#655 AC1/AC2: the HA stop event must synchronously flush both deferred stores; '
+ 'otherwise the latest virtual-light toggle and vacuum-trail points disappear on restart',
patches: [{
file: 'custom_components/houseplan/__init__.py',
find: ' await _async_flush_runtime(hass, entry)\n\n stop_listener = [',
replace: ' return # mutant: shutdown drops both pending stores\n\n stop_listener = [',
}],
},
{
id: 'virtual-light-save-bypasses-ha-task-tracking',
guard: 'node scripts/backend-test-guard.mjs '
+ 'runtime_controller_coalesces_rapid_toggles '
+ 'tests_backend/test_virtual_lights.py',
because: '#655 AC4: HA must own the delayed writer so normal shutdown waits for it; a raw '
+ 'asyncio task can be cancelled before its 0.5 second debounce reaches durable storage',
patches: [{
file: 'custom_components/houseplan/virtual_lights.py',
find: ' self._save_task = self.hass.async_create_task(self._delayed_save())\n',
replace: ' self._save_task = asyncio.create_task(self._delayed_save())\n',
}],
},
{
id: 'virtual-light-concurrent-flushes-bypass-lock',
guard: 'node scripts/backend-test-guard.mjs '
+ 'concurrent_flushes_after_failed_delayed_save_write_once '
+ 'tests_backend/test_virtual_lights.py',
because: '#655 AC3 / review r1: stop and config transitions may flush the same dirty '
+ 'post-failure state concurrently; the lock must keep the Store write idempotent',
patches: [{
file: 'custom_components/houseplan/virtual_lights.py',
find: ' async with self._flush_lock:\n',
replace: ' if True: # mutant: concurrent flushes are not serialised\n',
}],
},
// r1 #695: инфраструктура без трековой метки — трек show (§5.1). С #707 трек и
// лимит guard берёт из process-track.mjs: якорь переехал туда, свидетель
// исполняет шаг guard настоящим bash.
{
id: 'guard-infra-keeps-ask-limit',
guard: 'node --test --test-name-pattern="r1 #695" test/review-doc-guard.test.mjs',
because: 'r1 #695: an infrastructure task without a track label reads as track:show (PROCESS §5.1); '
+ 'the guard must give it the show cycle limit 2, not the ask limit 4 (#707: one rule in process-track.mjs)',
patches: [{
file: 'scripts/process-track.mjs',
find: "(hasTrackLabel(labels) ? trackFromLabels(labels) : (infrastructure ? 'show' : 'ask'));",
replace: "(hasTrackLabel(labels) ? trackFromLabels(labels) : (infrastructure ? 'ask' : 'ask')); // mutant: infra keeps the ask limit",
}],
},
{
id: 'packet-infra-track-ignores-show-default',
guard: 'node --test --test-name-pattern="r1 #695" test/task-packet.test.mjs',
because: 'r1 #695: the packet names the track an infrastructure task actually runs on — show '
+ 'without a label, the owner label otherwise (#707: via trackOrigin, shared with the pipeline)',
patches: [{
file: 'scripts/task-packet.mjs',
find: ' const origin = trackOrigin({ labels, comments, owner, infrastructure: infrastructure || infrastructureHint });',
replace: ' const origin = trackOrigin({ labels, comments, owner, infrastructure: false }); // mutant: unlabelled infra reads as ask',
}],
},
// #696: цена захода по треку — мутанты, рамки ship, пакетное ревью перед бетой.
{
id: 'track-pays-for-mutants-again',
guard: 'node --test --test-name-pattern="#709" test/process-track.test.mjs',
because: '#709: no track and no label buys a mutant run during development — the 28-minute '
+ 'run on ask and ci:mutants was the cost the owner removed',
patches: [{
file: 'scripts/process-track.mjs',
find: ' const mutants = false;',
replace: " const mutants = track === 'ask' || labels.includes('ci:mutants'); // mutant",
}],
},
{
id: 'ship-limits-miss-new-src-file',
guard: 'node --test --test-name-pattern="рамки ship: новые файлы" test/process-track.test.mjs',
because: '#696: a new file in src/** is outside ship limits — ship merges without a model '
+ 'review, so the mechanical limits are the only thing standing between it and dev',
patches: [{
file: 'scripts/process-track.mjs',
find: ' if (added.length) out.push(',
replace: ' if (false && added.length) out.push(',
}],
},
{
id: 'ship-limit-off-by-one',
guard: 'node --test --test-name-pattern="граница включительна" test/process-track.test.mjs',
because: '#696: the ship limit is 30 src lines inclusive (owner decision); 30 lines still ship',
patches: [{
file: 'scripts/process-track.mjs',
find: ' if (lines > SHIP_SRC_LINE_LIMIT) out.push(',
replace: ' if (lines >= SHIP_SRC_LINE_LIMIT) out.push(',
}],
},
{
// #707: решение по ship на S7 переехало из bash шага трека в decideTrack.
id: 'pipeline-ship-ignores-limits',
guard: 'node --test --test-name-pattern="#707 AC3" test/process-track.test.mjs',
because: '#696: ship skips the model only inside the limits; ignoring the limits merges '
+ 'unread code of any size — even when the owner confirmed the ship track (#707)',
patches: [{
file: 'scripts/process-track.mjs',
find: ' if (violations.length || riskRaises) {',
replace: ' if (riskRaises) { // mutant: ship limits ignored',
}],
},
{
id: 'light-review-waits-running-push',
guard: 'node --test --test-name-pattern="#696" test/validate-gate.test.mjs',
because: '#696: a running push run cannot wake the round (process-resume listens to '
+ 'dispatch only); treating it as proof candidate parks the round forever',
patches: [{
file: 'scripts/validate-gate.mjs',
find: " return run?.event === 'workflow_dispatch' || (run?.event === 'push' && run?.status === 'completed');",
replace: " return run?.event === 'workflow_dispatch' || run?.event === 'push'; // mutant: running push counts",
}],
},
{
id: 'light-merge-dispatches-second-run',
guard: 'node --test --test-name-pattern="слияние ждёт push-прогон" test/merge-candidate.test.mjs',
because: '#696: the candidate push already starts the light Validate; a second dispatch '
+ 'doubles runner minutes on every show/ship merge',
patches: [{
file: 'scripts/merge-candidate.mjs',
find: " ({ result, url } = await ops.waitValidate(candidate, { event: 'push' }));",
replace: " ops.dispatchValidate(branch); ({ result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' })); // mutant: second run",
}],
},
{
id: 'ship-review-ignores-merge-marker',
guard: 'node --test --test-name-pattern="ship-задача — по маркеру" test/ship-review.test.mjs',
because: '#696: the pipeline marker proves a merge without model review even when the '
+ 'label was changed afterwards; missing it lets unread code into a beta',
patches: [{
file: 'scripts/ship-review.mjs',
find: " return names.includes('track:ship') || comments.some((c) => SHIP_MERGE_MARKER_RE.test(String(c?.body ?? '')));",
replace: " return names.includes('track:ship'); // mutant: marker ignored",
}],
},
{
id: 'ship-review-accepts-partial-coverage',
guard: 'node --test --test-name-pattern="машинный блок покрывает" test/ship-review.test.mjs',
because: '#696: a ship task merged after the batch review ran was read by nobody; the beta '
+ 'gate must refuse until the review is re-run',
patches: [{
file: 'scripts/ship-review.mjs',
find: ' if (missing.length) {',
replace: ' if (false && missing.length) {',
}],
},
{
id: 'ship-review-accepts-high',
guard: 'node --test --test-name-pattern="машинный блок покрывает" test/ship-review.test.mjs',
because: '#696: a High in the batch review is a defect a beta user would see; it blocks the beta',
patches: [{
file: 'scripts/ship-review.mjs',
find: ' else if (block.high > 0) problems.push(',
replace: ' else if (false && block.high > 0) problems.push(',
}],
},
// #697: производные артефакты — на dev, тяжёлое на ветке задачи — по меткам.
{
id: 'task-branch-release-trailer-heavy-again',
guard: 'node --test --test-name-pattern="#697" test/classify-changes.test.mjs',
because: '#697: on a task branch the Release: trailer only marks a baseline acceptance; '
+ 'it must not switch on smokes, golden and perf again — ci:full/ci:golden do that',
patches: [{
file: 'scripts/classify-changes.mjs',
find: ' if (isTaskBranch(refName)) return false;\n',
replace: '',
}],
},
{
id: 'ci-golden-label-does-not-order-full-set',
guard: 'node --test --test-name-pattern="#697" test/process-track.test.mjs',
because: '#697: a task that changes visuals on purpose sets ci:golden; without the full set '
+ 'on its review material its shifted frames surface in the next task again',
patches: [{
file: 'scripts/process-track.mjs',
find: " const full = labels.includes('ci:full') || labels.includes('ci:golden');",
replace: " const full = labels.includes('ci:full'); // mutant: ci:golden ignored",
}],
},
{
id: 'review-gate-accepts-light-proof-for-full',
guard: 'node --test --test-name-pattern="#697" test/validate-gate.test.mjs',
because: '#697: with ci:full/ci:golden a light push run proves nothing about smokes and golden; '
+ 'the gate must dispatch full=true instead of accepting it',
patches: [{
file: 'scripts/validate-gate.mjs',
find: " const policy = full ? Object.freeze({ ...base, name: `${base.name}-full`, full: true }) : base;",
replace: ' const policy = base; // mutant: full never required',
}],
},
{
id: 'bot-golden-commit-without-provenance',
guard: 'node --test --test-name-pattern="провенанс" test/beta-derived.test.mjs',
because: '#697: the bot commit that moves baselines must carry Release: and Baseline-Reviewed:, '
+ 'otherwise validate-commit-provenance turns dev red on the push',
patches: [{
file: '.github/workflows/_beta-derived.yml',
find: ' echo "Release: $TAG"\n',
replace: '',
}],
},
// #694: цена переключения этажа — три правки без изменения поведения.
{
id: 'pointer-hover-batch-requeries-shared-subtree',
guard: 'node --test --test-name-pattern="#694 AC1" test/device-hit-owner.test.mjs',
because: '#694 AC1: a floor switch delivers hundreds of MutationObserver records into the '
+ 'same subtrees; each node is queried once per batch, not once per record',
patches: [{
file: 'src/device-hit-owner.ts',
find: ' if (node.nodeType !== 1 || checked.has(node)) return false;\n',
replace: ' if (node.nodeType !== 1) return false; // mutant: no per-batch memo\n',
}],
},
{
id: 'stairs-view-reads-model-per-stair',
guard: 'node --test --test-name-pattern="#694 AC2" test/stairs.test.mjs',
because: '#694 AC2: the card _model getter fingerprints the whole config on every read; '
+ 'the View stair layer reads it once per render, not once more per navigable stair',
patches: [{
file: 'src/stairs-view.ts',
find: " ? model.find((item) => item.id === stair.target_space_id)?.title ?? ''\n",
replace: " ? this.owner._model.find((item) => item.id === stair.target_space_id)?.title ?? ''"
+ ' // mutant\n',
}],
},
{
id: 'language-gate-rewrites-unchanged-lang',
guard: 'node --test --test-name-pattern="#694 AC3" test/i18n-runtime.test.mjs',
because: '#694 AC3: every card render passes the locale gate; an unchanged inherited lang '
+ 'must not be written again',
patches: [{
file: 'src/i18n/language-runtime.ts',
find: " if (host.getAttribute?.('lang') !== lang) host.setAttribute('lang', lang);\n",
replace: " host.setAttribute('lang', lang); // mutant: written on every render\n",
}],
},
// #725 AC5: принудительные layout в пути рендера — возврат ловит AST-гейт.
{
id: 'iso-scene-reads-stage-box-during-render',
guard: 'node scripts/render-layout-read.mjs',
because: '#725 AC5: _isoScene runs inside _renderBody through _effectiveProjection; reading '
+ 'the stage box there forces a synchronous layout on every 2.5D floor render, and the fit '
+ 'frame does not depend on the aspect since #713',
patches: [{
file: 'src/houseplan-card.ts',
find: ' const aspect = scene.frame.w / scene.frame.h; // #713/#725: the fit frame ignores aspect; no layout read in render\n',
replace: ' const stageSize = this._stageEl?.getBoundingClientRect?.() ?? null;\n'
+ ' const aspect = stageSize?.height ? stageSize.width / stageSize.height : scene.frame.w / scene.frame.h;\n',
}],
},
{
id: 'summary-layout-reads-safe-insets-during-render',
guard: 'node scripts/render-layout-read.mjs',
because: '#725 AC5: the summary panel computes layout() up to five times per card render; '
+ 'reading the safe-area probe style there forces a style recalculation each time instead of '
+ 'using the insets measureLayout stored',
patches: [{
file: 'src/summary-panel-runtime-loaded.ts',
find: ' return resolveSummaryLayout({ ...this.stage, controlTop: this.host._kiosk ? this.stage.controlTop : 0 });\n',
replace: " const probe = this.host.renderRoot.querySelector('.summary-safe-probe') as HTMLElement | null;\n"
+ ' const style = probe ? this.host.ownerDocument.defaultView?.getComputedStyle(probe) : undefined;\n'
+ ' return resolveSummaryLayout({ ...this.stage, safeTop: Number.parseFloat(style?.paddingTop ?? \'\') || 0,\n'
+ ' controlTop: this.host._kiosk ? this.stage.controlTop : 0 });\n',
}],
},
// #744: четыре кэша геометрии этажа — по отпечатку записи этажа, а не по эпохе.
{
id: 'floor-geometry-key-global-epoch',
guard: 'node demo/smoke_floor_geometry_cache.mjs',
because: '#744 AC1: an edit of one floor bumps the global epoch; a floor key made of the epoch '
+ 'turns every other floor cold, and its first visit rebuilds the union, contours and clean floors',
patches: [{
file: 'src/floor-geometry-key.ts',
find: ' const key = `${spaceId}|${contentFingerprint(model === current ? model : [model, current])}`;\n',
replace: ' const key = `${spaceId}|${source._cfgEpoch}`; // mutant: the global epoch\n',
}],
},
{
id: 'floor-geometry-key-ignores-content',
guard: 'node demo/smoke_floor_geometry_cache.mjs',
because: '#744 AC2a: a server push that changes a floor\'s walls must give the floor a new key; a key '
+ 'without the content fingerprint draws the walls and areas of the previous record',
patches: [{
file: 'src/floor-geometry-key.ts',
find: ' const key = `${spaceId}|${contentFingerprint(model === current ? model : [model, current])}`;\n',
replace: ' const key = `${spaceId}|`; // mutant: no content fingerprint\n',
}],
},
{
id: 'prepare-reuse-runs-material-script',
guard: 'node --test --test-name-pattern="#765 AC2" test/process-prepare-tools.test.mjs',
because: '#765: reuse зелёного вердикта (#499) решает скрипт снимка dev; скрипт рабочей копии — '
+ 'материал задачи, и подменённый review-doc-guard.mjs выдаёт себе reuse=true — слияние без модели',
patches: [{
file: '.github/workflows/_process.yml',
find: ' out=$(node "$TOOLS/scripts/review-doc-guard.mjs" --reuse --marker=CODE-REVIEW',
replace: ' out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW',
}],
},
{
id: 'usage-script-from-moving-dev',
guard: 'node --test --test-name-pattern="#765 AC3" test/process-prepare-tools.test.mjs',
because: '#765: расход снимается скриптом того коммита dev, что закрепила подготовка; архив '
+ 'подвижной origin/dev после сдвига dev исполняет другую версию — смесь версий в одном заходе',
patches: [{
file: '.github/workflows/_process.yml',
find: ' git archive "$TOOLS_SHA" scripts | tar -x -C "$tools"',
replace: ' git fetch -q origin dev; git archive origin/dev scripts | tar -x -C "$tools"',
}],
},
{
id: 'material-anchors-pile-separators',
guard: 'node --test --test-name-pattern="#765" test/review-doc-guard.test.mjs',
because: '#765: повторная приписка блока якорей снимает и свой разделитель; без этого каждый '
+ 'повтор копит ещё один `---` перед блоком',
patches: [{
file: 'scripts/review-doc-guard.mjs',
find: " ? body.slice(0, at).replace(/(?:\\s*\\n---)*\\s*$/, '')",
replace: " ? body.slice(0, at).replace(/\\s+$/, '')",
}],
},
{
id: 'led-icon-not-suppressed',
guard: 'node demo/smoke_led_strip_glow.mjs',
because: "#780 ТЗ §5: a marker shown as an LED strip draws no ordinary icon; the old icon must not stay under the stripe",
patches: [{
file: 'src/houseplan-card.ts',
find: "(d) => d.space === space.id && (!d.hidden || showGhosts) && !leds.has(d.id));",
replace: "(d) => d.space === space.id && (!d.hidden || showGhosts));",
}],
},
{
id: 'led-source-stays-round-at-anchor',
guard: 'node demo/smoke_led_strip_glow.mjs',
because: "#780 ТЗ §3: the strip is a linear source — no second round pool from its anchor",
patches: [{
file: 'src/houseplan-card.ts',
find: " if (leds.has(key.slice(space.id.length + 1))) continue;\n",
replace: "\n",
}],
},
{
id: 'led-auto-slot-reserved',
guard: 'node demo/smoke_led_strip_glow.mjs',
because: "#780 ТЗ §5: a marker represented by a strip reserves no auto-grid slot",
patches: [{
file: 'src/houseplan-card.ts',
find: "d.space === s.id && !leds.has(d.id));",
replace: "d.space === s.id);",
}],
},
{
id: 'led-default-radius-shared',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/led-strip-runtime.test.mjs',
because: "#780 ТЗ §3: the field radius is 50 cm by default, independent of the shared radius of ordinary sources",
patches: [{
file: 'src/led-strip-geometry.ts',
find: "export const LED_DEFAULT_RADIUS_CM = 50;",
replace: "export const LED_DEFAULT_RADIUS_CM = 360;",
}],
},
{
id: 'led-core-coloured-under-glow',
guard: 'node demo/smoke_led_strip_glow.mjs',
because: "#780 ТЗ §3: with Glow the core stays white and the colour is the field; only without Glow the core takes the source colour",
patches: [{
file: 'src/led-strip-runtime.ts',
find: ": view.state === 'on' && !view.glow && view.appearance ? view.appearance.c : CORE_IDLE;",
replace: ": view.state === 'on' && view.appearance ? view.appearance.c : CORE_IDLE;",
}],
},
{
id: 'led-wall-offset-removed',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/led-strip-geometry.test.mjs',
because: "#780 AC8: on a thick face the visible stripe moves t/2 into the free floor",
patches: [{
file: 'src/led-strip-geometry.ts',
find: " const shift = piece.free ? [piece.free[0] * offset, piece.free[1] * offset] : [0, 0];",
replace: " const shift = [0, 0];",
}],
},
{
id: 'led-emits-from-body',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/led-strip-geometry.test.mjs',
because: "#780 ТЗ §6: a part of the strip inside a body emits nothing",
patches: [{
file: 'src/led-strip-geometry.ts',
find: " if (ctx && ctx.inside(s)) continue;\n",
replace: "\n",
}],
},
{
id: 'led-long-polyline-loses-vertices',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/led-strip-geometry.test.mjs',
because: "#780 AC10: every segment of a long polyline emits — no vertex beyond the first eight is dropped",
patches: [{
file: 'src/led-strip-geometry.ts',
find: " for (const piece of stripPieces(points, ctx)) {\n const len = dist(piece.a, piece.b);",
replace: " for (const piece of stripPieces(points, ctx).slice(0, 8)) {\n const len = dist(piece.a, piece.b);",
}],
},
{
id: 'led-pan-adds-point',
guard: 'node demo/smoke_led_strip_draw.mjs',
because: "#780 AC4: a pan or pinch is no clean click and adds no chain point",
patches: [{
file: 'src/led-strip-editor.ts',
find: " const clean = !!start && !this.gesture && e.button === 0",
replace: " const clean = !!start && e.button === 0",
}],
},
{
id: 'led-pinch-calls-action',
guard: 'node demo/smoke_led_strip_glow.mjs',
because: "#780 AC4: the stripe shares the card gesture owner, so a pan over it calls no service",
patches: [{
file: 'src/led-strip-runtime.ts',
find: " @pointerdown=${(e: PointerEvent) => h.pointerdown(e, own(e))}\n"
+ " @pointermove=${(e: PointerEvent) => h.pointermove(e, own(e))}\n"
+ " @pointerup=${(e: PointerEvent) => h.pointerup(e, own(e))}",
replace: " @pointerdown=${(e: PointerEvent) => { e.stopPropagation(); h.pointerdown(e, own(e)); }}\n"
+ " @pointermove=${(e: PointerEvent) => e.stopPropagation()}\n"
+ " @pointerup=${(e: PointerEvent) => { e.stopPropagation(); h.pointerup(e, own(e)); }}",
}],
},
{
id: 'led-binding-not-unique',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/led-strip-editor.test.mjs',
because: "#780 ТЗ §5: a marker bound to another strip cannot be taken silently",
patches: [{
file: 'src/led-strip-editor.ts',
find: " if (owner && owner.strip.id !== stripId) { this.host._showToast(this.t('led.taken')); return; }",
replace: " void owner;",
}],
},
{
id: 'led-unbound-in-view',
guard: 'node demo/smoke_led_strip_glow.mjs',
because: "#780 ТЗ §5: an unbound strip has no View representation, light or target",
patches: [{
file: 'src/led-strip-runtime.ts',
find: " if (!device || device.space !== input.space.id) continue;",
replace: " if (device && device.space !== input.space.id) continue;",
}],
},
{
id: 'led-transfer-remap-ignored',
guard: 'node scripts/backend-test-guard.mjs issue_780_space_import_remaps '
+ 'tests_backend/test_ha_import_export.py',
because: '#780 ТЗ §9: a transferred strip follows the marker-id remap; a coinciding old id never binds it',
patches: [{
file: 'custom_components/houseplan/import_export.py',
find: ' unbound_led_strips = unbind_strips(space, remap={\n old_id: new_id for old_id, new_id in marker_map.items()',
replace: ' unbound_led_strips = unbind_strips(space, remap={\n old_id: old_id for old_id, new_id in marker_map.items()',
}],
},
{
id: 'led-badge-dropped',
guard: 'node demo/smoke_led_strip_glow.mjs',
because: "#780 r1 M1: a strip keeps the device value badge, passive, at the half-length anchor — measured against the rendered stripe in Chromium",
patches: [{
file: 'src/houseplan-card.ts',
find: " ${ledDevs.map((d) => this._ledBadge(d, view, isoOverlays?.devices.get(d.id)))}\n",
replace: "",
}],
},
{
id: 'led-room-id-ignored',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/led-strip-runtime.test.mjs',
because: '#780 r1 M2: an explicit valid room_id wins over the anchor room for the strip Glow',
patches: [{
file: 'src/led-strip-runtime.ts',
find: " return (roomId ? rooms.find((r) => r.id === roomId) : undefined)\n ?? (anchor",
replace: " return (anchor",
}],
},
{
id: 'led-chain-written-to-shown-space',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/led-strip-editor.test.mjs',
because: '#780 r1 M3: a space switch finishes an unfinished chain in the space it was drawn in, never the one shown now',
patches: [{
file: 'src/led-strip-editor.ts',
find: " chain.space, id, () => strip);",
replace: " this.host._spaceModel()?.id ?? chain.space, id, () => strip);",
}],
},
{
id: 'led-hidden-marker-loads-chunk',
guard: 'node demo/smoke_led_strip_glow.mjs',
because: '#780 r1 M4: a hidden marker or an HA-disabled device decides before import() — no LED chunk request in the browser network log',
patches: [{
file: 'src/led-strip-gate.ts',
find: " && (!d.hidden || showHidden) && !hidden(d.id));",
replace: ");",
}],
},
{
id: 'led-static-live-ignored',
guard: 'node demo/smoke_led_strip_glow.mjs',
because: '#780 r1 M6: the static card with live_states:false draws a neutral stripe without a field in both light_pools modes — the rendered SVG state of four real cards',
patches: [{
file: 'src/led-strip-runtime.ts',
find: " const views = input.live ? frame.views",
replace: " const views = true ? frame.views",
}],
},
{
id: 'led-old-writer-drops-strips',
guard: 'node scripts/backend-test-guard.mjs old_writer tests_backend/test_led_strips.py',
because: '#780 r1 H1: an ordinary save without the unknown field keeps the stored shapes; only an explicit list deletes',
patches: [{
file: 'custom_components/houseplan/validation.py',
find: ' preserve_summary_panel_namespace(candidate, previous)\n preserve_led_strips(candidate, previous)',
replace: ' preserve_summary_panel_namespace(candidate, previous)',
}],
},
];
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
const mutationEditorSource = readFileSync(join(repoRoot, 'src/houseplan-editor-runtime.ts'), 'utf8');
// #659: these witnesses exercise pure state/geometry/CSS contracts and do not
// need Chromium. Keep the grouping explicit: every promoted mutant is proven
// by `mutation-gate`, not merely reclassified to avoid a bundle build.
const unitGuard = (...files) => `node --test ${files.join(' ')} test/mutation-browser-offload.test.mjs`;
const UNIT_GUARD_GROUPS = [
[unitGuard('test/stairs.test.mjs'), [
'stairs-fixed-floor-still-navigates',
'stairs-link-auto-creates-target-object',
'stairs-broken-targets-become-active',
]],
// #676: gesture-click suppression and the tooltip's `active` gate are template
// wiring; the box model beside them is pure and tested in stairs-box.test.mjs.
[unitGuard('test/stairs-box.test.mjs'), [
'stairs-gesture-click-reaches-plan-tool',
'stairs-view-tooltip-ignores-target-state',
]],
[unitGuard('test/device-hit-owner.test.mjs'), [
'device-hit-scroll-observer-disabled',
]],
[unitGuard('test/summary-panel-runtime.test.mjs'), [
'summary-hide-unmounts-before-animation',
'summary-first-paint-shows-unavailable',
'summary-metrics-block-first-frame',
]],
[unitGuard('test/form-shell-consumers.test.mjs'), [
'discard-confirm-action-icon-falls-back-to-lock',
]],
[unitGuard('test/form-kit.test.mjs'), [
'range-line-clamps-every-keystroke',
]],
[unitGuard('test/dialog-baseline.test.mjs', 'test/styles-split.test.mjs'), [
'ha-form-shell-width-falls-back-to-generic',
'ha-form-shell-loses-canvas',
'ha-form-shell-mobile-keeps-desktop-inset',
]],
[unitGuard('test/summary-panel.test.mjs'), [
'summary-dialog-loses-wide-shell',
'summary-mobile-ignores-local-off',
]],
[unitGuard('test/styles-split.test.mjs'), [
'marker-shadow-animates-again',
]],
[unitGuard('test/device-inbox.test.mjs'), [
'device-inbox-batch-single-write',
]],
[unitGuard('test/houseplan-panel.test.mjs'), [
'panel-ignores-pre-upgrade-properties',
'panel-host-height-from-parent',
]],
[unitGuard('test/version-recovery-card.test.mjs'), [
'version-recovery-delays-config-capability-adoption',
]],
[unitGuard('test/vacuum-calibration-write.test.mjs'), [
'vacuum-manual-fit-after-proposal-uses-the-dock',
]],
[unitGuard('test/devices.test.mjs'), [
'room-climate-ignores-marker-placement',
]],
[unitGuard('test/device-inbox.test.mjs'), [
'device-tombstone-blocks-child-picker',
]],
[unitGuard('test/room-fit.test.mjs'), [
'room-fit-interactive-owner-leaks-through',
]],
[unitGuard('test/iso-stage6.test.mjs', 'test/styles-split.test.mjs'), [
'iso-tile-ring-visible',
'iso-tile-edge-zero',
'iso-tile-badge-gap-dropped',
'iso-tile-shadow-layer-above',
'iso-frame-selected-beats-focus',
'iso-shadow-in-forced-colors',
'iso-sun-length-ignores-elevation',
'iso-sun-streaks-on-dark-floor',
'iso-wall-top-constant',
]],
[unitGuard('test/backdrop-probe.test.mjs'), [
'backdrop-hard-demoted-to-warn',
]],
[unitGuard('test/bundle-assets.test.mjs'), [
'css-minifier-eats-required-space',
]],
[unitGuard('test/wall-thickness.test.mjs', 'test/wall-union-isolation.test.mjs'), [
'wall-component-failure-kills-primary',
]],
[unitGuard('test/glow-scene.test.mjs'), [
'union-failure-silent',
'glow-fail-dark-weakened',
'opening-cut-degenerate',
'column-shadow-removed',
'feather-20px',
]],
[unitGuard('test/visual-continuity.test.mjs'), [
'continuity-long-resume-noop',
]],
[unitGuard('test/ha-binding-status.test.mjs'), [
'registryless-opening-requires-registry-row',
]],
[unitGuard('test/header-menu.test.mjs'), [
'header-menu-item-keeps-menu-open',
'header-menu-escape-ignored',
'active-tab-not-revealed',
]],
[unitGuard('test/iso-scene-render.test.mjs', 'test/iso-stage6.test.mjs'), [
'grid-scale-iso-height-unscaled',
'stage3-w8-material-defs-created-per-face',
]],
[unitGuard('test/support-feedback.test.mjs'), [
'support-edited-retry-reuses-old-idempotency-key',
]],
[unitGuard('test/touch-gesture-click-guard.test.mjs'), [
'touch-pinch-contextmenu-guard-removed',
]],
[unitGuard('test/live-editor.test.mjs'), [
'live-editor-devices-drops-align-guides',
'live-editor-decor-drops-align-guides',
'live-editor-plan-drops-align-guides',
'live-editor-keeps-the-settled-guides-visible',
]],
[unitGuard('test/live-viewport.test.mjs'), [
'live-pan-incoming-edge-clipped',
'live-pan-rewrites-viewbox-every-frame',
]],
[unitGuard('test/summary-runtime-loader.test.mjs'), [
'summary-runtime-attaches-after-first-render',
]],
[unitGuard('test/config-reload-authority.test.mjs'), [
'config-reload-drops-asset-gap-ownership',
]],
[unitGuard('test/summary-panel.test.mjs'), [
'summary-picker-renders-unbounded-results',
]],
[unitGuard('test/form-shell-consumers.test.mjs', 'test/dialog-baseline.test.mjs',
'test/space-dialog.test.mjs'), [
'same-binding-click-resets-source',
'state-callout-hidden-under-help',
'dialog-card-loses-its-heading',
'form-kit-writes-to-a-neighbour-key',
'space-fill-segment-writes-a-neighbour-key',
'space-save-enabled-without-changes',
'space-discard-without-asking',
'space-layer-toggle-loses-inversion',
'general-save-enabled-without-changes',
'general-tile-opacity-writes-a-neighbour-key',
'general-north-clear-writes-zero',
'topology-embedded-draws-its-own-heading',
'room-save-enabled-without-changes',
'room-discard-without-asking',
'room-fill-segment-starts-at-none',
'room-temp-min-writes-max',
'marker-save-enabled-without-changes',
'marker-save-click-forgets-baseline-before-result',
'marker-discard-without-asking',
'marker-confirm-row-shown-for-do-nothing',
'marker-never-keeps-glow-block-live',
'marker-badge-position-forgets-touch',
]],
[unitGuard('test/color-picker.test.mjs'), [
'M-615-tile',
'M-615-plate',
]],
[unitGuard('test/form-kit.test.mjs', 'test/styles-split.test.mjs'), [
'form-kit-segment-breaks-words',
]],
[unitGuard('test/room-gear-drag.test.mjs'), [
'room-gear-drag-reopens-settings',
]],
];
const UNIT_GUARD_OVERRIDES = new Map(UNIT_GUARD_GROUPS.flatMap(([guard, ids]) => (
ids.map((id) => [id, guard])
)));
export const MUTANTS = MUTANT_DEFINITIONS.map((mutant) => ({
...mutant,
guard: UNIT_GUARD_OVERRIDES.get(mutant.id) ?? mutant.guard,
patches: mutant.patches.map((patch) => relocateEditorPatch(
patch, mutationCardSource, mutationEditorSource,
)),
}));