test(gates): мутанты на восемь модулей горячего пути из #451

#451 принёс 1 651 строку в восьми новых модулях, а единственный мутант
того релиза патчил houseplan-card.ts и houseplan-editor-runtime.ts —
места, ОТКУДА код ушёл. Вынос в модули был правильным решением, но
защита осталась смотреть на старые файлы.

Восемь мутантов, семь модулей, все прогнаны штатным раннером (чистый
прогон зелёный, мутант красный):

- live-editor-view-mode-routes-live — режим View обязан оставаться
  реактивным: живой путь редактора там означает план, который перестал
  отвечать на Home Assistant у двух персон из трёх;
- live-editor-first-gesture-frame-goes-live — первый кадр жеста меняет
  выделение и хром и обязан остаться реактивным;
- pointer-move-queue-keeps-first-move — очередь last-wins: сохранение
  ПЕРВОГО коллбэка кадра рисует план там, где палец уже не находится;
- live-hass-tick-never-deferred — тик состояния посреди жеста
  откладывается намеренно, и флаг отложенности гарантирует его
  воспроизведение после;
- live-viewport-identity-projection-not-recognized — см. ниже;
- render-invalidation-unknown-key-ignored — классификатор обязан
  ошибаться в сторону перерисовки на незнакомых ключах hass;
- resize-live-preflight-keeps-every-room — живой resize проверяет только
  задетые комнаты, иначе каждый кадр становится полной проверкой плана;
- render-lifecycle-diagnostics-cache-never-invalidated — кэш диагностики
  обязан сбрасываться, иначе красная точка нового устройства не загорится.

Мутант на live-viewport пришлось заменить, и это стоит записать. Issue
предлагал снять `setLayerProjection(layer, null)` из
`commitHouseplanViewport` — прогон показал, что тест остаётся ЗЕЛЁНЫМ:
следом идёт `paintLiveViewport(root, painted, painted)`, который на
равных аргументах даёт identity и обнуляет проекцию сам. Тот цикл —
подстраховка, а не контракт. Настоящий контракт — распознавание identity
(`isIdentityLiveLayerProjection`), потому что даже единичный transform
переключает путь композитинга и сдвигает установившийся растр на
несколько уровней цвета. Мутируется теперь он.

`src/live-hover.ts` остался без мутанта сознательно: его контракты либо
чисто производительные (мемо по наведённой комнате), либо доменные
(подсветка комнаты, застрявшая после ухода курсора). Первое мутантом не
ловится в принципе, второе — только браузерным смоком, которого в
песочнице нет. Записано в тесте и в issue.

Чтобы требование не жило в памяти, добавлен гейт: у каждого модуля
горячего пути обязан быть свой мутант, и он не имеет права патчить
houseplan-card.ts или houseplan-editor-runtime.ts — то есть исходный
дефект #458 больше не воспроизводим молча. Проверено отрицательным
прогоном: перевод патча любого из модулей на старое ядро краснит гейт.

Гейты: npm test 1960 tests, 1959 pass, 0 fail; typecheck зелёный;
mutation-gate --check зелёный на всех якорях; каждый из восьми мутантов
прогнан отдельно.

