Release v1.62.0-beta.1 candidate

This commit is contained in:
Matysh
2026-08-11 19:15:21 +03:00
parent 9419842333
commit 446f33ed31
66 changed files with 10750 additions and 1683 deletions
+49
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import {
presentationSourceSignature,
resolveDevicePresentation,
resolvePresentationSources,
} from '../test-build/device-presentation.js';
import {
resolveBindingProviders,
@@ -40,6 +41,54 @@ const options = {
showSignal: true,
};
test('passive source and its controller share the plan-wide derived state', () => {
const h = hass({
'switch.wall': state('switch.wall', 'on'),
});
const controller = device({
id: 'controller',
name: 'Wall switch',
entities: ['switch.wall'],
primary: 'switch.wall',
bindingRef: 'switch.wall',
marker: {
id: 'controller', binding: 'entity:switch.wall', controls: ['marker:bulb'],
},
});
const bulb = device({
id: 'bulb',
name: 'Dumb bulb',
entities: [],
primary: null,
bindingKind: 'virtual',
bindingRef: 'bulb',
marker: { id: 'bulb', binding: 'virtual:bulb', is_light: true },
});
const target = resolvePresentationSources(h, bulb, [controller, bulb]);
const switchPresentation = resolvePresentationSources(h, controller, [controller, bulb]);
assert.equal(target.sourceKind, 'light');
assert.equal(target.visualSources[0].eid, 'marker:bulb');
assert.equal(target.visualSources[0].sample.status, 'working');
assert.equal(switchPresentation.sourceKind, 'controls');
assert.equal(switchPresentation.visualSources[0].eid, 'marker:bulb');
assert.equal(switchPresentation.visualSources[0].sample.status, 'working');
});
test('ordinary presentation stays on the local light path', () => {
const h = hass({ 'switch.relay': state('switch.relay', 'on') });
const forbiddenGlobalGraph = new Proxy([], {
get(target, prop, receiver) {
if (prop === Symbol.iterator) throw new Error('ordinary marker traversed the global graph');
return Reflect.get(target, prop, receiver);
},
});
const sources = resolvePresentationSources(h, device(), forbiddenGlobalGraph);
assert.equal(sources.sourceKind, 'device_role');
assert.equal(sources.visualSources[0].eid, 'switch.relay');
});
test('always-static display suppresses every live visual but keeps diagnostics', () => {
const h = hass({
'binary_sensor.smoke': state('binary_sensor.smoke', 'on', { device_class: 'smoke' }),
+167 -3
View File
@@ -6,6 +6,9 @@ import {
litLightEntity, resolvedDeviceStateEntities, resolvedLightSources, resolvedLightState,
resolvedLightStats, hasOwnSpatialSource, selectSpatialGlowSource, seedHiddenBindings,
deletePlanMarkerRecords, effectiveMarkerControls, persistedExternalControls,
hasOwnStatefulLightSource, ownControllableEntities, forcedLightEntityOf,
resolvedControlServiceEntities, removeMarkerControlReferences,
rewriteMarkerControlReferences, markerControlWouldCycle, resolveDeviceLightSettings,
} from '../test-build/devices.js';
import { compileIconRules, iconFor } from '../test-build/rules.js';
@@ -969,18 +972,18 @@ test('resolvedLightSources: tri-state source role follows the controls matrix',
assert.equal(selectSpatialGlowSource(bothOn)?.eid, 'light.first', 'stable candidate order wins');
});
test('hasOwnSpatialSource requires a live controllable entity and ignores visibility/state', () => {
test('hasOwnSpatialSource is capability-based and ignores visibility/transient state', () => {
const hass = { states: { 'switch.relay': { state: 'unavailable' } } };
const relay = {
id: 'relay', area: 'living', hidden: true, primary: 'switch.relay', entities: ['switch.relay'],
marker: { is_light: true },
};
assert.equal(hasOwnSpatialSource(hass, relay), true);
assert.equal(hasOwnSpatialSource({ states: {} }, relay), false, 'HA-disabled binding has no runtime entity');
assert.equal(hasOwnSpatialSource({ states: {} }, relay), true, 'a missing snapshot does not turn stateful into passive');
assert.equal(hasOwnSpatialSource(hass, {
id: 'sensor', area: 'living', primary: 'sensor.temp', entities: ['sensor.temp'],
marker: { is_light: true },
}), false);
}), true, 'Always deliberately turns a non-controllable marker into a passive source');
});
test('resolvedLightSources: marker room_id is more precise than a shared HA area', () => {
@@ -1075,6 +1078,167 @@ test('a real lamp marker owns Glow even when a controller names the same entity
);
});
test('issue 84: Always without an own entity is a constant passive source', () => {
const marker = {
id: 'dumb', area: 'living', entities: [],
marker: { id: 'dumb', binding: 'virtual', is_light: true },
};
const [source] = resolvedLightSources({ states: {} }, [marker]);
assert.deepEqual({
key: source.key, eid: source.eid, state: source.stateEids,
service: source.serviceEids, passive: source.passive, on: source.on,
}, {
key: 'marker:dumb', eid: '', state: [], service: [], passive: true, on: true,
});
assert.equal(hasOwnSpatialSource({ states: {} }, marker), true);
assert.equal(hasOwnStatefulLightSource({ states: {} }, marker), false);
assert.deepEqual(resolvedLightStats([source]), { on: 1, total: 1 });
});
test('issue 84: passive target state is OR of controller drivers and remains target-owned', () => {
const target = {
id: 'dumb', area: 'bedroom', entities: [],
marker: { id: 'dumb', binding: 'virtual', is_light: true, room_id: 'bed' },
};
const controllerA = {
id: 'a', area: 'hall', primary: 'switch.a', entities: ['switch.a'],
marker: { id: 'a', binding: 'entity:switch.a', controls: ['marker:dumb'] },
};
const controllerB = {
id: 'b', area: 'yard', entities: [], controls: ['light.external'],
marker: { id: 'b', binding: 'virtual', controls: ['light.external', 'marker:dumb'] },
};
const hass = { states: {
'switch.a': { state: 'off' }, 'light.external': { state: 'on' },
} };
const [source] = resolvedLightSources(hass, [controllerA, controllerB, target], { id: 'bed', area: null });
assert.equal(source.key, 'marker:dumb');
assert.equal(source.device.id, 'dumb');
assert.equal(source.on, true);
hass.states['light.external'].state = 'off';
assert.equal(resolvedLightSources(hass, [controllerA, controllerB, target])
.find((item) => item.key === 'marker:dumb').on, false);
});
test('issue 84: a saved link with no active driver makes passive source dormant', () => {
const target = { id: 'dumb', area: 'r', entities: [], marker: { id: 'dumb', is_light: true } };
const controller = {
id: 'controller', area: 'r', entities: [], controls: [],
marker: { id: 'controller', controls: ['light.gone', 'marker:dumb'] },
};
const source = resolvedLightSources({ states: {} }, [controller, target])
.find((item) => item.key === 'marker:dumb');
assert.equal(source.on, false);
});
test('issue 84: marker and entity aliases dedupe and services never receive marker refs', () => {
const target = {
id: 'smart', area: 'r', primary: 'light.smart', entities: ['light.smart'],
marker: { id: 'smart', binding: 'device:smart', is_light: true },
};
const controller = {
id: 'controller', area: 'r', entities: [], controls: ['light.smart'],
marker: { id: 'controller', controls: ['light.smart', 'marker:smart'] },
};
const hass = { states: { 'light.smart': { state: 'on' } } };
const sources = resolvedLightSources(hass, [controller, target]);
assert.equal(sources.filter((source) => source.key === 'marker:smart').length, 1);
assert.deepEqual(resolvedControlServiceEntities(hass, [controller, target], controller), ['light.smart']);
});
test('issue 88: explicit leading entity wins; stale selection falls back without state dependence', () => {
const base = {
id: 'composite', area: 'r', primary: 'switch.power',
entities: ['switch.power', 'light.channel'],
marker: { id: 'composite', binding: 'device:composite', is_light: true },
};
assert.deepEqual(ownControllableEntities(base), ['switch.power', 'light.channel']);
assert.equal(forcedLightEntityOf({
...base, marker: { ...base.marker, light_entity: 'light.channel' },
}), 'light.channel');
assert.equal(forcedLightEntityOf({
...base, marker: { ...base.marker, light_entity: 'light.disappeared' },
}), 'switch.power');
const [source] = resolvedLightSources({ states: {} }, [{
...base, marker: { ...base.marker, light_entity: 'light.channel' },
}]);
assert.deepEqual(source.stateEids, ['light.channel']);
assert.deepEqual(source.serviceEids, ['light.channel']);
assert.equal(source.on, false);
});
test('issue 84 lifecycle: delete/rebind updates marker links and cycles are rejected', () => {
const markers = [
{ id: 'a', controls: ['marker:b', 'light.one'] },
{ id: 'b', controls: ['marker:c'] },
{ id: 'c' },
];
assert.deepEqual(removeMarkerControlReferences(markers, new Set(['b']))[0].controls, ['light.one']);
assert.equal(rewriteMarkerControlReferences(markers, 'b', 'renamed')[0].controls[0], 'marker:renamed');
assert.equal(markerControlWouldCycle(markers, 'c', 'a'), true);
assert.equal(markerControlWouldCycle(markers, 'a', 'c'), false);
assert.equal(markerControlWouldCycle(markers, 'a', 'a'), true);
});
test('issue 84 graph cache invalidates from content, not an external epoch', () => {
const devices = [
{
id: 'controller', area: 'r', entities: [],
marker: { id: 'controller', controls: [] },
},
{
id: 'lamp', area: 'r', entities: [],
marker: { id: 'lamp', is_light: true },
},
];
assert.equal(resolvedLightSources({ states: {} }, devices)
.find((source) => source.key === 'marker:lamp').on, true);
// Mutate only the cached marker graph. An epoch-only cache would return the
// old unlinked state because no external revision is advanced here.
devices[0].marker.controls.push('light.driver', 'marker:lamp');
assert.equal(resolvedLightSources({ states: { 'light.driver': { state: 'off' } } }, devices)
.find((source) => source.key === 'marker:lamp').on, false);
});
test('issue 84 source-key dedupe keeps two marker-owned sources over one entity', () => {
const devices = ['left', 'right'].map((id) => ({
id, area: 'r', primary: 'light.shared', entities: ['light.shared'],
marker: { id, binding: `device:${id}`, is_light: true },
}));
const sources = resolvedLightSources({ states: { 'light.shared': { state: 'on' } } }, devices);
assert.deepEqual(sources.map((source) => source.key), ['marker:left', 'marker:right']);
assert.deepEqual(resolvedLightStats(sources), { on: 2, total: 2 });
});
test('Auto ignores a registry light whose live state is absent; Always retains capability', () => {
const auto = {
id: 'auto', area: 'r', primary: 'light.gone', entities: ['light.gone'], marker: { id: 'auto' },
};
assert.deepEqual(resolvedLightSources({ states: {} }, [auto]), []);
const forced = { ...auto, marker: { id: 'auto', is_light: true } };
assert.equal(resolvedLightSources({ states: {} }, [forced])[0].key, 'marker:auto');
});
test('issues 84/88: exhaustive 36-case light settings matrix is internally consistent', () => {
const roles = ['auto', 'always', 'never'];
const modes = ['auto', 'color', 'fixed'];
let cases = 0;
for (const role of roles) for (const autoHasSource of [false, true]) {
for (const stateful of [false, true]) for (const mode of modes) {
cases++;
const row = resolveDeviceLightSettings(role, autoHasSource, stateful, mode);
const exists = role === 'always' || (role === 'auto' && autoHasSource);
assert.equal(row.sourceExists, exists);
assert.equal(row.manualEnabled, exists);
assert.equal(row.radiusEnabled, exists);
assert.equal(row.fromSourceEnabled, exists && stateful);
assert.equal(row.passive, exists && !stateful);
assert.equal(row.effectiveMode, exists && !stateful && mode === 'auto' ? 'fixed' : mode);
}
}
assert.equal(cases, 36);
});
// ---------- explicit hide-from-plan flags (docs/FILTERING.md) ----------
+57
View File
@@ -0,0 +1,57 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { floatingViewport, placeFloatingSurface } from '../test-build/floating-surface.js';
const rect = (left, top, width, height) => ({
left, top, width, height, right: left + width, bottom: top + height,
});
test('floating surface uses the preferred bottom side when it fits', () => {
assert.deepEqual(
placeFloatingSurface(rect(100, 100, 20, 20), rect(0, 0, 100, 50), {
left: 0, top: 0, width: 500, height: 500,
}),
{ left: 100, top: 127, side: 'bottom', maxWidth: 484, maxHeight: 484 },
);
});
test('floating surface flips above an anchor near the lower edge', () => {
assert.deepEqual(
placeFloatingSurface(rect(100, 440, 20, 20), rect(0, 0, 100, 80), {
left: 0, top: 0, width: 500, height: 500,
}),
{ left: 100, top: 353, side: 'top', maxWidth: 484, maxHeight: 484 },
);
});
test('floating surface shifts back inside when its trigger is below the viewport', () => {
assert.deepEqual(
placeFloatingSurface(rect(100, 540, 20, 20), rect(0, 0, 100, 80), {
left: 0, top: 0, width: 500, height: 500,
}),
{ left: 100, top: 412, side: 'top', maxWidth: 484, maxHeight: 484 },
);
});
test('floating surface shifts inside both horizontal safe edges', () => {
const viewport = { left: 0, top: 0, width: 500, height: 500 };
assert.equal(placeFloatingSurface(rect(-10, 100, 20, 20), rect(0, 0, 100, 50), viewport).left, 8);
assert.equal(placeFloatingSurface(rect(450, 100, 20, 20), rect(0, 0, 100, 50), viewport).left, 370);
});
test('oversized surfaces are constrained and pinned to the safe viewport', () => {
assert.deepEqual(
placeFloatingSurface(rect(20, 10, 10, 20), rect(0, 0, 900, 700), {
left: 0, top: 0, width: 300, height: 200,
}),
{ left: 8, top: 8, side: 'bottom', maxWidth: 284, maxHeight: 184 },
);
});
test('visual viewport offsets are preserved for pinch zoom and virtual keyboards', () => {
assert.deepEqual(floatingViewport({
innerWidth: 1000,
innerHeight: 800,
visualViewport: { offsetLeft: 31, offsetTop: 47, width: 390, height: 520 },
}), { left: 31, top: 47, width: 390, height: 520 });
});
+35 -1
View File
@@ -21,6 +21,19 @@ test('golden matrix has stable unique ids and bounded comparison thresholds', ()
assert.equal(Number.isInteger(region.minPixels) && region.minPixels > 0, true, scenario.id);
assert.equal(region.minRedBlueDelta > 0, true, scenario.id);
}
if (scenario.tunnelContinuity) {
assert.match(scenario.tunnelContinuity.openingId, /^[a-z0-9-]+$/, scenario.id);
assert.equal(Number.isInteger(scenario.tunnelContinuity.insetPx)
&& scenario.tunnelContinuity.insetPx >= 1, true, scenario.id);
assert.equal(scenario.tunnelContinuity.maxChannelJump >= 0
&& scenario.tunnelContinuity.maxChannelJump <= 32, true, scenario.id);
}
if (scenario.helpTextRegion) {
assert.match(scenario.helpTextRegion.key, /^[a-z0-9_.-]+\.help$/, scenario.id);
assert.equal(Number.isInteger(scenario.helpTextRegion.minPixels)
&& scenario.helpTextRegion.minPixels > 0, true, scenario.id);
assert.equal(scenario.openHelp, scenario.helpTextRegion.key, scenario.id);
}
}
});
@@ -29,7 +42,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', 'opaque-glow-two-doorways'])
'tray-narrow', 'opaque-glow-two-doorways', 'filled-tunnel', 'backup-full', 'backup-space'])
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);
@@ -38,6 +51,17 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.theme)).has('dark'), true);
});
test('filled opening golden has a pixel-level seam detector', () => {
const scenario = GOLDEN_SCENARIOS.find((item) => item.id === 'openings-filled-tunnel-dark');
assert.ok(scenario);
assert.equal(scenario.fillMode, 'custom');
assert.equal(scenario.customFill.a > 0 && scenario.customFill.a < 1, true);
assert.equal(scenario.glowEnabled, false);
assert.equal(scenario.hideOpenings, true, 'opening symbols must not mask the sampled tunnel pixels');
assert.equal(scenario.tunnelContinuity.openingId, 'light-door');
assert.equal(scenario.tunnelContinuity.maxChannelJump <= 8, 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);
@@ -160,3 +184,13 @@ test('device dialog goldens expose the complete light-source controls at desktop
assert.match(harness, /if \(scenario\.deviceLightControls\)/,
'the harness must activate the declared light-controls scenario flag');
});
test('help affordance golden covers an open text-bearing surface in both themes', () => {
const help = GOLDEN_SCENARIOS.filter((scenario) => scenario.openHelp);
assert.equal(help.length >= 2, true);
assert.deepEqual(new Set(help.map((scenario) => scenario.theme)), new Set(['dark', 'light']));
for (const scenario of help) {
assert.equal(scenario.helpTextRegion?.key, scenario.openHelp, scenario.id);
assert.equal(scenario.helpTextRegion?.minPixels >= 30, true, scenario.id);
}
});
+38
View File
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs';
const en = JSON.parse(readFileSync(new URL('../src/i18n/en.json', import.meta.url)));
const ru = JSON.parse(readFileSync(new URL('../src/i18n/ru.json', import.meta.url)));
const cardSource = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
test('i18n: en and ru dictionaries carry the same key set', () => {
const enKeys = Object.keys(en).sort();
@@ -26,6 +27,43 @@ test('i18n: placeholders match between languages', () => {
}
});
test('i18n: every literal help call has body and full aria keys in both languages', () => {
const allCalls = cardSource.match(/this\._help\(/g) || [];
const helpKeys = [...cardSource.matchAll(/this\._help\('([^']+\.help)'\)/g)].map((match) => match[1]);
assert.equal(helpKeys.length, allCalls.length, 'every _help call must use one string literal ending in .help');
assert.ok(helpKeys.length > 0, 'the help affordance pilot disappeared');
for (const key of helpKeys) {
for (const [lang, dictionary] of [['en', en], ['ru', ru]]) {
assert.ok(dictionary[key]?.trim(), `${lang}:${key} is missing or empty`);
assert.ok(dictionary[`${key}.aria`]?.trim(), `${lang}:${key}.aria is missing or empty`);
}
}
});
test('light pilot has no duplicate legacy hint copy', () => {
for (const key of [
'marker.light_role_tip', 'marker.glow_color_tip',
'marker.glow_brightness_hint', 'marker.glow_radius_hint',
]) {
assert.equal(en[key], undefined, `en:${key} should be removed`);
assert.equal(ru[key], undefined, `ru:${key} should be removed`);
assert.ok(!cardSource.includes(`'${key}'`), `${key} is still rendered`);
}
});
test('help hosts do not duplicate their explanation in a title attribute', () => {
const calls = [...cardSource.matchAll(/this\._help\('([^']+\.help)'\)/g)];
assert.ok(calls.length > 0, 'the help affordance pilot disappeared');
for (const call of calls) {
const prefix = cardSource.slice(Math.max(0, call.index - 700), call.index);
const hosts = [...prefix.matchAll(/<(legend|label|div)\b([^>]*)>/g)];
const host = hosts.at(-1);
assert.ok(host, `no host element found for ${call[1]}`);
assert.doesNotMatch(host[2], /\btitle\s*=/i, `${call[1]} duplicates help in title=`);
}
assert.ok(!cardSource.includes('markerlighttip'), 'legacy help copy is still rendered');
});
test('vac toasts never mention the removed point calibration (HP-1540-06)', () => {
for (const [lang, d] of [['en', en], ['ru', ru]]) {
for (const k of ['vac.autocal_no_rooms', 'vac.autocal_no_match', 'vac.residual_message']) {
+4 -1
View File
@@ -5,7 +5,10 @@ import { fileURLToPath } from 'node:url';
import test from 'node:test';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const SKIP = new Set(['.git', 'node_modules', 'dist', 'coverage', '.venv', '__pycache__', '.pytest_cache']);
const SKIP = new Set([
'.git', 'node_modules', 'dist', 'coverage', 'artifacts',
'.venv', '__pycache__', '.pytest_cache',
]);
const walk = (dir, out = []) => {
for (const name of readdirSync(dir)) {
+49 -8
View File
@@ -10,7 +10,7 @@ import {
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT,
atomicPolyForRoom, insetOffsetsForRoom, wallIntervals, normalizeWallIntervals,
intervalCmAt, wallBodyNeedsSolid, openingInnerFaceOffset, openingTunnelGeometry,
openingTunnelGeometries,
openingTunnelGeometries, tunnelFacePath,
WALL_HATCH_MIN_PX,
} from '../test-build/wall-thickness.js';
import { polygonArea, paperRoomShapes } from '../test-build/logic.js';
@@ -383,8 +383,9 @@ test('openingTunnelGeometry: a 45° wall keeps the opening-local width and physi
assert.ok(g);
closeTo(g.minY, -2);
closeTo(g.maxY, 2);
assert.match(g.faces[0].d, /M -2(?:\.\d+)? /);
assert.match(g.faces[0].d, /L 2(?:\.\d+)? /);
const positive = g.faces.find((face) => face.side === 1);
assert.match(positive.d, /M -2(?:\.\d+)? /);
assert.match(positive.d, /L 2(?:\.\d+)? /);
});
test('openingTunnelGeometry: a shared wall is owned by the room on each local side', () => {
@@ -417,11 +418,49 @@ test('openingTunnelGeometry: mixed atomic thickness clips each piece to its real
assert.ok(g);
assert.match(g.faces[0].d, /-1(?:\.0+)?\b/, '10 cm half-depth is present');
assert.match(g.faces[0].d, /-2(?:\.0+)?\b/, '20 cm half-depth is present');
assert.match(g.faces[0].d, /M -0\.02\b/,
'touching thickness steps overlap instead of exposing an antialiasing seam');
for (const face of g.faces) {
assert.equal((face.d.match(/\bM /g) || []).length, 1,
'a thickness step is part of one outer contour, not two touching rectangles');
assert.doesNotMatch(face.d, /-2\.02|2\.02/,
'the contour does not overpaint past either physical jamb');
}
closeTo(g.maxY, 2);
});
test('openingTunnelGeometry: overlapping wall pieces use their physical union depth', () => {
const path = tunnelFacePath(1, [
{ x0: -3, x1: 3, half: 1, cm: 10, key: 'shallow', axis: [1, 0] },
{ x0: -1, x1: 1, half: 2, cm: 20, key: 'deep', axis: [1, 0] },
]);
assert.match(path, /L 1 2 L -1 2/,
'the overlap must reach the deeper body instead of taking the minimum depth');
assert.match(path, /L 3 1 L 1 1/,
'the shallow shoulders remain part of the same non-overlapping contour');
});
test('openingTunnelGeometry: three stepped atomic strips form one non-overlapping contour', () => {
const rooms = [{ id: 'r', poly: [[0, 0], [10, 0], [10, 6], [0, 6]] }];
const walls = [
{ key: wallKey([0, 0], [3, 0], pitch), a: [0, 0], b: [3, 0], cm: 10 },
{ key: wallKey([3, 0], [5, 0], pitch), a: [3, 0], b: [5, 0], cm: 20 },
{ key: wallKey([5, 0], [7, 0], pitch), a: [5, 0], b: [7, 0], cm: 15 },
{ key: wallKey([7, 0], [10, 0], pitch), a: [7, 0], b: [10, 0], cm: 15 },
];
const g = openingTunnelGeometry(
rooms, { x: 5, y: 0, angle: 0, length: 6 }, walls, [], pitch, 5, 1,
);
assert.ok(g);
const negative = g.faces.find((face) => face.side === -1);
const positive = g.faces.find((face) => face.side === 1);
assert.equal((negative.d.match(/\bM /g) || []).length, 1);
assert.equal((positive.d.match(/\bM /g) || []).length, 1);
assert.match(negative.d, /^M 3 0\.02 L -3 0\.02 /,
'negative and positive faces use matching nonzero winding around the wall axis');
assert.match(positive.d, /^M -3 -0\.02 L 3 -0\.02 /);
assert.match(positive.d, /L -2 1 L -3 1 Z$/,
'the one contour follows every real thickness step back to the first jamb');
});
test('openingTunnelGeometry: equal atomic strips collapse into one path without hairlines', () => {
const rooms = [{ id: 'r', poly: [[0, 0], [10, 0], [10, 6], [0, 6]] }];
const walls = [[0, 3], [3, 5], [5, 7], [7, 10]].map(([x0, x1]) => ({
@@ -516,8 +555,9 @@ test('openingTunnelGeometry: a legacy opening outside the span is clipped to the
rooms, { x: 10.5, y: 0, angle: 0, length: 4 }, walls, [], pitch, 5, 1,
);
assert.ok(g);
assert.match(g.faces[0].d, /M -2(?:\.0+)? [^L]+L -0\.5(?:0+)? /);
assert.doesNotMatch(g.faces[0].d, /L 2(?:\.0+)? /, 'the missing wall extension is not painted');
const positive = g.faces.find((face) => face.side === 1);
assert.match(positive.d, /M -2(?:\.0+)? [^L]+L -0\.5(?:0+)? /);
assert.doesNotMatch(positive.d, /L 2(?:\.0+)? /, 'the missing wall extension is not painted');
});
test('openingTunnelGeometries removes overlap so translucent fills never composite twice', () => {
@@ -537,7 +577,8 @@ test('openingTunnelGeometries removes overlap so translucent fills never composi
walls, [], pitch, 5, 1,
);
assert.ok(partial[0] && partial[1]);
assert.match(partial[1].faces[0].d, /M 0(?:\.0+)? /, 'only the non-overlapping extension remains');
const positive = partial[1].faces.find((face) => face.side === 1);
assert.match(positive.d, /M 0(?:\.0+)? /, 'only the non-overlapping extension remains');
});
// ------------------------------- bodies / paper -----------------------------