mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
- 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
806 lines
42 KiB
JavaScript
806 lines
42 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { readdirSync, readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import {
|
|
checkHiddenObstacles, checkMixedRoleRecords, checkReferences, checkWallKeys,
|
|
checkWallRecordsPreserved, keyMidpoint, latticeProfile, nearAxisProfile,
|
|
readModel, wallKey,
|
|
} from '../scripts/model-invariants.mjs';
|
|
import { wallKey as productWallKey } from '../test-build/wall-thickness.js';
|
|
import { GRID_STEP_N } from '../test-build/space-geometry.js';
|
|
import {
|
|
canonicalizeConfigGeometry, latticeCanonicalizationReport,
|
|
} from '../test-build/coordinate-canonicalization.js';
|
|
|
|
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
|
|
// #254. Инварианты нужны не сами по себе, а чтобы ловить уже случившиеся
|
|
// дефекты: #253 (запись толщины исчезла при ресайзе), #244 (маркеры на
|
|
// удалённые пространства), #252 (позиции без владельца). Поэтому каждый тест
|
|
// ниже воспроизводит либо реальный дефект, либо законный случай, который
|
|
// проверка обязана НЕ ловить — второе не менее важно: инвариант, кричащий на
|
|
// законную операцию, отключат в первую неделю.
|
|
|
|
const rect = (x0, y0, x1, y1) => [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
|
|
const model = (overrides = {}) => ({
|
|
config: {
|
|
spaces: [{
|
|
id: 'sp1', cell_cm: 5,
|
|
rooms: [{ id: 'r1', name: 'R', area: 'kitchen', poly: rect(0.1, 0.1, 0.4, 0.4) }],
|
|
walls: [{ key: 'k1', cm: 30, a: [0.1, 0.1], b: [0.4, 0.1] }],
|
|
partitions: [], open_spans: [],
|
|
}],
|
|
markers: [{ id: 'm1', binding: 'virtual', space: 'sp1' }],
|
|
...overrides.config,
|
|
},
|
|
layout: overrides.layout ?? { m1: { s: 'sp1', x: 0.2, y: 0.2 } },
|
|
});
|
|
|
|
test('чистая модель нарушений не даёт (#254)', () => {
|
|
assert.deepEqual(checkReferences(model()), []);
|
|
});
|
|
|
|
test('#244: маркер на удалённое пространство — нарушение', () => {
|
|
const m = model();
|
|
m.config.markers[0].space = 'f1';
|
|
const found = checkReferences(m);
|
|
assert.equal(found.length, 1);
|
|
assert.equal(found[0].kind, 'marker_space');
|
|
assert.equal(found[0].reference, 'f1');
|
|
});
|
|
|
|
test('#244: удалённый маркер не считается нарушением', () => {
|
|
// `removed: true` — это надгробие, а не живая ссылка. Ловить его значит
|
|
// приучить читателя пролистывать отчёт.
|
|
const m = model();
|
|
m.config.markers[0].space = 'f1';
|
|
m.config.markers[0].removed = true;
|
|
assert.deepEqual(checkReferences(m), []);
|
|
});
|
|
|
|
test('#265: полный внутренний ссылочный граф проверяется независимо от импорта', () => {
|
|
const m = model();
|
|
m.config.spaces[0].rooms[0].open_to = ['missing-room'];
|
|
m.config.spaces[0].openings = [{
|
|
id: 'door', type: 'door', x: 0.2, y: 0.1, angle: 0, length: 0.1,
|
|
host: { kind: 'partition', id: 'missing-partition', t: 0.5 },
|
|
}];
|
|
Object.assign(m.config.markers[0], {
|
|
room_id: 'missing-room',
|
|
vacuum: { segment_map: { 12: 'missing-room' } },
|
|
controls: ['marker:missing-light'],
|
|
value_badge: {
|
|
enabled: true,
|
|
source: { kind: 'derived_marker_state', ref: 'marker:missing-source' },
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(checkReferences(m).map((item) => item.kind).sort(), [
|
|
'marker_badge', 'marker_control', 'marker_room', 'opening_host',
|
|
'room_open_to', 'vacuum_room',
|
|
]);
|
|
});
|
|
|
|
test('#265: marker-ссылки принимают только активную цель-источник света', () => {
|
|
const m = model();
|
|
m.config.markers.push({ id: 'plain', binding: 'virtual', space: 'sp1' });
|
|
m.config.markers[0].controls = ['marker:plain'];
|
|
m.config.markers[0].value_badge = {
|
|
enabled: true,
|
|
source: { kind: 'derived_marker_state', ref: 'marker:plain' },
|
|
};
|
|
|
|
assert.deepEqual(checkReferences(m).map((item) => item.kind).sort(), [
|
|
'marker_badge', 'marker_control',
|
|
]);
|
|
});
|
|
|
|
test('#252/#566: позиция на удалённое пространство судится по владельцу', () => {
|
|
// #566 сузил правило. Прежде ЛЮБАЯ позиция на удалённом пространстве была
|
|
// нарушением, и это расходилось с самим продуктом: `space-reference-repair`
|
|
// удаляет такую запись только когда может ДОКАЗАТЬ, что владелец тоже
|
|
// исчез, а иначе хранит её намеренно — удаление уносит расстановку
|
|
// пользователя. Конфиг сразу после Optimize законно содержит такие записи.
|
|
const m = model({ layout: {
|
|
m1: { s: 'sp1', x: 0.2, y: 0.2 },
|
|
rl_room_gone: { s: 'sp1', x: 0.3, y: 0.3 },
|
|
'grp_no_such_area': { s: 'sp1', x: 0.3, y: 0.3 },
|
|
} });
|
|
const kinds = checkReferences(m).map((v) => `${v.kind}:${v.owner}`);
|
|
assert.ok(kinds.includes('layout_owner:rl_room_gone'));
|
|
assert.ok(kinds.includes('layout_owner:grp_no_such_area'));
|
|
assert.equal(kinds.length, 2, 'живая позиция m1 нарушением быть не должна');
|
|
});
|
|
|
|
test('#566: на удалённом пространстве нарушение — только исчезнувший владелец', () => {
|
|
const m = model({
|
|
config: {
|
|
spaces: [{
|
|
id: 'sp1', cell_cm: 5,
|
|
rooms: [{ id: 'r1', name: 'R', area: 'kitchen', poly: rect(0.1, 0.1, 0.4, 0.4) }],
|
|
walls: [{ key: 'k1', cm: 30, a: [0.1, 0.1], b: [0.4, 0.1] }],
|
|
partitions: [], open_spans: [],
|
|
}],
|
|
markers: [
|
|
{ id: 'm1', binding: 'virtual', space: 'sp1' },
|
|
{ id: 'm_gone', binding: 'virtual', space: 'sp1', removed: true },
|
|
],
|
|
},
|
|
layout: {
|
|
// Владелец жив, исчезло только пространство: продукт хранит намеренно.
|
|
rl_r1: { s: 'dead_space', x: 0.3, y: 0.3 },
|
|
grp_kitchen: { s: 'dead_space', x: 0.3, y: 0.3 },
|
|
m1: { s: 'dead_space', x: 0.3, y: 0.3 },
|
|
// Владельца по конфигурации не видно: это устройство HA либо мусор,
|
|
// отличить нельзя — наблюдение, как и внутри живого пространства.
|
|
'980f1446c4ec1a3a9fa9ff5f6d93caed': { s: 'dead_space', x: 0.3, y: 0.3 },
|
|
// Владелец ОТСУТСТВУЕТ по самой конфигурации — это нарушение.
|
|
rl_room_never_existed: { s: 'dead_space', x: 0.3, y: 0.3 },
|
|
grp_area_never_existed: { s: 'dead_space', x: 0.3, y: 0.3 },
|
|
m_gone: { s: 'dead_space', x: 0.3, y: 0.3 },
|
|
},
|
|
});
|
|
const notes = [];
|
|
const found = checkReferences(m, { notes });
|
|
assert.deepEqual(found.map((v) => `${v.kind}:${v.owner}`).sort(), [
|
|
'layout_space:grp_area_never_existed',
|
|
'layout_space:m_gone',
|
|
'layout_space:rl_room_never_existed',
|
|
]);
|
|
// Три состояния, а не два: продукт различает `live` и `unverified`, и
|
|
// проверка обязана различать их тоже. Сказать «владелец жив» про ключ,
|
|
// который ни во что не резолвится, — заявить доказанность там, где её нет
|
|
// (находка r1). Поэтому сверяется и вид наблюдения, и ТЕКСТ причины: вид без
|
|
// текста эту асимметрию пропускает — именно так она и проехала.
|
|
assert.deepEqual(notes.filter((n) => n.kind === 'stale_layout_space')
|
|
.map((n) => n.owner).sort(), ['grp_kitchen', 'm1', 'rl_r1']);
|
|
assert.deepEqual(notes.filter((n) => n.kind === 'unknown_owner')
|
|
.map((n) => n.owner), ['980f1446c4ec1a3a9fa9ff5f6d93caed']);
|
|
for (const note of notes.filter((n) => n.kind === 'stale_layout_space')) {
|
|
assert.match(note.detail, /владелец жив — Optimize хранит позицию намеренно/);
|
|
}
|
|
for (const note of notes.filter((n) => n.kind === 'unknown_owner')) {
|
|
assert.match(note.detail, /владелец не найден в конфигурации/);
|
|
assert.doesNotMatch(note.detail, /владелец жив/);
|
|
}
|
|
});
|
|
|
|
test('позиция устройства без записи маркера — наблюдение, а не нарушение (#254)', () => {
|
|
// Маркеры создаются лениво, позиция сохраняется сразу: по одной
|
|
// конфигурации мусор от живого устройства не отличить. Проверка с ложными
|
|
// срабатываниями будет отключена в первую же неделю, поэтому такие случаи
|
|
// выводятся отдельным списком.
|
|
const m = model({ layout: { '980f1446c4ec1a3a9fa9ff5f6d93caed': { s: 'sp1', x: 0.2, y: 0.2 } } });
|
|
const notes = [];
|
|
assert.deepEqual(checkReferences(m, { notes }), []);
|
|
assert.equal(notes.length, 1);
|
|
assert.equal(notes[0].kind, 'unknown_owner');
|
|
});
|
|
|
|
// Все модели, которые проект возит с собой, обязаны быть согласованы. Список
|
|
// не хардкодится: фикстуры перечисляются чтением каталога, поэтому новая
|
|
// фикстура попадает под проверку сама, без правки этого теста.
|
|
const fixtureModules = () => readdirSync(resolve(repoRoot, 'demo/fixtures'))
|
|
.filter((name) => name.endsWith('.mjs')).sort();
|
|
|
|
const demoStandModel = () => {
|
|
// Конфигурация стенда живёт внутри demo.html — на ней стоят все 170 смоков.
|
|
// Если разбор перестанет находить блоки, это само по себе сигнал: стенд
|
|
// сменил форму, и проверять его надо заново.
|
|
const html = readFileSync(resolve(repoRoot, 'demo/srv/demo.html'), 'utf8');
|
|
const block = (name) => {
|
|
// `let` с #629: фикстура подменяет конфиг и раскладку, когда смок
|
|
// доставляет их событием сервера (__pushServerConfig/__pushServerLayout).
|
|
const declaration = html.search(new RegExp(`\\b(?:const|let) ${name} = `));
|
|
assert.ok(declaration >= 0, `в demo.html не найден блок ${name}`);
|
|
const from = html.indexOf('{', declaration);
|
|
let depth = 0, index = from;
|
|
for (; index < html.length; index++) {
|
|
const char = html[index];
|
|
if (char === '{') depth++;
|
|
else if (char === '}' && --depth === 0) { index++; break; }
|
|
}
|
|
// eslint-disable-next-line no-new-func
|
|
return new Function(`return ${html.slice(from, index)}`)();
|
|
};
|
|
return { config: block('CFG'), layout: block('LAYOUT') };
|
|
};
|
|
|
|
test('все модели, которые возит с собой проект, инварианты не нарушают (#254)', async () => {
|
|
const models = [];
|
|
for (const file of fixtureModules()) {
|
|
const loaded = await import(`../demo/fixtures/${file}`);
|
|
for (const [name, value] of Object.entries(loaded)) {
|
|
if (typeof value !== 'function' || !/^make.*Fixture$/.test(name)) continue;
|
|
const fixture = value();
|
|
if (fixture?.config) models.push([`${file}:${name}`, fixture]);
|
|
}
|
|
}
|
|
models.push(['demo/srv/demo.html', demoStandModel()]);
|
|
assert.ok(models.length >= 3, `моделей найдено ${models.length}: список подозрительно короток`);
|
|
|
|
for (const [label, fixture] of models) {
|
|
const found = checkReferences({ config: fixture.config, layout: fixture.layout || {} });
|
|
assert.deepEqual(found.map((v) => `${v.kind}:${v.owner} → ${v.reference}`), [],
|
|
`${label}: модель, нарушающая инварианты, обесценивает и golden, и смоки на ней`);
|
|
|
|
// Долг совместимых ключей на dev виден наблюдениями, а нарушением считается
|
|
// только объявленная неразрешимой запись с точными концами. Для фикстур
|
|
// проекта требование строже: ни нарушений, ни наблюдений. Фикстура, чей
|
|
// ключ не равен ключу своего ребра, держится на запасе и на точных концах,
|
|
// то есть на удаче, — а на ней стоят golden-эталоны и перф-бюджеты (#260).
|
|
const keyNotes = [];
|
|
assert.deepEqual(checkWallKeys(fixture.config, { notes: keyNotes }), [],
|
|
`${label}: запись толщины объявлена неразрешимой (#258)`);
|
|
assert.deepEqual(keyNotes.map((n) => n.owner), [],
|
|
`${label}: ключ записи не равен ключу своего ребра (#260)`);
|
|
}
|
|
});
|
|
|
|
test('подпись существующей комнаты и метка существующей области законны', () => {
|
|
const m = model({ layout: {
|
|
rl_r1: { s: 'sp1', x: 0.2, y: 0.2 },
|
|
grp_kitchen: { s: 'sp1', x: 0.25, y: 0.25 },
|
|
} });
|
|
assert.deepEqual(checkReferences(m), []);
|
|
});
|
|
|
|
test('запись толщины обязана лежать на ребре комнаты или на перегородке', () => {
|
|
const m = model();
|
|
m.config.spaces[0].walls.push({ key: 'k2', cm: 20, a: [0.8, 0.8], b: [0.9, 0.8] });
|
|
const found = checkReferences(m).filter((v) => v.kind === 'wall_carrier');
|
|
assert.equal(found.length, 1);
|
|
assert.match(found[0].detail, /не лежит/);
|
|
|
|
// Перегородка — такой же законный носитель, как ребро комнаты.
|
|
m.config.spaces[0].partitions.push({ id: 'p1', a: [0.8, 0.8], b: [0.9, 0.8], cm: 20 });
|
|
assert.deepEqual(checkReferences(m).filter((v) => v.kind === 'wall_carrier'), []);
|
|
});
|
|
|
|
test('запись только с ключом (совместимость) проверку не роняет', () => {
|
|
const m = model();
|
|
m.config.spaces[0].walls.push({ key: 'legacy', cm: 15 });
|
|
assert.deepEqual(checkReferences(m).filter((v) => v.kind === 'wall_carrier'), []);
|
|
});
|
|
|
|
test('#253: исчезнувшая запись толщины — нарушение первого инварианта', () => {
|
|
// Точные числа реального дефекта: 24 записи до жеста, 23 после; исчезла
|
|
// горизонтальная стена 33 см, общая с соседними комнатами.
|
|
const before = [{ cm: 33 }, { cm: 29 }, { cm: 20 }];
|
|
const after = [{ cm: 29 }, { cm: 20 }];
|
|
const found = checkWallRecordsPreserved(before, after);
|
|
assert.equal(found.length, 1);
|
|
assert.equal(found[0].owner, '33 см');
|
|
assert.equal(found[0].kind, 'lost');
|
|
});
|
|
|
|
test('законные операции первый инвариант не нарушают (#254)', () => {
|
|
// Укорачивание: ресайз сдвинул ребро, записи те же.
|
|
assert.deepEqual(checkWallRecordsPreserved([{ cm: 30 }], [{ cm: 30 }]), []);
|
|
// Разрез: одна запись стала двумя того же значения.
|
|
assert.deepEqual(checkWallRecordsPreserved([{ cm: 30 }], [{ cm: 30 }, { cm: 30 }]), []);
|
|
// Склейка: две одинаковые записи стали одной.
|
|
assert.deepEqual(checkWallRecordsPreserved([{ cm: 30 }, { cm: 30 }], [{ cm: 30 }]), []);
|
|
// Явная очистка толщины пользователем — объявляется вызывающим.
|
|
assert.deepEqual(checkWallRecordsPreserved([{ cm: 30 }], [], { allowClear: true }), []);
|
|
// Нулевые и мусорные значения записями не считаются.
|
|
assert.deepEqual(checkWallRecordsPreserved([{ cm: 0 }, { cm: null }], []), []);
|
|
});
|
|
|
|
test('readModel понимает экспорт, ответ config/get и сырой config (#254)', () => {
|
|
const config = { spaces: [], markers: [] };
|
|
assert.deepEqual(readModel(JSON.stringify({ payload: { config, layout: { a: 1 } } })),
|
|
{ config, layout: { a: 1 } });
|
|
assert.deepEqual(readModel(JSON.stringify({ result: { config } })), { config, layout: {} });
|
|
assert.deepEqual(readModel(JSON.stringify(config)), { config, layout: {} });
|
|
const space = { id: 'tracked-real-plan', rooms: [] };
|
|
assert.deepEqual(readModel(JSON.stringify({ note: 'fixture', space })), {
|
|
config: { spaces: [space] }, layout: {},
|
|
});
|
|
});
|
|
|
|
// --------------------------- инвариант 3: ключи ------------------------------
|
|
// #258/#259. После строгого same-span resolver валидные exact endpoints
|
|
// доказывают, что запись найдётся независимо от старого compatibility key.
|
|
// Несовпадение остаётся наблюдением для явного Optimize, но не нарушением.
|
|
|
|
const KEYED = (key, cm, a, b) => ({
|
|
spaces: [{ id: 'sp1', cell_cm: 5, rooms: [], walls: [{ key, cm, a, b }] }],
|
|
});
|
|
|
|
test('#258: старый и неразбираемый compatibility key — наблюдение по exact endpoints', () => {
|
|
for (const [key, cm, a, b] of [
|
|
['0.887500,0.195833@1.5706', 29, [0.8875, 0.05], [0.8875, 0.345833333]],
|
|
['0.979167,0.445833@1.5706', 28, [0.979166667, 0.345833333], [0.979166667, 0.55]],
|
|
['perf-wall-0-3', 15, [0.1, 0.1], [0.5, 0.1]],
|
|
['0.300000,0.108333@0.0000', 15, [0.1, 0.1], [0.5, 0.1]],
|
|
]) {
|
|
const notes = [];
|
|
assert.deepEqual(checkWallKeys(KEYED(key, cm, a, b), { notes }), [],
|
|
'exact endpoints make the record resolvable before legacy key fallback');
|
|
assert.equal(notes.length, 1);
|
|
assert.equal(notes[0].kind, 'stale_wall_key');
|
|
assert.match(notes[0].detail, /точной паре endpoints/);
|
|
}
|
|
});
|
|
|
|
test('#258: ключ, равный ключу своего ребра, не даёт даже наблюдения', () => {
|
|
const notes = [];
|
|
assert.deepEqual(checkWallKeys(KEYED(
|
|
'0.887500,0.200000@1.5706', 29, [0.8875, 0.05], [0.8875, 0.345833333],
|
|
), { notes }), []);
|
|
assert.deepEqual(notes, []);
|
|
});
|
|
|
|
test('запись без концов ключевую проверку не роняет', () => {
|
|
// Совместимость: у старых записей есть только ключ. Сверять не с чем, и это
|
|
// не повод объявлять их сломанными.
|
|
const notes = [];
|
|
assert.deepEqual(checkWallKeys({
|
|
spaces: [{ id: 'sp1', walls: [{ key: 'legacy', cm: 15 }, { cm: 20 }] }],
|
|
}, { notes }), []);
|
|
assert.deepEqual(checkWallKeys({ spaces: [{ id: 'sp1', walls: [
|
|
{ key: '', cm: 15, a: [0, 0], b: [0.1, 0] },
|
|
{ key: 42, cm: 15, a: [0, 0], b: [0.1, 0] },
|
|
] }] }, { notes }), []);
|
|
assert.deepEqual(checkWallKeys(null), []);
|
|
assert.deepEqual(notes, []);
|
|
});
|
|
|
|
test('keyMidpoint читает координаты ключа и отвергает метку (#259)', () => {
|
|
assert.deepEqual(keyMidpoint('0.887500,0.200000@1.5706'), [0.8875, 0.2]);
|
|
assert.deepEqual(keyMidpoint('-0.637500,-0.208333@0.0000'), [-0.6375, -0.208333]);
|
|
for (const bad of ['perf-wall-0-3', 'legacy', '', null, undefined, 'a,b@c', '1,2']) {
|
|
assert.equal(keyMidpoint(bad), null, `разобрано то, что не должно: ${bad}`);
|
|
}
|
|
});
|
|
|
|
test('копия wallKey в скрипте совпадает с продуктовой (#259)', () => {
|
|
// Скрипт не импортирует src/**: он читает сырой JSON без сборки. Формулу
|
|
// приходится дублировать, а дубль величины — тот самый дефект, который проект
|
|
// ловил трижды. Поэтому копия прикреплена к настоящей здесь.
|
|
assert.equal(GRID_STEP_N, 1 / 240, 'решётка изменилась — копия ключа устарела');
|
|
const nodes = [0, 1, 2, 3, 7, 47, 48, 71, 83, 107, 120, 239, 240];
|
|
let checked = 0;
|
|
for (const i of nodes) {
|
|
for (const j of nodes) {
|
|
for (const [dx, dy] of [[0, 1], [1, 0], [1, 1], [1, 2], [3, 1], [0, -1], [-2, 1]]) {
|
|
const a = [i / 240, j / 240];
|
|
const b = [(i + dx) / 240, (j + dy) / 240];
|
|
assert.equal(wallKey(a, b), productWallKey(a, b, GRID_STEP_N),
|
|
`копия разошлась с продуктовой на ${JSON.stringify([a, b])}`);
|
|
checked++;
|
|
}
|
|
}
|
|
}
|
|
assert.ok(checked > 500, `сверено ${checked} отрезков: набор подозрительно мал`);
|
|
// Вырожденный отрезок, ничья округления и координаты не с решётки — там, где
|
|
// формулы расходятся первыми.
|
|
for (const [a, b] of [
|
|
[[0.5, 0.5], [0.5, 0.5]],
|
|
[[0.8875, 0.05], [0.8875, 0.345833333]],
|
|
[[0.979166667, 0.345833333], [0.979166667, 0.55]],
|
|
[[0.06, 0.08], [0.48, 0.08]],
|
|
[[-1.670833333, -0.208333333], [0.4, -0.208333333]],
|
|
[[0, 0], [1 / 240, 0]],
|
|
]) {
|
|
assert.equal(wallKey(a, b), productWallKey(a, b, GRID_STEP_N),
|
|
`копия разошлась на особом случае ${JSON.stringify([a, b])}`);
|
|
}
|
|
});
|
|
|
|
test('near-grid key normalization removes last-bit grading (#258, #259)', () => {
|
|
// Four records from the owner's second space differ only in nine-decimal
|
|
// endpoint representation. Stable wallKey makes all four canonical directly;
|
|
// neither a violation nor a stale-key observation is left to grade by bits.
|
|
const walls = [
|
|
['-0.637500,-0.208333@0.0000', 30, [-1.670833333, -0.208333333], [0.4, -0.208333333]],
|
|
['-0.637500,1.266667@0.0000', 20, [0.4, 1.266666667], [-1.670833333, 1.266666667]],
|
|
['0.354167,2.287500@0.0000', 20, [-0.354166667, 2.2875], [1.058333333, 2.2875]],
|
|
['0.354167,3.866667@0.0000', 30, [1.058333333, 3.866666667], [-0.354166667, 3.866666667]],
|
|
].map(([key, cm, a, b]) => ({ key, cm, a, b }));
|
|
const notes = [];
|
|
assert.deepEqual(checkWallKeys({ spaces: [{ id: 'sp2', cell_cm: 1, walls }] }, { notes }), []);
|
|
assert.deepEqual(notes, []);
|
|
});
|
|
|
|
// ------------------- стадия 0 ADR #282: профиль решётки ----------------------
|
|
// Мера, а не приговор. Нарушений здесь не бывает по построению: авторская
|
|
// координата вне сетки законна, а «шум» — это отдельное население, из которого
|
|
// растут #258, #279 и несходящийся Optimize.
|
|
|
|
const oneRoom = (poly) => ({
|
|
config: { spaces: [{ id: 'sp1', cell_cm: 5, rooms: [{ id: 'r1', poly }] }] },
|
|
layout: {},
|
|
});
|
|
|
|
test('профиль различает узел, шум и законную геометрию вне сетки (#282)', () => {
|
|
// 83/240 выбрано не случайно: это та самая вершина с дачи владельца. В
|
|
// двоичном виде она 0.34583333333333333, в хранилище лежит как 0.345833333 —
|
|
// и эти 8e-8 шага перебросили ключ стены в соседний бакет в #258. Узел вида
|
|
// 24/240 = 0.1 для примера не годится: он выживает округление до девяти
|
|
// знаков без изменений, и «шума» на нём не получить.
|
|
const node = 83 / 240;
|
|
const noise = Number((83 / 240).toFixed(9));
|
|
assert.notEqual(node, noise, 'выбранный узел обязан терять точность при записи');
|
|
const offGrid = 0.06; // 14.4 шага от узла — авторская координата
|
|
const profile = latticeProfile(oneRoom([[node, node], [noise, noise], [offGrid, offGrid]]));
|
|
assert.equal(profile.total, 6);
|
|
assert.equal(profile.exact, 2, 'узел обязан считаться точным');
|
|
assert.equal(profile.noise, 2, '9 знаков от того же узла — это шум, а не узел');
|
|
assert.equal(profile.offGrid, 2, '0.06 — законная геометрия, а не дефект');
|
|
assert.equal(profile.offGridUnique, 1, 'compatibility projections do not multiply one carrier');
|
|
assert.equal(profile.worstNoise.kind, 'room');
|
|
assert.ok(profile.worstNoise.steps > 0 && profile.worstNoise.steps < profile.noiseSteps);
|
|
});
|
|
|
|
test('профиль видит все виды объектов, а не только комнаты (#282)', () => {
|
|
const off = 0.0605;
|
|
const profile = latticeProfile({
|
|
config: { spaces: [{
|
|
id: 'sp1',
|
|
rooms: [{ id: 'r1', poly: [[off, off]] }],
|
|
partitions: [{ id: 'p1', a: [off, off], b: [off, off] }],
|
|
walls: [{ key: 'k', cm: 20, a: [off, off], b: [off, off] }],
|
|
open_spans: [{ id: 's1', a: [off, off], b: [off, off] }],
|
|
wall_columns: [{ id: 'c1', center: [off, off] }],
|
|
}] },
|
|
layout: { m1: { s: 'sp1', x: off, y: off } },
|
|
});
|
|
assert.deepEqual(Object.keys(profile.byKind).sort(),
|
|
['column', 'layout', 'open_span', 'partition', 'room', 'wall']);
|
|
assert.equal(profile.total, 18);
|
|
});
|
|
|
|
test('профиль решётки покрывает полный persisted allow-list (#291)', () => {
|
|
const off = 0.0605;
|
|
const profile = latticeProfile({
|
|
config: { spaces: [{
|
|
id: 'sp1',
|
|
rooms: [{ id: 'legacy', x: off, y: off, w: off, h: off }],
|
|
openings: [{ id: 'door', x: off, y: off }],
|
|
room_drafts: [{ id: 'draft', points: [[off, off]] }],
|
|
decor: [
|
|
{ id: 'line', kind: 'line', x1: off, y1: off, x2: off, y2: off },
|
|
{ id: 'rect', kind: 'rect', x: off, y: off, w: off, h: off },
|
|
{ id: 'text', kind: 'text', x: off, y: off },
|
|
],
|
|
}] },
|
|
layout: {},
|
|
});
|
|
assert.deepEqual(Object.keys(profile.byKind).sort(),
|
|
['decor', 'opening', 'room', 'room_draft']);
|
|
assert.equal(profile.total, 18);
|
|
assert.equal(profile.offGrid, 18);
|
|
});
|
|
|
|
test('модели проекта не несут шума решётки (#282)', async () => {
|
|
// Свойство, которое стоит знать про себя: тестовые данные проекта чисты —
|
|
// весь их офф-грид авторский. Значит воспроизвести на них #258/#279 нельзя
|
|
// по построению, а на реальном плане владельца шум составляет две трети
|
|
// координат. Именно поэтому этот класс дефектов находит владелец, а не гейт.
|
|
const models = [];
|
|
for (const file of fixtureModules()) {
|
|
const loaded = await import(`../demo/fixtures/${file}`);
|
|
for (const [name, value] of Object.entries(loaded)) {
|
|
if (typeof value !== 'function' || !/^make.*Fixture$/.test(name)) continue;
|
|
const fixture = value();
|
|
if (fixture?.config) models.push([`${file}:${name}`, fixture]);
|
|
}
|
|
}
|
|
models.push(['demo/srv/demo.html', demoStandModel()]);
|
|
assert.ok(models.length >= 3);
|
|
for (const [label, fixture] of models) {
|
|
const profile = latticeProfile({ config: fixture.config, layout: fixture.layout || {} });
|
|
assert.equal(profile.noise, 0,
|
|
`${label}: в тестовых данных появился шум решётки`
|
|
+ `${profile.worstNoise ? ` — ${profile.worstNoise.owner}` : ''}`);
|
|
}
|
|
});
|
|
|
|
// ------------------ реальные планы как фикстуры (#285, #286) -----------------
|
|
// Синтетика не воспроизводит два класса сразу: координатный шум и конфигурации
|
|
// живого плана. Здесь закреплены свойства, ради которых эти фикстуры и лежат в
|
|
// репозитории. Если кто-то «почистит» их координаты, фикстуры потеряют смысл —
|
|
// и тест скажет об этом раньше, чем это выяснится через месяц.
|
|
|
|
const REAL_PLANS = [
|
|
{ file: 'real-plan-first-floor.json', minNoise: 100, zeroThicknessSolidEdges: 2 },
|
|
{ file: 'real-plan-second-floor.json', minNoise: 100, zeroThicknessSolidEdges: 0 },
|
|
];
|
|
|
|
test('реальные планы сохраняют координатный шум, ради которого их взяли (#286)', () => {
|
|
for (const plan of REAL_PLANS) {
|
|
const { space } = JSON.parse(
|
|
readFileSync(resolve(repoRoot, 'test/fixtures', plan.file), 'utf8'));
|
|
const profile = latticeProfile({ config: { spaces: [space] }, layout: {} });
|
|
assert.ok(profile.noise >= plan.minNoise,
|
|
`${plan.file}: шума ${profile.noise}, ожидалось не меньше ${plan.minNoise} —`
|
|
+ ' фикстура канонизирована и больше не воспроизводит свой класс дефектов');
|
|
assert.equal(checkWallKeys({ spaces: [space] }, { notes: [] }).length, 0,
|
|
`${plan.file}: запись толщины объявлена неразрешимой`);
|
|
}
|
|
});
|
|
|
|
test('барьер очищает клоны реальных планов, не переписывая source fixtures (#291)', () => {
|
|
for (const plan of REAL_PLANS) {
|
|
const path = resolve(repoRoot, 'test/fixtures', plan.file);
|
|
const rawBefore = readFileSync(path, 'utf8');
|
|
const { space } = JSON.parse(rawBefore);
|
|
const source = { spaces: [space], markers: [], settings: {} };
|
|
const measured = latticeCanonicalizationReport(source, {});
|
|
assert.ok(measured.canonicalized >= plan.minNoise, `${plan.file}: cleanup must be measurable`);
|
|
const candidate = canonicalizeConfigGeometry(source);
|
|
const profile = latticeProfile({ config: candidate, layout: {} });
|
|
assert.equal(profile.noise, 0, `${plan.file}: boundary must remove all near-node tails`);
|
|
assert.equal(profile.offGrid, measured.far, `${plan.file}: authored off-grid population survives`);
|
|
assert.equal(checkWallKeys(candidate, { notes: [] }).length, 0);
|
|
assert.equal(checkMixedRoleRecords(candidate).length, 0);
|
|
assert.equal(readFileSync(path, 'utf8'), rawBefore, 'the raw regression fixture is immutable');
|
|
}
|
|
});
|
|
|
|
test('реальные планы: сплошные рёбра без записи толщины закреплены числом (#286)', async () => {
|
|
// Ребро, объявленное сплошным, но с нулевой толщиной, рисуется без кладки.
|
|
// На первом этаже таких два, длиной по 2 шага, и соседние тела их накрывают —
|
|
// поэтому браузерный смок разрывов не видит. Число закреплено здесь: станет
|
|
// больше или короче накрытие — увидим до того, как это станет дыркой.
|
|
const { spaceModels, GRID_STEP_N, GRID_PITCH, NORM_W } =
|
|
await import('../test-build/space-geometry.js');
|
|
const { wallIntervals } = await import('../test-build/wall-thickness.js');
|
|
const { resolveOpenCuts } = await import('../test-build/open-spans.js');
|
|
for (const plan of REAL_PLANS) {
|
|
const { space } = JSON.parse(
|
|
readFileSync(resolve(repoRoot, 'test/fixtures', plan.file), 'utf8'));
|
|
const model = spaceModels({ spaces: [space], markers: [], settings: {} })[0];
|
|
const cuts = resolveOpenCuts(
|
|
model.rooms, space.open_spans ?? null, NORM_W, GRID_PITCH * 0.02, true);
|
|
const intervals = wallIntervals(
|
|
model.rooms, space.walls, cuts, GRID_STEP_N,
|
|
space.cell_cm || 5, GRID_PITCH, NORM_W);
|
|
const zero = intervals.filter((item) => item.kind && !(item.cm > 0));
|
|
assert.equal(zero.length, plan.zeroThicknessSolidEdges,
|
|
`${plan.file}: сплошных рёбер без толщины ${zero.length},`
|
|
+ ` ожидалось ${plan.zeroThicknessSolidEdges}`);
|
|
}
|
|
});
|
|
|
|
// ------- инвариант 4: запись толщины не смешивает общее и наружное (#287) ----
|
|
// Воспроизведение дефекта владельца из `66.json`: ресайз сдвинул одну комнату
|
|
// общей пары, вторая осталась, и нижняя часть стены перестала быть общей —
|
|
// но продолжает нести толщину бывшей общей границы.
|
|
|
|
const pair = (leftPoly, wall) => ({
|
|
spaces: [{
|
|
id: 'sp', cell_cm: 1,
|
|
rooms: [
|
|
{ id: 'left', poly: leftPoly },
|
|
{ id: 'right', poly: [[1, 0], [2, 0], [2, 1], [1, 1]] },
|
|
],
|
|
walls: [wall],
|
|
}],
|
|
});
|
|
|
|
test('#287: до ресайза общая стена нарушением не считается', () => {
|
|
assert.deepEqual(checkMixedRoleRecords(pair(
|
|
[[0, 0], [1, 0], [1, 1], [0, 1]], { key: 'k', cm: 20, a: [1, 0], b: [1, 1] },
|
|
)), []);
|
|
});
|
|
|
|
test('#287: после частичного ресайза запись описывает и общее, и наружное', () => {
|
|
const found = checkMixedRoleRecords(pair(
|
|
[[0, 0], [1, 0], [1, 1.2], [0, 1.2]], { key: 'k2', cm: 20, a: [1, 0], b: [1, 1.2] },
|
|
));
|
|
assert.equal(found.length, 1);
|
|
assert.equal(found[0].kind, 'mixed_role_record');
|
|
assert.match(found[0].detail, /частью общий, частью наружный/);
|
|
assert.match(found[0].detail, /наружного/);
|
|
});
|
|
|
|
test('#287: наружный угол одной комнаты — не общая граница', () => {
|
|
// Считать надо РАЗНЫЕ комнаты, а не рёбра: в углу точка лежит сразу на двух
|
|
// рёбрах одной комнаты. Подсчёт рёбер давал ложное срабатывание на каждой
|
|
// наружной стене.
|
|
assert.deepEqual(checkMixedRoleRecords({
|
|
spaces: [{
|
|
id: 'sp', cell_cm: 1,
|
|
rooms: [{ id: 'only', poly: [[0, 0], [1, 0], [1, 1], [0, 1]] }],
|
|
walls: [{ key: 'k', cm: 30, a: [0, 0], b: [1, 0] }],
|
|
}],
|
|
}), []);
|
|
});
|
|
|
|
test('#287: наружная стена, упирающаяся в общую, нарушением не считается', () => {
|
|
// Конец записи — это узел, а не участок: там стена законно касается рёбер
|
|
// двух комнат. Включение концов в выборку давало «95% наружного» на каждой
|
|
// такой стене, то есть ложное срабатывание на ровном месте.
|
|
const found = checkMixedRoleRecords({
|
|
spaces: [{
|
|
id: 'sp', cell_cm: 1,
|
|
rooms: [
|
|
{ id: 'left', poly: [[0, 0], [1, 0], [1, 1], [0, 1]] },
|
|
{ id: 'right', poly: [[1, 0], [2, 0], [2, 1], [1, 1]] },
|
|
],
|
|
// наружная стена левой комнаты, оба конца упираются в общую границу x=1
|
|
walls: [{ key: 'outer', cm: 30, a: [0, 0], b: [1, 0] }],
|
|
}],
|
|
});
|
|
assert.deepEqual(found, []);
|
|
});
|
|
|
|
test('#287: реальные планы проекта эту проверку проходят', () => {
|
|
for (const file of ['real-plan-first-floor.json', 'real-plan-second-floor.json']) {
|
|
const { space } = JSON.parse(
|
|
readFileSync(resolve(repoRoot, 'test/fixtures', file), 'utf8'));
|
|
assert.deepEqual(checkMixedRoleRecords({ spaces: [space] }), [],
|
|
`${file}: ложное срабатывание на реальном плане`);
|
|
}
|
|
});
|
|
|
|
test('#290 near-axis audit deduplicates shared room-owner copies', () => {
|
|
const fixture = JSON.parse(readFileSync(
|
|
resolve(repoRoot, 'test/fixtures/279-near-orthogonal-junction.json'), 'utf8',
|
|
));
|
|
const profile = nearAxisProfile({ spaces: [{ id: 'near', ...fixture }] });
|
|
assert.deepEqual(profile, { total: 1, spaces: [{ spaceId: 'near', count: 1 }] });
|
|
});
|
|
|
|
// #296/#297. Геометрия, которая ничего не рисует, но выключает ручки ресайза.
|
|
// Проверка нужна ровно потому, что все прежние гейтики её не видят: перегородка
|
|
// под стеной комнаты не портит ни ключи, ни роли, ни решётку, ни кладку.
|
|
const hidden = (space) => checkHiddenObstacles({ spaces: [{ id: 'sp1', cell_cm: 1, ...space }] });
|
|
const roomAt = (x0, y0, x1, y1) => ({ id: 'r1', poly: rect(x0, y0, x1, y1) });
|
|
|
|
test('перегородка под стеной комнаты — нарушение (#296)', () => {
|
|
const step = GRID_STEP_N;
|
|
const found = hidden({
|
|
rooms: [roomAt(0, 0, 40 * step, 30 * step)],
|
|
// Ровно нижнее ребро комнаты: на плане неотличимо от стены.
|
|
partitions: [{ id: 'p1', a: [0, 0], b: [40 * step, 0], cm: 20 }],
|
|
});
|
|
assert.equal(found.length, 1);
|
|
assert.equal(found[0].kind, 'partition_over_room_wall');
|
|
});
|
|
|
|
test('перегородка, лежащая на части стены, — тоже нарушение (#296)', () => {
|
|
const step = GRID_STEP_N;
|
|
const found = hidden({
|
|
rooms: [roomAt(0, 0, 40 * step, 30 * step)],
|
|
partitions: [{ id: 'p1', a: [10 * step, 0], b: [25 * step, 0], cm: 20 }],
|
|
});
|
|
assert.equal(found.length, 1);
|
|
assert.equal(found[0].kind, 'partition_over_room_wall');
|
|
});
|
|
|
|
test('перегородка поперёк комнаты законна и не ловится (#296)', () => {
|
|
// Единственный смысл существования перегородки: разделить комнату внутри.
|
|
// Инвариант, кричащий на это, отключат в первую неделю.
|
|
const step = GRID_STEP_N;
|
|
const found = hidden({
|
|
rooms: [roomAt(0, 0, 40 * step, 30 * step)],
|
|
partitions: [{ id: 'p1', a: [20 * step, 0], b: [20 * step, 30 * step], cm: 20 }],
|
|
});
|
|
assert.deepEqual(found, []);
|
|
});
|
|
|
|
test('касание углом перекрытием не считается (#296)', () => {
|
|
const step = GRID_STEP_N;
|
|
const found = hidden({
|
|
rooms: [roomAt(0, 0, 40 * step, 30 * step)],
|
|
// Продолжение нижнего ребра за угол: общего с ребром — ровно точка.
|
|
partitions: [{ id: 'p1', a: [40 * step, 0], b: [60 * step, 0], cm: 20 }],
|
|
});
|
|
assert.deepEqual(found, []);
|
|
});
|
|
|
|
test('черновик из двух точек не может стать комнатой (#296)', () => {
|
|
const step = GRID_STEP_N;
|
|
const found = hidden({
|
|
rooms: [roomAt(0, 0, 40 * step, 30 * step)],
|
|
room_drafts: [{
|
|
id: 'd1', points: [[50 * step, 0], [70 * step, 0]], segments: [{ cm: 30 }],
|
|
}],
|
|
});
|
|
assert.equal(found.length, 1);
|
|
assert.equal(found[0].kind, 'unusable_draft');
|
|
});
|
|
|
|
test('черновик из трёх точек в стороне от стен законен (#296)', () => {
|
|
const step = GRID_STEP_N;
|
|
const found = hidden({
|
|
rooms: [roomAt(0, 0, 40 * step, 30 * step)],
|
|
room_drafts: [{
|
|
id: 'd1',
|
|
points: [[50 * step, 0], [70 * step, 0], [70 * step, 20 * step]],
|
|
segments: [{ cm: 30 }, { cm: 30 }],
|
|
}],
|
|
});
|
|
assert.deepEqual(found, []);
|
|
});
|
|
|
|
test('незакрытый контур на стене комнаты — нарушение (#296)', () => {
|
|
const step = GRID_STEP_N;
|
|
const found = hidden({
|
|
rooms: [roomAt(0, 0, 40 * step, 30 * step)],
|
|
room_drafts: [{
|
|
id: 'd1',
|
|
points: [[5 * step, 0], [35 * step, 0], [35 * step, 20 * step]],
|
|
segments: [{ cm: 30 }, { cm: 30 }],
|
|
}],
|
|
});
|
|
assert.equal(found.length, 1);
|
|
assert.equal(found[0].kind, 'draft_over_room_wall');
|
|
});
|
|
|
|
test('реальный план: перегородка поверх наружной стены закреплена числом (#296)', () => {
|
|
// Долг фикстуры, а не «допустимо»: `partition-mt2on9ou-0` лежит на наружной
|
|
// стене трёх комнат и выключает ресайз Мастера с/у и Гардеробной. Число здесь
|
|
// обязано стать нулём вместе с закрытием #296 — иначе починка пройдёт молча.
|
|
const debt = { 'real-plan-second-floor.json': 1, 'real-plan-first-floor.json': 0 };
|
|
for (const [file, expected] of Object.entries(debt)) {
|
|
const { config } = readModel(
|
|
readFileSync(resolve(repoRoot, 'test/fixtures', file), 'utf8'));
|
|
assert.equal(checkHiddenObstacles(config).length, expected, file);
|
|
}
|
|
});
|
|
|
|
test('#316: непривязанный контурный проём — валидное состояние v9, но не v8', () => {
|
|
const base = () => ({
|
|
config: {
|
|
model_version: 9,
|
|
spaces: [{
|
|
id: 'sp316', title: 'S', view_box: [0, 0, 1, 1],
|
|
rooms: [], openings: [{
|
|
id: 'orphan', type: 'door', x: 0.9, y: 0.65, angle: 90, length: 0.05,
|
|
}],
|
|
}],
|
|
markers: [], settings: {},
|
|
},
|
|
layout: {},
|
|
});
|
|
const v9 = base();
|
|
assert.deepEqual(
|
|
checkReferences(v9).filter((item) => item.kind === 'opening_host'), [],
|
|
'model v9 keeps the unhosted degraded state of #316 §3.3 without a violation',
|
|
);
|
|
const v8 = base();
|
|
v8.config.model_version = 8;
|
|
assert.equal(
|
|
checkReferences(v8).filter((item) => item.kind === 'opening_host').length, 1,
|
|
'model v8 still requires an explicit host on every opening',
|
|
);
|
|
});
|
|
|
|
test('#316 r4: room_wall_ids проверяется и в v9 — фантомные ссылки ловятся', () => {
|
|
// CODE-REVIEW-316-r4 H1: правка условия opening_host не имеет права трогать
|
|
// независимый инвариант room_wall_ids (#244/#252) — он действует для v8 и
|
|
// всех последующих версий модели.
|
|
const m = {
|
|
config: {
|
|
model_version: 9,
|
|
spaces: [{
|
|
id: 'sp316b', title: 'S', view_box: [0, 0, 1, 1],
|
|
rooms: [{
|
|
id: 'r', name: 'R', area: null,
|
|
poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.4], [0.1, 0.4]],
|
|
wall_ids: ['wall-ghost-1', 'wall-ghost-2', 'wall-ghost-3', 'wall-ghost-4'],
|
|
}],
|
|
wall_segments: [],
|
|
}],
|
|
markers: [], settings: {},
|
|
},
|
|
layout: {},
|
|
};
|
|
const kinds = checkReferences(m).filter((item) => item.kind === 'room_wall_ids');
|
|
assert.ok(kinds.length >= 1, 'phantom wall_ids must be reported on model v9');
|
|
const v8 = structuredClone(m);
|
|
v8.config.model_version = 8;
|
|
assert.ok(
|
|
checkReferences(v8).filter((item) => item.kind === 'room_wall_ids').length >= 1,
|
|
'and stay reported on model v8',
|
|
);
|
|
});
|