mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
terminalTrailers,
|
||||
validateCommitMessage,
|
||||
} from '../scripts/validate-commit-provenance.mjs';
|
||||
|
||||
test('provenance accepts positive issues and one visibility trailer at the end', () => {
|
||||
const message = `Fix relay\n\nIssue: #94\nIssue: #98\nUser-Visible: yes\n`;
|
||||
assert.deepEqual(validateCommitMessage(message), []);
|
||||
assert.deepEqual(terminalTrailers(message).get('Issue'), ['#94', '#98']);
|
||||
});
|
||||
|
||||
test('provenance ignores trailer-like prose and rejects zero or duplicate visibility', () => {
|
||||
assert.notDeepEqual(validateCommitMessage('Issue: #12\n\nExplanation after it'), []);
|
||||
assert.notDeepEqual(validateCommitMessage('Fix\n\nIssue: #0\nUser-Visible: no'), []);
|
||||
assert.notDeepEqual(validateCommitMessage(
|
||||
'Fix\n\nIssue: #12\nUser-Visible: no\nUser-Visible: yes',
|
||||
), []);
|
||||
});
|
||||
|
||||
test('golden files require exact release-review provenance', () => {
|
||||
const changed = ['demo/golden/baselines/example.png'];
|
||||
const base = 'Update baseline\n\nIssue: #75\nUser-Visible: no';
|
||||
assert.equal(validateCommitMessage(base, changed).length, 2);
|
||||
assert.deepEqual(validateCommitMessage(
|
||||
`${base}\nRelease: v1.2.3-beta.1\nBaseline-Reviewed: https://example.test/run`, changed,
|
||||
), []);
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
deviceFaceStyle, legacySupplementalMetrics, lqiClassName, valueBadgeClassName,
|
||||
deviceFaceStyle, legacySupplementalMetrics, lqiClassName, renderDeviceFace,
|
||||
valueBadgeClassName,
|
||||
} from '../test-build/device-face.js';
|
||||
|
||||
const face = (rippleColor) => ({
|
||||
@@ -59,3 +60,16 @@ test('value badge classes cover all four positions and only bottom displaces LQI
|
||||
}
|
||||
assert.equal(lqiClassName(null), 'lqi');
|
||||
});
|
||||
|
||||
test('rendered pulse retains the documented activity-ring compatibility hook', () => {
|
||||
const presentation = {
|
||||
...face('#ff9800'), classes: [], icon: 'mdi:lightbulb', angle: 0,
|
||||
valueText: null, valueFullText: null, valueBadge: null,
|
||||
lqiText: null, lqiColor: null, haDisabled: false,
|
||||
tempText: null, humText: null,
|
||||
};
|
||||
const root = renderDeviceFace(presentation, { surface: 'preview' });
|
||||
const nested = root.values.find((value) => value?.strings);
|
||||
assert.ok(nested);
|
||||
assert.match(nested.strings.join(''), /device-pulse activity-ring/);
|
||||
});
|
||||
|
||||
@@ -102,6 +102,21 @@ test('passive sensor source keeps its normal scalar value and never probes marke
|
||||
assert.deepEqual(hits, []);
|
||||
});
|
||||
|
||||
test('secondary humidity diagnostics do not create a legacy humidity satellite', () => {
|
||||
const h = hass({
|
||||
'switch.relay': state('switch.relay', 'on'),
|
||||
'sensor.relay_humidity': state('sensor.relay_humidity', '49', {
|
||||
device_class: 'humidity', unit_of_measurement: '%',
|
||||
}),
|
||||
});
|
||||
const relay = device({
|
||||
entities: ['switch.relay', 'sensor.relay_humidity'],
|
||||
primary: 'switch.relay',
|
||||
});
|
||||
const presentation = resolveDevicePresentation(h, relay, options);
|
||||
assert.equal(presentation.humText, null);
|
||||
});
|
||||
|
||||
test('derived marker-state badge follows a stateful Always source', () => {
|
||||
const h = hass({ 'light.bulb': state('light.bulb', 'on') });
|
||||
const bulb = device({
|
||||
@@ -443,7 +458,7 @@ test('derived temperature and humidity retain compact plan formatting', () => {
|
||||
assert.equal(humidity.valueText, '48%');
|
||||
});
|
||||
|
||||
test('one legacy marker keeps both temperature and humidity for face compatibility', () => {
|
||||
test('temperature-led composite marker does not add a legacy humidity satellite', () => {
|
||||
const h = hass({
|
||||
'sensor.temp': state('sensor.temp', '22.4', { device_class: 'temperature', unit_of_measurement: '°C' }),
|
||||
'sensor.humidity': state('sensor.humidity', '47.7', { device_class: 'humidity', unit_of_measurement: '%' }),
|
||||
@@ -457,7 +472,7 @@ test('one legacy marker keeps both temperature and humidity for face compatibili
|
||||
}), options);
|
||||
assert.equal(result.valueBadge?.text, '22.4°');
|
||||
assert.equal(result.tempText, '22.4');
|
||||
assert.equal(result.humText, '48');
|
||||
assert.equal(result.humText, null);
|
||||
});
|
||||
|
||||
test('preview explanations distinguish activity display and composite Power source', () => {
|
||||
@@ -658,3 +673,21 @@ test('an incidental cover does not hijack the light face of a mixed device', ()
|
||||
assert.equal(explicitCover.sourceKind, 'cover');
|
||||
assert.equal(explicitCover.visualSources[0].eid, 'cover.mixed');
|
||||
});
|
||||
|
||||
test('an incidental cover does not hijack another whole-device role on info tap', () => {
|
||||
const h = hass({
|
||||
'media_player.screen': state('media_player.screen', 'playing'),
|
||||
'cover.screen': state('cover.screen', 'opening', {
|
||||
device_class: 'curtain', supported_features: 15,
|
||||
}),
|
||||
}, {
|
||||
'media_player.screen': { entity_id: 'media_player.screen', device_id: 'd1', platform: 'demo' },
|
||||
'cover.screen': { entity_id: 'cover.screen', device_id: 'd1', platform: 'demo' },
|
||||
});
|
||||
const result = resolvePresentationSources(h, device({
|
||||
entities: ['cover.screen', 'media_player.screen'],
|
||||
primary: 'media_player.screen', tapAction: 'info',
|
||||
}));
|
||||
assert.equal(result.sourceKind, 'device_role');
|
||||
assert.deepEqual(result.visualSources.map((source) => source.eid), ['media_player.screen']);
|
||||
});
|
||||
|
||||
@@ -160,6 +160,25 @@ test('passive forced-light marker is driven by the controller own relay and de-d
|
||||
assert.equal(intent.targets[0].via, 'control-marker-driver');
|
||||
});
|
||||
|
||||
test('passive marker driver matches the light graph without explicit light flags', () => {
|
||||
const h = hass({
|
||||
'switch.wall': state('switch.wall', 'off'),
|
||||
'light.indicator': state('light.indicator', 'on'),
|
||||
});
|
||||
const lamp = device({
|
||||
id: 'lamp', name: 'Dumb lamp', tapAction: null, primary: undefined, entities: [],
|
||||
marker: { id: 'lamp', binding: 'virtual', is_light: true, controls: [] },
|
||||
});
|
||||
const controller = device({
|
||||
id: 'wall', bindingKind: 'device', bindingRef: 'wall', primary: 'switch.wall',
|
||||
entities: ['switch.wall', 'light.indicator'], controls: ['marker:lamp'],
|
||||
marker: { id: 'wall', binding: 'device:wall', controls: ['marker:lamp'] },
|
||||
});
|
||||
const intent = resolveToggleIntent({ hass: h, devices: [controller, lamp], device: controller });
|
||||
assert.deepEqual(toggleCommandEntityIds(intent.command), ['switch.wall']);
|
||||
assert.equal(intent.targets[0].via, 'control-marker-driver');
|
||||
});
|
||||
|
||||
test('legacy cover keeps cover priority, ignores controls and uses open/close/stop semantics', () => {
|
||||
const h = hass({
|
||||
'light.mixed': state('light.mixed', 'on'),
|
||||
|
||||
@@ -999,6 +999,24 @@ test('resolvedLightSources: marker room_id is more precise than a shared HA area
|
||||
);
|
||||
});
|
||||
|
||||
test('room-scoped light graph keeps a source controlled from another room', () => {
|
||||
const hass = { states: { 'light.bed': { state: 'on' } } };
|
||||
const source = {
|
||||
id: 'bed-light', area: 'bedroom', primary: 'light.bed', entities: ['light.bed'],
|
||||
marker: { id: 'bed-light', room_id: 'bedroom-room', is_light: true },
|
||||
};
|
||||
const remote = {
|
||||
id: 'hall-switch', area: 'hall', primary: 'switch.hall', entities: ['switch.hall'],
|
||||
controls: ['light.bed'], marker: { id: 'hall-switch', room_id: 'hall-room', controls: ['light.bed'] },
|
||||
};
|
||||
const roomSources = resolvedLightSources(
|
||||
hass, [remote, source], { id: 'bedroom-room', area: 'bedroom' },
|
||||
);
|
||||
assert.deepEqual(roomSources.map((item) => ({ eid: item.eid, owner: item.device.id })), [
|
||||
{ eid: 'light.bed', owner: 'bed-light' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('self-control is not external and only explicit is_light makes the switch shine', () => {
|
||||
assert.deepEqual(
|
||||
persistedExternalControls('entity:switch.hood', [
|
||||
|
||||
@@ -36,6 +36,11 @@ test('golden matrix has stable unique ids and bounded comparison thresholds', ()
|
||||
&& scenario.openingPreviewPixels.minChannelDelta > 0
|
||||
&& scenario.openingPreviewPixels.minChannelDelta <= 32, true, scenario.id);
|
||||
}
|
||||
if (scenario.openingGeometry) {
|
||||
assert.match(scenario.openingGeometry.id, /^[a-z0-9-]+$/, scenario.id);
|
||||
assert.equal(['door', 'window', 'gate'].includes(scenario.openingGeometry.type), true, scenario.id);
|
||||
assert.equal(Number.isFinite(scenario.openingGeometry.angle), true, scenario.id);
|
||||
}
|
||||
if (scenario.tunnelContinuity) {
|
||||
assert.match(scenario.tunnelContinuity.openingId, /^[a-z0-9-]+$/, scenario.id);
|
||||
assert.equal(Number.isInteger(scenario.tunnelContinuity.insetPx)
|
||||
@@ -145,10 +150,23 @@ test('opening placement golden requires browser-painted preview pixels', () => {
|
||||
assert.equal(scenario.mode, 'plan');
|
||||
assert.equal(scenario.openingPreview?.type, 'door');
|
||||
assert.deepEqual(scenario.openingPreview?.pointer, [0.48, 0.65]);
|
||||
assert.equal(scenario.openingPreviewPixels.minPixels >= 40, true);
|
||||
assert.equal(scenario.openingPreviewPixels.minPixels >= 150, true);
|
||||
assert.equal(scenario.openingPreviewPixels.minChannelDelta >= 4, true);
|
||||
});
|
||||
|
||||
test('diagonal opening golden asserts a real painted 45 degree symbol', () => {
|
||||
const scenario = GOLDEN_SCENARIOS.find(
|
||||
(item) => item.id === 'geometry-diagonal-45-opening-dark',
|
||||
);
|
||||
assert.ok(scenario);
|
||||
assert.deepEqual(scenario.openingGeometry, {
|
||||
id: 'geo-diagonal-window', type: 'window', angle: 45,
|
||||
});
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.deepEqual(space.openings.map((opening) => opening.id), ['geo-diagonal-window']);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
helpHasContent,
|
||||
helpScrollShouldDismiss,
|
||||
} from '../test-build/help-behavior.js';
|
||||
|
||||
test('help is absent unless both visible copy and an accessible name exist', () => {
|
||||
assert.equal(helpHasContent('Explanation', 'More information'), true);
|
||||
assert.equal(helpHasContent(' ', 'More information'), false);
|
||||
assert.equal(helpHasContent('Explanation', ''), false);
|
||||
assert.equal(helpHasContent(null, 'More information'), false);
|
||||
});
|
||||
|
||||
test('only scrolling the owning dialog outside the floating surface dismisses help', () => {
|
||||
assert.equal(helpScrollShouldDismiss(true, true), false);
|
||||
assert.equal(helpScrollShouldDismiss(false, true), true);
|
||||
assert.equal(helpScrollShouldDismiss(false, false), false);
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const cardSource = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const secondarySource = readFileSync(new URL('../src/editor-secondary.ts', import.meta.url), 'utf8');
|
||||
|
||||
test('split commit materialises the old wall profile before replacing room outlines', () => {
|
||||
const materialiseAt = cardSource.indexOf('const splitWalls = wasSplit');
|
||||
const mutateAt = cardSource.indexOf('main.poly = this._pendingSplit.mainPoly');
|
||||
const normaliseAt = cardSource.indexOf('this._normalizeWalls(splitWalls');
|
||||
assert.ok(materialiseAt >= 0 && mutateAt > materialiseAt && normaliseAt > mutateAt);
|
||||
assert.match(cardSource.slice(materialiseAt, mutateAt), /materializeWallIntervals\([\s\S]*pendingSplit/);
|
||||
});
|
||||
|
||||
test('spatial glow rejects both wall masonry and independent physical bodies', () => {
|
||||
assert.match(
|
||||
cardSource,
|
||||
/pointInOpaquePlanBody\(sourcePoint, masonryGeometry, physical\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('late editor permission is adopted through the mode state machine', () => {
|
||||
assert.doesNotMatch(cardSource, /this\._mode\s*=\s*this\._pendingNavMode/);
|
||||
assert.match(cardSource, /this\._pendingNavMode = null;\s*this\._setMode\(pendingMode, false\)/);
|
||||
});
|
||||
|
||||
test('editor navigation stays actionable while transient editor surfaces dismiss', () => {
|
||||
assert.match(cardSource, /data-editor-navigation="view"/);
|
||||
assert.match(secondarySource, /hasAttribute\('data-editor-navigation'\)/);
|
||||
assert.match(secondarySource, /if \(onNavigation\)[\s\S]*return false;/);
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('../src/hp-help.ts', import.meta.url), 'utf8');
|
||||
|
||||
test('empty help renders no trigger and cannot create dialog overflow geometry', () => {
|
||||
assert.match(source, /if \(!this\._hasContent\(\)\) return nothing/);
|
||||
assert.match(source, /\.sr-only\s*\{[\s\S]*?position:\s*fixed/);
|
||||
assert.doesNotMatch(source, /class="sr-only"[^>]*\?hidden/);
|
||||
});
|
||||
|
||||
test('help owns global listeners only while its surface is open', () => {
|
||||
assert.match(source, /private _subscribeOpenListeners\(\)[\s\S]*?ownerDocument\.addEventListener\('keydown'/);
|
||||
assert.match(source, /private _unsubscribeOpenListeners\(\)[\s\S]*?ownerDocument\.removeEventListener\('keydown'/);
|
||||
assert.match(source, /this\._open = true;\s*this\._subscribeOpenListeners\(\)/);
|
||||
assert.match(source, /this\._open = false;\s*this\._unsubscribeOpenListeners\(\)/);
|
||||
});
|
||||
|
||||
test('help surface is keyboard focusable and its own scroll does not dismiss it', () => {
|
||||
assert.match(source, /role="tooltip" aria-hidden="true" tabindex="0"/);
|
||||
assert.match(source, /if \(this\._floating\.containsPath\(event\.composedPath\(\)\)\) return/);
|
||||
});
|
||||
@@ -124,6 +124,24 @@ test('Grid snap and soft centre magnet are deterministic', () => {
|
||||
assert.equal(resolve({ pointer: [34, 0] }).measure.guide, null);
|
||||
});
|
||||
|
||||
test('centre magnet wins over grid on a wall whose midpoint is off-grid', () => {
|
||||
const offGridWall = interval({ b: [105, 0] });
|
||||
const centred = resolve({ pointer: [50, 0], intervals: [offGridWall] });
|
||||
assert.equal(centred.x, 52.5);
|
||||
assert.deepEqual(centred.measure.guide, { x: 52.5, y: 0, angle: 0 });
|
||||
|
||||
const awayFromCentre = resolve({ pointer: [43, 0], intervals: [offGridWall] });
|
||||
assert.equal(awayFromCentre.x, 40);
|
||||
assert.equal(awayFromCentre.measure.guide, null);
|
||||
});
|
||||
|
||||
test('non-wall geometry cannot become an opening placement target', () => {
|
||||
assert.deepEqual(openingPlacementTargets([
|
||||
interval({ key: 'partition-shape', kind: null, open: false }),
|
||||
interval({ key: 'column-shape', kind: null, open: false, a: [40, 40], b: [60, 40] }),
|
||||
]), []);
|
||||
});
|
||||
|
||||
test('Junction tie-break does not depend on interval input order', () => {
|
||||
const horizontal = interval({ key: 'a-horizontal' });
|
||||
const vertical = interval({ key: 'b-vertical', a: [50, -50], b: [50, 50] });
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
openingVisibleMetrics,
|
||||
renderOpeningVisibleGeometry,
|
||||
} from '../test-build/render/opening-symbol.js';
|
||||
|
||||
const spec = (patch = {}) => ({
|
||||
type: 'door', length: 100, angle: 0, amount: 0,
|
||||
flipH: false, flipV: false, base: '#000', tone: '#000',
|
||||
cellCm: 10, gridPitch: 20,
|
||||
face: { ox: 0, oy: 0, cm: 0, side: -1 },
|
||||
...patch,
|
||||
});
|
||||
|
||||
const templateText = (value) => {
|
||||
if (!value || !value.strings) return '';
|
||||
return value.strings.join('') + value.values.map(templateText).join('');
|
||||
};
|
||||
|
||||
test('opening metrics expand hit and outline zones with a thick wall face', () => {
|
||||
assert.deepEqual(openingVisibleMetrics(spec()), {
|
||||
half: 50, jambHalf: 4, gateDepth: 0, outlineHalf: 16, hitHalf: 20,
|
||||
});
|
||||
assert.deepEqual(openingVisibleMetrics(spec({ face: { ox: 0, oy: 20, cm: 20, side: 1 } })), {
|
||||
half: 50, jambHalf: 20, gateDepth: 0, outlineHalf: 28, hitHalf: 30,
|
||||
});
|
||||
});
|
||||
|
||||
test('shared renderer emits the expected visible symbol for every opening type', () => {
|
||||
const windowText = templateText(renderOpeningVisibleGeometry(spec({
|
||||
type: 'window', face: { ox: 0, oy: 20, cm: 20, side: 1 },
|
||||
})));
|
||||
const doorText = templateText(renderOpeningVisibleGeometry(spec({ type: 'door' })));
|
||||
const gateText = templateText(renderOpeningVisibleGeometry(spec({ type: 'gate' })));
|
||||
assert.match(windowText, /op-glass/);
|
||||
assert.match(doorText, /op-leaf/);
|
||||
assert.match(doorText, /op-arc/);
|
||||
assert.equal((gateText.match(/op-leaf/g) || []).length, 2);
|
||||
assert.ok(openingVisibleMetrics(spec({ type: 'gate' })).gateDepth > 0);
|
||||
});
|
||||
@@ -770,7 +770,7 @@ test('split materialisation cuts a partial shared interval at the new divider',
|
||||
key: wallKey([0, 0], [6, 0], pitch), a: [0, 0], b: [6, 0], cm: 15,
|
||||
}];
|
||||
const preserved = materializeWallIntervals(
|
||||
original, walls, [], pitch, cellCm, GRID_PITCH, 1, [[4, 0], [4, 10]],
|
||||
original, walls, [], pitch, cellCm, GRID_PITCH,
|
||||
);
|
||||
const split = [
|
||||
{ id: 'source', poly: [[4, 0], [10, 0], [10, 10], [4, 10]] },
|
||||
|
||||
Reference in New Issue
Block a user