mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Release v1.62.0-beta.1 candidate
This commit is contained in:
@@ -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
@@ -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) ----------
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 -----------------------------
|
||||
|
||||
Reference in New Issue
Block a user