Files
houseplan-card/demo/smoke_grid_snap.mjs
Claudeandclaude[bot] fa319e37b3 test(harness): тестовый фасад window.__hpTest и гейт no-new-private-writes (#629)
- scripts/no-new-private-writes.mjs: смоки и demo/helpers/** не добавляют
  записей в приватное состояние карточки (присваивание, ++/--, delete по
  цепочке с сегментом _x; от this — нет) и вызовов _setMode/_openRoomEdit/
  _openMarkerDialog/_openSpaceDialog. Зачёт правки по полю, перенос блока —
  movedLinesByFile из no-new-any; исключение // private-ok: <причина>.
  --count — остаток на HEAD. Подключён в gate:small и в шаг frontend рядом с
  no-new-any, с той же базой.
- demo/helpers/hp-test.mjs: 10 операций через контрактные хуки и события
  фикстуры (setMode, setTool, switchSpace, openRoomEdit, openMarkerDialog,
  openSpaceDialog, setServerConfig, setLayout, input, close); ставится
  launch*() из demo/serve.mjs. В бандле фасада нет.
- demo/srv/demo.html: доставка houseplan_config_updated/_layout_updated,
  __pushServerConfig/__pushServerLayout; после доставки запись со старым
  expected_rev — conflict, как у настоящего сервера.
- HP_SMOKE_CHECKS=1 печатает имена проверок в finish().
- smoke_area_relocation, smoke_glow, smoke_grid_snap переведены на фасад без
  потери утверждений; новый smoke_test_facade доказывает каждую операцию.
- 7 мутантов, docs/TESTING.md (раздел + правило №6), PROCESS.md §2.7, AGENTS.md.

Issue: #629
User-Visible: no
2026-09-25 01:25:20 +00:00

315 lines
16 KiB
JavaScript

// «Всё строго по сетке» (docs/CANVAS.md §9). Two halves:
// 1. every element placed or dragged by hand lands on a node — devices, room
// labels, decor, room vertices, resize handles; an opening lands ON ITS
// WALL at a whole number of steps along it; positional off-grid placement
// is not supported. Shift keeps the room-outline point on the grid while
// additionally constraining its wall to the nearest 45° direction;
// 2. the explicit «Оптимизировать планы» action moves a deliberately
// detuned plan onto the grid through one atomic config+layout commit,
// tells the truth about how much it moves, and is idempotent.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
// #629: режимы и инструменты — настоящими кнопками, конфиг и раскладка —
// событием сервера через фасад. Состояние жестов (_drag, _path, черновики
// декора) по-прежнему выставляется напрямую: операций для него нет.
const hp = window.__hpTest;
const sr = () => c.shadowRoot || c.renderRoot;
const NORM_W = 1000, GRID_N = 240;
const PITCH = NORM_W / GRID_N;
const makePhysical = (space) => {
delete space.wall_segments;
delete space.open_spans;
space.walls = (space.rooms || []).flatMap((room) => {
const poly = room.poly || (Number.isFinite(room.x) ? [
[room.x, room.y], [room.x + room.w, room.y],
[room.x + room.w, room.y + room.h], [room.x, room.y + room.h],
] : []);
delete room.wall_ids;
return poly.map((a, index) => ({
key: `${room.id}-${index}`, a, b: poly[(index + 1) % poly.length], cm: 15,
}));
});
};
const firstSpace = c._space;
await hp.setServerConfig((cfg) => {
cfg.model_version = 7;
makePhysical(cfg.spaces.find((space) => space.id === firstSpace));
});
const onGridN = (v) => Math.abs(v * GRID_N - Math.round(v * GRID_N)) < 1e-9;
const onGridR = (v) => Math.abs(v / PITCH - Math.round(v / PITCH)) < 1e-7;
/** A click/pointer event at a given RENDER-unit point of the current view. */
const at = (X, Y, type = 'pointermove', extra = {}) => {
const rect = sr().querySelector('.stage').getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return new PointerEvent(type, {
clientX: rect.left + ((X - v.x) / v.w) * rect.width,
clientY: rect.top + ((Y - v.y) / v.h) * rect.height, bubbles: true, ...extra,
});
};
// an off-grid target: a third of a step past a node, in both axes
const OFF = PITCH / 3;
// ---- 1a) the snap itself, the one gate every gesture goes through -----
const s1 = c._snap([100 + OFF, 200 + OFF]);
o.snapRoundsToTheNode = onGridR(s1[0]) && onGridR(s1[1]);
const s2 = c._snap([100 + OFF, 200 + OFF], { shiftKey: true });
o.shiftCannotSuspendTheSnap = onGridR(s2[0]) && onGridR(s2[1]);
o.snapAlsoClampsTheGarbage = c._snap([1e12, -1e12])[0] === 5000 * NORM_W;
// the step is NORM_W/GRID_N and nothing else — it did NOT change with the
// infinite canvas, so no plan's nodes ever moved out from under it
o.pitchIsTheCanvasFreeConstant = Math.abs(c._gridPitch - PITCH) < 1e-12;
// ---- 1b) a DEVICE dropped with the mouse lands on a node --------------
await hp.setMode('devices');
const dev = c._devices.find((d) => !d.virtual);
const p0 = c._pos(dev);
const mkDelta = (dxu, dyu, extra = {}) => {
const rect = sr().querySelector('.stage').getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return new PointerEvent('pointermove', {
clientX: 400 + (dxu / v.w) * rect.width, clientY: 400 + (dyu / v.h) * rect.height,
bubbles: true, ...extra,
});
};
const devNode = sr().querySelector(`.dev[data-id="${dev.id}"]`);
const devRect = devNode.getBoundingClientRect();
const stageRect = sr().querySelector('.stage').getBoundingClientRect();
const deviceView = c._viewOr(c._baseVb());
const startX = devRect.left + devRect.width / 2;
const startY = devRect.top + devRect.height / 2;
const dxPx = ((137.7 - p0.x + OFF) / deviceView.w) * stageRect.width;
const dyPx = ((251.3 - p0.y + OFF) / deviceView.h) * stageRect.height;
const devicePointer = (type, x, y, buttons) => devNode.dispatchEvent(new PointerEvent(type, {
pointerId: 174, pointerType: 'mouse', button: 0, buttons,
clientX: x, clientY: y, bubbles: true, composed: true, cancelable: true,
}));
devicePointer('pointerdown', startX, startY, 1);
devicePointer('pointermove', startX + dxPx, startY + dyPx, 1);
devicePointer('pointerup', startX + dxPx, startY + dyPx, 0);
while (c._devicePositionBusy) await new Promise((resolve) => setTimeout(resolve, 5));
o.deviceLandsOnANode = onGridN(c._layout[dev.id].x) && onGridN(c._layout[dev.id].y);
// ---- 1c) a ROOM LABEL too ---------------------------------------------
await hp.setMode('plan');
const room = c._spaceModel(c._space).rooms.find((r) => r.name);
const lp = c._labelPos(room, c._space);
c._drag = { id: 'rl_' + room.id, sx: 400, sy: 400, ox: lp.x, oy: lp.y, moved: false };
c._labelMove(mkDelta(311.4 - lp.x + OFF, 402.9 - lp.y + OFF), room, c._space);
c._drag = null;
o.roomLabelLandsOnANode = onGridN(c._layout['rl_' + room.id].x)
&& onGridN(c._layout['rl_' + room.id].y);
// …and one that has never been dragged is on a node as well
const other = c._spaceModel(c._space).rooms.find((r) => r.name && r.id !== room.id);
const op = c._labelPos(other, c._space);
o.untouchedLabelIsOnANode = onGridR(op.x) && onGridR(op.y);
// ---- 1d) DECOR: draft, text anchor, and a move ------------------------
await hp.setMode('decor');
await hp.setTool('rect');
// dispatched for real, so the handler sees a target (it looks for .dshape)
sr().querySelector('.stage').dispatchEvent(at(300 + OFF, 300 + OFF, 'pointerdown', { pointerId: 21 }));
o.decorDraftStartsOnANode = onGridR(c._decorDraft.a[0]) && onGridR(c._decorDraft.a[1]);
c._decorDraft = { ...c._decorDraft, b: c._snap([420 + OFF, 380 + OFF]) };
c._decorCommitDraft(); await c.updateComplete;
const dsh = c._decorList[c._decorList.length - 1];
o.decorShapeIsOnNodes = onGridN(dsh.x) && onGridN(dsh.y)
&& onGridN(dsh.x + dsh.w) && onGridN(dsh.y + dsh.h);
await hp.setTool('text');
sr().querySelector('.stage').dispatchEvent(at(500 + OFF, 500 + OFF, 'pointerdown', { pointerId: 22 }));
o.decorTextAnchorIsOnANode = onGridN(c._decorTextDialog.x) && onGridN(c._decorTextDialog.y);
c._decorTextDialog = null;
// a shape that is ALREADY off the grid is put on it by one drag: the mover
// snaps the resulting anchor, not the delta (which used to preserve the drift)
const decorSpace = c._space;
await hp.setServerConfig((cfg) => {
const space = cfg.spaces.find((item) => item.id === decorSpace);
space.decor = [...(space.decor || []), { id: 'dcOff', kind: 'rect',
x: 0.3013, y: 0.4017, w: 0.1, h: 0.05, color: '#889', width: 2 }];
});
const off = c._decorList.find((x) => x.id === 'dcOff');
c._decorMove = { id: 'dcOff', start: c._svgPoint(at(301.3, 401.7)),
orig: JSON.parse(JSON.stringify(off)), pid: 9, moved: false };
c._decorMoveUpdate(at(351.3, 451.7));
await c.updateComplete;
const off2 = c._decorList.find((x) => x.id === 'dcOff');
o.oneDragPutsADriftedShapeOnTheGrid = onGridN(off2.x) && onGridN(off2.y);
c._decorMove = null;
await hp.setServerConfig((cfg) => {
const space = cfg.spaces.find((item) => item.id === decorSpace);
space.decor = (space.decor || []).filter((x) => x.id !== 'dcOff' && x.id !== dsh.id);
});
// ---- 1e) an OPENING is WALL-bound: on its wall, whole steps along it ---
await hp.setMode('plan');
c._activateOpeningPlacement('door');
const spm = c._spaceModel(c._space);
const wall = (() => {
for (const r of spm.rooms) {
const poly = r.poly || null;
if (!poly) continue;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
if (Math.hypot(b[0] - a[0], b[1] - a[1]) > PITCH * 20) return [a, b];
}
}
return null;
})();
o.foundAWallToTestWith = !!wall;
if (wall) {
const [a, b] = wall;
const mid = [(a[0] + b[0]) / 2 + 0.37 * PITCH, (a[1] + b[1]) / 2 + 0.4];
c._openingClick(mid);
const d = c._openingDialog;
o.openingPlaced = !!d;
if (d) {
// exactly on the wall segment…
const t = ((d.x - a[0]) * (b[0] - a[0]) + (d.y - a[1]) * (b[1] - a[1]))
/ ((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2);
const px = a[0] + t * (b[0] - a[0]), py = a[1] + t * (b[1] - a[1]);
o.openingSitsOnItsWall = Math.hypot(d.x - px, d.y - py) < 1e-6;
// …and at a whole number of steps along it (or at the wall's own centre,
// which is what the magnet is for and what the ruler shows)
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
const along = t * len;
o.openingIsQuantisedAlongTheWall =
Math.abs(along / PITCH - Math.round(along / PITCH)) < 1e-6
|| Math.abs(along - len / 2) < 1e-6;
}
c._openingDialog = null;
}
// ---- 1f) a room VERTEX drawn by hand ----------------------------------
await hp.setTool('draw'); c._path = [];
c._markupClick(at(220 + OFF, 220 + OFF, 'click'));
o.roomVertexOnANode = c._path.length === 1 && onGridR(c._path[0][0]) && onGridR(c._path[0][1]);
c._path = [[200, 200]];
c._markupClick(at(240 + OFF, 310 + OFF, 'click', { shiftKey: true }));
const locked = c._path[1];
const ldx = Math.abs(locked[0] - 200), ldy = Math.abs(locked[1] - 200);
o.shiftLocksRoomWallTo45 = c._path.length === 2
&& (ldx < 1e-7 || ldy < 1e-7 || Math.abs(ldx - ldy) < 1e-7)
&& onGridR(locked[0]) && onGridR(locked[1]);
c._path = []; await hp.setTool('draw');
// ---- 2) «Оптимизировать планы» -----------------------------------------
await hp.setMode('view');
// The preceding real editor gestures schedule a physical config write.
// Finish it before replacing the whole server snapshot with the next
// synthetic fixture: production adoption does this at a revision boundary,
// while a direct private-field assignment cannot invalidate #278's pending
// exact-candidate approval by itself.
if (c._saveConfigDebounced.pending()) c._saveConfigDebounced.flush();
await c._writeChain;
// a deliberately detuned fixture: a third of a step off, everywhere
const D = 1 / GRID_N / 3;
const FIX = { spaces: [{
id: 'g1', title: 'Grid test', view_box: [0, 0, 1, 1],
rooms: [
{ id: 'r1', name: 'A', area: 'living_room',
poly: [[0.2 + D, 0.2], [0.5, 0.2 - D], [0.5 + D, 0.5], [0.2, 0.5]] },
{ id: 'r2', name: 'B', area: 'kitchen', x: 0.5, y: 0.2, w: 0.3 + D, h: 0.3 },
],
openings: [{ id: 'o1', type: 'window', x: 0.3 + D, y: 0.2 + D / 2, angle: 0, length: 0.06 }],
walls: [{
key: 'r1-top', a: [0.2 + D, 0.2], b: [0.5, 0.2 - D], cm: 15,
}],
decor: [{ id: 'd1', kind: 'line', x1: 0.1 + D, y1: 0.7, x2: 0.9, y2: 0.7 - D }],
}], markers: [], settings: {} };
const FIXLAY = { d_light1: { s: 'g1', x: 0.3 + D, y: 0.3 }, rl_r1: { s: 'g1', x: 0.25 + D, y: 0.25 } };
await hp.setServerConfig(structuredClone(FIX));
await hp.setLayout(structuredClone(FIXLAY));
await hp.switchSpace('g1');
c._editorRuntime.optimizePlans.open();
await c.updateComplete;
const dlg = c._alignDialog;
o.alignDialogOpens = !!dlg && !!sr().querySelector('hp-dialog .alignmsg');
o.alignCountsWhatMoves = !!dlg && dlg.report.moved > 0 && dlg.report.moved <= dlg.report.total;
// half a step of 5 cm cells ≈ 2.5 cm; a third of a step is well under that
o.alignPromisesASmallShift = !!dlg && dlg.cm > 0 && dlg.cm < 3;
o.alignExplainsSafeUndo = (sr().querySelector('hp-dialog .rhint')?.textContent || '').length > 20;
// capture what the network is told, then run it
const sent = [];
const base = c.hass.callWS;
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m.type); return base(m); } };
await c._editorRuntime.optimizePlans.run();
await c.updateComplete;
o.alignDialogClosed = c._alignDialog === null;
o.alignUsedAtomicConfigLayoutWrite = sent.filter((t) => t === 'houseplan/plan/optimize').length === 1;
const g = c._serverCfg.spaces[0];
o.alignedRoomPoly = g.rooms[0].poly.every((p) => onGridN(p[0]) && onGridN(p[1]));
o.alignedRoomRect = onGridN(g.rooms[1].x) && onGridN(g.rooms[1].x + g.rooms[1].w);
o.alignedDecor = onGridN(g.decor[0].x1) && onGridN(g.decor[0].y1)
&& onGridN(g.decor[0].x2) && onGridN(g.decor[0].y2);
o.alignedLayout = onGridN(c._layout.d_light1.x) && onGridN(c._layout.rl_r1.x);
// the opening stayed ON the top wall of r1 rather than being rounded off it
const w0 = g.rooms[0].poly[0], w1 = g.rooms[0].poly[1];
const tt = ((g.openings[0].x - w0[0]) * (w1[0] - w0[0]) + (g.openings[0].y - w0[1]) * (w1[1] - w0[1]))
/ ((w1[0] - w0[0]) ** 2 + (w1[1] - w0[1]) ** 2);
o.alignedOpeningStaysOnItsWall = Math.hypot(
g.openings[0].x - (w0[0] + tt * (w1[0] - w0[0])),
g.openings[0].y - (w0[1] + tt * (w1[1] - w0[1]))) < 1e-9;
// ---- 2b) …and the second run has nothing to do ------------------------
c._editorRuntime.optimizePlans.open();
await c.updateComplete;
o.secondRunMovesNothing = c._alignDialog.report.moved === 0;
o.secondRunOffersNoButton = !sr().querySelector('hp-dialog .btn.on');
c._alignDialog = null;
await c.updateComplete;
// ---- 2c) the promise is an UPPER BOUND, in EACH space's own scale -----
// AUD-158B1-01: one normalised maximum converted through the first space's
// cell size promised 2.5 cm for a vertex that moved 50 cm on the floor above.
const MULTI = { spaces: [
{ id: 'm1', title: 'Ground', cell_cm: 5, view_box: [0, 0, 1, 1],
rooms: [{ id: 'ma', name: 'A', area: 'living_room',
poly: [[0.1, 0.1], [0.3, 0.1], [0.3, 0.3], [0.1, 0.3]] }] },
{ id: 'm2', title: 'Attic', cell_cm: 100, view_box: [0, 0, 1, 1],
// the ONLY off-grid vertex on the plan, and it is on the 100 cm floor
rooms: [{ id: 'mb', name: 'B', area: 'kitchen',
poly: [[0.2 + 1 / GRID_N / 2, 0.2], [0.4, 0.2], [0.4, 0.4], [0.2, 0.4]] }] },
], markers: [], settings: {} };
await hp.setServerConfig(structuredClone(MULTI));
await hp.setLayout({});
await hp.switchSpace('m1');
c._editorRuntime.optimizePlans.open(); await c.updateComplete;
const md = c._alignDialog;
o.alignPromiseUsesTheOwnScaleOfEachSpace = !!md && md.cm >= 50 && md.cm < 51;
o.alignPromiseNamesTheSpaceItBelongsTo = !!md && md.where === 'Attic'
&& (sr().querySelector('hp-dialog .body')?.textContent || '').includes('Attic');
c._alignDialog = null; await c.updateComplete;
// ---- 2d) an angle-only opening fix is offerable (AUD-158B1-02) --------
// its centre is already on the wall; only the stored angle is wrong, and the
// batch used to report "nothing to move" while returning a different plan
const TURN = { spaces: [{ id: 'a1', title: 'Flat', cell_cm: 5, view_box: [0, 0, 1, 1],
rooms: [{ id: 'r1', name: 'A', area: 'living_room',
poly: [[0.2, 0.2], [0.5, 0.2], [0.5, 0.5], [0.2, 0.5]] }],
walls: [{ key: 'r1-top', a: [0.2, 0.2], b: [0.5, 0.2], cm: 15 }],
openings: [{ id: 'oa', type: 'window', x: 0.35, y: 0.2, angle: 90, length: 0.1 }],
}], markers: [], settings: {} };
await hp.setServerConfig(structuredClone(TURN));
await hp.setLayout({});
await hp.switchSpace('a1');
c._editorRuntime.optimizePlans.open(); await c.updateComplete;
const ad = c._alignDialog;
o.angleOnlyOpeningCounts = !!ad && ad.report.moved === 1 && ad.report.rotated === 1;
o.angleOnlyOpeningOffersTheButton = !!sr().querySelector('hp-dialog .btn.on');
await c._editorRuntime.optimizePlans.run();
await c.updateComplete;
o.angleOnlyOpeningIsActuallyFixed = c._serverCfg.spaces[0].openings[0].angle === 0;
return o;
});
await finish(browser, checkAll(out));