mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: the baseline cache keys on geometry, and the smoke tells all three worlds apart (#330 r2-M1)
The reviewer proved my behavioural claim false by running the stale-cache mutant against the smoke: 11 vs 12 total calls — indistinguishable. Two real defects hid behind that finding: 1. The cache keyed on _cfgEpoch, which ticks on every ACCEPTED PREVIEW — the cache missed on every pointermove and the baseline was recomputed ~4 times per gesture (measured). The key is now the document identity plus spacePhysicalGeometryFingerprint of its space: content, not a counter. A preview overlay leaves the fingerprint alone; an in-place structural commit changes it and honestly invalidates. 2. The smoke now counts BASELINE computations only (calls whose document is _serverCfg) across two gestures with a commit in between, expecting exactly 1 then exactly 2. A disabled cache lands near 20, an eternal cache stays at 1 — every mutant class turns the smoke red, and the smoke is now the mutant's guard alongside the source-contract unit. Issue: #330 User-Visible: no
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -183,14 +183,16 @@ const handle = await page.evaluate(() => {
|
||||
return { start: map(cx, cy), end: map(cx + (6 / 2) * (1000 / 240), cy) };
|
||||
});
|
||||
|
||||
// #330 AC4: в одном жесте baseline считается один раз — N move дают N+1
|
||||
// вызовов _junctionLimitViolations (кандидаты + один baseline), не 2N.
|
||||
// #330 AC4 (код-ревью r2-M1: общий счётчик не различал кэш — 11 против 12
|
||||
// вызовов тонули в шуме). Считаем ОТДЕЛЬНО вычисления baseline: вызовы
|
||||
// _junctionLimitViolations с документом === _serverCfg. С кэшем их ровно
|
||||
// один на жест; без кэша — по одному на каждый pointermove.
|
||||
await page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
window.__jlCalls = 0;
|
||||
window.__jlBaselineCalls = 0;
|
||||
const original = card._junctionLimitViolations.bind(card);
|
||||
card._junctionLimitViolations = (...args) => {
|
||||
window.__jlCalls += 1;
|
||||
if (args[0] === card._serverCfg) window.__jlBaselineCalls += 1;
|
||||
return original(...args);
|
||||
};
|
||||
});
|
||||
@@ -209,11 +211,47 @@ if (handle) {
|
||||
resize.resizeStoppedAtLastAllowed = await gapCm();
|
||||
resize.resizeRefusalNamesRule = toasts.some((text) => text.includes('5'));
|
||||
resize.resizeRefusalOnce = toasts.length;
|
||||
const jlCalls = await page.evaluate(() => window.__jlCalls);
|
||||
// Жест из 10 move: без кэша (#330 §4.4) было бы ~2N вызовов; с кэшем —
|
||||
// N кандидатов + один baseline. Верхняя граница с запасом на дребезг
|
||||
// квантования шагов, но заведомо ниже 2N.
|
||||
resize.resizeBaselineCachedPerGesture = jlCalls > 0 && jlCalls <= 12;
|
||||
const baselineCalls = await page.evaluate(() => window.__jlBaselineCalls);
|
||||
// Ровно одно вычисление baseline на жест: второй и дальнейшие move обязаны
|
||||
// попадать в кэш (#330 §4.4). Без кэша здесь было бы ~10 — порог различает
|
||||
// рабочий кэш от отключённого без права на шум.
|
||||
resize.resizeBaselineComputedOncePerGesture = baselineCalls === 1;
|
||||
|
||||
// Обратная сторона (r2-M1): кэш обязан ЧЕСТНО инвалидироваться. Первый
|
||||
// жест закоммитил план — геометрия baseline изменилась, и второй жест
|
||||
// обязан пересчитать его ровно один раз. Вечный кэш оставил бы счётчик на
|
||||
// 1, отключённый — унёс к ~20; ожидание строго 2 различает все три мира.
|
||||
const second = await page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
const handles = [...card.renderRoot.querySelectorAll('.rszhandle[aria-disabled="false"]')];
|
||||
const rooms = card._curSpaceCfg.rooms;
|
||||
const aRight = Math.max(...rooms.find((room) => room.name === 'A').poly.map((p) => p[0])) * 1000;
|
||||
const found = handles.find((entry) => Math.abs(Number(entry.getAttribute('cx')) - aRight) < 1.5);
|
||||
if (!found) return null;
|
||||
const svg = found.ownerSVGElement;
|
||||
const map = (x, y) => {
|
||||
const point = svg.createSVGPoint();
|
||||
point.x = x; point.y = y;
|
||||
const mapped = point.matrixTransform(found.getScreenCTM());
|
||||
return [mapped.x, mapped.y];
|
||||
};
|
||||
const cx = Number(found.getAttribute('cx'));
|
||||
const cy = Number(found.getAttribute('cy'));
|
||||
// Один шаг назад: законное движение, границы П4 не задевает.
|
||||
return { start: map(cx, cy), end: map(cx - (2 / 2) * (1000 / 240), cy) };
|
||||
});
|
||||
resize.resizeSecondHandleFound = !!second;
|
||||
if (second) {
|
||||
await page.mouse.move(...second.start);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(...second.end, { steps: 6 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
await page.waitForTimeout(700);
|
||||
const afterSecond = await page.evaluate(() => window.__jlBaselineCalls);
|
||||
resize.resizeBaselineRecomputedAfterCommit = afterSecond === 2;
|
||||
}
|
||||
}
|
||||
|
||||
checkAll({ ...out, ...resize }, {
|
||||
|
||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -563,13 +563,18 @@ export const MUTANTS = [
|
||||
},
|
||||
{
|
||||
id: 'junction-limit-baseline-cache-stale',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
// 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',
|
||||
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: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: " if (cached && cached.epoch === this._cfgEpoch && cached.spaceId === spaceId) {",
|
||||
find: " if (cached && fingerprint && cached.fingerprint === fingerprint\n"
|
||||
+ ' && cached.spaceId === spaceId) {',
|
||||
replace: ' if (cached && cached.spaceId === spaceId) {',
|
||||
}],
|
||||
},
|
||||
|
||||
+17
-8
@@ -7498,9 +7498,9 @@ class HouseplanCard extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
/** #330 §4.4: baseline violations per (document identity, epoch, space). */
|
||||
/** #330 §4.4: baseline violations per (document identity, space, geometry). */
|
||||
private _junctionBaselineCache = new WeakMap<object, {
|
||||
epoch: number; spaceId: string; violations: JunctionLimitViolation[];
|
||||
spaceId: string; fingerprint: string; violations: JunctionLimitViolation[];
|
||||
}>();
|
||||
|
||||
/** #329: violations this write would ADD; inherited ones stay untouched. */
|
||||
@@ -7517,12 +7517,21 @@ class HouseplanCard extends LitElement {
|
||||
// re-migration cost 69 ms per pointermove on a 576-atom plan.
|
||||
let inherited: JunctionLimitViolation[] | undefined;
|
||||
// #330 §4.4: a resize gesture judges the SAME previous document on every
|
||||
// pointermove. Object identity plus the config epoch make the cache both
|
||||
// exact and self-invalidating — any real write bumps the epoch, and a
|
||||
// freshly built baseline object simply never hits.
|
||||
// pointermove. The cache key is the document's identity plus the
|
||||
// geometry fingerprint of ITS space — content, not a counter: the
|
||||
// preview epoch (_cfgEpoch) ticks on every accepted preview overlay
|
||||
// without the stored document changing (that miss cost one full baseline
|
||||
// pass per pointermove, review 330-r2), while an in-place structural
|
||||
// commit changes the fingerprint and honestly invalidates.
|
||||
const previousSpace = (previousConfig?.spaces || [])
|
||||
.find((space: any) => space?.id === spaceId);
|
||||
let fingerprint = '';
|
||||
try { fingerprint = spacePhysicalGeometryFingerprint(previousSpace); }
|
||||
catch { fingerprint = ''; }
|
||||
const cached = previousConfig && typeof previousConfig === 'object'
|
||||
? this._junctionBaselineCache.get(previousConfig) : undefined;
|
||||
if (cached && cached.epoch === this._cfgEpoch && cached.spaceId === spaceId) {
|
||||
if (cached && fingerprint && cached.fingerprint === fingerprint
|
||||
&& cached.spaceId === spaceId) {
|
||||
inherited = cached.violations;
|
||||
}
|
||||
if (!inherited) {
|
||||
@@ -7536,9 +7545,9 @@ class HouseplanCard extends LitElement {
|
||||
// refuse the write on that basis.
|
||||
return [];
|
||||
}
|
||||
if (previousConfig && typeof previousConfig === 'object') {
|
||||
if (previousConfig && typeof previousConfig === 'object' && fingerprint) {
|
||||
this._junctionBaselineCache.set(previousConfig, {
|
||||
epoch: this._cfgEpoch, spaceId, violations: inherited,
|
||||
spaceId, fingerprint, violations: inherited,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,8 +390,10 @@ test('#330 AC4: кэш baseline инвалидируется по конфиг-
|
||||
source.indexOf('private _junctionLimitsIntroduced('),
|
||||
source.indexOf('private _commitPhysicalGeometry('),
|
||||
);
|
||||
assert.match(method, /cached\.epoch === this\._cfgEpoch/,
|
||||
'кэш baseline обязан сверять конфиг-эпоху — иначе он переживает write и судит план, которого больше нет');
|
||||
assert.match(method, /cached\.fingerprint === fingerprint/,
|
||||
'кэш baseline обязан сверять геометрический fingerprint — счётчик превью тикает без изменения документа, а in-place commit меняет геометрию без смены identity');
|
||||
assert.match(method, /spacePhysicalGeometryFingerprint\(previousSpace\)/,
|
||||
'fingerprint берётся от пространства baseline-документа');
|
||||
assert.match(method, /_junctionBaselineCache\.set\(previousConfig/,
|
||||
'кэш ключуется идентичностью документа');
|
||||
assert.match(method, />= WALL_SEGMENT_MODEL_VERSION\s*\n?\s*\? previousConfig/,
|
||||
|
||||
Reference in New Issue
Block a user