Cover dense v8 draft invariants

Issue: #314
User-Visible: no
This commit is contained in:
Matysh
2026-08-26 09:56:35 +03:00
parent 069c4867ea
commit 64e9bf8f6b
7 changed files with 192 additions and 20 deletions
File diff suppressed because one or more lines are too long
+86
View File
@@ -0,0 +1,86 @@
/**
* #314 anonymised dense-plan fixture.
*
* It preserves the structural shape of the owner's report without names,
* markers, layout, or original coordinates: 13 rooms, 44 catalogue walls,
* 24 intentional partitions, and one pre-existing two-point draft. The last
* object is known debt and must remain the only invariant finding before and
* after drawing another room.
*/
import { WALL_KEY_PITCH } from './wall-key.mjs';
export function makeV8DraftRegressionFixture() {
const step = WALL_KEY_PITCH;
const point = (x, y) => [x * step, y * step];
const segmentIds = new Map();
const wallSegments = [];
const keyOf = (a, b) => {
const left = `${a[0]},${a[1]}`;
const right = `${b[0]},${b[1]}`;
return left < right ? `${left}|${right}` : `${right}|${left}`;
};
const wallId = (a, b) => {
const key = keyOf(a, b);
let id = segmentIds.get(key);
if (!id) {
id = `wall-314-${String(wallSegments.length + 1).padStart(3, '0')}`;
segmentIds.set(key, id);
wallSegments.push({ id, a: [...a], b: [...b], cm: 15 });
}
return id;
};
const rooms = Array.from({ length: 13 }, (_, index) => {
const x0 = 4 + index * 12;
const x1 = x0 + 12;
const y0 = 4;
const y1 = 16;
const poly = [point(x0, y0), point(x1, y0), point(x1, y1)];
// Four split top edges make the catalogue population exactly 44 while
// keeping all rooms valid and non-overlapping.
if (index < 4) poly.push(point(x0 + 6, y1));
poly.push(point(x0, y1));
return {
id: `room-314-${String(index + 1).padStart(2, '0')}`,
name: `Room ${index + 1}`,
poly,
wall_ids: poly.map((a, edge) => wallId(a, poly[(edge + 1) % poly.length])),
};
});
const partitions = Array.from({ length: 24 }, (_, index) => {
const row = Math.floor(index / 8);
const x = 4 + (index % 8) * 18;
const y = 28 + row * 8;
return {
id: `partition-314-${String(index + 1).padStart(2, '0')}`,
a: point(x, y),
b: point(x + 8, y + 2),
cm: 15 + (index % 3) * 5,
};
});
if (wallSegments.length !== 44) {
throw new Error(`fixture catalogue drifted: expected 44, got ${wallSegments.length}`);
}
return {
model_version: 8,
spaces: [{
id: 'space-314-dense',
title: 'Dense regression fixture',
cell_cm: 5,
view_box: [0, 0, 400, 400],
rooms,
wall_segments: wallSegments,
partitions,
room_drafts: [{
id: 'draft-314-known-debt',
points: [point(4, 56), point(14, 64)],
segments: [{ id: 'wall-314-known-draft', cm: 15 }],
}],
}],
markers: [],
settings: {},
};
}
+79 -7
View File
@@ -1,9 +1,18 @@
/** #314: model-v8 draft identities survive writes/Undo; rejected writes fail closed. */
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { makeV8DraftRegressionFixture } from './fixtures/v8-draft-regression.mjs';
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 760 });
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
const denseFixture = makeV8DraftRegressionFixture();
const result = await page.evaluate(async () => {
const result = await page.evaluate(async (denseFixture) => {
const out = {};
const card = window.__card;
const clone = (value) => JSON.parse(JSON.stringify(value));
@@ -48,10 +57,10 @@ const result = await page.evaluate(async () => {
} };
};
const reset = async () => {
const reset = async (initialConfig = emptyConfig()) => {
card._saveConfigDebounced.cancel();
await card._writeChain.catch(() => undefined);
server = emptyConfig();
server = clone(initialConfig);
rev = 1;
writes = [];
writeBehavior = null;
@@ -62,7 +71,7 @@ const result = await page.evaluate(async () => {
card._geometryHistory.clear();
card._clearGeometryGesture();
card._toast = '';
card._space = 'v8-draft';
card._space = server.spaces[0].id;
card._layout = {};
card._modelCache = null;
card._wallUnionCache = null;
@@ -221,7 +230,70 @@ const result = await page.evaluate(async () => {
&& !server.spaces[0].room_drafts?.length
&& !server.spaces[0].partitions?.length;
return out;
});
// AC8: repeat the real click/close/reload path on an anonymised fixture with
// the same population shape as the owner's report. Existing independent
// geometry and the one known unusable draft must survive byte-for-byte.
await reset(denseFixture);
const invariantBefore = clone(server);
const originalPartitionIds = server.spaces[0].partitions.map((item) => item.id);
const originalDraft = JSON.stringify(server.spaces[0].room_drafts[0]);
for (const point of [[800, 100], [900, 100], [900, 200], [800, 200], [800, 100]]) {
await clickPoint(point[0], point[1]);
card._saveConfigDebounced.flush();
await settleWrites();
}
card._nameSel = 'Invariant room';
card._saveRoom();
card._saveConfigDebounced.flush();
await settleWrites();
await card._reloadConfigOnly(true);
const denseAfter = card._serverCfg.spaces[0];
out.denseFixtureHasOwnerReportShape = invariantBefore.spaces[0].rooms.length === 13
&& invariantBefore.spaces[0].wall_segments.length === 44
&& originalPartitionIds.length === 24
&& invariantBefore.spaces[0].room_drafts.length === 1;
out.denseFixtureRoomSurvivesReload = denseAfter.rooms.length === 14
&& denseAfter.rooms.some((room) => room.name === 'Invariant room');
out.denseFixturePreservesIndependentGeometry = JSON.stringify(
denseAfter.partitions.map((item) => item.id),
) === JSON.stringify(originalPartitionIds)
&& denseAfter.room_drafts.length === 1
&& JSON.stringify(denseAfter.room_drafts[0]) === originalDraft;
await finish(browser, checkAll(result));
return { checks: out, invariantBefore, invariantAfter: clone(card._serverCfg) };
}, denseFixture);
const runInvariants = (config, label) => {
const dir = mkdtempSync(join(tmpdir(), `houseplan-314-${label}-`));
const path = join(dir, 'config.json');
try {
writeFileSync(path, JSON.stringify(config));
const run = spawnSync('npm', [
'run', 'invariants', '--', '--config', path, '--json',
], { cwd: repoRoot, encoding: 'utf8', shell: process.platform === 'win32' });
const stdout = run.stdout || '';
const jsonStart = stdout.indexOf('{');
if (jsonStart < 0) {
throw new Error(`invariant CLI produced no JSON (status ${run.status}):\n`
+ `${stdout}\n${run.stderr || run.error || ''}`);
}
const parsed = JSON.parse(stdout.slice(jsonStart));
return { status: run.status, violations: parsed.violations, notes: parsed.notes };
} finally {
rmSync(dir, { recursive: true, force: true });
}
};
const before = runInvariants(result.invariantBefore, 'before');
const after = runInvariants(result.invariantAfter, 'after');
const signature = (run) => run.violations.map((item) => [
item.invariant, item.kind, item.owner,
]);
result.checks.denseFixtureKnownDebtIsExplicit = before.status === 1
&& before.violations.length === 1
&& before.violations[0].kind === 'unusable_draft'
&& before.violations[0].owner.endsWith(':draft-314-known-debt');
result.checks.denseFixtureAddsNoInvariantViolations = after.status === 1
&& JSON.stringify(signature(after)) === JSON.stringify(signature(before));
await finish(browser, checkAll(result.checks));
+1 -1
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -66,6 +66,13 @@
observes synchronous rollback of its whole pending batch: active path,
pending map and command history are empty, and finishing the tool cannot
create a ghost partition.
- [ ] The smoke also loads the anonymised
`demo/fixtures/v8-draft-regression.mjs` population (13 rooms, 44 wall
catalogue segments, 24 intentional partitions, one known unusable
draft), closes and reloads a new room through the real editor path, then
runs the `model-invariants.mjs` CLI before and after. The exact existing
`unusable_draft` finding must remain the only violation; no independent
object may disappear and no new hidden obstacle may appear.
- [ ] `tests_backend/test_wall_segment_model.py` accepts valid independent v8
draft/partition/column/hosted-opening changes with an unchanged contour
catalog, while the existing current/downgraded stale-contour negatives
+11 -11
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "cd14953b3d076cfa936f2d3d9f614bc245e14f13374594bf93868cc86bb14a95",
"sourceFingerprint": "daf805126d889c54ca7ae63af86c016f442b148fedf12a570f650efb93245ceb",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,7 +14,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cd14953b3d076cfa936f2d3d9f614bc245e14f13374594bf93868cc86bb14a95",
"sourceSha256": "daf805126d889c54ca7ae63af86c016f442b148fedf12a570f650efb93245ceb",
"imageSha256": "36223106c073f07d8cc3ecf8eaab37192ebb2687daba65c5c21047d0b7890de0"
},
"view-touch": {
@@ -25,7 +25,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cd14953b3d076cfa936f2d3d9f614bc245e14f13374594bf93868cc86bb14a95",
"sourceSha256": "daf805126d889c54ca7ae63af86c016f442b148fedf12a570f650efb93245ceb",
"imageSha256": "41e3ba67f8db0e98f26f484293af83ef937c369ca5ca6a59a3350d8954c906f4"
},
"space-create": {
@@ -36,7 +36,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cd14953b3d076cfa936f2d3d9f614bc245e14f13374594bf93868cc86bb14a95",
"sourceSha256": "daf805126d889c54ca7ae63af86c016f442b148fedf12a570f650efb93245ceb",
"imageSha256": "c33a7279165a4cec6fa6fadb6fd08cd967e082a17fe101ef442d27d36ae59b6b"
},
"room-contour-close": {
@@ -47,7 +47,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cd14953b3d076cfa936f2d3d9f614bc245e14f13374594bf93868cc86bb14a95",
"sourceSha256": "daf805126d889c54ca7ae63af86c016f442b148fedf12a570f650efb93245ceb",
"imageSha256": "b4777162eae89e0d95801721330bcd74ba761624b82b3362069d7e2d38317e08"
},
"plan-context-tray": {
@@ -58,7 +58,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cd14953b3d076cfa936f2d3d9f614bc245e14f13374594bf93868cc86bb14a95",
"sourceSha256": "daf805126d889c54ca7ae63af86c016f442b148fedf12a570f650efb93245ceb",
"imageSha256": "c0e28edf82f45ccc6df568d3023b681e9e4394262ad34c1605fe9eacdb57a390"
},
"device-editor": {
@@ -69,7 +69,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cd14953b3d076cfa936f2d3d9f614bc245e14f13374594bf93868cc86bb14a95",
"sourceSha256": "daf805126d889c54ca7ae63af86c016f442b148fedf12a570f650efb93245ceb",
"imageSha256": "d0ffd31ce80bfde21ab75da356a5fc1af38246f2b301030880320620c228d89d"
},
"device-display-preview": {
@@ -80,7 +80,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cd14953b3d076cfa936f2d3d9f614bc245e14f13374594bf93868cc86bb14a95",
"sourceSha256": "daf805126d889c54ca7ae63af86c016f442b148fedf12a570f650efb93245ceb",
"imageSha256": "2cdabae1f89c3286e4fac0ce30f757ee1690b707ab8a5488748b7cd420626160"
},
"background-editor": {
@@ -91,7 +91,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cd14953b3d076cfa936f2d3d9f614bc245e14f13374594bf93868cc86bb14a95",
"sourceSha256": "daf805126d889c54ca7ae63af86c016f442b148fedf12a570f650efb93245ceb",
"imageSha256": "30147bb00a90eea7136b4cee30995f6e6a9217b5132f3e8d3ad7471413b1af8a"
},
"room-card": {
@@ -102,7 +102,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cd14953b3d076cfa936f2d3d9f614bc245e14f13374594bf93868cc86bb14a95",
"sourceSha256": "daf805126d889c54ca7ae63af86c016f442b148fedf12a570f650efb93245ceb",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
@@ -113,7 +113,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cd14953b3d076cfa936f2d3d9f614bc245e14f13374594bf93868cc86bb14a95",
"sourceSha256": "daf805126d889c54ca7ae63af86c016f442b148fedf12a570f650efb93245ceb",
"imageSha256": "dd492f53150b7149085daada5cce9eeae9bde9e7ea1d86679a54b3041f72f517"
}
}
@@ -281,6 +281,13 @@ F2 остаётся pending и следующим write сохраняется.
**Доказательство:** model-invariant unit + review кода.
Исполняемое доказательство находится в `demo/smoke_v8_draft_write.mjs`: оно
загружает обезличенную `demo/fixtures/v8-draft-regression.mjs` с формой отчёта
13 rooms / 44 wall segments / 24 partitions / 1 unusable draft, закрывает новую
комнату реальным editor path и запускает CLI `model-invariants.mjs` на снимках
до и после. Единственная заранее известная находка обязана сохраниться без
новых violations.
### AC9. Совместимость, View и touch
Persisted schema остаётся v8; v7 safe round-trip и v8 read path не меняют