Release v1.62.0-beta.9 candidate

Issue: #108
User-Visible: yes
This commit is contained in:
Matysh
2026-08-12 21:49:29 +03:00
parent 9bb5f7c5a8
commit 8e2973fa7a
40 changed files with 779 additions and 393 deletions
Regular → Executable
+3 -20
View File
@@ -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
+23
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"
}
+16
View File
@@ -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)
+2 -1
View File
@@ -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 },
+33
View File
@@ -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
+77 -77
View File
File diff suppressed because one or more lines are too long
+12
View File
@@ -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
+13
View File
@@ -8,6 +8,19 @@
## Unreleased
## v1.62.0-beta.9 — 2026-08-12
- По результатам повторного код-ревью устранены расхождения между состоянием
устройства и действием по нажатию: точная привязка больше не переключает
соседнюю сущность, пассивное реле управляет своей ведущей сущностью, а
вспомогательные cover- и humidity-сущности не перехватывают отображение
составного устройства.
- Усилено размещение проёмов: единые размеры по умолчанию, устойчивый кеш
геометрии, корректная центральная привязка и более строгая визуальная
проверка предпросмотра. Восстановлены публичные CSS-хуки активности и точки
предпросмотра; улучшены доступность подсказок и внутренние проверки процесса.
- Мелкие исправления и улучшения.
## v1.62.0-beta.8 — 2026-08-12
- Переработано размещение проёмов в Редакторе плана. Кнопка **«Проём»** теперь
+7 -7
View File
@@ -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)
+2 -2
View File
@@ -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
View File
@@ -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",
+104
View File
@@ -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
View File
@@ -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'
+7 -3
View File
@@ -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'
+7 -4
View File
@@ -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),
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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>`;
}
+6 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+29
View File
@@ -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,
), []);
});
+15 -1
View File
@@ -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/);
});
+35 -2
View File
@@ -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']);
});
+19
View File
@@ -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'),
+18
View File
@@ -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', [
+19 -1
View File
@@ -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);
+19
View File
@@ -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);
});
-32
View File
@@ -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;/);
});
-23
View File
@@ -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/);
});
+18
View File
@@ -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] });
+41
View File
@@ -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);
});
+1 -1
View File
@@ -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
View File
@@ -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",