test: add a second real plan to the masonry gate

The second floor covers one class: the multi-wall corridor eating a neighbouring
wall. The first floor brings what neither it nor any synthetic fixture has —
three virtual spans, two wall columns, two solid edges with no thickness record
at all, and 127 noisy coordinates.

The two plans pin opposite states, which is worth more than two plans with the
same defect: one records a known debt of 181 steps, the other records cleanliness
at zero. A regression is caught in both directions.

A declared virtual span is a declared break, not a defect, so the rule from #285
would have reddened on the first floor's own contract. Samples lying on
open_spans are now excluded: 830 of 6800 on that plan, and the remaining gap
count is zero.

The two zero-thickness solid edges are two grid steps long and covered by the
bodies of their neighbours, so the browser sees no break. Their count is
therefore pinned in the model test rather than the smoke — if it grows, or if the
neighbours stop covering them, that shows up before it becomes a hole.

Both fixtures must stay noisy: the project's synthetic models carry exactly zero
noise, which is why #258 and #248 are not reproducible on them. A profile check
asserts at least a hundred noisy coordinates each, so a future write barrier run
over the fixtures cannot quietly rob them of their purpose.

Privacy: names, ids, markers, device bindings and layout are gone; geometry
stays, coordinates unchanged, because they are the point.