Issue: #458
User-Visible: no
This commit is contained in:
Claude
2026-09-05 09:08:09 +03:00
parent 6ef0804bc4
commit de30066be7
2 changed files with 145 additions and 0 deletions
+110
View File
@@ -5766,6 +5766,116 @@ const MUTANT_DEFINITIONS = [
replace: " if (doubleFit) this._resetZoom('double-tap');",
}],
},
{
id: 'live-editor-view-mode-routes-live',
guard: 'node --test test/live-editor.test.mjs',
because: 'View is the product for two of three personas: the live editor path must never '
+ 'take over rendering there, or a plan nobody is editing stops reacting to Home '
+ 'Assistant (#451, #458)',
patches: [{
file: 'src/live-editor.ts',
find: " if (!host?.isConnected || host._mode === 'view') return false;",
replace: ' if (!host?.isConnected) return false;',
}],
},
{
id: 'live-editor-first-gesture-frame-goes-live',
guard: 'node --test test/live-editor.test.mjs',
because: 'the first frame of a gesture may change selection and chrome, so it must stay '
+ 'reactive; routing it live leaves the newly selected object drawn in its previous '
+ 'state until the gesture ends (#451, #458)',
patches: [{
file: 'src/live-editor.ts',
find: ' if (name !== undefined && gestureProperties.has(name)\n'
+ ' && oldValue == null\n'
+ ' && (host as unknown as Record<PropertyKey, unknown>)[name] != null\n'
+ ' && activeEditorGesture(host)) {\n'
+ ' // Pointerdown may change selection/chrome once. Subsequent moves stay live.\n'
+ ' return false;\n'
+ ' }\n',
replace: '',
}],
},
{
id: 'pointer-move-queue-keeps-first-move',
guard: 'node --test test/live-editor.test.mjs',
because: 'the coalescing queue is last-wins: keeping the FIRST callback of an event turn '
+ 'means the pointer moves on and the plan paints where the finger used to be (#451, #458)',
patches: [{
file: 'src/pointer-move-queue.ts',
find: ' if (queued) {\n queued.run = run;\n return;\n }',
replace: ' if (queued) {\n return;\n }',
}],
},
{
id: 'live-hass-tick-never-deferred',
guard: 'node --test test/houseplan-render-lifecycle.test.mjs',
because: 'a state tick arriving mid-gesture is deferred on purpose; rendering it '
+ 'immediately drops frames of the gesture the user is performing, and the deferral flag '
+ 'is what guarantees the skipped tick is replayed afterwards (#451, #458)',
patches: [{
file: 'src/live-interaction-runtime.ts',
find: " const defer = change === 'state' && this.active();",
replace: ' const defer = false;',
}],
},
{
id: 'live-viewport-identity-projection-not-recognized',
guard: 'node --test test/live-viewport.test.mjs',
because: 'a settled viewport must be recognized as identity and leave NO compositor '
+ 'transform: an identity transform still switches the browser compositing path and '
+ 'shifts the settled raster by a few colour levels, which is exactly what makes golden '
+ 'frames flap (#451, #458)',
patches: [{
file: 'src/live-viewport.ts',
// Первая редакция этого мутанта снимала `setLayerProjection(layer, null)`
// из `commitHouseplanViewport` (так предлагал issue) — и тест оставался
// ЗЕЛЁНЫМ: следом идёт `paintLiveViewport(root, painted, painted)`, а он
// на равных аргументах даёт identity и обнуляет проекцию сам. То есть тот
// цикл не контракт, а подстраховка. Настоящий контракт — распознавание
// identity, и мутируется именно оно.
find: ' projection.translateXPercent === 0',
replace: ' projection.translateXPercent === 1',
}],
},
{
id: 'render-invalidation-unknown-key-ignored',
guard: 'node --test test/render-invalidation.test.mjs',
because: 'the classifier fails OPEN on Home Assistant keys it does not know: without that '
+ 'rule a new HA capability silently leaves the plan stale, and a stale plan looks '
+ 'exactly like a working one (#451, #458)',
patches: [{
file: 'src/render-invalidation.ts',
find: ' for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {\n'
+ " if (!compared.has(key) && before[key] !== after[key]) return 'structural';\n"
+ ' }\n',
replace: '',
}],
},
{
id: 'resize-live-preflight-keeps-every-room',
guard: 'node --test test/resize-controller.test.mjs',
because: 'live resize checks only the rooms the gesture touches; widening that to the whole '
+ 'plan turns every frame into a full-plan geometry check and the drag stutters on large '
+ 'plans — the very regression #451 removed (#458)',
patches: [{
file: 'src/resize-live-preflight.ts',
find: ' return id && changed.has(id) ? [id] : [];',
replace: ' return id ? [id] : [];',
}],
},
{
id: 'render-lifecycle-diagnostics-cache-never-invalidated',
guard: 'node --test test/houseplan-render-lifecycle.test.mjs',
because: 'the diagnostics cache must drop when the registry or a tracked state appears or '
+ 'disappears; a cache that never invalidates reports yesterday binding health as todays '
+ 'and the red dot for a silently added device never lights (#451, #458)',
patches: [{
file: 'src/houseplan-render-lifecycle.ts',
find: ' if (!this.diagnosticsCache) return;\n',
replace: ' if (!this.diagnosticsCache) return;\n if (true) return;\n',
}],
},
];
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');