mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 06:59:46 +00:00
fix(editor): preserve residual wall identity when creating room
Issue: #804 User-Visible: yes
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
/** #804: partial partition promotion through the real room dialog and config/set. */
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const point = (x, y) => [x / 240, y / 240];
|
||||
const partition = (id, x1, y1, x2, y2, cm) => ({
|
||||
id, a: point(x1, y1), b: point(x2, y2), cm,
|
||||
});
|
||||
const room = (id, x1, y1, x2, y2) => ({
|
||||
id, name: id, area: null,
|
||||
poly: [point(x1, y1), point(x2, y1), point(x2, y2), point(x1, y2)],
|
||||
});
|
||||
|
||||
// Only the reported lattice geometry survives here; no private export or HA data.
|
||||
// In particular there is NO short overlay carrier on x=74, y=112..133.
|
||||
function garderoba({ opening = null, duplicate = false } = {}) {
|
||||
const rooms = [room('neighbour', 102, 112, 130, 133)];
|
||||
rooms[0].wall_ids = ['neighbour-top', 'neighbour-right', 'neighbour-bottom', 'neighbour-left'];
|
||||
const wallSegments = [
|
||||
partition('neighbour-left', 102, 112, 102, 133, 15),
|
||||
partition('neighbour-top', 102, 112, 130, 112, 15),
|
||||
partition('neighbour-bottom', 102, 133, 130, 133, 15),
|
||||
partition('neighbour-right', 130, 112, 130, 133, 15),
|
||||
];
|
||||
// Explicit current-model seed: standalone browser/mutation runs do not need
|
||||
// an unrelated test-build compilation just to construct this rectangle.
|
||||
const keys = ['0.425000,0.512500@1.5706', '0.483333,0.466667@0.0000',
|
||||
'0.483333,0.554167@0.0000', '0.541667,0.512500@1.5706'];
|
||||
const config = {
|
||||
model_version: 10, markers: [], settings: {},
|
||||
spaces: [{
|
||||
id: 'lineage', title: 'Lineage', cell_cm: 8, view_box: [0.24, 0.34, 0.36, 0.52],
|
||||
rooms, wall_segments: wallSegments,
|
||||
walls: wallSegments.map(({ a, b, cm }, index) => ({ key: keys[index], cm, a, b })),
|
||||
openings: [],
|
||||
partitions: [
|
||||
partition('long-source', 74, 90, 74, 200, 25),
|
||||
partition('upper-source', 74, 112, 102, 112, 15),
|
||||
partition('lower-source', 102, 133, 74, 133, 15),
|
||||
],
|
||||
}, {
|
||||
id: 'other-floor', title: 'Other floor', cell_cm: 8,
|
||||
view_box: [0, 0, 1, 1], rooms: [], wall_segments: [],
|
||||
partitions: [partition('untouched-wall', 10, 10, 30, 10, 15)],
|
||||
}],
|
||||
};
|
||||
const space = config.spaces[0];
|
||||
if (opening) {
|
||||
const y = opening === 'same-residual' ? 101 : opening === 'absorbed' ? 122 : 160;
|
||||
space.openings = [{
|
||||
id: 'retained-opening', type: 'window', x: 74 / 240, y: y / 240,
|
||||
angle: 90, length: 6 / 240, cm: 48,
|
||||
host: { kind: 'partition', id: 'long-source', t: (y - 90) / 110 },
|
||||
contact: 'binary_sensor.synthetic_window', future_field: { keep: true },
|
||||
}];
|
||||
}
|
||||
if (duplicate) space.wall_segments.push(structuredClone(space.wall_segments[0]));
|
||||
return config;
|
||||
}
|
||||
|
||||
function batchConfig() {
|
||||
return {
|
||||
model_version: 10, markers: [], settings: {},
|
||||
spaces: [{
|
||||
id: 'lineage', title: 'Lineage', cell_cm: 8, view_box: [0.24, 0.34, 0.30, 0.36],
|
||||
rooms: [], wall_segments: [], openings: [],
|
||||
partitions: [
|
||||
partition('batch-source', 74, 90, 74, 160, 25),
|
||||
partition('batch-top', 74, 112, 102, 112, 15),
|
||||
partition('batch-divider', 74, 126, 102, 126, 15),
|
||||
partition('batch-bottom', 74, 140, 102, 140, 15),
|
||||
],
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
const results = {};
|
||||
let lastBrowser;
|
||||
async function scenario(name, config, body) {
|
||||
if (process.env.HP_LINEAGE_SCENARIO && !name.includes(process.env.HP_LINEAGE_SCENARIO)) return;
|
||||
console.log(`SCENARIO ${name}`);
|
||||
const { page, browser } = await launch({ width: 1280, height: 960 });
|
||||
page.setDefaultTimeout(10000);
|
||||
lastBrowser = browser;
|
||||
try {
|
||||
await page.evaluate(async (config) => {
|
||||
await window.__hpTest.setServerConfig(config);
|
||||
await window.__hpTest.setLayout({});
|
||||
await window.__hpTest.switchSpace('lineage');
|
||||
await window.__hpTest.setMode('plan');
|
||||
await window.__hpTest.setTool('draw');
|
||||
// Mode transition owns the stage briefly after the toolbar settles.
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await window.__hpTest.settled();
|
||||
const card = window.__card;
|
||||
const fallback = card.hass.callWS.bind(card.hass);
|
||||
const state = {
|
||||
saved: structuredClone(card._serverCfg), revision: card._cfgRev,
|
||||
attempts: [], accepted: [], rejected: [], reads: 0,
|
||||
};
|
||||
// A public HA boundary, not a private editor override. The mock actually
|
||||
// persists accepted writes and enforces the existing host-kind/id rule.
|
||||
card.hass = { ...card.hass, callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get') {
|
||||
state.reads++;
|
||||
return { config: structuredClone(state.saved), rev: state.revision, can_write: true };
|
||||
}
|
||||
if (message.type !== 'houseplan/config/set') return fallback(message);
|
||||
const candidate = structuredClone(message.config);
|
||||
state.attempts.push(candidate);
|
||||
for (const oldSpace of state.saved.spaces || []) {
|
||||
const nextSpace = candidate.spaces.find((item) => item.id === oldSpace.id);
|
||||
for (const oldOpening of oldSpace.openings || []) {
|
||||
if (oldOpening.host?.kind !== 'partition') continue;
|
||||
const next = nextSpace?.openings?.find((item) => item.id === oldOpening.id);
|
||||
if (next && (next.host?.kind !== 'partition' || next.host.id !== oldOpening.host.id)) {
|
||||
state.rejected.push({ opening: oldOpening.id, code: 'invalid_format' });
|
||||
throw Object.assign(new Error('surviving partition opening cannot change its host'), {
|
||||
code: 'invalid_format',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
state.saved = candidate;
|
||||
state.accepted.push(candidate);
|
||||
return { ok: true, rev: ++state.revision };
|
||||
} };
|
||||
window.__lineageSmoke = state;
|
||||
}, config);
|
||||
const output = await body(page);
|
||||
results[name] = output;
|
||||
for (const [key, value] of Object.entries(output)) check(`${name}: ${key}`, value);
|
||||
} catch (error) {
|
||||
console.error(`${name}: ${error.stack || error.message}`);
|
||||
console.error('UI state', await page.evaluate(() => ({
|
||||
mode: window.__card._mode, tool: window.__card._tool, space: window.__card._space,
|
||||
path: window.__card._path, toast: window.__card._toast,
|
||||
roomDialog: window.__card._roomDialog, view: window.__card._view,
|
||||
dialogs: [...window.__card.renderRoot.querySelectorAll('[data-hp="dialog"]')].map((item) => item.outerHTML.slice(0, 400)),
|
||||
})));
|
||||
results[name] = { exception: error.message };
|
||||
check(`${name}: scenario completed`, false);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function clickPoint(page, x, y) {
|
||||
const screen = await page.evaluate(([x, y]) => {
|
||||
const rect = window.__card.renderRoot.querySelector('.stage').getBoundingClientRect();
|
||||
const view = window.__card._view;
|
||||
return {
|
||||
x: rect.left + (x / 240 * 1000 - view.x) / view.w * rect.width,
|
||||
y: rect.top + (y / 240 * 1000 - view.y) / view.h * rect.height,
|
||||
};
|
||||
}, [x, y]);
|
||||
await page.mouse.click(screen.x, screen.y);
|
||||
await page.evaluate(() => window.__hpTest.settled());
|
||||
}
|
||||
|
||||
async function decide(page, name, keep = false) {
|
||||
const dialog = page.locator('[data-hp="dialog"][data-kind="room"]');
|
||||
await dialog.waitFor({ state: 'visible' });
|
||||
if (keep) {
|
||||
await dialog.locator('[data-hp="dialog-confirm"]').filter({ hasText: 'Keep as walls' }).click();
|
||||
} else {
|
||||
await dialog.locator('#room-name').fill(name);
|
||||
await dialog.locator('.dialog-action-commit [data-hp="dialog-confirm"]').click();
|
||||
}
|
||||
await page.evaluate(() => window.__hpTest.settled());
|
||||
}
|
||||
|
||||
async function settleWrites(page) {
|
||||
// The public save path is debounced. Waiting also proves a rejected gesture
|
||||
// did not merely postpone a write until after an immediate assertion.
|
||||
await page.waitForTimeout(800);
|
||||
await page.waitForFunction(() => window.__card._writesPending === 0);
|
||||
await page.evaluate(() => window.__hpTest.settled());
|
||||
}
|
||||
|
||||
async function snapshot(page) {
|
||||
return page.evaluate(() => ({
|
||||
config: structuredClone(window.__card._serverCfg),
|
||||
history: window.__card._geometryHistory.size,
|
||||
toast: window.__card._toast,
|
||||
server: structuredClone(window.__lineageSmoke),
|
||||
pendingRooms: window.__card._wallFaceBatch?.candidates.length || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
const geometry = (config) => config.spaces.map((space) => ({
|
||||
id: space.id, rooms: space.rooms || [], wall_segments: space.wall_segments || [],
|
||||
walls: space.walls || [], partitions: space.partitions || [], openings: space.openings || [],
|
||||
}));
|
||||
const uniqueIds = (space) => {
|
||||
const segments = [...space.wall_segments || [], ...space.partitions || []];
|
||||
const ids = segments.map((item) => item.id);
|
||||
const catalog = new Set((space.wall_segments || []).map((item) => item.id));
|
||||
return new Set(ids).size === ids.length
|
||||
&& (space.rooms || []).every((item) => item.wall_ids?.length === item.poly.length
|
||||
&& item.wall_ids.every((id) => catalog.has(id)));
|
||||
};
|
||||
|
||||
await scenario('Garderoba without workaround', garderoba(), async (page) => {
|
||||
const before = await snapshot(page);
|
||||
await clickPoint(page, 88, 122);
|
||||
await decide(page, 'Garderoba');
|
||||
await settleWrites(page);
|
||||
const after = await snapshot(page);
|
||||
const space = after.config.spaces[0];
|
||||
const created = space.rooms.find((item) => item.name === 'Garderoba');
|
||||
const residuals = (space.partitions || []).filter((item) => (
|
||||
Math.abs(item.a[0] * 240 - 74) < 1e-6 && Math.abs(item.b[0] * 240 - 74) < 1e-6
|
||||
));
|
||||
const out = {
|
||||
roomCreated: !!created && space.rooms.length === before.config.spaces[0].rooms.length + 1,
|
||||
actualConfigSaved: after.server.accepted.length === before.server.accepted.length + 1
|
||||
&& !!created && equal(geometry(after.server.saved), geometry(after.config)),
|
||||
twoOriginalContinuations: residuals.length === 2
|
||||
&& residuals.every((item) => item.cm === 25)
|
||||
&& equal(residuals.map((item) => [item.a[1] * 240, item.b[1] * 240].map(Math.round)).sort((a, b) => a[0] - b[0]), [[90, 112], [133, 200]]),
|
||||
roomThicknessPreserved: !!created && equal(created.wall_ids.map((id) => space.wall_segments.find((item) => item.id === id).cm).sort((a, b) => a - b), [15, 15, 15, 25]),
|
||||
neighboursAndOtherFloorUnchanged: equal(space.rooms.find((item) => item.id === 'neighbour'), before.config.spaces[0].rooms[0])
|
||||
&& equal(after.config.spaces[1], before.config.spaces[1]),
|
||||
identitiesValid: uniqueIds(space),
|
||||
oneHistoryCommand: after.history === before.history + 1,
|
||||
noMigrationError: !String(after.toast).includes('wall identifiers'),
|
||||
};
|
||||
if (!created) return out;
|
||||
await page.keyboard.press('Escape');
|
||||
await page.keyboard.press('Control+z');
|
||||
await settleWrites(page);
|
||||
const undone = await snapshot(page);
|
||||
out.undoRestoresSource = equal(geometry(undone.config), geometry(before.config));
|
||||
await page.keyboard.press('Control+Shift+z');
|
||||
await settleWrites(page);
|
||||
const redone = await snapshot(page);
|
||||
out.redoRestoresExactIds = equal(geometry(redone.config), geometry(after.config));
|
||||
// A distinct card reads the mock server via the real config/get lifecycle.
|
||||
await page.evaluate(async () => {
|
||||
const old = window.__card;
|
||||
const next = document.createElement('houseplan-card');
|
||||
next.setConfig({ type: 'custom:houseplan-card' });
|
||||
next.hass = old.hass;
|
||||
old.replaceWith(next);
|
||||
window.__card = next;
|
||||
});
|
||||
await page.waitForFunction(() => window.__card._serverCfg?.spaces?.[0]?.rooms?.length === 2);
|
||||
const reloaded = await snapshot(page);
|
||||
out.reloadReadsDurableIdentity = reloaded.server.reads > 0
|
||||
&& equal(geometry(reloaded.config), geometry(after.config));
|
||||
return out;
|
||||
});
|
||||
|
||||
for (const ending of ['save', 'keep', 'cancel', 'escape']) {
|
||||
await scenario(`batch ${ending}`, batchConfig(), async (page) => {
|
||||
await clickPoint(page, 102, 112);
|
||||
await clickPoint(page, 102, 140);
|
||||
await settleWrites(page);
|
||||
const before = await snapshot(page);
|
||||
await decide(page, 'First');
|
||||
const pending = await snapshot(page);
|
||||
const out = {
|
||||
twoFacesOffered: before.pendingRooms === 2,
|
||||
firstDecisionDoesNotWritePartialRoom: pending.config.spaces[0].rooms.length === 0
|
||||
&& pending.server.accepted.length === before.server.accepted.length,
|
||||
};
|
||||
if (ending === 'save') await decide(page, 'Second');
|
||||
else if (ending === 'keep') await decide(page, '', true);
|
||||
else {
|
||||
await page.evaluate(async (via) => {
|
||||
const dialog = window.__card.renderRoot.querySelector('[data-hp="dialog"][data-kind="room"]');
|
||||
const outcome = await window.__hpTest.close(dialog, { via });
|
||||
if (outcome.confirm) outcome.confirm.querySelector('[data-hp="dialog-confirm"]').click();
|
||||
}, ending === 'escape' ? 'escape' : 'cancel');
|
||||
}
|
||||
await settleWrites(page);
|
||||
const after = await snapshot(page);
|
||||
const space = after.config.spaces[0];
|
||||
if (ending === 'save' || ending === 'keep') {
|
||||
const expected = ending === 'save' ? 2 : 1;
|
||||
out.onlyAcceptedRoomsCreated = space.rooms.length === expected;
|
||||
out.oneAtomicHistoryAndSave = after.history === before.history + 1
|
||||
&& after.server.accepted.length === before.server.accepted.length + 1;
|
||||
out.identitiesValid = uniqueIds(space);
|
||||
if (ending === 'save' && space.rooms.length === 2) {
|
||||
const shared = space.rooms[0].wall_ids.filter((id) => space.rooms[1].wall_ids.includes(id));
|
||||
out.oneSharedPhysicalAtom = shared.length === 1
|
||||
&& space.wall_segments.filter((item) => item.id === shared[0]).length === 1;
|
||||
}
|
||||
} else {
|
||||
out.cancelDiscardsEarlierDecision = equal(geometry(after.config), geometry(before.config))
|
||||
&& after.history === before.history
|
||||
&& after.server.accepted.length === before.server.accepted.length;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
await scenario('true duplicate rollback', garderoba({ duplicate: true }), async (page) => {
|
||||
const before = await snapshot(page);
|
||||
await clickPoint(page, 88, 122);
|
||||
await decide(page, 'Must not exist');
|
||||
await settleWrites(page);
|
||||
const after = await snapshot(page);
|
||||
return {
|
||||
duplicateStillRejected: String(after.toast).includes('wall identifiers'),
|
||||
noPartialMutation: equal(geometry(after.config), geometry(before.config)),
|
||||
noHistoryOrServerWrite: after.history === before.history && after.server.attempts.length === 0,
|
||||
};
|
||||
});
|
||||
|
||||
for (const opening of ['same-residual', 'absorbed', 'new-residual']) {
|
||||
await scenario(`opening ${opening}`, garderoba({ opening }), async (page) => {
|
||||
const before = await snapshot(page);
|
||||
await clickPoint(page, 88, 122);
|
||||
await decide(page, 'Opening room');
|
||||
await settleWrites(page);
|
||||
const after = await snapshot(page);
|
||||
if (opening !== 'same-residual') return {
|
||||
actualBackendRejection: after.server.attempts.length === 1 && after.server.rejected.length === 1,
|
||||
serverRetainsOriginal: after.server.accepted.length === 0 && equal(after.server.saved, before.server.saved),
|
||||
optimisticGeometryRolledBack: equal(geometry(after.config), geometry(before.config)),
|
||||
noRejectedHistory: after.history === before.history,
|
||||
};
|
||||
const oldOpening = before.config.spaces[0].openings[0];
|
||||
const newOpening = after.config.spaces[0].openings[0];
|
||||
const { host: _oldHost, ...oldFields } = oldOpening;
|
||||
const { host: _newHost, ...newFields } = newOpening;
|
||||
return {
|
||||
sameResidualAccepted: after.server.accepted.length === 1 && after.server.rejected.length === 0,
|
||||
openingGeometryAndMetadataPreserved: equal(oldFields, newFields),
|
||||
originalPartitionHostRetained: newOpening.host.kind === 'partition'
|
||||
&& newOpening.host.id === oldOpening.host.id && newOpening.host.t !== oldOpening.host.t,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
await finish(lastBrowser, results);
|
||||
@@ -272,6 +272,12 @@ revision. Model and lineage: [`WALL-THICKNESS.md`](WALL-THICKNESS.md) §1;
|
||||
migrations and stale-client guard: [`CONFIG-COMPATIBILITY.md`](CONFIG-COMPATIBILITY.md)
|
||||
(model v8–v10); rationale: [`adr/282-wall-geometry-representation.md`](adr/282-wall-geometry-representation.md).
|
||||
|
||||
Room-face acceptance uses `wall-face-lineage.ts` to choose provisional carriers
|
||||
and settle only new-room hints against the partitions left after reconciliation
|
||||
(#804). A residual keeps its identity without colliding with the promoted room
|
||||
edge. The common model barrier, host validation and atomic history remain the
|
||||
authorities; this helper neither allocates IDs nor writes configuration.
|
||||
|
||||
Rooms may not partially overlap (lying on a shared wall is legal, a fully nested
|
||||
island is supported). Merge/Split use **polyclip-ts** (not `polygon-clipping`,
|
||||
see [`DEVELOPMENT.md`](DEVELOPMENT.md)): Merge accepts a pair only when the union
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Fixed creating a room from part of a longer independent wall failing with
|
||||
conflicting wall identifiers. The outside wall pieces retain their geometry
|
||||
and thickness; no redraw or Optimize workaround is needed
|
||||
([#804](https://github.com/Matysh/houseplan-card/issues/804)).
|
||||
|
||||
## v1.80.0-beta.4 — 2026-10-05
|
||||
|
||||
- Zigbee links to another space now name the destination device too. Device
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Исправлен отказ создания комнаты на части длинной отдельной стены из-за
|
||||
конфликта идентификаторов. Продолжения стены сохраняют геометрию и толщину;
|
||||
обводить стену заново или запускать оптимизацию больше не требуется
|
||||
([#804](https://github.com/Matysh/houseplan-card/issues/804)).
|
||||
|
||||
## v1.80.0-beta.4 — 2026-10-05
|
||||
|
||||
- Подписи Zigbee-связей в другое пространство теперь содержат имя устройства.
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Development and deployment
|
||||
|
||||
## Room-face lineage regression (#804)
|
||||
|
||||
`node demo/smoke_wall_face_lineage.mjs` exercises creation from a partial
|
||||
independent wall using synthetic geometry, without a second exact-span carrier
|
||||
over the room edge. Adding that duplicate carrier would hide the original bug.
|
||||
Run against a fresh demo bundle; private runtime writes are not a test API.
|
||||
Pure lineage cases and shared frontend/backend candidates cover retained IDs,
|
||||
partial/full promotion and the unchanged partition-opening host restrictions.
|
||||
Never add a user's complete exported home configuration to the test fixtures.
|
||||
|
||||
## Input support contract
|
||||
|
||||
Read `docs/TOUCH-SUPPORT.md` before changing interaction code.
|
||||
|
||||
@@ -31,6 +31,7 @@ Everything computable from the tree and git; regenerate, never edit by hand
|
||||
| Branches | `main` carries stable releases only; pre-release tags point at `dev`. Work lands on `dev`, which is equal to or ahead of `main`, never behind. |
|
||||
| Zigbee routes | #798 replaces inferred neighbour trees with integration-reported end-parent and active coordinator next-hop evidence. Unknown/conflicting routes are not guessed; stale/partial snapshots remain labelled. Solid arrows have a separate 0–255 palette; ordinary device LQI colours are unchanged. |
|
||||
| Zigbee caption layout | #802 adds remote destination names and keeps the matching pointer tooltip clear of diagnostic text; only an unplaceable tooltip yields in a small card. Focus/touch/actions and provider transport remain unchanged. |
|
||||
| Partial wall promotion | #804 settles new-room lineage after reconciliation: surviving independent wall pieces reserve their IDs, preventing the Garderoba duplicate-ID failure without changing geometry or relaxing opening-host validation. |
|
||||
| Zigbee scan lifecycle | #800 replaces #799's 10-minute total budget with integration-owned background jobs: closing the browser does not stop waiting; after 10 minutes an exact-job cancel becomes available. MQTT setup/publish caps and the retained-info check remain bounded. Tests cover 15-minute success, cross-client restore, cancel races, stale-cache retention and reload on a live HA connection. |
|
||||
| LED regression acceptance | #788 covers mixed face/free and fractional-coordinate joins, winding-safe field unions, retained endpoints/acute vertices and circle–wall transition events. The actual owner export is exercised locally; public fixtures are synthetic. Pixel oracles, not just SVG counts, protect the visible falloff. |
|
||||
| LED core colour | #790 makes the on core follow the resolved light colour with or without Glow. Off, unavailable and non-live static strips keep their neutral presentation; geometry and field brightness are unchanged. |
|
||||
|
||||
@@ -47,6 +47,14 @@ room deletion, opening edits, Undo/Redo/recovery, Optimize and import/export
|
||||
apply the same lineage and validation rules before one atomic persistence write.
|
||||
Initial legacy IDs are deterministic, so frontend, backend and repeated
|
||||
migrations converge; only genuinely new segments get UUIDs.
|
||||
Room-face acceptance settles its provisional partition lineage after coincident
|
||||
carriers have been reconciled (#804). A surviving partition or residual reserves
|
||||
its ID: only new room-edge hints that still claim that ID are cleared, preserving
|
||||
their slots. The common model barrier allocates new room-wall IDs; fully consumed
|
||||
carriers retain their usual promotion lineage. Existing room IDs and the global
|
||||
duplicate-ID guard are not relaxed. Ordinary config writes still reject changes
|
||||
to the kind or ID of a surviving partition-opening host; the narrowly proved
|
||||
Optimize rehost capability is not granted to room creation.
|
||||
`src/wall-segment-model.ts` and `custom_components/houseplan/wall_segment_model.py`
|
||||
share `test/fixtures/282-wall-identity-parity.json`. Writer-bypass mutants in
|
||||
`scripts/mutation-registry.mjs` guard every structural writer entrance. A `cm:0`
|
||||
|
||||
@@ -36,8 +36,8 @@ that transition events alone prove disposal.
|
||||
| Paint, cascade and layer composition | 40 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. |
|
||||
| Pointer geometry and trusted interaction | 49 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. |
|
||||
| Responsive DOM layout | 39 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. |
|
||||
| Custom-element and HA browser lifecycle | 103 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
|
||||
| **Total** | **239 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. |
|
||||
| Custom-element and HA browser lifecycle | 104 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
|
||||
| **Total** | **240 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. |
|
||||
|
||||
## Measured effect
|
||||
|
||||
@@ -240,6 +240,11 @@ The invariant depends on measured element boxes, responsive breakpoints, native/
|
||||
|
||||
### Custom-element and HA browser lifecycle
|
||||
|
||||
`wall-face-lineage-skips-post-reconcile-settlement` (#804) needs the production room
|
||||
dialog, post-reconciliation runtime wiring, history, asynchronous `config/set` and
|
||||
reload. Pure lineage-helper tests cannot prove that the editor actually settles
|
||||
residual-owned IDs before the strict wall-model barrier or rolls rejected writes back.
|
||||
|
||||
The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition.
|
||||
|
||||
- `accepted-marker-rolled-back-by-layout-failure`
|
||||
@@ -329,6 +334,7 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy
|
||||
- `view-current-space-aria-removed`
|
||||
- `volumetric-kiosk-ignores-setting`
|
||||
- `wall-face-apply-skips-overlap-guard`
|
||||
- `wall-face-lineage-skips-post-reconcile-settlement`
|
||||
- `wallthick-hit-narrowed`
|
||||
- `warm-late-resume-beats-user-mode`
|
||||
- `warm-late-resume-crosses-space`
|
||||
|
||||
@@ -72,6 +72,18 @@ function relocateEditorPatch(patch, cardSource, editorSource) {
|
||||
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
|
||||
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
|
||||
const MUTANT_DEFINITIONS = [
|
||||
{
|
||||
id: 'wall-face-lineage-skips-post-reconcile-settlement',
|
||||
guard: 'node demo/smoke_wall_face_lineage.mjs',
|
||||
because: '#804 AC1/AC3/AC5: only the production room-dialog save proves that residual '
|
||||
+ 'partition IDs are settled before the strict wall-model barrier; pure helper tests '
|
||||
+ 'cannot detect an omitted runtime call, and the duplicate-ID guard must remain intact.',
|
||||
patches: [{
|
||||
file: 'src/houseplan-editor-runtime.ts',
|
||||
find: ' if (room.wall_ids) room.wall_ids = settleWallFaceLineage(room.wall_ids, remainingPartitionIds);\n',
|
||||
replace: ' // mutant: stale partition lineage reaches the wall-model barrier\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'stairs-continuous-transform-snaps-to-lattice',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
|
||||
@@ -29,6 +29,13 @@
|
||||
|
||||
/** @type {SmokeLink[]} */
|
||||
export const SMOKE_LINKS = [
|
||||
{
|
||||
symbols: ['selectWallFaceLineage', 'settleWallFaceLineage'],
|
||||
smokes: ['smoke_wall_face_lineage.mjs'],
|
||||
because: '#804: a partial partition carrier is promoted through the production room dialog, '
|
||||
+ 'actual config/set, history and reload; the smoke observes persisted IDs and atomic '
|
||||
+ 'rollback rather than importing or naming the lineage helper.',
|
||||
},
|
||||
{
|
||||
files: ['src/zigbee-topology-runtime.ts', 'src/hp-zigbee-topology-settings.ts'],
|
||||
smokes: ['smoke_zigbee_topology_job.mjs'],
|
||||
|
||||
@@ -269,6 +269,7 @@ import { safeStoredColor } from './color';
|
||||
import { gridCellFieldValue, gridVisualUnits, newSpaceCellCm } from './grid-scale';
|
||||
import { applyOpeningMoves, mergeCollinearPartitions, spaceMergeGeometry } from './wall-merge';
|
||||
import { reconcileCoincidentPartitions } from './coincident-partitions';
|
||||
import { selectWallFaceLineage, settleWallFaceLineage } from './wall-face-lineage';
|
||||
import { finalizeWallChainSpace } from './writer-fixed-point';
|
||||
import {
|
||||
captureMarkerRoomReferences, restoreMarkerRoomReferences,
|
||||
@@ -6599,29 +6600,9 @@ public _applyWallFaceBatch(): void {
|
||||
}
|
||||
const activePartitionIds = new Set(batch.activePartitionIds);
|
||||
const epsilon = this.host._gridPitch * 0.0002;
|
||||
const roomLineage = accepted.map((decision) => decision.candidate.ring.map((a, index, ring) => {
|
||||
const b = ring[(index + 1) % ring.length];
|
||||
const carriers = model.partitions.filter((partition) => {
|
||||
const length = Math.hypot(partition.b[0] - partition.a[0], partition.b[1] - partition.a[1]);
|
||||
if (!(length > epsilon)) return false;
|
||||
const ux = (partition.b[0] - partition.a[0]) / length;
|
||||
const uy = (partition.b[1] - partition.a[1]) / length;
|
||||
const along = (point: number[]) => (
|
||||
(point[0] - partition.a[0]) * ux + (point[1] - partition.a[1]) * uy
|
||||
);
|
||||
return distToSegment(a, [partition.a[0], partition.a[1], partition.b[0], partition.b[1]]) <= epsilon
|
||||
&& distToSegment(b, [partition.a[0], partition.a[1], partition.b[0], partition.b[1]]) <= epsilon
|
||||
&& along(a) >= -epsilon && along(a) <= length + epsilon
|
||||
&& along(b) >= -epsilon && along(b) <= length + epsilon;
|
||||
});
|
||||
carriers.sort((left, right) => (
|
||||
Number(activePartitionIds.has(right.id)) - Number(activePartitionIds.has(left.id))
|
||||
|| Math.hypot(left.b[0] - left.a[0], left.b[1] - left.a[1])
|
||||
- Math.hypot(right.b[0] - right.a[0], right.b[1] - right.a[1])
|
||||
|| left.id.localeCompare(right.id)
|
||||
));
|
||||
return carriers[0]?.id || '';
|
||||
}));
|
||||
const roomLineage = accepted.map((decision) => selectWallFaceLineage(
|
||||
decision.candidate.ring, model.partitions, activePartitionIds, epsilon,
|
||||
));
|
||||
const before = this._geometrySnapshot();
|
||||
if (!before) {
|
||||
abort('toast.geometry_unsafe');
|
||||
@@ -6743,6 +6724,10 @@ public _applyWallFaceBatch(): void {
|
||||
else delete sp.partitions;
|
||||
if (reconciled.openings.length) sp.openings = reconciled.openings;
|
||||
else delete sp.openings;
|
||||
const remainingPartitionIds = new Set(reconciled.partitions.map((partition) => partition.id));
|
||||
for (const { room } of newRooms) {
|
||||
if (room.wall_ids) room.wall_ids = settleWallFaceLineage(room.wall_ids, remainingPartitionIds);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this._commitPhysicalGeometry(this.host._t('history.wall_face_batch'), before)) return;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { distToSegment } from './logic';
|
||||
|
||||
interface WallFaceCarrier {
|
||||
id: string;
|
||||
a: readonly number[];
|
||||
b: readonly number[];
|
||||
}
|
||||
|
||||
/** Provisional promotion hints; coordinates share the caller's model scale. */
|
||||
export function selectWallFaceLineage(
|
||||
ring: readonly number[][],
|
||||
partitions: readonly WallFaceCarrier[],
|
||||
activePartitionIds: ReadonlySet<string>,
|
||||
epsilon: number,
|
||||
): string[] {
|
||||
return ring.map((a, index) => {
|
||||
const b = ring[(index + 1) % ring.length];
|
||||
const carriers = partitions.filter((partition) => {
|
||||
const length = Math.hypot(partition.b[0] - partition.a[0], partition.b[1] - partition.a[1]);
|
||||
if (!(length > epsilon)) return false;
|
||||
const ux = (partition.b[0] - partition.a[0]) / length;
|
||||
const uy = (partition.b[1] - partition.a[1]) / length;
|
||||
const along = (point: readonly number[]) => (
|
||||
(point[0] - partition.a[0]) * ux + (point[1] - partition.a[1]) * uy
|
||||
);
|
||||
return distToSegment(a, [partition.a[0], partition.a[1], partition.b[0], partition.b[1]]) <= epsilon
|
||||
&& distToSegment(b, [partition.a[0], partition.a[1], partition.b[0], partition.b[1]]) <= epsilon
|
||||
&& along(a) >= -epsilon && along(a) <= length + epsilon
|
||||
&& along(b) >= -epsilon && along(b) <= length + epsilon;
|
||||
});
|
||||
carriers.sort((left, right) => (
|
||||
Number(activePartitionIds.has(right.id)) - Number(activePartitionIds.has(left.id))
|
||||
|| Math.hypot(left.b[0] - left.a[0], left.b[1] - left.a[1])
|
||||
- Math.hypot(right.b[0] - right.a[0], right.b[1] - right.a[1])
|
||||
|| left.id.localeCompare(right.id)
|
||||
));
|
||||
return carriers[0]?.id || '';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A partially consumed partition keeps its ID on a residual, so that ID cannot
|
||||
* also be promoted into the room catalogue. Clear only those provisional hints;
|
||||
* the model barrier owns fresh identity allocation. Empty slots preserve edges.
|
||||
*/
|
||||
export function settleWallFaceLineage(
|
||||
lineage: readonly string[],
|
||||
remainingPartitionIds: ReadonlySet<string>,
|
||||
): string[] {
|
||||
return lineage.map((id) => remainingPartitionIds.has(id) ? '' : id);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"grid": 240,
|
||||
"cellCm": 8,
|
||||
"source": { "id": "long-source", "a": [74, 90], "b": [74, 200], "cm": 25 },
|
||||
"face": [[74, 112], [102, 112], [102, 133], [74, 133]],
|
||||
"neighbor": [[102, 112], [130, 112], [130, 133], [102, 133]],
|
||||
"crossCm": 15
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { selectWallFaceLineage, settleWallFaceLineage } from '../test-build/wall-face-lineage.js';
|
||||
import { commitWallSegmentModel } from '../test-build/wall-segment-model.js';
|
||||
import { reconcileCoincidentPartitions } from '../test-build/coincident-partitions.js';
|
||||
import { spaceModels, GRID_PITCH, GRID_STEP_N, NORM_W } from '../test-build/space-geometry.js';
|
||||
import { setWallThickness, applyWallThicknessToNewRoom } from '../test-build/wall-thickness.js';
|
||||
import { resolvePartitionOpeningStrict } from '../test-build/partition-openings.js';
|
||||
import { checkSpacePhysicalGeometry } from '../test-build/plan-geometry-preflight.js';
|
||||
|
||||
const fixture = JSON.parse(readFileSync(new URL('./fixtures/804-wall-face-lineage.json', import.meta.url), 'utf8'));
|
||||
const sourceId = fixture.source.id;
|
||||
const normalized = (point) => point.map((value) => value / fixture.grid);
|
||||
const modelPoint = (point) => normalized(point).map((value) => value * NORM_W);
|
||||
const close = (actual, expected, message) => assert.ok(Math.abs(actual - expected) < 1e-9,
|
||||
`${message}: ${actual} != ${expected}`);
|
||||
|
||||
function makeBefore(options = {}) {
|
||||
const cm = options.sourceCm ?? fixture.source.cm;
|
||||
const crossCm = options.crossCm ?? fixture.crossCm;
|
||||
const a = [fixture.source.a[0], options.start ?? fixture.source.a[1]];
|
||||
const b = [fixture.source.b[0], options.end ?? fixture.source.b[1]];
|
||||
if (options.reverse) [a[1], b[1]] = [b[1], a[1]];
|
||||
const room = { id: 'neighbor', name: 'Neighbor', area: null, poly: fixture.neighbor.map(normalized) };
|
||||
const source = { id: sourceId, a: normalized(a), b: normalized(b), cm };
|
||||
const partition = (id, first, second) => ({ id, a: normalized(first), b: normalized(second), cm: crossCm });
|
||||
const partitions = [
|
||||
source,
|
||||
partition('top-source', fixture.face[0], fixture.face[1]),
|
||||
partition('bottom-source', fixture.face[3], fixture.face[2]),
|
||||
];
|
||||
if (options.multiple) partitions.push(partition('divider-source', [74, 122], [102, 122]));
|
||||
let walls = [];
|
||||
for (const [index, first] of room.poly.entries()) {
|
||||
walls = setWallThickness(walls, first, room.poly[(index + 1) % room.poly.length], crossCm, GRID_STEP_N, 1);
|
||||
}
|
||||
const space = { id: 'floor', title: 'Floor', cell_cm: fixture.cellCm,
|
||||
view_box: [0, 0, 1, 1], rooms: [room], partitions, walls };
|
||||
if (options.openingY !== undefined) {
|
||||
const opening = {
|
||||
id: 'hosted-opening', type: options.openingType || 'door',
|
||||
x: a[0] / fixture.grid, y: options.openingY / fixture.grid,
|
||||
angle: -90, length: (options.openingLength ?? 8) / fixture.grid,
|
||||
contact: 'binary_sensor.synthetic_door', lock: 'lock.synthetic_door',
|
||||
cover: 'cover.synthetic_opening', invert: true, flip_h: true,
|
||||
future_field: { keep: ['opaque', 7] },
|
||||
host: { kind: 'partition', id: sourceId, t: (options.openingY - a[1]) / (b[1] - a[1]) },
|
||||
};
|
||||
space.openings = [opening];
|
||||
}
|
||||
return commitWallSegmentModel({ spaces: [space, {
|
||||
id: 'unrelated-space', title: 'Unrelated', cell_cm: 8, view_box: [0, 0, 1, 1], rooms: [],
|
||||
}], markers: [], settings: {} }).config;
|
||||
}
|
||||
|
||||
/** Pure production stages; emit mode exposes actual frontend candidates to Python. */
|
||||
function acceptFaces(before, options = {}, settle = true) {
|
||||
const candidate = structuredClone(before);
|
||||
const space = candidate.spaces[0];
|
||||
const initialModel = spaceModels(candidate)[0];
|
||||
const rings = options.multiple ? [
|
||||
[[74, 112], [102, 112], [102, 122], [74, 122]],
|
||||
[[74, 122], [102, 122], [102, 133], [74, 133]],
|
||||
] : [fixture.face];
|
||||
const newRooms = rings.map((ring, index) => ({
|
||||
id: `accepted-${index}`, name: `Accepted ${index}`, area: null,
|
||||
poly: ring.map(normalized),
|
||||
wall_ids: selectWallFaceLineage(ring.map(modelPoint), initialModel.partitions,
|
||||
new Set(), GRID_PITCH * 0.0002),
|
||||
}));
|
||||
space.rooms.push(...newRooms);
|
||||
for (const room of newRooms) {
|
||||
space.walls = applyWallThicknessToNewRoom(space.walls, spaceModels(candidate)[0].rooms,
|
||||
room.id, options.crossCm ?? fixture.crossCm, GRID_STEP_N, [], NORM_W);
|
||||
for (const [index, point] of room.poly.entries()) {
|
||||
const carrier = initialModel.partitions.find((partition) => partition.id === room.wall_ids[index]);
|
||||
if (carrier) space.walls = setWallThickness(space.walls, point,
|
||||
room.poly[(index + 1) % room.poly.length], carrier.cm, GRID_STEP_N, 1);
|
||||
}
|
||||
}
|
||||
const reconciled = reconcileCoincidentPartitions(space, spaceModels(candidate)[0], space.walls, [], {
|
||||
pitch: GRID_STEP_N, cellCm: fixture.cellCm, gridPitch: GRID_PITCH,
|
||||
coordScale: NORM_W, allowCoincidentPartitions: true,
|
||||
});
|
||||
for (const key of ['walls', 'partitions', 'openings']) {
|
||||
if (reconciled[key].length) space[key] = reconciled[key];
|
||||
else delete space[key];
|
||||
}
|
||||
if (settle) {
|
||||
const remainingIds = new Set(reconciled.partitions.map((partition) => partition.id));
|
||||
for (const room of newRooms) room.wall_ids = settleWallFaceLineage(room.wall_ids, remainingIds);
|
||||
}
|
||||
return commitWallSegmentModel(candidate).config;
|
||||
}
|
||||
|
||||
function assertIdentity(space) {
|
||||
const physicalIds = [...(space.partitions || []), ...space.wall_segments].map((wall) => wall.id);
|
||||
assert.equal(new Set(physicalIds).size, physicalIds.length, 'physical IDs are globally disjoint');
|
||||
for (const room of space.rooms) {
|
||||
assert.equal(room.wall_ids.length, room.poly.length);
|
||||
for (const id of room.wall_ids) assert.equal(space.wall_segments.filter((wall) => wall.id === id).length, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Captures keep real UUIDs for Python. Replay compares every field while giving
|
||||
// only newly allocated contour IDs a deterministic name from their exact span.
|
||||
function comparableNewIdentities(config, before) {
|
||||
const result = structuredClone(config);
|
||||
const existing = new Set(before.spaces.flatMap((space) => [
|
||||
...(space.wall_segments || []), ...(space.partitions || []),
|
||||
]).map((wall) => wall.id));
|
||||
for (const space of result.spaces) {
|
||||
const replacement = new Map((space.wall_segments || []).filter((wall) => !existing.has(wall.id))
|
||||
.map((wall) => [wall.id, `new:${JSON.stringify([wall.a, wall.b].sort((a, b) => a[0] - b[0] || a[1] - b[1]))}`]));
|
||||
for (const wall of space.wall_segments || []) wall.id = replacement.get(wall.id) || wall.id;
|
||||
for (const room of space.rooms) room.wall_ids = room.wall_ids?.map((id) => replacement.get(id) || id);
|
||||
for (const opening of space.openings || []) {
|
||||
if (opening.host?.kind === 'wall') opening.host.id = replacement.get(opening.host.id) || opening.host.id;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const variants = [
|
||||
{ name: 'two residuals, mixed thickness', options: {}, residuals: 2 },
|
||||
{ name: 'one trailing residual', options: { start: 112 }, residuals: 1 },
|
||||
{ name: 'one leading residual', options: { end: 133 }, residuals: 1 },
|
||||
{ name: 'fully consumed carrier', options: { start: 112, end: 133 }, residuals: 0 },
|
||||
{ name: 'reversed source', options: { reverse: true }, residuals: 2 },
|
||||
{ name: 'uniform positive thickness', options: { sourceCm: 15 }, residuals: 2 },
|
||||
{ name: 'zero carrier', options: { sourceCm: 0 }, residuals: 2 },
|
||||
{ name: 'fully consumed zero carrier', options: { sourceCm: 0, start: 112, end: 133 }, residuals: 0 },
|
||||
];
|
||||
const hostVariants = [
|
||||
{ name: 'same-id-residual-host', options: { openingY: 100 }, expectedPartitionHostResult: 'ok' },
|
||||
{ name: 'other-residual-host', options: { openingY: 170 }, expectedPartitionHostResult: 'reject' },
|
||||
{ name: 'consumed-partition-host', options: { openingY: 122 }, expectedPartitionHostResult: 'reject' },
|
||||
];
|
||||
|
||||
if (process.argv.includes('--emit-backend-fixtures')) {
|
||||
const cases = [
|
||||
...variants.map(({ name, options }) => ({ name, options, expectedPartitionHostResult: 'ok' })),
|
||||
...hostVariants,
|
||||
].map(({ name, options, expectedPartitionHostResult }) => {
|
||||
const before = makeBefore(options);
|
||||
return { name, before, after: acceptFaces(before, options), expectedPartitionHostResult };
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify(cases)}\n`);
|
||||
} else {
|
||||
test('carrier selection keeps active, length and ID priority without mutating inputs', () => {
|
||||
const ring = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
const partitions = [
|
||||
{ id: 'long', a: [-5, 0], b: [15, 0] },
|
||||
{ id: 'short-z', a: [0, 0], b: [10, 0] },
|
||||
{ id: 'short-a', a: [10, 0], b: [0, 0] },
|
||||
{ id: 'degenerate', a: [0, 0], b: [0, 0] },
|
||||
{ id: 'partial', a: [0, 0], b: [5, 0] },
|
||||
];
|
||||
const original = structuredClone({ ring, partitions });
|
||||
assert.deepEqual(selectWallFaceLineage(ring, partitions, new Set(), 0.001), ['short-a', '', '', '']);
|
||||
assert.deepEqual(selectWallFaceLineage(ring, partitions, new Set(['long']), 0.001), ['long', '', '', '']);
|
||||
assert.deepEqual({ ring, partitions }, original);
|
||||
assert.deepEqual(selectWallFaceLineage([], partitions, new Set(), 0.001), []);
|
||||
});
|
||||
|
||||
test('settlement clears only occupied hints and preserves edge slots and full promotions', () => {
|
||||
const hints = ['long-source', '', 'fully-consumed', 'long-source'];
|
||||
const remaining = new Set(['long-source', 'unrelated']);
|
||||
assert.deepEqual(settleWallFaceLineage(hints, remaining), ['', '', 'fully-consumed', '']);
|
||||
assert.deepEqual(hints, ['long-source', '', 'fully-consumed', 'long-source']);
|
||||
assert.deepEqual([...remaining], ['long-source', 'unrelated']);
|
||||
assert.deepEqual(settleWallFaceLineage([], remaining), []);
|
||||
});
|
||||
|
||||
for (const { name, options, residuals } of variants) test(`AC2: ${name}`, () => {
|
||||
const before = makeBefore(options);
|
||||
const original = structuredClone(before);
|
||||
const after = acceptFaces(before, options);
|
||||
const space = after.spaces[0];
|
||||
const room = space.rooms.find((item) => item.id === 'accepted-0');
|
||||
assert.equal(space.rooms.length, 2);
|
||||
assert.deepEqual(room.poly, fixture.face.map(normalized));
|
||||
assert.deepEqual(space.rooms.find((item) => item.id === 'neighbor'), before.spaces[0].rooms[0]);
|
||||
assert.deepEqual(after.spaces[1], before.spaces[1]);
|
||||
assert.deepEqual(after.markers, before.markers);
|
||||
assert.deepEqual(before, original, 'pure pipeline does not mutate its source');
|
||||
assert.equal((space.partitions || []).length, residuals);
|
||||
assert.equal((space.partitions || []).some((wall) => wall.id === sourceId), residuals > 0);
|
||||
const leftId = room.wall_ids[3];
|
||||
assert.equal(leftId === sourceId, residuals === 0, 'source identity belongs to a residual before promotion');
|
||||
assert.deepEqual(room.wall_ids.slice(0, 3), ['top-source', before.spaces[0].rooms[0].wall_ids[3], 'bottom-source']);
|
||||
assert.deepEqual(room.wall_ids.map((id) => space.wall_segments.find((wall) => wall.id === id).cm),
|
||||
[fixture.crossCm, fixture.crossCm, fixture.crossCm, options.sourceCm ?? fixture.source.cm]);
|
||||
for (const residual of space.partitions || []) {
|
||||
close(residual.a[0], 74 / fixture.grid, 'residual x');
|
||||
close(residual.b[0], 74 / fixture.grid, 'residual x');
|
||||
const low = Math.min(residual.a[1], residual.b[1]);
|
||||
const high = Math.max(residual.a[1], residual.b[1]);
|
||||
assert.ok(high <= 112 / fixture.grid + 1e-9 || low >= 133 / fixture.grid - 1e-9,
|
||||
'residual does not duplicate the consumed span');
|
||||
assert.equal(residual.cm, options.sourceCm ?? fixture.source.cm);
|
||||
}
|
||||
const source = before.spaces[0].partitions.find((wall) => wall.id === sourceId);
|
||||
const residualLength = (space.partitions || []).reduce((sum, wall) => sum + Math.abs(wall.b[1] - wall.a[1]), 0);
|
||||
close(residualLength + 21 / fixture.grid, Math.abs(source.b[1] - source.a[1]), 'source coverage');
|
||||
assertIdentity(space);
|
||||
assert.equal(checkSpacePhysicalGeometry(after, space.id).ok, true);
|
||||
assert.deepEqual(commitWallSegmentModel(after).config, after, 'no identity churn on the second commit');
|
||||
});
|
||||
|
||||
test('the original unsatisfied hints still fail the unchanged duplicate-ID barrier', () => {
|
||||
const before = makeBefore();
|
||||
const original = structuredClone(before);
|
||||
assert.throws(() => acceptFaces(before, {}, false), (error) => error.reason === 'duplicate-id'
|
||||
&& error.message.includes(sourceId));
|
||||
assert.deepEqual(before, original);
|
||||
const malformed = acceptFaces(before);
|
||||
malformed.spaces[0].partitions[0].id = malformed.spaces[0].wall_segments[0].id;
|
||||
const unchanged = structuredClone(malformed);
|
||||
assert.throws(() => commitWallSegmentModel(malformed), (error) => error.reason === 'duplicate-id');
|
||||
assert.deepEqual(malformed, unchanged, 'failed model commit is pure');
|
||||
});
|
||||
|
||||
for (const fullyConsumed of [false, true]) test(`two accepted faces share one divider atom; full consumption=${fullyConsumed}`, () => {
|
||||
const options = { multiple: true, ...(fullyConsumed ? { start: 112, end: 133 } : {}) };
|
||||
const before = makeBefore(options);
|
||||
const after = acceptFaces(before, options);
|
||||
const space = after.spaces[0];
|
||||
const first = space.rooms.find((room) => room.id === 'accepted-0');
|
||||
const second = space.rooms.find((room) => room.id === 'accepted-1');
|
||||
assert.equal(first.wall_ids[2], 'divider-source');
|
||||
assert.equal(second.wall_ids[0], first.wall_ids[2]);
|
||||
assert.notEqual(first.wall_ids[3], second.wall_ids[3]);
|
||||
assert.equal([first.wall_ids[3], second.wall_ids[3]].filter((id) => id === sourceId).length,
|
||||
fullyConsumed ? 1 : 0, 'a fully consumed source ID belongs to exactly one physical atom');
|
||||
assertIdentity(space);
|
||||
assert.deepEqual(commitWallSegmentModel(after).config, after);
|
||||
});
|
||||
|
||||
for (const { name, options } of hostVariants) test(`AC4 frontend candidate: ${name}`, () => {
|
||||
const before = makeBefore(options);
|
||||
const original = structuredClone(before);
|
||||
const after = acceptFaces(before, options);
|
||||
const space = after.spaces[0];
|
||||
assert.equal(space.openings.length, 1);
|
||||
const oldOpening = before.spaces[0].openings[0];
|
||||
const opening = space.openings[0];
|
||||
const { host: oldHost, ...oldData } = oldOpening;
|
||||
const { host, ...data } = opening;
|
||||
assert.deepEqual(data, oldData, 'absolute geometry and all metadata are preserved');
|
||||
if (options.openingY === 122) {
|
||||
assert.equal(host.kind, 'wall');
|
||||
assert.equal(space.wall_segments.filter((wall) => wall.id === host.id).length, 1);
|
||||
} else {
|
||||
assert.equal(host.kind, 'partition');
|
||||
assert.equal(host.id === oldHost.id, options.openingY === 100);
|
||||
assert.notEqual(host.t, oldHost.t, 'the parameter changes to preserve world position');
|
||||
const resolved = resolvePartitionOpeningStrict(opening, space.partitions, 1,
|
||||
fixture.cellCm, GRID_STEP_N).resolved;
|
||||
assert.ok(resolved, 'residual host resolves with strict jamb margins');
|
||||
close(resolved.center[0], oldOpening.x, 'opening x');
|
||||
close(resolved.center[1], oldOpening.y, 'opening y');
|
||||
close(resolved.length, oldOpening.length, 'opening length');
|
||||
}
|
||||
assertIdentity(space);
|
||||
assert.deepEqual(commitWallSegmentModel(after).config, after);
|
||||
assert.deepEqual(before, original);
|
||||
});
|
||||
|
||||
test('ambiguous overlapping openings retain their hosts instead of disappearing', () => {
|
||||
const before = makeBefore({ openingY: 122 });
|
||||
before.spaces[0].openings.push({ ...structuredClone(before.spaces[0].openings[0]), id: 'overlapping-opening' });
|
||||
const after = acceptFaces(before);
|
||||
assert.deepEqual(after.spaces[0].openings, before.spaces[0].openings);
|
||||
for (const opening of after.spaces[0].openings) {
|
||||
assert.ok(after.spaces[0].partitions.some((wall) => wall.id === opening.host.id));
|
||||
}
|
||||
assertIdentity(after.spaces[0]);
|
||||
});
|
||||
|
||||
const backendFixture = new URL('./fixtures/804-wall-face-lineage-backend.json', import.meta.url);
|
||||
for (const captured of JSON.parse(readFileSync(backendFixture, 'utf8'))) {
|
||||
test(`shared backend candidate is a fixed point: ${captured.name}`, () => {
|
||||
const scenario = [...variants, ...hostVariants].find((item) => item.name === captured.name);
|
||||
assert.ok(scenario, 'captured scenario has a current generator');
|
||||
assert.deepEqual(captured.before, makeBefore(scenario.options), 'capture input matches the synthetic seed');
|
||||
const crossCm = captured.before.spaces[0].partitions.find((wall) => wall.id === 'top-source').cm;
|
||||
const replay = acceptFaces(captured.before, { crossCm });
|
||||
assert.deepEqual(comparableNewIdentities(replay, captured.before),
|
||||
comparableNewIdentities(captured.after, captured.before), 'capture matches current frontend stages');
|
||||
assertIdentity(captured.after.spaces[0]);
|
||||
assert.deepEqual(commitWallSegmentModel(captured.after).config, captured.after);
|
||||
assert.equal(checkSpacePhysicalGeometry(captured.after, captured.after.spaces[0].id).ok, true);
|
||||
assert.deepEqual(captured.after.spaces[1], captured.before.spaces[1]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"""#804: actual frontend room candidates obey unchanged backend barriers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.houseplan.junction_limits import validate_junction_limits
|
||||
from custom_components.houseplan.validation import (
|
||||
CONFIG_SCHEMA,
|
||||
PartitionOpeningHostError,
|
||||
validate_partition_opening_hosts,
|
||||
validate_wall_model_transition,
|
||||
)
|
||||
from custom_components.houseplan.wall_segment_model import (
|
||||
WallSegmentMigrationError,
|
||||
commit_wall_segment_model,
|
||||
)
|
||||
|
||||
|
||||
_CASES = json.loads((
|
||||
Path(__file__).parents[1]
|
||||
/ "test" / "fixtures" / "804-wall-face-lineage-backend.json"
|
||||
).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", _CASES, ids=lambda case: case["name"])
|
||||
def test_frontend_room_face_candidate_and_partition_host_policy(case: dict) -> None:
|
||||
before = copy.deepcopy(case["before"])
|
||||
after = copy.deepcopy(case["after"])
|
||||
original_before, original_after = copy.deepcopy(before), copy.deepcopy(after)
|
||||
|
||||
# This material is emitted by the frontend production-function pipeline,
|
||||
# not a Python reconstruction which could miss the original provisional-ID
|
||||
# conflict. The Node test replays the same captured cases independently.
|
||||
checked = CONFIG_SCHEMA(after)
|
||||
committed, _ = commit_wall_segment_model(checked)
|
||||
assert committed == checked
|
||||
assert commit_wall_segment_model(committed)[0] == committed
|
||||
validate_wall_model_transition(committed, before)
|
||||
validate_junction_limits(committed, before)
|
||||
|
||||
if case["expectedPartitionHostResult"] == "reject":
|
||||
with pytest.raises(PartitionOpeningHostError, match="host changed"):
|
||||
validate_partition_opening_hosts(committed, before)
|
||||
else:
|
||||
assert case["expectedPartitionHostResult"] == "ok"
|
||||
validate_partition_opening_hosts(committed, before)
|
||||
|
||||
assert before == original_before
|
||||
assert after == original_after
|
||||
|
||||
|
||||
def test_real_duplicate_catalogue_ids_are_still_rejected_without_mutation() -> None:
|
||||
source = copy.deepcopy(_CASES[0]["after"])
|
||||
space = source["spaces"][0]
|
||||
assert len(space["wall_segments"]) >= 2
|
||||
space["wall_segments"][1]["id"] = space["wall_segments"][0]["id"]
|
||||
original = copy.deepcopy(source)
|
||||
with pytest.raises(WallSegmentMigrationError, match="duplicate-id"):
|
||||
commit_wall_segment_model(source)
|
||||
assert source == original
|
||||
+1
-1
@@ -40,7 +40,7 @@
|
||||
"src/hp-help.ts", "src/hp-dialog.ts", "src/hp-color-opacity.ts", "src/danger-confirm.ts",
|
||||
"src/opening-placement.ts", "src/opening-dimensions.ts", "src/partition-openings.ts", "src/plan-snap-overlay.ts", "src/wall-face-graph.ts", "src/wall-face-repair.ts", "src/room-deletion.ts", "src/render/opening-symbol.ts",
|
||||
"src/wall-thickness.ts",
|
||||
"src/wall-segment-model.ts",
|
||||
"src/wall-segment-model.ts", "src/wall-face-lineage.ts",
|
||||
"src/wall-merge.ts", "src/writer-fixed-point.ts", "src/room-reference-transaction.ts",
|
||||
"src/physical-geometry.ts",
|
||||
"src/labs.ts", "src/iso-projection.ts", "src/iso-walls.ts", "src/iso-openings.ts", "src/iso-overlays.ts", "src/iso-scene-render.ts",
|
||||
|
||||
Reference in New Issue
Block a user