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');
+35
View File
@@ -182,3 +182,38 @@ test('#332: шарды покрывают реестр целиком и не п
assert.ok(Math.max(...counts) - Math.min(...counts) <= MUTANTS.length / TOTAL / 2,
`браузерные мутанты скучковались: ${counts.join(', ')}`);
});
test('#458 у каждого модуля горячего пути отрисовки есть свой мутант', () => {
// #451 принёс 1 651 строку в восьми новых модулях, и защита осталась
// смотреть на старые ядровые файлы: единственный мутант того релиза патчил
// houseplan-card.ts и houseplan-editor-runtime.ts, то есть места, ОТКУДА код
// ушёл. Это системный побочный эффект выноса в модули, и он повторяется на
// каждой такой задаче — поэтому требование записано гейтом, а не памятью.
//
// Цена ошибки здесь необычная: обычный дефект рендера виден (экран падает или
// мигает), а дефект этой оптимизации даёт устаревший экран без единого
// признака поломки — план тихо показывает вчерашнее состояние лампы.
const modules = [
'src/live-editor.ts',
'src/pointer-move-queue.ts',
'src/live-interaction-runtime.ts',
'src/live-viewport.ts',
'src/render-invalidation.ts',
'src/resize-live-preflight.ts',
'src/houseplan-render-lifecycle.ts',
];
// `src/live-hover.ts` сознательно вне списка: его контракты — либо чисто
// производительные (мемо по наведённой комнате), либо доменные (подсветка
// комнаты, застрявшая после ухода курсора). Первое мутантом не ловится в
// принципе, второе ловится только браузерным смоком. Появится смок — модуль
// добавляется сюда вместе с мутантом.
for (const file of modules) {
const covered = MUTANTS.filter((mutant) => mutant.patches.some((patch) => patch.file === file));
assert.ok(covered.length, `${file}: ни одного мутанта — защита не там, где код`);
for (const mutant of covered) {
assert.ok(!mutant.patches.some((patch) => patch.file === 'src/houseplan-card.ts'
|| patch.file === 'src/houseplan-editor-runtime.ts'),
`${mutant.id}: патчит старое ядро вместо ${file} — это и есть исходный дефект #458`);
}
}
});