From 5ff97858f8905ece43a38d8be121a988c9979258 Mon Sep 17 00:00:00 2001 From: Matysh Date: Sat, 5 Sep 2026 22:13:25 +0300 Subject: [PATCH] Repair escaped mutation witnesses Issue: #466 Issue: #467 Issue: #468 Issue: #469 User-Visible: no --- demo/smoke_danger_confirm_branches.mjs | 59 +++++++++++++++++++------- demo/smoke_room_fit.mjs | 12 ++++-- scripts/mutation-gate.mjs | 18 ++++---- test/wall-thickness.test.mjs | 55 ++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 28 deletions(-) diff --git a/demo/smoke_danger_confirm_branches.mjs b/demo/smoke_danger_confirm_branches.mjs index e61d1b23..d81c4b59 100644 --- a/demo/smoke_danger_confirm_branches.mjs +++ b/demo/smoke_danger_confirm_branches.mjs @@ -194,6 +194,28 @@ const out = await page.evaluate(async () => { })); await settle(); }; + const boundedDangerDecision = async (decision, timeoutMs = 250) => { + let timeoutId; + const outcome = await Promise.race([ + decision.then((value) => ({ settled: true, value })), + new Promise((resolve) => { + timeoutId = setTimeout(() => resolve({ settled: false, value: 'timeout' }), timeoutMs); + }), + ]); + clearTimeout(timeoutId); + if (!outcome.settled) { + // A missing or dead decision surface must fail the smoke quickly instead + // of consuming the whole mutation shard. Cancelling also keeps later + // branches independent from the timed-out controller state. + card._cancelDangerConfirm(); + await Promise.race([ + decision, + new Promise((resolve) => setTimeout(resolve, 100)), + ]); + await settle(); + } + return outcome; + }; await settle(); @@ -213,7 +235,9 @@ const out = await page.evaluate(async () => { await settle(); result.mainBranchRendersExactlyOneConfirm = dialogs() === 1; card._cancelDangerConfirm(); - result.mainBranchCancelResolvesFalse = (await single) === false; + const singleDecision = await boundedDangerDecision(single); + result.mainBranchCancelResolvesFalse = singleDecision.settled + && singleDecision.value === false; const savedCfg = card._serverCfg; const savedModel = card._model; @@ -240,7 +264,9 @@ const out = await page.evaluate(async () => { await settle(); result.onboardingBranchShowsConfirm = dialogs() === 1; await decide(false); - result.onboardingBranchResolves = (await onboarding) === false; + const onboardingDecision = await boundedDangerDecision(onboarding); + result.onboardingBranchResolves = onboardingDecision.settled + && onboardingDecision.value === false; await leaveBranch(); // Открытое подтверждение переживает смену ветки: раньше оно исчезало вместе @@ -251,8 +277,9 @@ const out = await page.evaluate(async () => { await enterBranch([]); const stillOpenInOnboarding = dialogs() === 1; await decide(true); + const survivorDecision = await boundedDangerDecision(survivor); result.openConfirmSurvivesBranchChange = openedInMainBranch && stillOpenInOnboarding - && (await survivor) === true; + && survivorDecision.settled && survivorDecision.value === true; await leaveBranch(); // The exact missed branch from #417: a non-empty model whose active space @@ -265,22 +292,17 @@ const out = await page.evaluate(async () => { card._spaceModel = () => undefined; card.requestUpdate(); await settle(); - const spaceLossDecision = await Promise.race([ - pendingAtSpaceLoss, - new Promise((resolve) => setTimeout(() => resolve('timeout'), 100)), - ]); + const spaceLossDecision = await boundedDangerDecision(pendingAtSpaceLoss); result.lostSpaceBranchIsActuallyEntered = root().childElementCount === 0; - result.openConfirmCancelsWhenSpaceIsLost = spaceLossDecision === false + result.openConfirmCancelsWhenSpaceIsLost = spaceLossDecision.settled + && spaceLossDecision.value === false && card._dangerConfirm === null && card._dangerConfirmController.state === null; const refusedWithoutSpacePromise = card._confirmDanger(request('space-already-lost')); const refusedWithoutSpaceWasNeverRegistered = card._dangerConfirm === null && card._dangerConfirmController.state === null; - const refusedWithoutSpace = await Promise.race([ - refusedWithoutSpacePromise, - new Promise((resolve) => setTimeout(() => resolve('timeout'), 100)), - ]); + const refusedWithoutSpace = await boundedDangerDecision(refusedWithoutSpacePromise); result.lostSpaceRequestRefusesImmediately = refusedWithoutSpaceWasNeverRegistered - && refusedWithoutSpace === false + && refusedWithoutSpace.settled && refusedWithoutSpace.value === false && card._dangerConfirm === null && card._dangerConfirmController.state === null && dialogs() === 0; card._spaceModel = originalSpaceModel; @@ -302,7 +324,8 @@ const out = await page.evaluate(async () => { await settle(); const shown = dialogs() === 1; await decide(false); - return branchEntered && shown && (await pending) === false; + const decision = await boundedDangerDecision(pending); + return branchEntered && shown && decision.settled && decision.value === false; }; result.fixedFloorPendingStillShowsConfirm = await confirmInFixedBranch(false, 'pending'); result.fixedFloorInvalidStillShowsConfirm = await confirmInFixedBranch(true, 'invalid'); @@ -334,7 +357,9 @@ const out = await page.evaluate(async () => { } } await settle(); - result.touchTapOnCancelResolvesFalse = !!cancelButton && (await byTap) === false + const tapDecision = await boundedDangerDecision(byTap); + result.touchTapOnCancelResolvesFalse = !!cancelButton + && tapDecision.settled && tapDecision.value === false && dialogs() === 0; await leaveBranch(); @@ -350,7 +375,9 @@ const out = await page.evaluate(async () => { card.hass = savedHass; card.requestUpdate(); await settle(); - result.notReadyCardRefusesInsteadOfHanging = (await refused) === false; + const refusedDecision = await boundedDangerDecision(refused); + result.notReadyCardRefusesInsteadOfHanging = refusedDecision.settled + && refusedDecision.value === false; // Match the public surface of HA's pinned ha-dialog closely enough to prove // branch selection and ARIA forwarding without depending on private shadow diff --git a/demo/smoke_room_fit.mjs b/demo/smoke_room_fit.mjs index 099bebd5..fac3f568 100644 --- a/demo/smoke_room_fit.mjs +++ b/demo/smoke_room_fit.mjs @@ -68,9 +68,14 @@ const out = await page.evaluate(async () => { localStorage.setItem('houseplan_card_zoom_v1', JSON.stringify({ 'fit-floor': 1 })); pointer(roomNode(), 'pointerdown', 15201); pointer(roomNode(), 'pointerup', 15201); + const tweenStarted = c._cameraTransition?.active === true; const syncFrames = []; - for (let frame = 0; frame < 3 && c._cameraTransition?.active; frame++) { - await new Promise((resolve) => requestAnimationFrame(resolve)); + if (tweenStarted) { + // The fast path normally updates the presented camera without a Lit render. + // Force reconciliation before the first animation frame, while the tween is + // known to be active. Unrelated state may legitimately do the same, and HTML + // overlays must still consume the presented viewBox. + c.requestUpdate(); await c.updateComplete; const activeView = c._view; const activeLabel = root.querySelector('.roomlabel[data-id="room-a"]'); @@ -91,7 +96,8 @@ const out = await page.evaluate(async () => { && Math.abs(labelRect.top + labelRect.height / 2 - (stageRect.top + stageRect.height * expectedTop / 100)) < 1); } - result.svgAndHtmlShareTweenFrames = syncFrames.length > 0 && syncFrames.every(Boolean); + result.svgAndHtmlShareTweenFrames = tweenStarted + && syncFrames.length === 1 && syncFrames.every(Boolean); await waitCamera(); const flatMargins = margins(); result.cleanRoomTapFits = Object.values(flatMargins).every((value) => value >= 0.099); diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 5715a633..041e9b9f 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -2708,11 +2708,11 @@ const MUTANT_DEFINITIONS = [ { id: 'multi-wall-orthogonal-strip-protection-disabled', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' - + '&& node --test --test-name-pattern="issue #275 preserves" ' + + '&& node --test --test-name-pattern="issue #271 keeps finite" ' + 'test/wall-thickness.test.mjs', - because: 'pairwise bevel cuts must exclude every finite strip with an orthogonal partner, ' - + 'and the post-cut reconstruction is the independent fail-safe; disabling both recreates ' - + 'the white notches and large missing wall areas from the exact #275 fixtures', + because: 'a short-support trim (#271) must still exclude and restore every finite strip ' + + 'protected by an orthogonal partner (#275); disabling both protections cuts visible ' + + 'notches from the reachable combined fixture', patches: [{ file: 'src/wall-thickness.ts', find: ' return cuts && protectedStrips ? difference(cuts, protectedStrips) : cuts;', @@ -2761,10 +2761,10 @@ const MUTANT_DEFINITIONS = [ { id: 'multi-wall-exterior-corridor-disabled', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' - + '&& node --test --test-name-pattern="issue #249 bounds" ' + + '&& node --test --test-name-pattern="issue #272 keeps a short non-orthogonal trim" ' + 'test/wall-thickness.test.mjs', - because: 'ending every excessive bevel cut at one mathematical point recreates the enclosed ' - + 'white junction triangles from #272 while the old retained/discarded probes still pass', + because: 'a reachable short-support bevel cut must cross the exterior with finite width; ' + + 'ending it at one mathematical point recreates the enclosed white triangle from #272', patches: [{ file: 'src/wall-thickness.ts', find: ' multiWallBevelCutsAt(nodeMap, retainToLimit, connectToExterior),', @@ -3154,7 +3154,7 @@ const MUTANT_DEFINITIONS = [ { id: 'optimize-storage-boundary-removed', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' - + '&& node --test --test-name-pattern="issue 248 Optimize stays" ' + + '&& node --test --test-name-pattern="Optimize canonicalizes the six-room ULP source" ' + 'test/plan-optimizer.test.mjs', because: 'Optimize must remove near-node tails before visible Align so storage-only cleanup ' + 'is not misreported as a user-visible move and the exact candidate survives reload (#291)', @@ -4864,7 +4864,7 @@ const MUTANT_DEFINITIONS = [ }, { id: 'junction-fan-limit-back-to-249', - guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="issue 302" test/wall-thickness.test.mjs', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="issue 309 the 57" test/wall-thickness.test.mjs', because: 'лимит веера 1.25·h — это отставка решения №5: узлы снова с ' + 'вырезами и ступеньками вместо полного mitre', patches: [{ diff --git a/test/wall-thickness.test.mjs b/test/wall-thickness.test.mjs index d938213e..f633224e 100644 --- a/test/wall-thickness.test.mjs +++ b/test/wall-thickness.test.mjs @@ -1552,6 +1552,15 @@ test('issue #271 keeps finite co-directional ray supports and never rebuilds pas rooms, walls, [], [], pitch, 1, GRID_PITCH, 1, ); assert.ok(geometry); + const protectedStrips = multiWallProtectedStripGeometry(node, nodeMap); + assert.ok(protectedStrips, 'the short orthogonal node lost its protected strips'); + assert.equal( + multiWallProtectedRayIndexes(node).length, + node.rays.length, + 'the fixture must keep every ray orthogonally protected', + ); + closeTo(geometryDifferenceArea(protectedStrips, geometry.roomGeom), 0, 1e-6); + closeTo(geometryDifferenceArea(protectedStrips, geometry.geom), 0, 1e-6); assertProbeInside(geometry.geom, [0, 10], 'the finite short arm disappeared'); assertProbeOutside( geometry.geom, [0, 100], @@ -1568,6 +1577,52 @@ test('issue #271 keeps finite co-directional ray supports and never rebuilds pas ); }); +test('issue #272 keeps a short non-orthogonal trim open to exterior', () => { + const scale = NORM_W; + const nodePoint = [0.5 * scale, 0.5 * scale]; + const angles = [0, 60, 210]; + const lengths = [300, 300, 30]; + const points = angles.map((degrees, index) => { + const radians = degrees * Math.PI / 180; + return [ + nodePoint[0] + Math.cos(radians) * lengths[index], + nodePoint[1] + Math.sin(radians) * lengths[index], + ]; + }); + const rooms = points.map((point, index) => ({ + id: `short-fan-${index}`, + poly: [nodePoint, point, points[(index + 1) % points.length]] + .map((value) => [...value]), + })); + let walls = []; + for (const point of points) { + walls = setWallThickness(walls, nodePoint, point, 50, pitch, scale); + } + + const map = buildMultiWallNodeMap( + wallIntervals(rooms, walls, [], pitch, 5, GRID_PITCH, scale), + pitch * scale * 0.04 * 4, + scale, + ); + assert.equal(map.nodes.length, 1); + const [node] = map.nodes; + assert.ok(node.rays.some((ray) => + ray.supports.some((support) => support.length < support.halfDepth * 2))); + assert.deepEqual(multiWallProtectedRayIndexes(node), []); + assert.ok(multiWallBevelTriangles(map).length > 0); + + const geometry = wallBodiesGeometry( + rooms, walls, [], [], pitch, 5, GRID_PITCH, scale, + ); + assert.ok(geometry); + assert.equal(geometry.status, 'ok'); + assertNoEnclosedLocalHoles( + geometry.geom, + node, + 'short non-orthogonal trim', + ); +}); + test('issue #288 keeps a shared wall attached beyond a short node ray finite', () => { for (const scale of [1, 5, 30]) { const interval = (key, a, b, kind, half) => ({