Issue: #286
User-Visible: no
This commit is contained in:
Claude
2026-08-24 13:10:33 +03:00
parent c654e0ec42
commit d5659478aa
3 changed files with 691 additions and 12 deletions
+59 -12
View File
@@ -18,8 +18,18 @@
import { readFileSync } from 'node:fs';
import { launch, checkAll, finish } from './serve.mjs';
const FIXTURE = new URL('../test/fixtures/real-plan-second-floor.json', import.meta.url);
const { space } = JSON.parse(readFileSync(FIXTURE, 'utf8'));
/**
* Реальные планы, каждый со своим классом дефектов (#285, #286).
*
* Второй этаж — стыки: коридор многостенного узла съедает соседнюю кладку.
* Первый этаж — другое: у него есть виртуальные пролёты, колонны и два
* сплошных ребра, у которых вообще нет записи толщины. Ни то, ни другое
* синтетика не воспроизводит.
*/
const PLANS = [
{ file: 'real-plan-second-floor.json', gapCount: 4, gapSteps: 181 },
{ file: 'real-plan-first-floor.json', gapCount: 0, gapSteps: 0 },
];
/**
* Признанный долг beta.9, ведётся в #284 вместе с #271/#275.
@@ -42,7 +52,7 @@ const KNOWN_GAP_STEPS = 181;
const { page, browser } = await launch();
const out = {};
const measured = await page.evaluate(async (space) => {
const measure = (space) => page.evaluate(async (space) => {
const card = window.__card;
card._serverCfg = { spaces: [space], markers: [], settings: {} };
card._cfgEpoch = (card._cfgEpoch || 0) + 1;
@@ -69,9 +79,25 @@ const measured = await page.evaluate(async (space) => {
// достаточно грубо, чтобы прогон оставался быстрым.
const SAMPLE = STEP / 4;
// Виртуальный пролёт — объявленный разрыв, а не дефект: там кладки быть не
// должно. Без этого исключения план с виртуальными границами покраснел бы
// на собственном контракте.
const spans = (space.open_spans || []).map((s) => [s.a, s.b]);
const onDeclaredSpan = (x, y) => spans.some(([a, b]) => {
const dx = b[0] - a[0], dy = b[1] - a[1];
const len2 = dx * dx + dy * dy;
if (len2 < 1e-18) return false;
const t = ((x - a[0]) * dx + (y - a[1]) * dy) / len2;
if (t < -0.02 || t > 1.02) return false;
const px = a[0] + dx * Math.max(0, Math.min(1, t));
const py = a[1] + dy * Math.max(0, Math.min(1, t));
return Math.hypot(x - px, y - py) <= 1 / 240;
});
const gaps = [];
let sampled = 0;
let missing = 0;
let skipped = 0;
for (const room of space.rooms) {
const poly = room.poly;
for (let i = 0; i < poly.length; i++) {
@@ -84,6 +110,14 @@ const measured = await page.evaluate(async (space) => {
const t = k / count;
const x = a[0] + (b[0] - a[0]) * t;
const y = a[1] + (b[1] - a[1]) * t;
if (onDeclaredSpan(x, y)) {
skipped++;
if (runStart !== null) {
gaps.push({ room: room.id, edge: i, steps: (t - runStart) * length / STEP });
runStart = null;
}
continue;
}
sampled++;
const solid = wall.isPointInFill(at(x, y));
if (!solid) {
@@ -102,6 +136,7 @@ const measured = await page.evaluate(async (space) => {
const worst = gaps.slice().sort((x, y) => y.steps - x.steps).slice(0, 5);
return {
sampled,
skipped,
missing,
gapCount: gaps.length,
totalGapSteps: gaps.reduce((sum, g) => sum + g.steps, 0),
@@ -109,15 +144,27 @@ const measured = await page.evaluate(async (space) => {
};
}, space);
console.log(JSON.stringify(measured, null, 2));
out.wallPathFound = !measured.error;
out.sampledEnough = measured.sampled > 5000;
out.gapCountMatchesKnownDebt = measured.gapCount === KNOWN_GAP_COUNT;
out.gapLengthMatchesKnownDebt = Math.abs(measured.totalGapSteps - KNOWN_GAP_STEPS) < 1;
if (!out.gapCountMatchesKnownDebt || !out.gapLengthMatchesKnownDebt) {
console.log(`Долг изменился: было ${KNOWN_GAP_COUNT} разрывов на ${KNOWN_GAP_STEPS} шага,`
+ ` стало ${measured.gapCount} на ${measured.totalGapSteps.toFixed(2)}.`
+ ' Меньше — обновите числа и приложите прогон; больше — это регрессия.');
for (const plan of PLANS) {
const { space } = JSON.parse(readFileSync(
new URL(`../test/fixtures/${plan.file}`, import.meta.url), 'utf8'));
const measured = await measure(space);
const tag = plan.file.replace('real-plan-', '').replace('.json', '');
console.log(tag, JSON.stringify(measured, null, 2));
out[`${tag}:wallPath`] = !measured.error;
out[`${tag}:sampledEnough`] = measured.sampled > 5000;
if (plan.gapCount === null) {
console.log(`ИЗМЕРЕНИЕ ${tag}: разрывов ${measured.gapCount},`
+ ` суммарно ${measured.totalGapSteps.toFixed(2)} шага`);
} else {
out[`${tag}:gapCount`] = measured.gapCount === plan.gapCount;
out[`${tag}:gapLength`] = Math.abs(measured.totalGapSteps - plan.gapSteps) < 1;
if (!out[`${tag}:gapCount`] || !out[`${tag}:gapLength`]) {
console.log(`Долг ${tag} изменился: было ${plan.gapCount} разрывов на`
+ ` ${plan.gapSteps} шага, стало ${measured.gapCount} на`
+ ` ${measured.totalGapSteps.toFixed(2)}. Меньше — обновите числа и приложите`
+ ' прогон; больше — это регрессия.');
}
}
}
checkAll(out);
await finish(browser, out);
+583
View File
@@ -0,0 +1,583 @@
{
"note": "Privacy-minimised geometry of a real first floor (issue #286 (gate introduced in #285)). Room names, ids and every device binding are removed; only geometry, thickness, virtual spans and openings remain. Coordinates are unchanged on purpose: this plan carries the coordinate noise that synthetic fixtures do not.",
"space": {
"id": "real-first-floor",
"title": "Real first floor",
"cell_cm": 5,
"view_box": [
0,
0,
1,
1
],
"settings": {
"show_borders": true,
"show_names": false,
"fill_mode": "custom",
"custom_fill": {
"c": "#607d8b",
"a": 0
},
"glow_enabled": false
},
"rooms": [
{
"id": "room-a",
"name": "Room A",
"poly": [
[
0.070833333,
0.4375
],
[
0.070833333,
0.05
],
[
0.8875,
0.05
],
[
0.8875,
0.345833333
],
[
0.691666667,
0.345833333
],
[
0.691666667,
0.55
],
[
0.620833333,
0.55
],
[
0.620833333,
0.425
],
[
0.420833333,
0.425
],
[
0.420833333,
0.4375
]
]
},
{
"id": "room-b",
"name": "Room B",
"poly": [
[
0.204166667,
0.645833333
],
[
0.204166667,
0.4375
],
[
0.420833333,
0.4375
],
[
0.420833333,
0.645833333
]
]
},
{
"id": "room-c",
"name": "Room C",
"poly": [
[
0.2375,
0.866666667
],
[
0.2375,
0.645833333
],
[
0.420833333,
0.645833333
],
[
0.420833333,
0.866666667
]
]
},
{
"id": "room-d",
"name": "Room D",
"poly": [
[
0.070833333,
0.866666667
],
[
0.070833333,
0.645833333
],
[
0.2375,
0.645833333
],
[
0.2375,
0.866666667
]
]
},
{
"id": "room-e",
"name": "Room E",
"poly": [
[
0.420833333,
0.866666667
],
[
0.420833333,
0.425
],
[
0.620833333,
0.425
],
[
0.620833333,
0.866666667
]
]
},
{
"id": "room-f",
"name": "Room F",
"poly": [
[
0.620833333,
0.866666667
],
[
0.620833333,
0.55
],
[
0.8875,
0.55
],
[
0.8875,
0.866666667
]
]
},
{
"id": "room-g",
"name": "Room G",
"poly": [
[
0.070833333,
0.645833333
],
[
0.070833333,
0.4375
],
[
0.204166667,
0.4375
],
[
0.204166667,
0.645833333
]
]
},
{
"id": "room-h",
"name": "Room H",
"poly": [
[
0.691666667,
0.345833333
],
[
0.979166667,
0.345833333
],
[
0.979166667,
0.55
],
[
0.691666667,
0.55
]
]
}
],
"walls": [
{
"key": "0.479167,0.050000@0.0000",
"cm": 29,
"a": [
0.070833333,
0.05
],
"b": [
0.8875,
0.05
]
},
{
"key": "0.420833,0.645833@1.5706",
"cm": 20,
"a": [
0.420833333,
0.866666667
],
"b": [
0.420833333,
0.425
]
},
{
"key": "0.070833,0.245833@1.5706",
"cm": 29,
"a": [
0.070833333,
0.4375
],
"b": [
0.070833333,
0.05
]
},
{
"key": "0.245833,0.437500@0.0000",
"cm": 33,
"a": [
0.420833333,
0.4375
],
"b": [
0.070833333,
0.4375
]
},
{
"key": "0.620833,0.708333@1.5706",
"cm": 20,
"a": [
0.620833333,
0.55
],
"b": [
0.620833333,
0.866666667
]
},
{
"key": "0.887500,0.708333@1.5706",
"cm": 29,
"a": [
0.8875,
0.55
],
"b": [
0.8875,
0.866666667
]
},
{
"key": "0.887500,0.195833@1.5706",
"cm": 29,
"a": [
0.8875,
0.05
],
"b": [
0.8875,
0.345833333
]
},
{
"key": "0.754167,0.550000@0.0000",
"cm": 20,
"a": [
0.620833333,
0.55
],
"b": [
0.8875,
0.55
]
},
{
"key": "0.754167,0.866667@0.0000",
"cm": 29,
"a": [
0.8875,
0.866666667
],
"b": [
0.620833333,
0.866666667
]
},
{
"key": "0.070833,0.758333@1.5706",
"cm": 29,
"a": [
0.070833333,
0.866666667
],
"b": [
0.070833333,
0.645833333
]
},
{
"key": "0.237500,0.758333@1.5706",
"cm": 20,
"a": [
0.2375,
0.866666667
],
"b": [
0.2375,
0.645833333
]
},
{
"key": "0.312500,0.645833@0.0000",
"cm": 20,
"a": [
0.420833333,
0.645833333
],
"b": [
0.204166667,
0.645833333
]
},
{
"key": "0.070833,0.541667@1.5706",
"cm": 29,
"a": [
0.070833333,
0.645833333
],
"b": [
0.070833333,
0.4375
]
},
{
"key": "0.204167,0.541667@1.5706",
"cm": 20,
"a": [
0.204166667,
0.645833333
],
"b": [
0.204166667,
0.4375
]
},
{
"key": "0.979167,0.445833@1.5706",
"cm": 28,
"a": [
0.979166667,
0.345833333
],
"b": [
0.979166667,
0.55
]
},
{
"key": "0.520833,0.866667@0.0000",
"cm": 29,
"a": [
0.620833333,
0.866666667
],
"b": [
0.420833333,
0.866666667
]
},
{
"key": "0.791667,0.345833@0.0000",
"cm": 22,
"a": [
0.8875,
0.345833333
],
"b": [
0.691666667,
0.345833333
]
},
{
"key": "0.329167,0.866667@0.0000",
"cm": 29,
"a": [
0.420833333,
0.866666667
],
"b": [
0.2375,
0.866666667
]
},
{
"key": "0.154167,0.645833@0.0000",
"cm": 20,
"a": [
0.070833333,
0.645833333
],
"b": [
0.2375,
0.645833333
]
},
{
"key": "0.154167,0.866667@0.0000",
"cm": 29,
"a": [
0.2375,
0.866666667
],
"b": [
0.070833333,
0.866666667
]
},
{
"key": "0.691667,0.400000@1.5706",
"cm": 22,
"a": [
0.691666667,
0.345833333
],
"b": [
0.691666667,
0.454166667
]
},
{
"key": "0.933333,0.550000@0.0000",
"cm": 22,
"a": [
0.979166667,
0.55
],
"b": [
0.8875,
0.55
]
},
{
"key": "0.933333,0.345833@0.0000",
"cm": 22,
"a": [
0.888881904,
0.345833333
],
"b": [
0.979166667,
0.345833333
]
},
{
"key": "0.887500,0.345833@0.0000",
"cm": 15,
"a": [
0.8875,
0.345833333
],
"b": [
0.888881904,
0.345833333
]
}
],
"partitions": [
{
"id": "partition_partition_partition_partition-msjey_30638872",
"a": [
0.691666667,
0.345833333
],
"b": [
0.604166667,
0.345833333
],
"cm": 22
}
],
"open_spans": [
{
"a": [
0.420833333,
0.425
],
"b": [
0.620833333,
0.425
]
},
{
"a": [
0.620833333,
0.425
],
"b": [
0.620833333,
0.541666667
]
},
{
"a": [
0.691666667,
0.454166667
],
"b": [
0.691666667,
0.55
]
}
],
"openings": [],
"wall_columns": [
{
"id": "column_column_column_column-msjeztk0_5a939_96c417e2",
"shape": "square",
"center": [
0.4,
0.441666667
],
"cm": 15
},
{
"id": "column-mt1olibe",
"shape": "square",
"center": [
0.416666667,
0.345833333
],
"cm": 29
}
]
}
}
+49
View File
@@ -369,3 +369,52 @@ test('модели проекта не несут шума решётки (#282)
+ `${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('реальные планы: сплошные рёбра без записи толщины закреплены числом (#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}`);
}
});