mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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:
@@ -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');
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user