mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
Regular → Executable
+3
-20
@@ -9,23 +9,6 @@ case "$(basename "$message_file")" in
|
||||
MERGE_MSG) exit 0 ;;
|
||||
esac
|
||||
|
||||
if ! grep -Eq '^Issue: #[0-9]+[[:space:]]*$' "$message_file"; then
|
||||
echo "House Plan: missing 'Issue: #<number>' trailer (see PROCESS.md)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Eq '^User-Visible: (yes|no)[[:space:]]*$' "$message_file"; then
|
||||
echo "House Plan: missing 'User-Visible: yes|no' trailer (see PROCESS.md)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git diff --cached --name-only --diff-filter=ACMR \
|
||||
| grep -Eq '^demo/golden/baselines/.*\.(png|json)$'; then
|
||||
if ! grep -Eq '^Release: [^[:space:]].*$' "$message_file"; then
|
||||
echo "House Plan: a golden baseline commit requires a Release trailer." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Eq '^Baseline-Reviewed: [^[:space:]].*$' "$message_file"; then
|
||||
echo "House Plan: a golden baseline commit requires Baseline-Reviewed provenance." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
repo_root=$(git rev-parse --show-toplevel)
|
||||
node "$repo_root/scripts/validate-commit-provenance.mjs" \
|
||||
--message-file "$message_file" --staged --check-hook-mode
|
||||
|
||||
@@ -15,6 +15,29 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
provenance:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- name: Validate commit trailers and hook mode
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
range="$BASE_SHA..$HEAD_SHA"
|
||||
elif [ -n "$BEFORE_SHA" ] && ! echo "$BEFORE_SHA" | grep -Eq '^0+$'; then
|
||||
range="$BEFORE_SHA..$HEAD_SHA"
|
||||
else
|
||||
range="$HEAD_SHA^..$HEAD_SHA"
|
||||
fi
|
||||
node scripts/validate-commit-provenance.mjs --check-hook-mode --range "$range"
|
||||
|
||||
hacs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
+3
-1
@@ -38,7 +38,9 @@ Install the repository hook once per clone:
|
||||
git config core.hooksPath .githooks
|
||||
```
|
||||
|
||||
The hook checks message provenance only. Test, build and release gates remain
|
||||
The hook checks message provenance locally; `validate.yml` enforces the same
|
||||
terminal-trailer contract for every non-merge commit in a push or PR, so
|
||||
`--no-verify`, rebases and fresh clones cannot bypass it. Test, build and release gates remain
|
||||
the commands documented in `CONTRIBUTING.md` and `docs/TESTING.md`.
|
||||
|
||||
## Release history
|
||||
|
||||
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.62.0-beta.8"
|
||||
VERSION = "1.62.0-beta.9"
|
||||
|
||||
# Portable backup format. This is deliberately independent from the Home
|
||||
# Assistant Store version above: storage migrations and files exported by a
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.62.0-beta.8"
|
||||
"version": "1.62.0-beta.9"
|
||||
}
|
||||
|
||||
@@ -84,6 +84,22 @@ export function prepareGoldenFixture(scenario) {
|
||||
}
|
||||
space.openings = [...(space.openings || []), ...structuredClone(scenario.extraOpenings)];
|
||||
}
|
||||
if (scenario.openingGeometry) {
|
||||
const space = requireSpace();
|
||||
const opening = (space.openings || []).find(
|
||||
(item) => item.id === scenario.openingGeometry.id,
|
||||
);
|
||||
if (!opening || opening.type !== scenario.openingGeometry.type
|
||||
|| Math.abs(Number(opening.angle) - scenario.openingGeometry.angle) > 0.001) {
|
||||
throw new Error(
|
||||
`golden openingGeometry references a missing/mismatched opening: `
|
||||
+ `${scenario.openingGeometry.id}`,
|
||||
);
|
||||
}
|
||||
// This scenario must not remain byte-identical to the generic geometry
|
||||
// capture: isolate the intended diagonal symbol in the rendered fixture.
|
||||
space.openings = [opening];
|
||||
}
|
||||
if (scenario.wallReplacements?.length) {
|
||||
const space = requireSpace();
|
||||
const samePoint = (a, b) => Array.isArray(a) && Array.isArray(b)
|
||||
|
||||
@@ -19,7 +19,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
// complete 90 cm door preset while still proving rotation, inner-face
|
||||
// offset and ruler placement on a thick wall.
|
||||
mode: 'plan', openingPreview: { type: 'door', pointer: [0.48, 0.65] },
|
||||
openingPreviewPixels: { minPixels: 40, minChannelDelta: 4 },
|
||||
openingPreviewPixels: { minPixels: 150, minChannelDelta: 4 },
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'geometry-devices-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'devices',
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
@@ -46,6 +46,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
editorTray: 'decor-tool', language: 'ru', theme: 'dark',
|
||||
viewport: { width: 390, height: 760 }, ...page },
|
||||
{ id: 'geometry-diagonal-45-opening-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
openingGeometry: { id: 'geo-diagonal-window', type: 'window', angle: 45 },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'openings-thick-wall-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'none', glowEnabled: false, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
|
||||
@@ -385,6 +385,34 @@ try {
|
||||
pageErrors.length = 0;
|
||||
result.runtime = await prepareGoldenScenario(page, scenario);
|
||||
if (pageErrors.length) throw new Error(`browser exception: ${pageErrors.join(' | ')}`);
|
||||
if (scenario.openingGeometry) {
|
||||
result.openingGeometry = await page.evaluate((expected) => {
|
||||
const card = window.__goldenCard;
|
||||
const opening = card?.renderRoot?.querySelector(
|
||||
`.opening[data-id="${CSS.escape(expected.id)}"]`,
|
||||
);
|
||||
if (!opening) return null;
|
||||
const transform = opening.getAttribute('transform') || '';
|
||||
const angle = Number(transform.match(/rotate\(([-+0-9.eE]+)\)/)?.[1]);
|
||||
const bounds = opening.getBoundingClientRect();
|
||||
return {
|
||||
type: opening.getAttribute('data-kind'), angle,
|
||||
width: bounds.width, height: bounds.height,
|
||||
visibleParts: opening.querySelectorAll('.op-leaf, .op-arc, .op-glass').length,
|
||||
};
|
||||
}, scenario.openingGeometry);
|
||||
const expected = scenario.openingGeometry;
|
||||
const actualOpening = result.openingGeometry;
|
||||
if (!actualOpening || actualOpening.type !== expected.type
|
||||
|| Math.abs(actualOpening.angle - expected.angle) > 0.001
|
||||
|| actualOpening.width <= 0 || actualOpening.height <= 0
|
||||
|| actualOpening.visibleParts <= 0) {
|
||||
throw new Error(
|
||||
`semantic golden opening geometry failed for ${expected.id}: `
|
||||
+ JSON.stringify(actualOpening),
|
||||
);
|
||||
}
|
||||
}
|
||||
const clip = await goldenClip(page, scenario.capture);
|
||||
const screenshotOptions = {
|
||||
...(clip ? { clip } : {}),
|
||||
@@ -418,6 +446,11 @@ try {
|
||||
result.openingPreviewParts = control.parts;
|
||||
result.openingPreviewChangedPixels = sample.changed;
|
||||
result.openingPreviewMaxChannelDelta = sample.maxDelta;
|
||||
if (control.parts < 2) {
|
||||
throw new Error(
|
||||
`semantic golden assertion failed: opening preview is incomplete (${control.parts} parts)`,
|
||||
);
|
||||
}
|
||||
if (sample.changed < scenario.openingPreviewPixels.minPixels) {
|
||||
throw new Error(
|
||||
`semantic golden assertion failed: opening preview paints ${sample.changed} pixels, `
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+77
-77
File diff suppressed because one or more lines are too long
@@ -2,6 +2,18 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v1.62.0-beta.9 — 2026-08-12
|
||||
|
||||
- Fixed post-review inconsistencies between device state and tap behaviour:
|
||||
exact bindings no longer toggle a sibling entity, a passive relay controls
|
||||
its leading entity, and incidental cover or humidity entities no longer
|
||||
take over a composite device's presentation.
|
||||
- Hardened opening placement with shared defaults, a stable geometry cache,
|
||||
correct centre snapping and a stricter visual preview gate. Restored the
|
||||
public activity and preview-dot CSS hooks, improved help accessibility and
|
||||
strengthened internal process checks.
|
||||
- Small fixes and improvements.
|
||||
|
||||
## v1.62.0-beta.8 — 2026-08-12
|
||||
|
||||
- Reworked opening placement in the Plan editor. **Opening** now opens a compact
|
||||
|
||||
@@ -8,6 +8,19 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v1.62.0-beta.9 — 2026-08-12
|
||||
|
||||
- По результатам повторного код-ревью устранены расхождения между состоянием
|
||||
устройства и действием по нажатию: точная привязка больше не переключает
|
||||
соседнюю сущность, пассивное реле управляет своей ведущей сущностью, а
|
||||
вспомогательные cover- и humidity-сущности не перехватывают отображение
|
||||
составного устройства.
|
||||
- Усилено размещение проёмов: единые размеры по умолчанию, устойчивый кеш
|
||||
геометрии, корректная центральная привязка и более строгая визуальная
|
||||
проверка предпросмотра. Восстановлены публичные CSS-хуки активности и точки
|
||||
предпросмотра; улучшены доступность подсказок и внутренние проверки процесса.
|
||||
- Мелкие исправления и улучшения.
|
||||
|
||||
## v1.62.0-beta.8 — 2026-08-12
|
||||
|
||||
- Переработано размещение проёмов в Редакторе плана. Кнопка **«Проём»** теперь
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<!-- release: v1.62.0-beta.8 -->
|
||||
<!-- release: v1.62.0-beta.9 -->
|
||||
|
||||
## Основное
|
||||
|
||||
- «Проём» теперь открывает компактное подменю «Окно / Дверь / Ворота» и показывает полный полупрозрачный предпросмотр будущего объекта ([#75](https://github.com/Matysh/houseplan-card/issues/75), [#76](https://github.com/Matysh/houseplan-card/issues/76)).
|
||||
- Исправлено закрытие редактора с первого нажатия ([#95](https://github.com/Matysh/houseplan-card/issues/95)).
|
||||
- Исправлены выбор управляемой сущности и отображение составных устройств.
|
||||
- Улучшены стабильность и визуальная проверка предпросмотра проёмов.
|
||||
- Мелкие исправления и улучшения.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Opening** now provides a compact Window / Door / Gate sub-menu and a complete translucent placement preview ([#75](https://github.com/Matysh/houseplan-card/issues/75), [#76](https://github.com/Matysh/houseplan-card/issues/76)).
|
||||
- Fixed editor closing on the first click ([#95](https://github.com/Matysh/houseplan-card/issues/95)).
|
||||
- Fixed target selection and presentation for composite devices.
|
||||
- Improved opening-preview stability and visual verification.
|
||||
- Small fixes and improvements.
|
||||
|
||||
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.62.0-beta.8/docs/CHANGELOG.ru.md)
|
||||
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.62.0-beta.8/docs/CHANGELOG.md)
|
||||
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.62.0-beta.9/docs/CHANGELOG.ru.md)
|
||||
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.62.0-beta.9/docs/CHANGELOG.md)
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.62.0-beta.8",
|
||||
"version": "1.62.0-beta.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.62.0-beta.8",
|
||||
"version": "1.62.0-beta.9",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.62.0-beta.8",
|
||||
"version": "1.62.0-beta.9",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { basename } from 'node:path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const TRAILER = /^([A-Za-z][A-Za-z0-9-]*):\s*(.*?)\s*$/;
|
||||
|
||||
export function terminalTrailers(message) {
|
||||
const lines = String(message).replace(/\r/g, '').split('\n');
|
||||
while (lines.length && !lines.at(-1).trim()) lines.pop();
|
||||
const out = new Map();
|
||||
for (let index = lines.length - 1; index >= 0; index--) {
|
||||
const match = lines[index].match(TRAILER);
|
||||
if (!match) break;
|
||||
const values = out.get(match[1]) || [];
|
||||
values.unshift(match[2]);
|
||||
out.set(match[1], values);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function validateCommitMessage(message, changedFiles = []) {
|
||||
const trailers = terminalTrailers(message);
|
||||
const errors = [];
|
||||
const issues = trailers.get('Issue') || [];
|
||||
if (!issues.length || issues.some((value) => !/^#[1-9][0-9]*$/.test(value))) {
|
||||
errors.push("missing or invalid terminal 'Issue: #<positive number>' trailer");
|
||||
}
|
||||
const visible = trailers.get('User-Visible') || [];
|
||||
if (visible.length !== 1 || !/^(yes|no)$/.test(visible[0])) {
|
||||
errors.push("expected exactly one terminal 'User-Visible: yes|no' trailer");
|
||||
}
|
||||
const changesGolden = changedFiles.some((file) =>
|
||||
/^demo\/golden\/baselines\/.*\.(png|json)$/.test(file.replaceAll('\\', '/')));
|
||||
if (changesGolden) {
|
||||
const release = trailers.get('Release') || [];
|
||||
const reviewed = trailers.get('Baseline-Reviewed') || [];
|
||||
if (release.length !== 1 || !release[0]) errors.push('golden baseline commit requires one Release trailer');
|
||||
if (reviewed.length !== 1 || !reviewed[0]) {
|
||||
errors.push('golden baseline commit requires one Baseline-Reviewed trailer');
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function git(args) {
|
||||
return execFileSync('git', args, { encoding: 'utf8' }).trim();
|
||||
}
|
||||
|
||||
function assertHookMode() {
|
||||
const row = git(['ls-files', '-s', '.githooks/commit-msg']);
|
||||
if (!row.startsWith('100755 ')) {
|
||||
throw new Error('.githooks/commit-msg must be tracked as executable (100755)');
|
||||
}
|
||||
}
|
||||
|
||||
function validateOne(label, message, files) {
|
||||
const errors = validateCommitMessage(message, files);
|
||||
if (errors.length) throw new Error(`${label}:\n- ${errors.join('\n- ')}`);
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
if (argv.includes('--check-hook-mode')) assertHookMode();
|
||||
const fileAt = argv.indexOf('--message-file');
|
||||
if (fileAt >= 0) {
|
||||
const messageFile = argv[fileAt + 1];
|
||||
if (!messageFile) throw new Error('--message-file requires a path');
|
||||
if (basename(messageFile) === 'MERGE_MSG') return;
|
||||
const files = argv.includes('--staged')
|
||||
? git(['diff', '--cached', '--name-only', '--diff-filter=ACMR']).split('\n').filter(Boolean)
|
||||
: [];
|
||||
validateOne('commit message', readFileSync(messageFile, 'utf8'), files);
|
||||
}
|
||||
const rangeAt = argv.indexOf('--range');
|
||||
if (rangeAt >= 0) {
|
||||
const range = argv[rangeAt + 1];
|
||||
if (!range) throw new Error('--range requires a git revision range');
|
||||
const commits = git(['rev-list', '--reverse', range]).split('\n').filter(Boolean);
|
||||
for (const commit of commits) {
|
||||
// PROCESS.md explicitly forbids rewriting published history. The commit
|
||||
// that introduces this validator is the enforcement boundary; older PR
|
||||
// ancestry without the script remains auditable but cannot block a
|
||||
// future release branch forever.
|
||||
try { git(['cat-file', '-e', `${commit}:scripts/validate-commit-provenance.mjs`]); }
|
||||
catch { continue; }
|
||||
const parentCount = Number(git(['rev-list', '--parents', '-n', '1', commit]).split(/\s+/).length) - 1;
|
||||
if (parentCount > 1) continue;
|
||||
validateOne(
|
||||
commit,
|
||||
git(['show', '-s', '--format=%B', commit]),
|
||||
git(['diff-tree', '--no-commit-id', '--name-only', '-r', commit]).split('\n').filter(Boolean),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
try { main(process.argv.slice(2)); }
|
||||
catch (error) {
|
||||
console.error(`House Plan provenance: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -75,7 +75,7 @@ export function renderDeviceFace(
|
||||
const legacyMetrics = legacySupplementalMetrics(presentation);
|
||||
return html`
|
||||
${pulse.kind !== 'none' && pulse.reducedMotionIndicator !== 'dot'
|
||||
? html`<span class="device-pulse ${pulse.kind} ${pulse.reason} reason-${pulse.reason} ${gen2 ? 'gen2' : ''}"
|
||||
? html`<span class="device-pulse activity-ring ${pulse.kind} ${pulse.reason} reason-${pulse.reason} ${gen2 ? 'gen2' : ''}"
|
||||
aria-hidden="true"><i></i><i></i><i></i></span>`
|
||||
: nothing}
|
||||
${pulse.reducedMotionIndicator === 'dot'
|
||||
|
||||
@@ -267,10 +267,10 @@ export function resolvePresentationSources(
|
||||
// More info. On a mixed device, however, an unrelated cover capability must
|
||||
// not hijack a primary/owned light unless the legacy explicit cover action
|
||||
// (or the primary entity itself) says that the marker represents the cover.
|
||||
const resolvedDeviceRole = resolvedDeviceStateEntities(hass, d.entities);
|
||||
const coverOwnsFace = !!cover && (
|
||||
d.tapAction === 'cover'
|
||||
|| !!d.primary?.startsWith('cover.')
|
||||
|| lights.length === 0
|
||||
|| resolvedDeviceRole.some((eid) => eid.startsWith('cover.'))
|
||||
);
|
||||
if (coverOwnsFace) {
|
||||
sourceKind = 'cover';
|
||||
@@ -564,8 +564,12 @@ export function resolveDevicePresentation(
|
||||
? (d.marker?.use_climate_temp === true ? climateTempFor(hass, d.entities)
|
||||
: (d.icon === 'mdi:thermometer' || d.icon === 'mdi:air-filter') ? tempFor(hass, d.entities) : null)
|
||||
: null;
|
||||
// Legacy inline humidity belongs only to a humidity-led marker. A random
|
||||
// diagnostic humidity sibling must not turn a composite device into a
|
||||
// humidity marker; users can still select that sibling explicitly through
|
||||
// the configurable value badge.
|
||||
const hum = !staticIcon && !effectiveHidden && options.showTemperature
|
||||
&& d.entities.some((eid) => isHumEntity(hass, eid))
|
||||
&& !!d.primary && isHumEntity(hass, d.primary)
|
||||
? humFor(hass, d.entities) : null;
|
||||
const lqi = !staticIcon && !effectiveHidden && options.showSignal && !d.virtual ? lqiFor(hass, d.entities) : null;
|
||||
const markerStateGraph = options.lightSources || (d.marker?.value_badge?.source?.kind === 'derived_marker_state'
|
||||
|
||||
@@ -353,13 +353,16 @@ function resolveEntity(
|
||||
}
|
||||
|
||||
function ownRoleCandidates(device: DevItem, registryHass: any): string[] {
|
||||
const leading = device.marker?.is_light === true || device.marker?.light_entity
|
||||
? forcedLightEntityOf(device)
|
||||
: null;
|
||||
if (device.bindingKind === 'entity' && device.bindingRef) {
|
||||
return [...new Set([leading, device.bindingRef].filter((eid): eid is string => !!eid))];
|
||||
// Entity bindings are exact by contract: a controllable sibling from the
|
||||
// same HA device must never replace the entity selected by the user.
|
||||
return [device.bindingRef];
|
||||
}
|
||||
const candidates = device.entities.length ? device.entities : device.allEntities || [];
|
||||
const leading = (device.marker?.light_entity
|
||||
|| (device.primary && isControllable(device.primary)))
|
||||
? forcedLightEntityOf(device)
|
||||
: null;
|
||||
return [...new Set([
|
||||
leading,
|
||||
...resolvedDeviceStateEntities(registryHass, candidates),
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Pure content gate shared by the component and executable unit tests. */
|
||||
export function helpHasContent(text: unknown, ariaLabel: unknown): boolean {
|
||||
return typeof text === 'string' && text.trim().length > 0
|
||||
&& typeof ariaLabel === 'string' && ariaLabel.trim().length > 0;
|
||||
}
|
||||
|
||||
/** Scrolling the floating help itself must not dismiss it. */
|
||||
export function helpScrollShouldDismiss(
|
||||
pathIsInsideFloatingSurface: boolean,
|
||||
targetIsDialogOrContainsHelp: boolean,
|
||||
): boolean {
|
||||
return !pathIsInsideFloatingSurface && targetIsDialogOrContainsHelp;
|
||||
}
|
||||
+61
-31
@@ -191,12 +191,24 @@ import {
|
||||
type OpeningFaceOffset, type OpeningVisibleSpec,
|
||||
} from './render/opening-symbol';
|
||||
import {
|
||||
openingPlacementPreset, resolveOpeningPlacement, sameOpeningPlacementInput,
|
||||
openingDefaultLengthCm, openingPlacementPreset, resolveOpeningPlacement, sameOpeningPlacementInput,
|
||||
type OpeningPlacementCore, type OpeningPlacementPreset, type OpeningPlacementType,
|
||||
} from './opening-placement';
|
||||
import { safeStoredColor } from './color';
|
||||
|
||||
const CARD_VERSION = '1.62.0-beta.8';
|
||||
const CARD_VERSION = '1.62.0-beta.9';
|
||||
const DISPLAY_LABEL_KEYS: Record<DeviceDisplayMode, I18nKey> = {
|
||||
badge: 'display.badge',
|
||||
icon_ripple: 'display.icon_ripple',
|
||||
value: 'display.value',
|
||||
static_icon: 'display.static_icon',
|
||||
};
|
||||
const DISPLAY_HINT_KEYS: Record<DeviceDisplayMode, I18nKey> = {
|
||||
badge: 'marker.display_hint_badge',
|
||||
icon_ripple: 'marker.display_hint_icon_ripple',
|
||||
value: 'marker.display_hint_value',
|
||||
static_icon: 'marker.display_hint_static_icon',
|
||||
};
|
||||
/** Keeps every previously valid scale at the maximum 20 cm grid scale lossless. */
|
||||
const DECOR_TEXT_CM_MAX = 2000;
|
||||
const CELL_CM_MIN = 0.1;
|
||||
@@ -1128,7 +1140,10 @@ class HouseplanCard extends LitElement {
|
||||
key: string;
|
||||
value: Array<OpeningTunnelGeometry | null>;
|
||||
} | null = null;
|
||||
private _openingWallIndexCache: { key: string; value: OpeningWallIndex } | null = null;
|
||||
/** A few consumers use intentionally different open-cut projections in one
|
||||
* frame. Keep a tiny keyed pool so hit-testing cannot evict placement data
|
||||
* (and vice versa) on every pointer move. */
|
||||
private _openingWallIndexCache = new Map<string, OpeningWallIndex>();
|
||||
private _openingPlacementIntervalsCache: { key: string; value: WallInterval[] } | null = null;
|
||||
private _physicalBodiesCache: {
|
||||
key: string; drafts: number[][][]; partitions: number[][][];
|
||||
@@ -2738,6 +2753,9 @@ class HouseplanCard extends LitElement {
|
||||
} | null = null;
|
||||
/** Last unsaved marker projection; invalidated by the complete dialog draft. */
|
||||
private _markerPreviewMemo: { key: string; device: DevItem | null } | null = null;
|
||||
private _markerPreviewDevicesMemo: {
|
||||
base: readonly DevItem[]; preview: DevItem; devices: readonly DevItem[];
|
||||
} | null = null;
|
||||
|
||||
/** Cheap structural fingerprint of the config (audit L1 cache key). */
|
||||
private _cfgFingerprint(): string {
|
||||
@@ -3930,7 +3948,7 @@ class HouseplanCard extends LitElement {
|
||||
return resolveDevicePresentation(this._renderPlanHass, d, {
|
||||
liveStates: this._config?.live_states !== false,
|
||||
showTemperature: this._config?.show_temperature !== false,
|
||||
showSignal: showLqi,
|
||||
showSignal: showLqi && this._config?.show_signal !== false,
|
||||
designPreview,
|
||||
activityRuntime: this._activityRt.get(d.id),
|
||||
sourceDetails: false,
|
||||
@@ -9639,16 +9657,19 @@ class HouseplanCard extends LitElement {
|
||||
space.id, this._cfgEpoch, this._wallKeyPitch, this._cellCm, this._gridPitch,
|
||||
roomFingerprint, wallFingerprint, cutFingerprint,
|
||||
].join('|');
|
||||
if (!this._openingWallIndexCache || this._openingWallIndexCache.key !== key) {
|
||||
this._openingWallIndexCache = {
|
||||
key,
|
||||
value: buildOpeningWallIndex(
|
||||
space.rooms, this._spaceWalls, openCuts,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
|
||||
),
|
||||
};
|
||||
let value = this._openingWallIndexCache.get(key);
|
||||
if (!value) {
|
||||
value = buildOpeningWallIndex(
|
||||
space.rooms, this._spaceWalls, openCuts,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
|
||||
);
|
||||
this._openingWallIndexCache.set(key, value);
|
||||
if (this._openingWallIndexCache.size > 4) {
|
||||
const oldest = this._openingWallIndexCache.keys().next().value;
|
||||
if (oldest) this._openingWallIndexCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
return this._openingWallIndexCache;
|
||||
return { key, value };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -10541,10 +10562,6 @@ class HouseplanCard extends LitElement {
|
||||
? materializeWallIntervals(
|
||||
this._spaceModel().rooms, sp.walls, this._openCuts(),
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
|
||||
[
|
||||
...(this._pendingSplit?.mainPoly || []),
|
||||
...(this._pendingSplit?.newPoly || []),
|
||||
],
|
||||
)
|
||||
: null;
|
||||
const wallsBeforeSplit = wasSplit && Array.isArray(sp.walls) ? sp.walls : null;
|
||||
@@ -14182,7 +14199,7 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
/** Start/restart a short semantic event or direct-terminal transition. */
|
||||
private _activitySourceKey(d: DevItem): string {
|
||||
return activitySourceSignature(this._planHass, d);
|
||||
return this._activitySnapshot(d).sourceKey;
|
||||
}
|
||||
|
||||
private _activitySnapshot(
|
||||
@@ -14190,7 +14207,7 @@ class HouseplanCard extends LitElement {
|
||||
planLightSources = resolvedLightSources(this._planHass, this._devices),
|
||||
): { samples: EntityVisualSample[]; sourceKey: string } {
|
||||
const sources = resolvePresentationSources(
|
||||
this._planHass, d, this._devices, planLightSources,
|
||||
this._planHass, d, this._devices, planLightSources, this._fullRegistryHass,
|
||||
);
|
||||
return {
|
||||
samples: sources.samples,
|
||||
@@ -15529,7 +15546,7 @@ class HouseplanCard extends LitElement {
|
||||
transform="translate(${candidate.x} ${candidate.y}) rotate(${candidate.angle})">
|
||||
${renderOpeningVisibleGeometry(visibleSpec)}
|
||||
</g>
|
||||
<circle class="opening-preview-dot" aria-hidden="true" pointer-events="none"
|
||||
<circle class="opening-preview-dot opghost-dot" aria-hidden="true" pointer-events="none"
|
||||
cx=${candidate.x} cy=${candidate.y} r=${this._gridPitch * 0.18}></circle>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
@@ -15713,14 +15730,19 @@ class HouseplanCard extends LitElement {
|
||||
<div class="body">
|
||||
<label>${this._t('opening.type_label')}</label>
|
||||
<label class="srcrow"><input type="radio" name="optype" .checked=${d.type === 'door'}
|
||||
@change=${() => (this._openingDialog = { ...d, type: 'door', lengthCm: d.id ? d.lengthCm : 90 })} />
|
||||
@change=${() => (this._openingDialog = {
|
||||
...d, type: 'door', lengthCm: d.id ? d.lengthCm : openingDefaultLengthCm('door'),
|
||||
})} />
|
||||
<span>${this._t('opening.door')}</span></label>
|
||||
<label class="srcrow"><input type="radio" name="optype" .checked=${d.type === 'window'}
|
||||
@change=${() => (this._openingDialog = { ...d, type: 'window', lengthCm: d.id ? d.lengthCm : 120 })} />
|
||||
@change=${() => (this._openingDialog = {
|
||||
...d, type: 'window', lengthCm: d.id ? d.lengthCm : openingDefaultLengthCm('window'),
|
||||
})} />
|
||||
<span>${this._t('opening.window')}</span></label>
|
||||
<label class="srcrow"><input type="radio" name="optype" .checked=${d.type === 'gate'}
|
||||
@change=${() => (this._openingDialog = {
|
||||
...d, type: 'gate', lengthCm: d.id ? d.lengthCm : 300, flipH: false,
|
||||
...d, type: 'gate',
|
||||
lengthCm: d.id ? d.lengthCm : openingDefaultLengthCm('gate'), flipH: false,
|
||||
})} />
|
||||
<span>${this._t('opening.gate')}</span></label>
|
||||
|
||||
@@ -16359,6 +16381,16 @@ class HouseplanCard extends LitElement {
|
||||
return device;
|
||||
}
|
||||
|
||||
/** Preserve array identity while the dialog draft and runtime roster stay
|
||||
* unchanged, allowing light/value resolvers to reuse their WeakMap caches. */
|
||||
private _markerPreviewDevices(preview: DevItem): readonly DevItem[] {
|
||||
const memo = this._markerPreviewDevicesMemo;
|
||||
if (memo?.base === this._devices && memo.preview === preview) return memo.devices;
|
||||
const devices = [...this._devices.filter((item) => item.id !== preview.id), preview];
|
||||
this._markerPreviewDevicesMemo = { base: this._devices, preview, devices };
|
||||
return devices;
|
||||
}
|
||||
|
||||
private _toggleIntent(
|
||||
device: DevItem,
|
||||
devices: readonly DevItem[] = this._devices,
|
||||
@@ -16376,7 +16408,7 @@ class HouseplanCard extends LitElement {
|
||||
): ResolvedToggleIntent | null {
|
||||
const preview = this._markerPreviewDevice(d);
|
||||
if (!preview) return null;
|
||||
const devices = [...this._devices.filter((item) => item.id !== preview.id), preview];
|
||||
const devices = this._markerPreviewDevices(preview);
|
||||
return this._toggleIntent(preview, devices);
|
||||
}
|
||||
|
||||
@@ -16647,7 +16679,7 @@ class HouseplanCard extends LitElement {
|
||||
? spaceDisplayOf(this._serverCfg?.spaces.find((space: any) => space.id === previewDevice.space))
|
||||
: null;
|
||||
const previewLightDevices = previewDevice
|
||||
? [...this._devices.filter((item) => item.id !== previewDevice.id), previewDevice]
|
||||
? this._markerPreviewDevices(previewDevice)
|
||||
: this._devices;
|
||||
const toggleIntent = effectiveTapAction === 'toggle' && previewDevice
|
||||
? this._toggleIntent(previewDevice, previewLightDevices) : null;
|
||||
@@ -17058,13 +17090,11 @@ class HouseplanCard extends LitElement {
|
||||
...d,
|
||||
display: normalizeDeviceDisplay((e.target as HTMLSelectElement).value),
|
||||
})}>
|
||||
${DISPLAY_MODES.map((v) => [v, 'display.' + v] as const).map(
|
||||
([v, k]) => html`<option value=${v} ?selected=${v === d.display}>
|
||||
${this._t(k as any)}
|
||||
</option>`,
|
||||
)}
|
||||
${DISPLAY_MODES.map((v) => html`<option value=${v} ?selected=${v === d.display}>
|
||||
${this._t(DISPLAY_LABEL_KEYS[v])}
|
||||
</option>`)}
|
||||
</select>
|
||||
<p class="muted">${this._t((`marker.display_hint_${d.display}`) as any)}</p>
|
||||
<p class="muted">${this._t(DISPLAY_HINT_KEYS[d.display])}</p>
|
||||
${d.display === 'static_icon' && this._bindingHasAlarm(d.binding)
|
||||
? html`<div class="habindingbanner" role="note">
|
||||
<ha-icon icon="mdi:alert-outline"></ha-icon>
|
||||
|
||||
+7
-6
@@ -3,6 +3,7 @@ import { mdiHelpCircleOutline } from '@mdi/js';
|
||||
import { floatingViewport, placeFloatingSurface } from './floating-surface';
|
||||
import { FloatingSurfaceController } from './floating-surface-controller';
|
||||
import type { HpDialog, HpOverlayCloseReason } from './hp-dialog';
|
||||
import { helpHasContent, helpScrollShouldDismiss } from './help-behavior';
|
||||
|
||||
let helpSequence = 0;
|
||||
|
||||
@@ -203,8 +204,7 @@ export class HpHelp extends LitElement {
|
||||
}
|
||||
|
||||
private _hasContent(): boolean {
|
||||
return typeof this.text === 'string' && this.text.trim().length > 0
|
||||
&& typeof this.ariaLabel === 'string' && this.ariaLabel.trim().length > 0;
|
||||
return helpHasContent(this.text, this.ariaLabel);
|
||||
}
|
||||
|
||||
private _clearTimers(): void {
|
||||
@@ -284,11 +284,12 @@ export class HpHelp extends LitElement {
|
||||
};
|
||||
|
||||
private _dialogScroll = (event: Event): void => {
|
||||
if (this._floating.containsPath(event.composedPath())) return;
|
||||
const insideSurface = this._floating.containsPath(event.composedPath());
|
||||
const target = event.target;
|
||||
const dialog = this._dialog();
|
||||
if (this._open && target instanceof Node
|
||||
&& (target === dialog || (target instanceof Element && target.contains(this)))) {
|
||||
const targetOwnsHelp = target instanceof Node
|
||||
&& (target === dialog || (target instanceof Element && target.contains(this)));
|
||||
if (this._open && helpScrollShouldDismiss(insideSurface, targetOwnsHelp)) {
|
||||
this._closeHelp(false, 'scroll');
|
||||
}
|
||||
};
|
||||
@@ -372,7 +373,7 @@ export class HpHelp extends LitElement {
|
||||
|
||||
private _tooltipTemplate(usePopover: boolean): TemplateResult {
|
||||
return html`<div class="tooltip" data-side="bottom" popover=${usePopover ? 'manual' : nothing}
|
||||
role="tooltip" aria-hidden="true" tabindex="0"
|
||||
role="tooltip" aria-hidden="true" tabindex="-1"
|
||||
@pointerenter=${this._surfacePointerEnter} @pointerleave=${this._surfacePointerLeave}>${this.text}</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,11 +59,16 @@ const DEFAULTS: Record<OpeningPlacementType, number> = {
|
||||
gate: 300,
|
||||
};
|
||||
|
||||
/** One authority for toolbar presets and dialog type changes. */
|
||||
export function openingDefaultLengthCm(type: OpeningPlacementType): number {
|
||||
return DEFAULTS[type];
|
||||
}
|
||||
|
||||
export function openingPlacementPreset(
|
||||
type: OpeningPlacementType,
|
||||
revision: number,
|
||||
): OpeningPlacementPreset {
|
||||
return { type, lengthCm: DEFAULTS[type], flipH: false, flipV: false, revision };
|
||||
return { type, lengthCm: openingDefaultLengthCm(type), flipH: false, flipV: false, revision };
|
||||
}
|
||||
|
||||
function pointOrder(a: readonly number[], b: readonly number[]): number {
|
||||
|
||||
+5
-3
@@ -297,7 +297,7 @@ class HouseplanSpaceCard extends LitElement {
|
||||
}
|
||||
|
||||
/** Track witnessed edges for the read-only card without inventing activity on first load. */
|
||||
private _syncActivity(devices: DevItem[], planHass: any): void {
|
||||
private _syncActivity(devices: DevItem[], planHass: any, registryHass = this.hass): void {
|
||||
if (this._config?.live_states === false) {
|
||||
for (const runtime of this._activityRuntime.values()) window.clearTimeout(runtime.timer);
|
||||
this._activityRuntime.clear();
|
||||
@@ -312,7 +312,9 @@ class HouseplanSpaceCard extends LitElement {
|
||||
// event that happened while no pulse was visible (#98 edge contract).
|
||||
if (normalizeDeviceDisplay(device.marker?.display) !== 'icon_ripple') continue;
|
||||
live.add(device.id);
|
||||
const sources = resolvePresentationSources(planHass, device, devices, planLightSources);
|
||||
const sources = resolvePresentationSources(
|
||||
planHass, device, devices, planLightSources, registryHass,
|
||||
);
|
||||
const samples = sources.samples;
|
||||
const signature = activitySourceSignature(planHass, device, sources);
|
||||
let runtime = this._activityRuntime.get(device.id);
|
||||
@@ -341,7 +343,7 @@ class HouseplanSpaceCard extends LitElement {
|
||||
cfg: this._snap.config,
|
||||
lang: this._lang,
|
||||
});
|
||||
this._syncActivity(devices, activeRegistryHass(this.hass, registry));
|
||||
this._syncActivity(devices, activeRegistryHass(this.hass, registry), this.hass);
|
||||
this._devices = devices;
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -20,7 +20,7 @@ import { t, type Lang } from './i18n';
|
||||
import { bgModeOf, northDegOf, sunStateOf, dayPhase } from './sun';
|
||||
import type { DevItem, ServerConfig } from './types';
|
||||
import { physicalBodies } from './physical-geometry';
|
||||
import { activeRegistryHass, type HaRegistrySnapshot } from './ha-binding-status';
|
||||
import { activeRegistryHass, fullRegistryHass, type HaRegistrySnapshot } from './ha-binding-status';
|
||||
import {
|
||||
resolveDevicePresentation, type PresentationActivityRuntime, type ResolvedDevicePresentation,
|
||||
} from './device-presentation';
|
||||
@@ -115,6 +115,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
|
||||
|
||||
const planHass = o.registry ? activeRegistryHass(o.hass, o.registry) : o.hass;
|
||||
const registryHass = o.registry ? fullRegistryHass(o.hass, o.registry) : o.hass;
|
||||
const all = o.devices || buildSpaceDevices(o);
|
||||
// Two lists, two jobs (HP-1510-01): AGGREGATION sees every device of the
|
||||
// space — hidden ones still count toward room LQI, same as the full card —
|
||||
@@ -252,6 +253,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
sourceDetails: false,
|
||||
lightDevices: devs,
|
||||
lightSources: planLightSources,
|
||||
registryHass,
|
||||
reducedMotion: o.reducedMotion,
|
||||
});
|
||||
const st = [`left:${left}%`, `top:${top}%`, ...deviceFaceStyle(presentation)];
|
||||
|
||||
+1
-16
@@ -1017,7 +1017,6 @@ export function materializeWallIntervals(
|
||||
cellCm: number,
|
||||
gridPitch: number,
|
||||
coordScale = 1,
|
||||
breakPoints: readonly number[][] = [],
|
||||
): WallEntry[] {
|
||||
// Rebuild from the effective profile instead of retaining midpoint-only
|
||||
// legacy rows beside their lossless replacements. Keeping both lets the
|
||||
@@ -1028,21 +1027,7 @@ export function materializeWallIntervals(
|
||||
);
|
||||
for (const iv of resolved) {
|
||||
if (iv.open || !(iv.cm > 0)) continue;
|
||||
const dx = iv.b[0] - iv.a[0], dy = iv.b[1] - iv.a[1];
|
||||
const length2 = dx * dx + dy * dy;
|
||||
const eps = openEps(pitch, coordScale) * 4;
|
||||
const cuts = length2 > 0 ? breakPoints.flatMap((point) => {
|
||||
const t = ((point[0] - iv.a[0]) * dx + (point[1] - iv.a[1]) * dy) / length2;
|
||||
if (t <= 0 || t >= 1) return [];
|
||||
const projected = [iv.a[0] + dx * t, iv.a[1] + dy * t];
|
||||
return Math.hypot(point[0] - projected[0], point[1] - projected[1]) <= eps ? [t] : [];
|
||||
}) : [];
|
||||
const stops = [0, ...new Set(cuts), 1].sort((a, b) => a - b);
|
||||
for (let i = 0; i + 1 < stops.length; i++) {
|
||||
const a = [iv.a[0] + dx * stops[i], iv.a[1] + dy * stops[i]];
|
||||
const b = [iv.a[0] + dx * stops[i + 1], iv.a[1] + dy * stops[i + 1]];
|
||||
out = setWallThickness(out, a, b, iv.cm, pitch, coordScale);
|
||||
}
|
||||
out = setWallThickness(out, iv.a, iv.b, iv.cm, pitch, coordScale);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -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]] },
|
||||
|
||||
+2
-2
@@ -26,9 +26,9 @@
|
||||
"src/align-grid.ts",
|
||||
"src/plan-optimizer.ts",
|
||||
"src/furniture.ts",
|
||||
"src/floating-surface.ts", "src/floating-surface-controller.ts",
|
||||
"src/floating-surface.ts", "src/floating-surface-controller.ts", "src/help-behavior.ts",
|
||||
"src/hp-help.ts", "src/hp-dialog.ts", "src/hp-color-opacity.ts",
|
||||
"src/opening-placement.ts",
|
||||
"src/opening-placement.ts", "src/render/opening-symbol.ts",
|
||||
"src/wall-thickness.ts",
|
||||
"src/physical-geometry.ts",
|
||||
"src/open-spans.ts",
|
||||
|
||||
Reference in New Issue
Block a user