From e36767773ae03b0214b233c2e9f898523d09ffaf Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 13 Sep 2026 17:06:24 +0300 Subject: [PATCH] test: make mutation oracles explicit before v1.76.0-beta.1 Run 34760156615 exposed stale registry witnesses under the honest #550 outcome taxonomy. Keep behavioural checks out of setup chains, retarget the preflight mutation to the editor host, and make the junction cache smoke exercise same-object in-place geometry changes. Release: v1.76.0-beta.1 Issue: #550 User-Visible: no --- demo/smoke_junction_limits.mjs | 37 ++++++++++++++++++++++++++++++++++ scripts/mutation-gate.mjs | 20 +++++++++--------- test/mutation-gate.test.mjs | 19 +++++++++++++++++ 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/demo/smoke_junction_limits.mjs b/demo/smoke_junction_limits.mjs index 853c36b4..9ff36468 100644 --- a/demo/smoke_junction_limits.mjs +++ b/demo/smoke_junction_limits.mjs @@ -279,9 +279,46 @@ if (handle) { } } +// #330 AC4 / #550: the production cache is keyed by the config object because +// physical commits intentionally update that object in place. Prove that a +// changed geometry fingerprint invalidates an entry even when object identity +// is unchanged; the gesture above normally receives a fresh object after the +// write and therefore cannot distinguish this case on its own. +resize.resizeBaselineFingerprintRejectsStaleObject = await page.evaluate(() => { + const card = window.__card; + const runtime = card._editorRuntime; + const previous = structuredClone(card._serverCfg); + const space = previous?.spaces?.find((entry) => entry.id === card._space); + if (!runtime || !space || !space.rooms?.length) return false; + const firstRoom = space.rooms[0]; + const point = Array.isArray(firstRoom.poly) ? firstRoom.poly[0] : null; + if (!Array.isArray(point)) return false; + const original = card._junctionLimitViolations; + let calls = 0; + card._junctionLimitViolations = (...args) => { + if (args[0] === previous) calls += 1; + return original(...args); + }; + try { + runtime._junctionLimitsIntroduced(structuredClone(previous), previous, space.id); + if (!runtime._junctionBaselineCache.get(previous)) return false; + point[0] += 0.001; + runtime._junctionLimitsIntroduced(structuredClone(previous), previous, space.id); + return calls === 2; + } finally { + card._junctionLimitViolations = original; + } +}); + checkAll({ ...out, ...resize }, { resizeGapBefore: 10, resizeStoppedAtLastAllowed: 6, resizeRefusalOnce: 1, + resizeHandleFound: true, + resizeRefusalNamesRule: true, + resizeBaselineComputedOncePerGesture: true, + resizeSecondHandleFound: true, + resizeBaselineRecomputedAfterCommit: true, + resizeBaselineFingerprintRejectsStaleObject: true, }); await finish(browser); diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index a9c07b03..312eaaef 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -3269,12 +3269,11 @@ const MUTANT_DEFINITIONS = [ }, { id: 'junction-limit-baseline-cache-stale', - // r2-M1: the behavioural guard — the smoke counts BASELINE computations - // (exactly one per gesture with a working cache, one per pointermove - // without) — plus the source-contract unit stays as the cheap first line. - guard: 'node demo/smoke_junction_limits.mjs ' - + '&& npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' - + '&& node --test --test-name-pattern="#330 AC4" test/junction-limits.test.mjs', + // r2-M1: the behavioural oracle counts BASELINE computations (exactly one + // per gesture with a working cache, one per pointermove without). Keep it + // as the sole oracle: a second behavioural command in an `&&` prefix is a + // setup phase under the honest #550 taxonomy and cannot prove the mutant. + guard: 'node demo/smoke_junction_limits.mjs', because: 'a baseline cache that survives a config epoch change serves verdicts of a ' + 'plan that no longer exists — the epoch check IS the invalidation contract (#330 §4.4)', patches: [{ @@ -5943,9 +5942,11 @@ const MUTANT_DEFINITIONS = [ + '&& node demo/smoke_preflight_diagnostics.mjs', because: 'диагностика с хэшем сохранённой геометрии не несёт ничего сверх экспорта (AC4)', patches: [{ - file: 'src/houseplan-card.ts', + // This method lives on HouseplanEditorRuntime after the editor split; + // the saved config belongs to the typed host, not to the runtime itself. + file: 'src/houseplan-editor-runtime.ts', find: " const spacesById = new Map(((candidate as any)?.spaces || [])", - replace: " const spacesById = new Map(((this._serverCfg as any)?.spaces || [])", + replace: " const spacesById = new Map(((this.host._serverCfg as any)?.spaces || [])", }], }, { @@ -5968,8 +5969,7 @@ const MUTANT_DEFINITIONS = [ // возвращает глобальный блокер рисования beta.3. guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="migrates unhosted instead of blocking" ' - + 'test/wall-segment-model.test.mjs ' - + '&& node demo/smoke_zero_wall_migration_unblocked.mjs', + + 'test/wall-segment-model.test.mjs', because: 'один конфликтный проём снова заблокировал бы структурные записи во всех пространствах (#316)', patches: [{ file: 'src/wall-segment-model.ts', diff --git a/test/mutation-gate.test.mjs b/test/mutation-gate.test.mjs index a3087135..ae896714 100644 --- a/test/mutation-gate.test.mjs +++ b/test/mutation-gate.test.mjs @@ -11,6 +11,7 @@ import { 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'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -64,6 +65,24 @@ test('every mutant explains itself', () => { assert.ok(MUTANTS.length >= 6, 'стартовый набор — шесть мутантов по дырам из #85'); }); +test('#550: assertion witnesses have no behavioural oracle hidden in setup', () => { + const behavioural = /(?:node --test|(?:^|\s)node demo\/(?:smoke|benchmark)_|(?:^|\s)(?:python3? -m )?pytest\b)/; + for (const mutant of MUTANTS.filter(({ oracle }) => (oracle || 'assertion') === 'assertion')) { + const phases = guardPhases(mutant.guard, 'assertion'); + for (const command of phases.setup) { + assert.doesNotMatch(command, behavioural, + `${mutant.id}: behavioural command is setup, not the declared oracle`); + } + } +}); + +test('#550: preflight saved-config mutant targets the editor host and still compiles', () => { + const mutant = MUTANTS.find(({ id }) => id === 'preflight-fingerprint-from-saved-config'); + assert.ok(mutant); + assert.equal(mutant.patches[0].file, 'src/houseplan-editor-runtime.ts'); + assert.match(mutant.patches[0].replace, /this\.host\._serverCfg/); +}); + test('#486 panel registration, cleanup and read-only protections have mutation witnesses', () => { const ids = new Set(MUTANTS.map(({ id }) => id)); for (const id of [