Files
houseplan-card/demo/smoke_junction_limits.mjs
T
Sergey Matyunin e36767773a 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
2026-09-13 17:06:24 +03:00

325 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Issue #329: the owner's junction limits refuse the WRITE, each surface
* through its own channel (spec §2), and a legacy plan that already violates
* them still accepts unrelated edits (spec §3).
*/
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 900 }, 1);
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const update = async () => { card.requestUpdate(); await card.updateComplete; };
const toasts = [];
card._showToast = (text) => { toasts.push(String(text)); };
const CELL = 5;
// NORM_W units per centimetre for a 5 cm grid cell.
const cm = (value) => (value / CELL) * (1000 / 240);
const reset = async () => {
card._serverCfg = { spaces: [{
id: 'limits', title: 'Limits', cell_cm: CELL, view_box: [0, 0, 1, 1],
rooms: [], openings: [], room_drafts: [], partitions: [], wall_columns: [],
}], markers: [], settings: {} };
card._space = 'limits'; card._layout = {};
card._cfgEpoch++; card._modelCache = null; card._frame = null;
card._path = []; card._activeDraftId = null;
card._nameSel = ''; card._areaSel = '';
card._setMode('plan'); card._tool = 'draw'; card._drawWallField = '15';
await update();
toasts.length = 0;
};
const drawRoom = async (points, name) => {
card._path = [...points, points[0]];
card._nameSel = name; card._areaSel = '';
card._commitRoom();
await update();
return (card._spaceModel().rooms || []).some((room) => room.name === name);
};
const wedge = (degrees, originX = 400, originY = 600) => {
const radians = (degrees * Math.PI) / 180;
const reach = cm(400);
return [
[originX, originY],
[originX + reach, originY],
[originX + reach * Math.cos(radians), originY - reach * Math.sin(radians)],
];
};
// AC1: 14° отклоняется и называет правило; 16° проходит.
await reset();
result.angle14Refused = !(await drawRoom(wedge(14), 'narrow14'));
result.angle14NamesRule = toasts.some((text) => text.includes('15'));
await reset();
result.angle16Accepted = await drawRoom(wedge(16), 'wide16');
// AC3: сегмент 19 см отклоняется, 20 см проходит.
await reset();
result.short19Refused = !(await drawRoom([
[400, 600], [400 + cm(19), 600], [400 + cm(19), 600 - cm(300)],
[400, 600 - cm(300)],
], 'short19'));
result.short19NamesRule = toasts.some((text) => text.includes('20'));
await reset();
result.side20Accepted = await drawRoom([
[400, 600], [400 + cm(300), 600], [400 + cm(300), 600 - cm(300)],
[400, 600 - cm(300)],
], 'square300');
// AC5a: «шпиль» фикстуры issue (≈9.9°) не нарисовать заново.
await reset();
result.spikeRefused = !(await drawRoom(wedge(9.9), 'spike'));
result.spikeNamesRule = toasts.some((text) => text.includes('15'));
// AC7b: «Толщина» отказывает через тост с названием правила: 100 см на
// стенах комнаты 100x100 съедает весь просвет (П5).
await reset();
await drawRoom([
[400, 600], [400 + cm(100), 600], [400 + cm(100), 600 - cm(100)],
[400, 600 - cm(100)],
], 'forThickness');
toasts.length = 0;
const room = (card._curSpaceCfg.rooms || []).find((item) => item.name === 'forThickness');
// Диалог «Толщины» открывается по реальной записи стены — так же, как из UI.
const wall = (card._curSpaceCfg.walls || [])[0];
card._tool = 'wallthick';
card._wallDialog = {
a: wall.a, b: wall.b, value: '100', roomId: room.id,
source: { kind: 'room' }, sx: 50, sy: 50,
};
card._wallThickApply(true);
await update();
const catalogue = (card._curSpaceCfg.wall_segments || []).map((item) => item.cm);
const legacy = (card._curSpaceCfg.walls || []).map((item) => item.cm);
result.thicknessRefusedByLimit = ![...catalogue, ...legacy].includes(100);
result.thicknessToastShown = toasts.some((text) => text.includes('25'));
// #331 AC5: исключение при проверке КАНДИДАТА — отказ с тостом
// limit_check_failed (fail-closed, как гард #278), baseline остаётся
// fail-open. Ломаем только вызовы с кандидатом: baseline — это _serverCfg.
await reset();
// Ломаем ТОЛЬКО кандидата. Порядок вызовов в барьере детерминирован:
// №1 — baseline (кэш пуст после reset: _serverCfg — новый объект),
// №2 — кандидат. Падение baseline легально пропускает запись целиком
// (недоказуемое наследование не повод для отказа — иначе унаследованные
// нарушения читались бы «новыми»), поэтому асимметрию §2.5 доказывает
// именно падение второго вызова.
const originalViolations = card._junctionLimitViolations.bind(card);
let violationCalls = 0;
card._junctionLimitViolations = (...args) => {
violationCalls += 1;
if (violationCalls >= 2) throw new Error('synthetic check failure');
return originalViolations(...args);
};
toasts.length = 0;
result.checkFailureRefusesWrite = !(await drawRoom([
[400, 600], [400 + cm(300), 600], [400 + cm(300), 600 - cm(300)],
[400, 600 - cm(300)],
], 'failClosed'));
result.checkFailureToastShown = toasts.some((text) => /не удалось|could not run/i.test(text));
card._junctionLimitViolations = originalViolations;
// §3: унаследованное нарушение не блокирует несвязанную запись.
card._serverCfg = { spaces: [{
id: 'limits', title: 'Limits', cell_cm: CELL, view_box: [0, 0, 1, 1],
rooms: [{ id: 'legacy', name: 'legacy', area: null, poly: [
[0.30, 0.70], [0.3167, 0.24], [0.36, 0.68],
] }],
walls: [
{ key: 'l0', a: [0.30, 0.70], b: [0.3167, 0.24], cm: 15 },
{ key: 'l1', a: [0.3167, 0.24], b: [0.36, 0.68], cm: 15 },
{ key: 'l2', a: [0.36, 0.68], b: [0.30, 0.70], cm: 15 },
],
openings: [], room_drafts: [], partitions: [], wall_columns: [],
}], markers: [], settings: {} };
card._cfgEpoch++; card._modelCache = null; card._frame = null;
card._path = []; card._tool = 'draw'; await update();
toasts.length = 0;
result.legacyKeepsAcceptingEdits = await drawRoom([
[700, 700], [700 + cm(300), 700], [700 + cm(300), 700 - cm(300)],
[700, 700 - cm(300)],
], 'unrelated');
return result;
});
// AC7a (канал Resize): стена упирается в последнюю ДОПУСТИМУЮ позицию, а не
// проезжает нарушение. Сетка 2 см: две комнаты в 10 см друг от друга, тяга
// на 6 см вправо оставила бы 4 см между чужими узлами (П4 — минимум 5 см).
// Жест настоящий: pointer по ручке, без вызовов внутренних методов Resize.
const settle = () => page.evaluate(() => new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve))));
await page.evaluate(async () => {
const card = window.__card;
window.__toasts = [];
card._showToast = (text) => { window.__toasts.push(String(text)); };
const cm = (value) => (value / 2) * (1000 / 240);
card._serverCfg = { spaces: [{
id: 'limits', title: 'Limits', cell_cm: 2, view_box: [0, 0, 1, 1],
rooms: [], openings: [], room_drafts: [], partitions: [], wall_columns: [],
}], markers: [], settings: {} };
card._space = 'limits'; card._layout = {};
card._cfgEpoch++; card._modelCache = null; card._frame = null;
card._setMode('plan'); card._tool = 'draw'; card._drawWallField = '15';
card.requestUpdate(); await card.updateComplete;
const draw = (points, name) => {
card._path = [...points, points[0]];
card._nameSel = name; card._areaSel = '';
card._commitRoom();
card._path = [];
};
draw([[200, 700], [200 + cm(200), 700], [200 + cm(200), 700 - cm(200)],
[200, 700 - cm(200)]], 'A');
draw([[200 + cm(210), 700], [200 + cm(400), 700],
[200 + cm(400), 700 - cm(200)], [200 + cm(210), 700 - cm(200)]], 'B');
card._tool = 'resize';
card.requestUpdate(); await card.updateComplete;
window.__toasts.length = 0;
});
await settle();
await page.waitForTimeout(700);
await page.evaluate(() => { window.__toasts.length = 0; });
// Просвет между правой гранью A и левой гранью B, в сантиметрах.
const gapCm = () => page.evaluate(() => {
const rooms = window.__card._curSpaceCfg.rooms;
const a = Math.max(...rooms.find((room) => room.name === 'A').poly.map((p) => p[0]));
const b = Math.min(...rooms.find((room) => room.name === 'B').poly.map((p) => p[0]));
return Math.round(((b - a) * 1000) / (1000 / 240) * 2);
});
const handle = 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'));
return { start: map(cx, cy), end: map(cx + (6 / 2) * (1000 / 240), cy) };
});
// #330 AC4 (код-ревью r2-M1: общий счётчик не различал кэш — 11 против 12
// вызовов тонули в шуме). Считаем ОТДЕЛЬНО вычисления baseline: вызовы
// _junctionLimitViolations с документом === _serverCfg. С кэшем их ровно
// один на жест; без кэша — по одному на каждый pointermove.
await page.evaluate(() => {
const card = window.__card;
window.__jlBaselineCalls = 0;
const original = card._junctionLimitViolations.bind(card);
card._junctionLimitViolations = (...args) => {
if (args[0] === card._serverCfg) window.__jlBaselineCalls += 1;
return original(...args);
};
});
const resize = { resizeHandleFound: !!handle, resizeGapBefore: await gapCm() };
if (handle) {
await page.mouse.move(...handle.start);
await page.mouse.down();
await page.mouse.move(...handle.end, { steps: 10 });
await settle();
await page.mouse.up();
await settle();
await page.waitForTimeout(700);
const toasts = await page.evaluate(() => window.__toasts.slice());
// 6 см дало бы 4 см — отказ; стена стоит на 6 см, последней допустимой.
resize.resizeStoppedAtLastAllowed = await gapCm();
resize.resizeRefusalNamesRule = toasts.some((text) => text.includes('5'));
resize.resizeRefusalOnce = toasts.length;
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;
}
}
// #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);