mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: scope near-axis resize validation
Keep unrelated pre-existing near-axis edges from disabling exact Resize handles, make tracked single-space fixtures visible to the invariants CLI, and add the three missing mutation gates plus real-plan coverage. Issue: #290 User-Visible: no
This commit is contained in:
File diff suppressed because one or more lines are too long
Vendored
+151
-148
File diff suppressed because one or more lines are too long
@@ -516,10 +516,13 @@ export function checkMixedRoleRecords(config) {
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** Разобрать любой из трёх форматов, в которых приходит конфигурация. */
|
||||
/** Разобрать runtime-ответы, сырой config и tracked single-space fixtures. */
|
||||
export function readModel(text) {
|
||||
const parsed = JSON.parse(text);
|
||||
const config = parsed?.payload?.config ?? parsed?.result?.config ?? parsed?.config ?? parsed;
|
||||
const source = parsed?.payload?.config ?? parsed?.result?.config ?? parsed?.config ?? parsed;
|
||||
const config = !Array.isArray(source?.spaces) && parsed?.space
|
||||
? { spaces: [parsed.space] }
|
||||
: source;
|
||||
const layout = parsed?.payload?.layout ?? parsed?.result?.layout ?? parsed?.layout ?? {};
|
||||
return { config, layout };
|
||||
}
|
||||
|
||||
@@ -975,6 +975,42 @@ export const MUTANTS = [
|
||||
replace: ' const point = snapped;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'near-axis-shared-owner-repair-partial',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="#290 repairs a duplicated" test/near-axis.test.mjs',
|
||||
because: 'a shared physical wall must move every coincident room-owner copy atomically (#290)',
|
||||
patches: [{
|
||||
file: 'src/near-axis.ts',
|
||||
find: 'const replaceRoomPoints = (rooms: any[], move: EndpointMove): any[] => rooms.map((room) => {',
|
||||
replace: 'const replaceRoomPoints = (rooms: any[], move: EndpointMove): any[] => rooms.map((room, index) => {\n'
|
||||
+ ' if (index > 0) return room;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'near-axis-shared-owner-double-counted',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="#290 repairs a duplicated" test/near-axis.test.mjs',
|
||||
because: 'coincident room-owner copies are one physical wall in the Optimize report (#290)',
|
||||
patches: [{
|
||||
file: 'src/near-axis.ts',
|
||||
find: ' const key = segmentKey(a, b);',
|
||||
replace: ' const key = `${segmentKey(a, b)}:${candidates.size}`;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'near-axis-optimize-confirmation-bypassed',
|
||||
guard: 'npm run bundle:sync && node demo/smoke_near_axis_optimize.mjs',
|
||||
because: 'opening the Optimize preview or cancelling it must never persist a lossy repair (#290)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ' private _openAlignDialog = (): void => this._previewAlignDialog(false);',
|
||||
replace: ' private _openAlignDialog = (): void => {\n'
|
||||
+ ' this._previewAlignDialog(false);\n'
|
||||
+ ' void this._runAlignToGrid();\n'
|
||||
+ ' };',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'optimizer-micro-interval-cleanup-disabled',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
|
||||
+5
-4
@@ -1015,8 +1015,11 @@ export function validateSafeResize(
|
||||
const edge = plan.edgeByRoom[roomId];
|
||||
if (!original || !next || next.length !== plan.topology[roomId]
|
||||
|| next.length !== original.poly.length || !polyIsSimple(next)) return false;
|
||||
for (let index = 0; index < next.length; index++) {
|
||||
if (classifyNearAxisSegment(next[index], next[(index + 1) % next.length])) return false;
|
||||
const n = original.poly.length;
|
||||
const prev = (edge - 1 + n) % n;
|
||||
const involvedEdges = [prev, edge, (edge + 1) % n];
|
||||
for (const index of involvedEdges) {
|
||||
if (classifyNearAxisSegment(next[index], next[(index + 1) % n])) return false;
|
||||
}
|
||||
const s0 = signedArea(original.poly);
|
||||
const s1 = signedArea(next);
|
||||
@@ -1029,8 +1032,6 @@ export function validateSafeResize(
|
||||
const newClearance = minSpanClearance(next, newSpan, eps);
|
||||
if (newClearance < Math.min(minDim, oldClearance) - eps) return false;
|
||||
|
||||
const n = original.poly.length;
|
||||
const prev = (edge - 1 + n) % n;
|
||||
const sideAOld: [number[], number[]] = [original.poly[prev], original.poly[edge]];
|
||||
const sideANew: [number[], number[]] = [next[prev], next[edge]];
|
||||
const sideBOld: [number[], number[]] = [original.poly[(edge + 1) % n], original.poly[(edge + 2) % n]];
|
||||
|
||||
@@ -197,6 +197,10 @@ test('readModel понимает экспорт, ответ config/get и сыр
|
||||
{ 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: ключи ------------------------------
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '../test-build/near-axis.js';
|
||||
import { optimizePlans } from '../test-build/plan-optimizer.js';
|
||||
import { MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES } from '../test-build/wall-thickness.js';
|
||||
import { nearAxisProfile } from '../scripts/model-invariants.mjs';
|
||||
|
||||
const fixture = JSON.parse(readFileSync(
|
||||
new URL('./fixtures/279-near-orthogonal-junction.json', import.meta.url), 'utf8',
|
||||
@@ -102,3 +103,30 @@ test('#290 Optimize reports physical count, exact max centimetres and is idempot
|
||||
assert.equal(second.report.wallsStraightened, 0);
|
||||
assert.equal(second.report.maxStraightenShiftCm, 0);
|
||||
});
|
||||
|
||||
test('#290 Optimize reduces the tracked real-plan near-axis profile and stays idempotent', () => {
|
||||
for (const file of ['real-plan-first-floor.json', 'real-plan-second-floor.json']) {
|
||||
const { space } = JSON.parse(readFileSync(
|
||||
new URL(`./fixtures/${file}`, import.meta.url), 'utf8',
|
||||
));
|
||||
const config = { spaces: [clone(space)] };
|
||||
const before = nearAxisProfile(config);
|
||||
const first = optimizePlans(config, {});
|
||||
const after = nearAxisProfile(first.config);
|
||||
const second = optimizePlans(first.config, first.layout);
|
||||
|
||||
assert.equal(after.total, before.total - first.report.wallsStraightened, file);
|
||||
assert.equal(after.total <= before.total, true, file);
|
||||
assert.equal(second.report.wallsStraightened, 0, file);
|
||||
assert.equal(nearAxisProfile(second.config).total, after.total, file);
|
||||
}
|
||||
const { space } = JSON.parse(readFileSync(
|
||||
new URL('./fixtures/real-plan-second-floor.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const config = { spaces: [clone(space)] };
|
||||
const before = nearAxisProfile(config);
|
||||
const repaired = optimizePlans(config, {});
|
||||
assert.equal(before.total, 1);
|
||||
assert.equal(repaired.report.wallsStraightened, 1);
|
||||
assert.equal(nearAxisProfile(repaired.config).total, 0);
|
||||
});
|
||||
|
||||
@@ -297,6 +297,24 @@ test('#277 diagonal and non-perpendicular side walls are disabled deterministica
|
||||
assert.deepEqual(resolveSafeResize(badSide, [], 'S', 0, SAFE), { enabled: false, reason: 'side-angle' });
|
||||
});
|
||||
|
||||
test('#290 a remote near-axis edge does not disable an unrelated exact resize handle', () => {
|
||||
const source = JSON.parse(fs.readFileSync(
|
||||
new URL('./fixtures/279-near-orthogonal-junction.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const rooms = source.rooms.map((room) => ({
|
||||
...room,
|
||||
poly: room.poly.map(([x, y]) => [x * 1000, y * 1000]),
|
||||
}));
|
||||
const resolution = resolveSafeResize(rooms, [], 'north-west', 3, {
|
||||
...SAFE,
|
||||
step: 1000 / 240,
|
||||
});
|
||||
assert.equal(resolution.enabled, true);
|
||||
assert.equal(validateSafeResize(
|
||||
rooms, [], resolution.plan, 1000 / 240, { ...SAFE, step: 1000 / 240 },
|
||||
), true);
|
||||
});
|
||||
|
||||
test('#277 partial shared and third-owner topology never enter a resize plan', () => {
|
||||
const partial = resolveSafeResize([A(), B()], [], 'A', 1, SAFE);
|
||||
assert.deepEqual(partial, { enabled: false, reason: 'partial-shared' });
|
||||
|
||||
Reference in New Issue
Block a user