Release v1.61.0-beta.6 candidate

This commit is contained in:
Matysh
2026-08-11 01:14:59 +03:00
parent b0c29fb57f
commit d2bc908280
59 changed files with 4417 additions and 1629 deletions
+69 -1
View File
@@ -1,5 +1,7 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { prepareGoldenFixture } from '../demo/golden/harness.mjs';
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from '../demo/golden/matrix.mjs';
test('golden matrix has stable unique ids and bounded comparison thresholds', () => {
@@ -20,7 +22,7 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
for (const token of ['geometry', 'diagonal-45-opening', 'openings', 'openings-hidden',
'fill-light', 'fill-temp', 'fill-lqi', 'lighting', 'hover', 'zoom-040', 'zoom-250',
'warm-remount', 'dialog-mobile', 'color-popover', 'tray-wide', 'tray-medium',
'tray-narrow'])
'tray-narrow', 'opaque-glow-two-doorways'])
assert.equal(ids.includes(token), true, token);
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('plan'), true);
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('devices'), true);
@@ -29,6 +31,57 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.theme)).has('dark'), true);
});
test('doorway spill golden exposes the opaque-fill failure mode from issue 71', () => {
const scenario = GOLDEN_SCENARIOS.find((item) => item.id === 'lighting-opaque-glow-two-doorways-dark');
assert.ok(scenario);
assert.equal(scenario.theme, 'dark');
assert.equal(scenario.customFill?.a, 1);
assert.equal(scenario.glowEnabled, true);
assert.equal(scenario.sunRays, false);
assert.equal(scenario.allLightsOff, true);
assert.equal(scenario.extraOpenings?.filter((opening) => opening.type === 'door').length, 1);
assert.equal(scenario.stateOverrides?.['light.golden_light_one']?.state, 'on');
assert.deepEqual(scenario.layoutOverrides?.['golden-light-one'], {
s: 'golden-lighting', x: 0.22, y: 0.48,
});
});
test('golden harness applies doorway, state and layout overrides to a cloned fixture', () => {
const scenario = GOLDEN_SCENARIOS.find((item) => item.id === 'lighting-opaque-glow-two-doorways-dark');
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(space.openings.filter((opening) => opening.type === 'door').length, 2);
assert.equal(fixture.states['light.golden_light_one'].state, 'on');
assert.deepEqual(fixture.layout['golden-light-one'], {
s: 'golden-lighting', x: 0.22, y: 0.48,
});
assert.equal(space.settings.sun_rays, false);
assert.equal(space.settings.custom_fill.a, 1);
});
test('a light source paints exactly one region: the floor it can see', () => {
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
// One region per source, and it is the visibility polygon clipped to floor.
assert.match(source, /visibilityPolygon\(\[pos\.x, pos\.y\], R, occluders/);
assert.match(source, /lit: seen\.length >= 3 \? intersectionPaths\(\[seen\], floor\) : \[\]/);
assert.match(source, /<circle class="glow-pool"/);
// No second layer of LIGHT may come back: a spill path or a shadow mask of
// its own is how the rendered result and the computed geometry got to
// disagree (#71, #73). The single blur below is the penumbra of the one
// region, measured in screen pixels and applied in one pass.
const layer = source.slice(source.indexOf('_renderGlowLayer'));
const glow = layer.slice(0, layer.indexOf('_renderAlignDialog'));
assert.doesNotMatch(glow, /glow-spill|glow-shadow|glow-blocker/);
// Exactly one blur, for the whole layer, sized in screen pixels.
assert.equal((glow.match(/<feGaussianBlur/g) || []).length, 1);
assert.doesNotMatch(glow, /<mask /);
assert.match(source, /GLOW_EDGE_FEATHER_PX \/ 2 \/ \(perUnit > 0 \? perUnit : 1\)/);
// Barriers are keyed by their own content: `_cfgEpoch` lags behind geometry
// edited in place, and a stale barrier set lights straight through a wall.
assert.doesNotMatch(glow, /_cfgEpoch/);
assert.match(source, /const fingerprint = hash\.toString\(36\)/);
});
test('editor tray golden contract covers every adaptive width in English and Russian', () => {
const tray = GOLDEN_SCENARIOS.filter((scenario) => scenario.editorTray);
assert.equal(tray.length, 6);
@@ -46,3 +99,18 @@ test('editor tray golden contract covers every adaptive width in English and Rus
'furniture-palette', 'decor-tool',
]));
});
test('device dialog goldens expose the complete light-source controls at desktop and mobile widths', () => {
const dialogs = GOLDEN_SCENARIOS.filter((scenario) => scenario.id.startsWith('device-dialog-'));
assert.equal(dialogs.length, 2);
assert.deepEqual(new Set(dialogs.map((scenario) => scenario.language)), new Set(['en', 'ru']));
for (const scenario of dialogs) {
assert.equal(scenario.deviceLightControls, true, scenario.id);
assert.equal(scenario.deviceId, 'golden-light-two', scenario.id);
assert.equal(scenario.capture, 'page', scenario.id);
assert.equal(scenario.viewport.height >= 1000, true, scenario.id);
}
const harness = readFileSync(new URL('../demo/golden/harness.mjs', import.meta.url), 'utf8');
assert.match(harness, /if \(scenario\.deviceLightControls\)/,
'the harness must activate the declared light-controls scenario flag');
});
+103
View File
@@ -0,0 +1,103 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
polygonSegments, splitAtIntersections, visibilityPolygon,
} from '../test-build/light-visibility.js';
const RAD = 100;
/** Distance the lamp actually reaches at `angleDeg`, per the returned ring. */
const reachAt = (ring, source, angleDeg) => {
const wanted = (angleDeg * Math.PI) / 180;
let best = null;
let bestDelta = Infinity;
for (const point of ring) {
const angle = Math.atan2(point[1] - source[1], point[0] - source[0]);
let delta = Math.abs(angle - wanted);
if (delta > Math.PI) delta = Math.PI * 2 - delta;
if (delta < bestDelta) { bestDelta = delta; best = point; }
}
return best ? Math.hypot(best[0] - source[0], best[1] - source[1]) : 0;
};
test('an empty plan lights the full radius in every direction', () => {
const source = [0, 0];
const ring = visibilityPolygon(source, RAD, []);
assert.ok(ring.length >= 12);
for (const degrees of [0, 37, 90, 180, -120]) {
assert.ok(Math.abs(reachAt(ring, source, degrees) - RAD) < RAD * 0.01);
}
});
test('a wall stops the light at the wall and leaves the other side lit', () => {
const source = [0, 0];
// vertical wall at x = 30, from y = -50 to y = 50
const ring = visibilityPolygon(source, RAD, [[30, -50, 30, 50]]);
assert.ok(Math.abs(reachAt(ring, source, 0) - 30) < 1, 'blocked ahead');
assert.ok(reachAt(ring, source, 180) > RAD * 0.98, 'free behind');
});
test('a doorway in a wall lets a beam through and only through', () => {
const source = [0, 0];
// same wall, but split into two pieces: the gap is y in (-10, 10)
const ring = visibilityPolygon(source, RAD, [
[30, -50, 30, -10],
[30, 10, 30, 50],
]);
assert.ok(reachAt(ring, source, 0) > RAD * 0.98, 'through the gap');
assert.ok(Math.abs(reachAt(ring, source, 30) - 30 / Math.cos(Math.PI / 6)) < 2,
'blocked where the wall is');
});
test('a column casts a shadow whose edges are the rays that graze it', () => {
const source = [0, 0];
const column = [[40, -5], [50, -5], [50, 5], [40, 5]];
const ring = visibilityPolygon(source, RAD, polygonSegments(column));
assert.ok(Math.abs(reachAt(ring, source, 0) - 40) < 1, 'stops at the column');
// Just outside the shadow the light is free again: the column subtends
// atan(5/40) ~ 7.1 degrees, so 12 degrees off-axis is lit to the radius.
assert.ok(reachAt(ring, source, 12) > RAD * 0.98, 'lit beside the column');
});
test('an occluder outside the radius changes nothing', () => {
const source = [0, 0];
const ring = visibilityPolygon(source, RAD, [[300, -50, 300, 50]]);
for (const degrees of [0, 45, 90]) {
assert.ok(Math.abs(reachAt(ring, source, degrees) - RAD) < RAD * 0.01);
}
});
test('an edge through the lamp itself is ignored instead of collapsing the ring', () => {
const source = [0, 0];
const ring = visibilityPolygon(source, RAD, [[-50, 0, 50, 0]]);
assert.ok(ring.length >= 12);
assert.ok(reachAt(ring, source, 90) > RAD * 0.98);
});
test('a corner made by two crossing barriers is lit right up to the corner', () => {
const source = [0, 0];
// A wall face running through another one — a junction of two wall bodies.
// The inner corner is at (40, 25), off the sweep's own arc angles, so only
// splitting can put a ray on it; the lamp sits below-left and sees it.
const crossing = [[40, -100, 40, 100], [-100, 25, 100, 25]];
const cut = splitAtIntersections(crossing);
assert.equal(cut.length, 4, 'both barriers are split at the crossing');
const naive = visibilityPolygon(source, 200, crossing);
const exact = visibilityPolygon(source, 200, cut);
const corner = (ring) => ring.some(([x, y]) => Math.abs(x - 40) < 0.01 && Math.abs(y - 25) < 0.01);
assert.equal(corner(naive), false, 'unsplit barriers leave the corner unsampled');
assert.equal(corner(exact), true, 'after the split the corner itself is a vertex');
});
test('splitAtIntersections leaves barriers that only touch end to end alone', () => {
const chain = [[0, 0, 10, 0], [10, 0, 10, 10]];
assert.deepEqual(splitAtIntersections(chain), chain);
// Collinear overlap is not a crossing: nothing to split.
const collinear = [[0, 0, 10, 0], [4, 0, 14, 0]];
assert.deepEqual(splitAtIntersections(collinear), collinear);
});
test('polygonSegments closes the ring and drops zero-length edges', () => {
const segments = polygonSegments([[0, 0], [10, 0], [10, 0], [10, 10]]);
assert.equal(segments.length, 3);
assert.deepEqual(segments[segments.length - 1], [10, 10, 0, 0]);
});
+13 -1
View File
@@ -5,7 +5,7 @@ import {
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
splitRoomPath, polyContainsPoly, islandsOf,
kelvinToRgb, glowColorOf, normalizeGlowColorOverride, liveGlowBrightness,
resolveGlowValues, resolveGlowAppearance, glowAlpha, doorSector, hasRoomBehind,
resolveGlowValues, resolveGlowAppearance, glowAlpha, doorSector, doorSpillRange, hasRoomBehind,
controlsAction, isControllable,
sharedBoundary, openZoneOf, distToSegment,
outlineWithout,
@@ -938,6 +938,18 @@ test('doorSector: thick doorway is clipped by both tunnel jamb faces', () => {
assert.ok(spreadAtFarFace(thick) < spreadAtFarFace(thin));
});
test('doorSpillRange: near and distant apertures retain only bounded post-aperture reach', () => {
const near = doorSpillRange(250, 60, 95);
assert.deepEqual(near, { reach: 95, outerRadius: 155 });
assert.ok(near.reach < 250);
const distant = doorSpillRange(250, 230, 95);
assert.deepEqual(distant, { reach: 20, outerRadius: 250 });
assert.ok(distant.reach < 250);
assert.equal(doorSpillRange(250, 250, 95), null);
assert.equal(doorSpillRange(250, 100, 0), null);
});
test('hasRoomBehind: neighbour room yes, street no', () => {
const neighbour = [[10, -5], [20, -5], [20, 5], [10, 5]];
// дверь в стене x=10 (стена вертикальна: угол 90°), источник слева в (5,0)
+8
View File
@@ -134,6 +134,14 @@ test('local orchestrator validates issue lists and public release assets', () =>
assert.equal(prereleaseWorkflowSucceeded('Announce release', 'skipped'), true);
assert.equal(prereleaseWorkflowSucceeded('Release', 'skipped'), false);
assert.equal(prereleaseWorkflowSucceeded('Announce release', 'failure'), false);
const orchestrator = readFileSync(
new URL('../scripts/release-prerelease.mjs', import.meta.url), 'utf8',
);
assert.match(
orchestrator,
/if \(!prereleaseWorkflowSucceeded\(label, row\.conclusion\)\)/,
'the workflow waiter must use the prerelease-aware conclusion policy',
);
});
test('release ZIP inspection is portable and does not depend on tar', () => {