",
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: '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',
}],
},
];
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,
)),
}));