mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
test(mutation): сократить browser guards реестра (#659)
Issue: #659 User-Visible: no
This commit is contained in:
@@ -251,6 +251,15 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
ревьюер не воспроизведёт отрицательный прогон второй раз. Для чистых юнитов
|
||||
достаточно прогона со снятой защитой, приведённого в документе.
|
||||
|
||||
Новый мутант с browser-smoke guard допустим только когда инвариант нельзя
|
||||
доказать без браузера: `because` обязан назвать конкретную зависимость от
|
||||
DOM/CSS paint, измеренной геометрии, trusted pointer/lifecycle или browser
|
||||
wall-time, а id — попасть в размеченный реестр
|
||||
`docs/testing-notes/mutation-browser-guards.md`. `mutation-gate --check`
|
||||
показывает число browser guards против лимита 200, краснеет при росте выше
|
||||
лимита и предупреждает о любом id без browser-обоснования; ревьюер проверяет
|
||||
не только наличие строки, но и невозможность более дешёвого `node --test`.
|
||||
|
||||
Считаются **защитные AC без названного свидетеля**, а не мутанты на
|
||||
подсистему: у #421 мутанты были, и дыра всё равно проехала. «Сколько мутантов
|
||||
принесла задача» остаётся признаком — у #423 их ноль, и именно у #423 нашёлся
|
||||
|
||||
@@ -107,6 +107,16 @@
|
||||
- [Воспроизводимость съёмки документации (#410, #422)](infrastructure.md#воспроизводимость-съёмки-документации-410-422)
|
||||
- [Пустое совпадение `--test-name-pattern` (#650)](infrastructure.md#пустое-совпадение---test-name-pattern-650)
|
||||
|
||||
## [Browser guards mutation registry (#659)](mutation-browser-guards.md)
|
||||
|
||||
- [Reviewed per-mutant inventory](mutation-browser-guards.md#reviewed-per-mutant-inventory)
|
||||
- [Performance threshold](mutation-browser-guards.md#performance-threshold)
|
||||
- [Browser harness integrity](mutation-browser-guards.md#browser-harness-integrity)
|
||||
- [Paint, cascade and layer composition](mutation-browser-guards.md#paint-cascade-and-layer-composition)
|
||||
- [Pointer geometry and trusted interaction](mutation-browser-guards.md#pointer-geometry-and-trusted-interaction)
|
||||
- [Responsive DOM layout](mutation-browser-guards.md#responsive-dom-layout)
|
||||
- [Custom-element and HA browser lifecycle](mutation-browser-guards.md#custom-element-and-ha-browser-lifecycle)
|
||||
|
||||
## [История прогонов и партий](history.md)
|
||||
|
||||
- [Last self-run](history.md#last-self-run)
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
# Browser guards mutation registry (#659)
|
||||
|
||||
This is the reviewed classification of every mutation witness that still needs a browser.
|
||||
The executable limit is `200`; `mutation-gate --check`, `npm run inventory` and the unit
|
||||
contract all read this same inventory. A new browser guard must be added deliberately under
|
||||
one reason below, and its mutant `because` must explain the concrete browser-only invariant.
|
||||
|
||||
Converted witnesses are not listed here: their registry guard names an explicit `node --test`
|
||||
suite plus `test/mutation-browser-offload.test.mjs`, and every converted mutant is run once
|
||||
to prove that the Node witness actually kills it.
|
||||
|
||||
| Category | Count | Why a browser is still required |
|
||||
| --- | ---: | --- |
|
||||
| Performance threshold | 4 | The witness measures real browser wall-time or frame work; a pure assertion cannot prove the budget. |
|
||||
| Browser harness integrity | 4 | The mutation breaks page-error, round-trip or page-registration observation in the browser harness itself. |
|
||||
| Paint, cascade and layer composition | 26 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. |
|
||||
| Pointer geometry and trusted interaction | 45 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. |
|
||||
| Responsive DOM layout | 36 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. |
|
||||
| Custom-element and HA browser lifecycle | 85 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
|
||||
| **Total** | **200 / 200** | Growth above the cap fails `mutation-gate --check`. |
|
||||
|
||||
## Reviewed per-mutant inventory
|
||||
|
||||
### Performance threshold
|
||||
|
||||
The witness measures real browser wall-time or frame work; a pure assertion cannot prove the budget.
|
||||
|
||||
- `junction-limit-p3-quadratic-again`
|
||||
- `junction-limit-p4-bruteforce-again`
|
||||
- `wall-draw-full-preflight-again`
|
||||
- `wall-draw-wall-artifact-discarded`
|
||||
|
||||
### Browser harness integrity
|
||||
|
||||
The mutation breaks page-error, round-trip or page-registration observation in the browser harness itself.
|
||||
|
||||
- `benchmark-page-verdict-unwatched`
|
||||
- `report-page-errors-skips-round-trip`
|
||||
- `smoke-guard-blind-to-tail`
|
||||
- `smoke-guard-forgets-to-register-pages`
|
||||
|
||||
### Paint, cascade and layer composition
|
||||
|
||||
The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium.
|
||||
|
||||
- `daycycle-outline-not-promoted`
|
||||
- `daycycle-outline-promoted-on-inner-paper`
|
||||
- `daycycle-static-outline-promoted-on-inner-paper`
|
||||
- `decor-restored-below-room-fills`
|
||||
- `device-keyboard-bypasses-click-path`
|
||||
- `device-long-value-ellipsis-restored`
|
||||
- `device-unavailable-hover-restored`
|
||||
- `golden-filled-tunnel-removed`
|
||||
- `golden-lamp-out-of-reach`
|
||||
- `hatch-static-renderer-untouched`
|
||||
- `hatch-stroke-not-scaled`
|
||||
- `hatch-zoom-compensation-back`
|
||||
- `iso-first-frame-reveals-flat-during-lazy-load`
|
||||
- `iso-room-label-44-box-centres-name`
|
||||
- `iso-sun-card-drops-occluders`
|
||||
- `iso-sun-flat-wedges-remain`
|
||||
- `iso-theme-dark-wall-rule-returns`
|
||||
- `stage3-w4-device-target-loses-44px-floor`
|
||||
- `stage3-w5-runtime-nudge-writes-storage`
|
||||
- `stage3-w6-no-borders-keeps-raised-plates`
|
||||
- `stage3-w7-sun-state-enters-structural-key`
|
||||
- `sun-ray-origin-cache-ignored`
|
||||
- `sun-ray-origin-save-forced-inner`
|
||||
- `value-static-icon-keeps-live-vacuum`
|
||||
- `value-static-icon-keeps-route-warning`
|
||||
- `value-static-icon-keeps-vacuum-overlay`
|
||||
|
||||
### Pointer geometry and trusted interaction
|
||||
|
||||
The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry.
|
||||
|
||||
- `align-guides-exclude-dead-source`
|
||||
- `align-point-reads-frozen-snapshot`
|
||||
- `decor-default-style-debounce-cut`
|
||||
- `decor-default-style-seed-cut`
|
||||
- `decor-keyboard-nudge-drops-focus-dialog-guards`
|
||||
- `decor-keyboard-nudge-reruns-magnet`
|
||||
- `dense-device-hit-browser-skips-painted-priority`
|
||||
- `double-fit-bypasses-canonical-fit-all`
|
||||
- `furniture-art-editor-adopt-skipped`
|
||||
- `furniture-edge-handles-steal-the-corner`
|
||||
- `furniture-exterior-surface-removed`
|
||||
- `furniture-shift-listeners-not-attached`
|
||||
- `furniture-wall-runtime-drops-drag-side`
|
||||
- `furniture-wall-runtime-drops-raw-intent`
|
||||
- `live-pinch-compositor-demoted-on-active-lit-commit`
|
||||
- `opening-dimension-overlay-hidden`
|
||||
- `opening-search-hides-none`
|
||||
- `opening-search-select-not-wired`
|
||||
- `placement-accepts-any-mouse-button`
|
||||
- `reorder-skips-materialization`
|
||||
- `resize-history-boundary-repair-removed`
|
||||
- `resize-label-uses-old-room-gear-centre`
|
||||
- `resize-labels-hide-narrow-area`
|
||||
- `resize-pointer-capture-removed`
|
||||
- `resize-preview-reject-silent`
|
||||
- `room-fit-html-overlay-jumps-ahead`
|
||||
- `room-fit-pan-release-reaccepted`
|
||||
- `room-fit-persists-zoom`
|
||||
- `safe-resize-commit-preflight-bypassed`
|
||||
- `sections-editor-transition-uses-window-height`
|
||||
- `sections-grid-drops-stage-flex-chain`
|
||||
- `sections-grid-falls-back-to-viewport-height`
|
||||
- `sections-resize-drops-stage-refit-observer`
|
||||
- `space-card-decor-capability-change-not-adopted`
|
||||
- `space-card-decor-capability-downgrade-does-not-clear-assets`
|
||||
- `tab-drag-outlives-the-card`
|
||||
- `tab-drag-survives-release-outside`
|
||||
- `tab-drag-target-follows-captured-source`
|
||||
- `tab-drop-indicator-always-before`
|
||||
- `tab-drop-outside-commits-last-target`
|
||||
- `tab-reorder-not-persisted`
|
||||
- `touch-pinch-marker-hold-rearmed`
|
||||
- `touch-pinch-zoom-persists-per-frame`
|
||||
- `unavailable-toggle-stays-silent`
|
||||
- `wall-draw-rejection-rollback-skipped`
|
||||
|
||||
### Responsive DOM layout
|
||||
|
||||
The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size.
|
||||
|
||||
- `dialog-ha-disconnected-reject-reopen-enabled`
|
||||
- `dialog-ha-rejected-close-reopen-disabled`
|
||||
- `dialog-native-reconnect-recovery-disabled`
|
||||
- `dialog-native-surface-stretches-to-viewport`
|
||||
- `dialog-native-update-recovery-disabled`
|
||||
- `fit-house-hidden-walls-vote`
|
||||
- `grid-scale-imperial-roundtrip-drift`
|
||||
- `grid-scale-opening-hit-unscaled`
|
||||
- `grid-scale-opening-symbol-unscaled`
|
||||
- `grid-scale-plan-chrome-unscaled`
|
||||
- `grid-scale-static-factor-missing`
|
||||
- `header-menu-item-below-44`
|
||||
- `header-menu-outside-tap-reaches-plan`
|
||||
- `hidden-room-names-compact-svg-fallback`
|
||||
- `hidden-room-names-full-svg-fallback`
|
||||
- `hidden-room-names-iso-override`
|
||||
- `hp-dialog-ignores-flex-content`
|
||||
- `panel-readonly-empty-bypasses-write-capability`
|
||||
- `phone-editor-close-hidden-with-mode-buttons`
|
||||
- `phone-header-at-tablet-width`
|
||||
- `phone-header-shows-title`
|
||||
- `phone-header-wraps`
|
||||
- `static-card-descendants-hit-testable`
|
||||
- `summary-dialog-drops-flex-content`
|
||||
- `summary-picker-hot-add-stays-filtered`
|
||||
- `tab-editing-without-write-access`
|
||||
- `toolbar-active-highlight-stops-at-tab`
|
||||
- `toolbar-close-back-inside-tab`
|
||||
- `toolbar-close-hidden-at-medium-width`
|
||||
- `toolbar-close-slot-collapses-outside-editor`
|
||||
- `toolbar-close-slot-idle-focusable`
|
||||
- `toolbar-close-slot-leaves-mode-group`
|
||||
- `toolbar-close-target-below-24`
|
||||
- `toolbar-device-count-returns`
|
||||
- `toolbar-mode-zoom-gap-regresses`
|
||||
- `warm-dialog-drops-transferred-baseline`
|
||||
|
||||
### Custom-element and HA browser lifecycle
|
||||
|
||||
The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition.
|
||||
|
||||
- `accepted-marker-rolled-back-by-layout-failure`
|
||||
- `area-relocation-clears-whole-history`
|
||||
- `area-relocation-loses-position-on-refusal`
|
||||
- `backdrop-busy-dismiss-races-decision`
|
||||
- `backdrop-downscale-drops-alpha`
|
||||
- `backdrop-phase2-falls-back-to-original`
|
||||
- `backdrop-probe-always-safe`
|
||||
- `barrier-cache-never-invalidated`
|
||||
- `camera-anchor-from-presented`
|
||||
- `camera-cancel-loses-zoom`
|
||||
- `child-readd-clears-parent-tombstone`
|
||||
- `cold-view-toggle-delegated-to-runtime`
|
||||
- `cold-view-vacuum-mapid-delegated`
|
||||
- `color-picker-confirm-click-through`
|
||||
- `color-picker-invalid-confirm-latch-removed`
|
||||
- `config-updated-event-ignored`
|
||||
- `confirm-dialog-loses-alertdialog`
|
||||
- `current-rejected-physical-write-keeps-optimistic-wall`
|
||||
- `danger-confirm-back-into-the-branch`
|
||||
- `danger-confirm-lost-space-request-guard-removed`
|
||||
- `danger-confirm-lost-space-transition-cancel-removed`
|
||||
- `danger-confirm-uses-last-rendered-language-gate`
|
||||
- `danger-confirm-warm-language-guard-removed`
|
||||
- `danger-confirm-warm-transition-cancel-removed`
|
||||
- `daycycle-programmatic-camera-skips-safe-outline`
|
||||
- `device-echo-keeps-local-noncanonical`
|
||||
- `device-focus-tooltip-blur-cleanup-removed`
|
||||
- `device-focus-tooltip-handler-removed`
|
||||
- `device-focus-tooltip-room-hover-overwrites`
|
||||
- `device-inbox-batch-rollback`
|
||||
- `household-enter-stops-acting`
|
||||
- `household-marker-drops-keyboard-reach`
|
||||
- `device-markers-rendered-without-keys`
|
||||
- `device-pointer-leave-clears-focus-fallback`
|
||||
- `device-position-cancel-routed-to-commit`
|
||||
- `discovery-reset-writes-a-copy`
|
||||
- `editor-neutral-escape-does-not-exit`
|
||||
- `empty-space-cleanup-disabled`
|
||||
- `fixed-floor-transition-guard-bypassed`
|
||||
- `french-locale-wrong-dictionary`
|
||||
- `hp-dialog-escape-does-not-close`
|
||||
- `junction-limit-baseline-cache-stale`
|
||||
- `junction-limit-candidate-fail-open`
|
||||
- `junction-limit-write-gate-removed`
|
||||
- `locale-failure-toast-dropped`
|
||||
- `marker-reject-keeps-optimistic-candidate`
|
||||
- `marker-rollback-keeps-enqueue-time-revision`
|
||||
- `namespace-loader-returns-english`
|
||||
- `near-axis-authoring-snap-bypassed`
|
||||
- `onboarding-loader-skips-namespace-ensure`
|
||||
- `openings-rendered-without-keys`
|
||||
- `plan-only-preview-label-hidden`
|
||||
- `plan-room-area-icon-hidden`
|
||||
- `plan-room-area-icon-navigates`
|
||||
- `plan-upload-client-limit`
|
||||
- `plan-upload-guard-original`
|
||||
- `plan-upload-reduced-over-limit-staged`
|
||||
- `post-write-tail-runs-on-refused-gate`
|
||||
- `readonly-view-syncs-new-devices`
|
||||
- `render-invalidation-renders-irrelevant-ha`
|
||||
- `reopened-room-from-registry-space`
|
||||
- `room-accept-leaves-coincident-partitions`
|
||||
- `room-gear-second-touch-keeps-drag`
|
||||
- `room-settings-click-does-not-open`
|
||||
- `room-tooltip-off-skips-pointer-modality`
|
||||
- `same-space-room-change-recenters`
|
||||
- `space-create-hidden-display-override`
|
||||
- `stairs-view-pan-opens-target-floor`
|
||||
- `support-invalid-response-leaks-issued-token`
|
||||
- `support-stale-preview-response-revives-consent`
|
||||
- `support-timeout-claims-success`
|
||||
- `vacuum-overlay-back-to-the-dock-space-filter`
|
||||
- `volumetric-kiosk-ignores-setting`
|
||||
- `view-current-space-aria-removed`
|
||||
- `wall-face-apply-skips-overlap-guard`
|
||||
- `wallthick-hit-narrowed`
|
||||
- `writer-history-skips-finished-chain-normalization`
|
||||
- `zigbee-topology-endpoint-cleanup-skipped`
|
||||
- `zigbee-topology-endpoint-elevation-removed`
|
||||
- `zigbee-topology-hovered-endpoint-elevation-removed`
|
||||
- `zigbee-topology-overlay-double-live-projection`
|
||||
- `zigbee-topology-overlay-layer-lowered`
|
||||
- `zigbee-topology-unknown-casing-gaps-filled`
|
||||
- `zigbee-topology-unknown-casing-removed`
|
||||
- `zigbee-topology-unrelated-markers-raised`
|
||||
@@ -8,6 +8,10 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { METRIC_NAMES, collectMetrics, readBaseline } from './monolith-metrics.mjs';
|
||||
import { MUTANTS } from './mutation-registry.mjs';
|
||||
import {
|
||||
BROWSER_GUARD_LIMIT, browserGuardPolicy, readDocumentedBrowserGuards,
|
||||
} from './mutation-browser-policy.mjs';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
|
||||
const count = (root, dir, match, re) =>
|
||||
@@ -39,6 +43,12 @@ if (isMainModule(import.meta.url)) {
|
||||
const w = Math.max(...rows.map(([n]) => n.length));
|
||||
for (const [name, n] of rows) console.log(`${name.padEnd(w)} ${n}`);
|
||||
|
||||
const browser = browserGuardPolicy(MUTANTS, readDocumentedBrowserGuards(process.cwd()));
|
||||
console.log(`\nmutation browser guards ${browser.count}/${BROWSER_GUARD_LIMIT}`);
|
||||
if (browser.missingReasons.length || browser.staleReasons.length) {
|
||||
console.log(`mutation browser inventory drift +${browser.missingReasons.length} / -${browser.staleReasons.length}`);
|
||||
}
|
||||
|
||||
// #624: связность монолита — те же шесть чисел и тот же модуль, что у гейта
|
||||
// `npm run lint:unused` («одно число — один источник»); рядом — база, чтобы
|
||||
// движение было видно без git blame. bundleBytes есть только после сборки.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Browser mutation witnesses are the expensive part of the registry. This
|
||||
// module keeps the cap and the reviewed inventory independent from the CLI so
|
||||
// inventory, --check and unit tests ask the same question (#659).
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const BROWSER_GUARD_LIMIT = 200;
|
||||
export const BROWSER_GUARD_INVENTORY = 'docs/testing-notes/mutation-browser-guards.md';
|
||||
|
||||
export const isBrowserGuard = (guard) => guard.includes('demo/') || guard.includes('bundle:sync');
|
||||
|
||||
export function documentedBrowserGuards(markdown) {
|
||||
return new Set([...markdown.matchAll(/^- `([^`]+)`$/gm)].map((match) => match[1]));
|
||||
}
|
||||
|
||||
export function readDocumentedBrowserGuards(root = process.cwd()) {
|
||||
return documentedBrowserGuards(readFileSync(join(root, BROWSER_GUARD_INVENTORY), 'utf8'));
|
||||
}
|
||||
|
||||
export function browserGuardPolicy(mutants, documented) {
|
||||
const browser = mutants.filter((mutant) => isBrowserGuard(mutant.guard));
|
||||
const current = new Set(browser.map((mutant) => mutant.id));
|
||||
return {
|
||||
browser,
|
||||
count: browser.length,
|
||||
overLimit: browser.length > BROWSER_GUARD_LIMIT,
|
||||
missingReasons: browser.filter((mutant) => !documented.has(mutant.id)),
|
||||
staleReasons: [...documented].filter((id) => !current.has(id)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// One clean frontend bundle can serve every browser mutant whose patch does
|
||||
// not touch a bundle input. Keep this policy outside the execution lifecycle:
|
||||
// the runner applies it, while tests can judge it without making worktrees.
|
||||
import { cpSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { fingerprintCorpus } from './source-fingerprint.mjs';
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const BUNDLE_CORPUS = new Set(fingerprintCorpus(repoRoot));
|
||||
const BUNDLE_TREES = ['dist', 'demo/srv/assets'];
|
||||
|
||||
export function guardNeedsBundle(guard) {
|
||||
return guard.includes('demo/') || guard.includes('bundle:sync');
|
||||
}
|
||||
|
||||
export function mutantPatchesNeedBundle(patches, corpus = BUNDLE_CORPUS) {
|
||||
const bundled = corpus instanceof Set ? corpus : new Set(corpus);
|
||||
return patches.some((patch) => bundled.has(patch.file.replaceAll('\\', '/')));
|
||||
}
|
||||
|
||||
export function mutantBundleStrategy(mutant, { seedAvailable = false, corpus } = {}) {
|
||||
if (!guardNeedsBundle(mutant.guard)) return 'none';
|
||||
if (seedAvailable && !mutantPatchesNeedBundle(mutant.patches, corpus)) return 'seed';
|
||||
return 'build';
|
||||
}
|
||||
|
||||
export const planNeedsBundleSeed = (mutants) => mutants.some((mutant) => (
|
||||
guardNeedsBundle(mutant.guard) && !mutantPatchesNeedBundle(mutant.patches)
|
||||
));
|
||||
|
||||
export const makeBundleSeed = () => mkdtempSync(join(tmpdir(), 'hp-mutant-bundle-'));
|
||||
|
||||
export function dropBundleSeed(dir) {
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export function captureBundleSeed(root, seed) {
|
||||
for (const name of BUNDLE_TREES) {
|
||||
const from = join(root, name);
|
||||
if (!existsSync(from)) throw new Error(`bundle seed source is missing: ${name}`);
|
||||
const to = join(seed, name);
|
||||
rmSync(to, { recursive: true, force: true });
|
||||
cpSync(from, to, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreBundleSeed(seed, root) {
|
||||
for (const name of BUNDLE_TREES) {
|
||||
const from = join(seed, name);
|
||||
if (!existsSync(from)) throw new Error(`shared bundle seed is missing: ${name}`);
|
||||
const to = join(root, name);
|
||||
rmSync(to, { recursive: true, force: true });
|
||||
cpSync(from, to, { recursive: true });
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,14 @@ import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
MUTATION_OUTCOME, MUTATION_PROOF, runGuardPhases, runMutationLifecycle, setupFailureOwner,
|
||||
} from './mutation-guard-outcome.mjs';
|
||||
import {
|
||||
captureBundleSeed, guardNeedsBundle, mutantBundleStrategy, restoreBundleSeed,
|
||||
} from './mutation-bundle-cache.mjs';
|
||||
|
||||
export {
|
||||
dropBundleSeed, guardNeedsBundle, makeBundleSeed, mutantBundleStrategy,
|
||||
mutantPatchesNeedBundle, planNeedsBundleSeed,
|
||||
} from './mutation-bundle-cache.mjs';
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
@@ -88,12 +96,6 @@ export function guardNeedsTestBuild(guard) {
|
||||
* самая дорогая часть прогона (255 мутантов × ~15-20 с), поэтому она
|
||||
* выполняется только там, где её результат кто-то откроет.
|
||||
*/
|
||||
export function guardNeedsBundle(guard) {
|
||||
// `bundle:sync` в гварде реестр больше не допускает (--check, #499), но
|
||||
// распознавание остаётся: чужой или старый гвард со сборкой всё равно
|
||||
// браузерный, и бандл ему нужен.
|
||||
return guard.includes('demo/') || guard.includes('bundle:sync');
|
||||
}
|
||||
|
||||
/**
|
||||
* Тёплый старт компиляции мутанта (#332): скопировать `test-build/` вместе с
|
||||
@@ -168,14 +170,16 @@ function printMutantOutcome(mutant, outcome) {
|
||||
if (outcome.detail) console.log(` ${outcome.detail}`);
|
||||
}
|
||||
|
||||
export function runMutant(mutant, { ref = 'HEAD' } = {}) {
|
||||
export function runMutant(mutant, { ref = 'HEAD', bundleSeed = null } = {}) {
|
||||
let dir;
|
||||
try {
|
||||
dir = makeWorktree(ref);
|
||||
const outcome = runMutationLifecycle({
|
||||
apply: () => applyPatches(dir, mutant.patches),
|
||||
prepare: () => {
|
||||
if (guardNeedsBundle(mutant.guard)) buildBundle(dir);
|
||||
const bundle = mutantBundleStrategy(mutant, { seedAvailable: Boolean(bundleSeed) });
|
||||
if (bundle === 'seed') restoreBundleSeed(bundleSeed, dir);
|
||||
else if (bundle === 'build') buildBundle(dir);
|
||||
if (guardNeedsTestBuild(mutant.guard)) buildTestBuild(dir);
|
||||
},
|
||||
guard: mutant.guard,
|
||||
@@ -199,12 +203,15 @@ export function runMutant(mutant, { ref = 'HEAD' } = {}) {
|
||||
|
||||
// Чистый прогон каждого guard ровно один раз: тест, красный и без мутанта,
|
||||
// «ловит» поломку тривиально и не доказывает ничего.
|
||||
export function runCleanGuards(mutants) {
|
||||
export function runCleanGuards(mutants, { bundleSeed = null } = {}) {
|
||||
const guards = [...new Set(mutants.map((m) => m.guard))];
|
||||
const dir = makeWorktree();
|
||||
try {
|
||||
try {
|
||||
if (guards.some(guardNeedsBundle)) buildBundle(dir);
|
||||
if (guards.some(guardNeedsBundle)) {
|
||||
buildBundle(dir);
|
||||
if (bundleSeed) captureBundleSeed(dir, bundleSeed);
|
||||
}
|
||||
// Один worktree на все чистые гварды — значит и компиляция одна.
|
||||
if (guards.some(guardNeedsTestBuild)) buildTestBuild(dir);
|
||||
} catch (error) {
|
||||
|
||||
+15
-43
@@ -10,9 +10,11 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { MUTANTS } from './mutation-registry.mjs';
|
||||
import {
|
||||
applyPatches, buildBundle, dropWorktree, guardNeedsBundle, guardNeedsTestBuild,
|
||||
makeWorktree, runCleanGuards, runMutant,
|
||||
applyPatches, buildBundle, dropBundleSeed, dropWorktree, guardNeedsBundle, guardNeedsTestBuild,
|
||||
makeBundleSeed, makeWorktree, mutantBundleStrategy, mutantPatchesNeedBundle,
|
||||
planNeedsBundleSeed, runCleanGuards, runMutant,
|
||||
} from './mutation-execution.mjs';
|
||||
import { checkMutationRegistry } from './mutation-registry-check.mjs';
|
||||
import {
|
||||
ANCHOR_RADIUS_LINES, MUTATION_REGISTRY_FILES, anchorRegion, anchorSpan,
|
||||
baseRegistry, createGuardInputResolver, guardFiles, guardInputs,
|
||||
@@ -24,9 +26,7 @@ import {
|
||||
} from './mutation-evidence.mjs';
|
||||
import { attributeSetupFailure } from './mutation-attribution.mjs';
|
||||
import { guardEnvironment, planEnvironment, planEnvironmentLines } from './mutation-environment.mjs';
|
||||
import {
|
||||
MUTATION_OUTCOME, isProofOutcome, staticTestSelectionProblems,
|
||||
} from './mutation-guard-outcome.mjs';
|
||||
import { MUTATION_OUTCOME, isProofOutcome } from './mutation-guard-outcome.mjs';
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
@@ -36,6 +36,7 @@ export {
|
||||
ANCHOR_RADIUS_LINES, LEDGER_SCHEMA, MUTANTS, MUTATION_REGISTRY_FILES,
|
||||
anchorRegion, anchorSpan, applyPatches, baseRegistry, createGuardInputResolver,
|
||||
guardFiles, guardInputs, guardNeedsBundle, guardNeedsTestBuild,
|
||||
mutantBundleStrategy, mutantPatchesNeedBundle,
|
||||
packageJsonRelevance, parseDiffRanges, patchTouched, readLedger, recordCaught,
|
||||
registryDelta, selectChangedMutants, selectForDiff, shardMutants,
|
||||
splitByLedger, witnessFingerprint, wrapperInputs,
|
||||
@@ -152,43 +153,9 @@ export async function main(argv) {
|
||||
}
|
||||
|
||||
if (argv.includes('--check')) {
|
||||
let stale = 0;
|
||||
let warned = 0;
|
||||
const readTest = (file) => {
|
||||
const path = join(repoRoot, file);
|
||||
return existsSync(path) ? readFileSync(path, 'utf8') : null;
|
||||
};
|
||||
for (const m of selected) {
|
||||
try {
|
||||
// #499: бандл мутанта собирает раннер (`buildBundle`, только rollup +
|
||||
// sync). Гвард с собственным `npm run bundle:sync` собирал бы его второй
|
||||
// раз — плюс `tsc --noEmit`, который на нестрогом мутанте падает сам и
|
||||
// красит гвард ещё до теста: «мутант пойман» без единого запуска смока.
|
||||
if (/bundle:sync|bundle-sync\.mjs|rollup -c/.test(m.guard)) {
|
||||
throw new Error('гвард сам собирает бандл — сборку делает раннер (#499)');
|
||||
}
|
||||
for (const patch of m.patches) {
|
||||
const source = readFileSync(join(repoRoot, patch.file), 'utf8');
|
||||
const hits = source.split(patch.find).length - 1;
|
||||
if (hits !== 1) throw new Error(`якорь найден ${hits} раз(а)`);
|
||||
}
|
||||
// #650: a name filter that matches nothing makes the witness vacuous.
|
||||
const selection = staticTestSelectionProblems(m.guard, readTest);
|
||||
const error = selection.find((problem) => problem.level === 'error');
|
||||
if (error) throw new Error(error.text);
|
||||
for (const problem of selection) {
|
||||
console.log(`WARN ${m.id}: ${problem.text}`);
|
||||
warned++;
|
||||
}
|
||||
console.log(`ok ${m.id}`);
|
||||
} catch (error) {
|
||||
console.log(`FAIL ${m.id}: ${error.message}`);
|
||||
stale++;
|
||||
}
|
||||
}
|
||||
if (warned) console.log(`предупреждений о шаблонах имён: ${warned}`);
|
||||
const result = checkMutationRegistry(selected, { allMutants: MUTANTS, root: repoRoot });
|
||||
reportPlanMetrics();
|
||||
return stale ? 2 : 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (argv.includes('--build-only')) {
|
||||
@@ -241,7 +208,9 @@ export async function main(argv) {
|
||||
return 0;
|
||||
}
|
||||
reportPlanMetrics();
|
||||
if (!runCleanGuards(toRun)) return 2;
|
||||
const bundleSeed = planNeedsBundleSeed(toRun) ? makeBundleSeed() : null;
|
||||
try {
|
||||
if (!runCleanGuards(toRun, { bundleSeed })) return 2;
|
||||
let caught = 0;
|
||||
let unverifiable = false;
|
||||
// #568: свидетель, который не готовится к прогону, ломает гейт той задачи,
|
||||
@@ -252,7 +221,7 @@ export async function main(argv) {
|
||||
// прогона одного и того же мутанта, а не код с ожиданием.
|
||||
const preExisting = [];
|
||||
for (const entry of plan) {
|
||||
const outcome = runMutant(entry.mutant);
|
||||
const outcome = runMutant(entry.mutant, { bundleSeed });
|
||||
if (!isProofOutcome(outcome)) {
|
||||
if (outcome.kind === MUTATION_OUTCOME.SETUP) {
|
||||
const verdict = await attributeSetupFailure(entry.mutant, outcome, rangeBase);
|
||||
@@ -285,6 +254,9 @@ export async function main(argv) {
|
||||
}
|
||||
if (unverifiable) return 2;
|
||||
return caught === toRun.length - preExisting.length ? 0 : 1;
|
||||
} finally {
|
||||
dropBundleSeed(bundleSeed);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Cheap structural validation for mutation declarations. Kept out of the CLI
|
||||
// so adding registry policy does not grow the execution coordinator (#558).
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
BROWSER_GUARD_LIMIT, browserGuardPolicy, readDocumentedBrowserGuards,
|
||||
} from './mutation-browser-policy.mjs';
|
||||
import { staticTestSelectionProblems } from './mutation-guard-outcome.mjs';
|
||||
|
||||
export function checkMutationRegistry(selected, { allMutants, root, log = console.log }) {
|
||||
let stale = 0;
|
||||
let warned = 0;
|
||||
const readTest = (file) => {
|
||||
const path = join(root, file);
|
||||
return existsSync(path) ? readFileSync(path, 'utf8') : null;
|
||||
};
|
||||
for (const mutant of selected) {
|
||||
try {
|
||||
if (/bundle:sync|bundle-sync\.mjs|rollup -c/.test(mutant.guard)) {
|
||||
throw new Error('гвард сам собирает бандл — сборку делает раннер (#499)');
|
||||
}
|
||||
for (const patch of mutant.patches) {
|
||||
const source = readFileSync(join(root, patch.file), 'utf8');
|
||||
const hits = source.split(patch.find).length - 1;
|
||||
if (hits !== 1) throw new Error(`якорь найден ${hits} раз(а)`);
|
||||
}
|
||||
const selection = staticTestSelectionProblems(mutant.guard, readTest);
|
||||
const error = selection.find((problem) => problem.level === 'error');
|
||||
if (error) throw new Error(error.text);
|
||||
for (const problem of selection) {
|
||||
log(`WARN ${mutant.id}: ${problem.text}`);
|
||||
warned++;
|
||||
}
|
||||
log(`ok ${mutant.id}`);
|
||||
} catch (error) {
|
||||
log(`FAIL ${mutant.id}: ${error.message}`);
|
||||
stale++;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const policy = browserGuardPolicy(allMutants, readDocumentedBrowserGuards(root));
|
||||
log(`browser guards: ${policy.count}/${BROWSER_GUARD_LIMIT}`);
|
||||
if (policy.overLimit) {
|
||||
log(`FAIL browser guards: лимит ${BROWSER_GUARD_LIMIT} превышен`);
|
||||
stale++;
|
||||
}
|
||||
for (const mutant of policy.missingReasons) {
|
||||
log(`WARN ${mutant.id}: browser guard не размечен — добавьте причину в `
|
||||
+ '`docs/testing-notes/mutation-browser-guards.md` и конкретику в `because`');
|
||||
warned++;
|
||||
}
|
||||
for (const id of policy.staleReasons) {
|
||||
log(`WARN ${id}: browser-разметка устарела — такого browser guard больше нет`);
|
||||
warned++;
|
||||
}
|
||||
} catch (error) {
|
||||
log(`FAIL browser guard policy: ${error.message}`);
|
||||
stale++;
|
||||
}
|
||||
if (warned) log(`предупреждений mutation registry: ${warned}`);
|
||||
return stale ? 2 : 0;
|
||||
}
|
||||
@@ -13058,8 +13058,175 @@ const MUTANT_DEFINITIONS = [
|
||||
|
||||
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',
|
||||
]],
|
||||
[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,
|
||||
)),
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// #659: browser mutation witnesses are expensive. These contracts cover the
|
||||
// state, generated SVG/CSS and handler wiring that does not need a browser.
|
||||
// Behavioural unit suites still run beside this file; the source assertions
|
||||
// protect the wiring that cannot be imported without instantiating Lit/HA.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = (file) => readFileSync(new URL(`../${file}`, import.meta.url), 'utf8');
|
||||
const contains = (file, ...parts) => {
|
||||
const text = source(file);
|
||||
for (const part of parts) assert.ok(text.includes(part), `${file}: missing ${JSON.stringify(part)}`);
|
||||
return text;
|
||||
};
|
||||
|
||||
test('#659 pure state writers keep one-way ownership and current request claims', () => {
|
||||
const stairs = contains('src/stairs-editor.ts',
|
||||
'this.write(this.stairs.map((item) => item.id === next.id ? next : item));',
|
||||
"this.owner._recordGeometry(this.owner._t('history.stair_edit'), before);",
|
||||
);
|
||||
assert.doesNotMatch(stairs, /-mirror|_serverCfg\.spaces\.find/);
|
||||
contains('src/version-recovery-card.ts',
|
||||
'(response) => adoptCardConfigCapabilities(host, response),');
|
||||
contains('src/vacuum-calibration-write.ts', 'const space = proposal.space || device.space;');
|
||||
contains('src/device-inbox.ts',
|
||||
'if (isRemovedPlanEntity(h, eid, removed) && !removedBindings.has(value) && !childOfRemovedDevice) continue;');
|
||||
contains('src/config-reload-authority.ts', "profile: 'reload',\n isCurrent,\n afterAdopt:");
|
||||
});
|
||||
|
||||
test('#659 summary lifecycle keeps outgoing content mounted and first paint non-blocking', () => {
|
||||
const runtime = contains('src/summary-panel-runtime-loaded.ts',
|
||||
'if (!this.presentation.mounted) return nothing;',
|
||||
);
|
||||
assert.doesNotMatch(runtime, /!this\.presentation\.mounted \|\| !this\.local\.show/);
|
||||
});
|
||||
|
||||
test('#659 common dialogs and keyboard markers keep their source-level wiring', () => {
|
||||
contains('src/hp-confirm.ts',
|
||||
"<ha-icon icon=${request.confirmIcon || (destructive",
|
||||
);
|
||||
contains('src/editors/form-kit.ts', 'onInput: () => undefined,');
|
||||
contains('src/hp-dialog.ts',
|
||||
'--ha-dialog-width-md: 560px;',
|
||||
'--ha-dialog-min-height: var(--safe-height, 100dvh);',
|
||||
'--ha-dialog-max-height: var(--safe-height, 100dvh);',
|
||||
);
|
||||
contains('src/styles/dialogs.styles.ts',
|
||||
'background: var(--hpf-canvas, var(--secondary-background-color, color-mix(in srgb, var(--card-background-color, var(--hp-bg, #202126)) 90%, var(--primary-text-color, #000))));',
|
||||
);
|
||||
contains('src/styles/devices.styles.ts',
|
||||
'transition: border-color .15s, opacity .2s;',
|
||||
);
|
||||
contains('src/summary-panel-editor.ts',
|
||||
".title=${t('summary.settings')} wide",
|
||||
'.checked=${dialog.draft.show_on_mobile} ?disabled=${!dialog.localShow || dialog.busy}',
|
||||
);
|
||||
contains('src/device-inbox-batch.ts',
|
||||
' try {\n await deps.saveConfigNow(); // #618 B5: one write per batch\n refreshRows();',
|
||||
);
|
||||
});
|
||||
|
||||
test('#659 2.5D CSS and generated material contracts stay unit-testable strings', () => {
|
||||
contains('src/styles/iso-tiles.styles.ts',
|
||||
'.stage.projection-iso.mode-view .dev .device-shell-frame {\n border-color: transparent;',
|
||||
'pointer-events: none;\n z-index: -1;',
|
||||
'.dev.sel:not(:focus-visible) { --iso-frame: #F0A00C; }',
|
||||
'.stage.projection-iso.mode-view .iso-tile-shadows { display: none; }',
|
||||
'@media (forced-colors: active) {\n .stage.projection-iso.mode-view .iso-tile-shadows { display: none; }',
|
||||
);
|
||||
contains('src/iso-tiles.ts', '${S} .dev .device-shell.with-values { gap:');
|
||||
contains('src/iso-sun.ts',
|
||||
'const streaks = beam.lightFloor ? [inset / length, 1 - inset / length] : [];');
|
||||
contains('src/iso-scene-render.ts',
|
||||
'const wallHeight = gridVisualUnits(ISO_WALL_HEIGHT, input.cellCm);\n'
|
||||
+ ' const floorEdgeHeight = gridVisualUnits(ISO_FLOOR_EDGE_HEIGHT, input.cellCm);\n'
|
||||
+ ' const raisedHeight = gridVisualUnits(ISO_RAISED_OVERLAY_HEIGHT, input.cellCm);\n'
|
||||
+ ' const cached = lruRead(input.cache, input.source.key);',
|
||||
'return svg`<path class="iso-wall-top" d=${face.d} data-component=${face.component}');
|
||||
contains('src/hp-color-opacity.ts', 'return this.flatSwatch && !this.coverSwatch;');
|
||||
});
|
||||
|
||||
test('#659 Glow fallbacks, blockers and feather remain explicit pure contracts', () => {
|
||||
contains('src/glow-scene.ts',
|
||||
' console.warn(\n'
|
||||
+ ' `HOUSEPLAN GLOW GEOMETRY FALLBACK: #218, space ${spaceId}, room ${roomId}, phase ${phase}`,\n'
|
||||
+ ' );',
|
||||
'return pointInOpaquePlanBody(\n [source.x, source.y], scene.masonryGeometry, scene.opaqueBodies,',
|
||||
'opening.x - dx, opening.y - dy,\n opening.x + dx, opening.y + dy,',
|
||||
'const opaqueBodies = input.physicalBodies(partitionCuts, cacheKey);',
|
||||
'export const GLOW_EDGE_FEATHER_PX = 2;',
|
||||
);
|
||||
});
|
||||
|
||||
test('#659 header menu state transitions are guarded without rendering a browser menu', () => {
|
||||
contains('src/header-menu.ts',
|
||||
'const run = (item: HeaderMenuItem) => { this.close(false); item.run(); };',
|
||||
"if (event.key !== 'Escape' || !this.open) return;",
|
||||
'if (!nav || !tab || nav.clientWidth === 0) return;',
|
||||
);
|
||||
});
|
||||
|
||||
test('#659 live editor templates own guides in every editor and hide settled copies', () => {
|
||||
contains('src/live-editor.ts',
|
||||
'${host._renderAlignGuides()}\n </g>`;',
|
||||
'${host._renderDecorLayer(activeId)}\n ${host._renderAlignGuides()}',
|
||||
'${host._renderAlignGuides()}\n ${host._tool === \'draw\' ? nothing : host._renderPlanSnapOverlay()}',
|
||||
"makeTransparent(state, root, '.hp-editor-only-layer:not(.hp-plan-snap-layer)');\n if (host._mode === 'plan') {",
|
||||
);
|
||||
});
|
||||
|
||||
test('#659 settings handlers preserve dirty, discard, inversion and field ownership', () => {
|
||||
contains('src/editors/space-form.ts',
|
||||
'onChange: (v) => set(port, { ...d, fillMode: v }),',
|
||||
'const canSave = dirty && problems.length === 0 && !d.busy;',
|
||||
'if (!dirty || d.busy) { close(); return; }',
|
||||
'checked: !d.hideDecor, onChange: (v) => set(port, { ...d, hideDecor: !v }),',
|
||||
);
|
||||
contains('src/editors/general-settings-dialog.ts',
|
||||
"? callout({ kind: 'warning', role: 'status', text: t('gs.sun_missing') })",
|
||||
"title: t('gs.card_fills'),",
|
||||
'const canSave = dirty && problems.length === 0 && !d.busy;',
|
||||
'onOpacity: (a) => this._setFillColor(key, { c: v.c, a }),',
|
||||
"textLink(t('gs.north_clear'), () => set({ northDeg: null, northDegInput: '' }))",
|
||||
);
|
||||
contains('src/hp-zigbee-topology-settings.ts',
|
||||
'if (this.embedded) return this._renderEmbedded();');
|
||||
contains('src/editors/room-settings-dialog.ts',
|
||||
"if (kind === 'temp') host._roomTempSrc = v;\n else host._roomHumSrc = v;",
|
||||
'const canSave = problems.length === 0 && tempValid && (edit ? dirty : true);',
|
||||
'if (!edit || !dirty) { close(); return; }',
|
||||
"onChange: (v) => setFill(v ? '' : startMode()),",
|
||||
'onInput: (raw) => { host._roomTempMin = raw; host.requestUpdate(); },',
|
||||
);
|
||||
});
|
||||
|
||||
test('#659 marker handlers preserve callouts, baselines and explicit user choices', () => {
|
||||
contains('src/editors/marker-dialog.ts',
|
||||
'if (value === d.binding) {',
|
||||
"? callout({ kind: 'warning', role: 'status', text: t('marker.run_target_gone', { id: d.tapTarget }) })",
|
||||
'&& (edit ? dirty : true);',
|
||||
'@click=${() => { void this._saveMarker(); }}',
|
||||
'if (!edit || !dirty) { forgetMarkerBaseline(this.host); this._closeMarkerDialog(); return; }',
|
||||
"${effectiveTapAction === 'run' || effectiveTapAction === 'toggle'",
|
||||
"markerglowblock ${glowSourceDisabled ? 'hpf-disabled' : ''}",
|
||||
'valueBadgePosition: position,\n valueBadgeTouched: true,',
|
||||
);
|
||||
});
|
||||
|
||||
test('#659 room gear drag keeps the click suppression window after a real move', () => {
|
||||
contains('src/room-gear-drag.ts',
|
||||
'this.suppressClickUntil = performance.now() + 700;');
|
||||
});
|
||||
@@ -7,11 +7,15 @@ import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
MUTANTS, applyPatches, guardNeedsBundle, guardNeedsTestBuild, selectChangedMutants, shardMutants, guardFiles, packageJsonRelevance,
|
||||
MUTANTS, applyPatches, guardNeedsBundle, guardNeedsTestBuild, mutantBundleStrategy,
|
||||
mutantPatchesNeedBundle, selectChangedMutants, shardMutants, guardFiles, packageJsonRelevance,
|
||||
anchorSpan, anchorRegion, parseDiffRanges, ANCHOR_RADIUS_LINES,
|
||||
witnessFingerprint, readLedger, recordCaught, splitByLedger, LEDGER_SCHEMA,
|
||||
} from '../scripts/mutation-gate.mjs';
|
||||
import { guardPhases } from '../scripts/mutation-guard-outcome.mjs';
|
||||
import {
|
||||
BROWSER_GUARD_LIMIT, browserGuardPolicy, documentedBrowserGuards,
|
||||
} from '../scripts/mutation-browser-policy.mjs';
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
@@ -202,6 +206,26 @@ test('#332: каждый гвард реестра классифицирует
|
||||
assert.ok(rest >= 150, `небраузерных гвардов подозрительно мало: ${rest}`);
|
||||
});
|
||||
|
||||
test('#659: browser guard inventory is reviewed, capped and exact', () => {
|
||||
const markdown = readFileSync(join(repoRoot, 'docs/testing-notes/mutation-browser-guards.md'), 'utf8');
|
||||
const policy = browserGuardPolicy(MUTANTS, documentedBrowserGuards(markdown));
|
||||
assert.equal(policy.count, BROWSER_GUARD_LIMIT);
|
||||
assert.deepEqual(policy.missingReasons, []);
|
||||
assert.deepEqual(policy.staleReasons, []);
|
||||
});
|
||||
|
||||
test('#659: browser-only mutations reuse one clean bundle unless their patch is bundled', () => {
|
||||
const corpus = new Set(['src/card.ts', 'scripts/source-fingerprint.mjs']);
|
||||
const smoke = (file) => ({ guard: 'node demo/smoke_x.mjs', patches: [{ file }] });
|
||||
assert.equal(mutantPatchesNeedBundle(smoke('src/card.ts').patches, corpus), true);
|
||||
assert.equal(mutantPatchesNeedBundle(smoke('scripts/check.mjs').patches, corpus), false);
|
||||
assert.equal(mutantBundleStrategy(smoke('scripts/check.mjs'), { seedAvailable: true, corpus }), 'seed');
|
||||
assert.equal(mutantBundleStrategy(smoke('tests_backend/test_x.py'), { seedAvailable: true, corpus }), 'seed');
|
||||
assert.equal(mutantBundleStrategy(smoke('src/card.ts'), { seedAvailable: true, corpus }), 'build');
|
||||
assert.equal(mutantBundleStrategy({ guard: 'node --test test/x.test.mjs', patches: [] },
|
||||
{ seedAvailable: true, corpus }), 'none');
|
||||
});
|
||||
|
||||
// --- #332: дифф-режим и шарды ---
|
||||
|
||||
test('#332: selectChangedMutants берёт мутанта при любом задетом patch.file', () => {
|
||||
@@ -717,7 +741,7 @@ test('#499: ни один гвард реестра не собирает бан
|
||||
const guard = 'npm run bundle:sync && node demo/smoke_furniture.mjs';
|
||||
assert.ok(/bundle:sync|bundle-sync\.mjs|rollup -c/.test(guard));
|
||||
assert.equal(guardNeedsBundle('node demo/smoke_furniture.mjs'), true, 'без префикса гвард остаётся браузерным');
|
||||
const source = readFileSync(new URL('../scripts/mutation-gate.mjs', import.meta.url), 'utf8');
|
||||
const source = readFileSync(new URL('../scripts/mutation-registry-check.mjs', import.meta.url), 'utf8');
|
||||
assert.match(source, /гвард сам собирает бандл — сборку делает раннер \(#499\)/);
|
||||
});
|
||||
|
||||
@@ -1000,7 +1024,7 @@ test('#620 (реестр): каждый смок-гард получает бр
|
||||
test('#620: --plan-only печатает окружение плана', () => {
|
||||
const script = join(repoRoot, 'scripts/mutation-gate.mjs');
|
||||
const plan = (id) => spawnSync(process.execPath, [script, `--id=${id}`, '--plan-only'], { encoding: 'utf8' });
|
||||
const smoke = plan('discard-confirm-action-icon-falls-back-to-lock');
|
||||
const smoke = plan('dense-device-hit-browser-skips-painted-priority');
|
||||
assert.equal(smoke.status, 0, smoke.stderr);
|
||||
assert.match(smoke.stdout, /^plan=1$/m);
|
||||
assert.match(smoke.stdout, /^plan-browser=true$/m);
|
||||
|
||||
Reference in New Issue
Block a user