mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 15:09:30 +00:00
feat(battery): добавляет тень и локальное скрытие индикатора
Issue: #806 User-Visible: yes
This commit is contained in:
@@ -289,6 +289,9 @@ def _project_marker(ids: _Pseudonyms, marker: dict[str, Any]) -> dict[str, Any]:
|
||||
"ripple_size", "size", "angle", "glow_radius_cm", "glow_color", "is_light",
|
||||
"use_climate_temp",
|
||||
)))
|
||||
hide_battery = marker.get("hide_battery")
|
||||
if isinstance(hide_battery, bool):
|
||||
out["hide_battery"] = hide_battery
|
||||
icon = marker.get("icon")
|
||||
if isinstance(icon, str) and _SAFE_ICON.fullmatch(icon):
|
||||
out["icon"] = icon
|
||||
|
||||
@@ -2000,6 +2000,8 @@ MARKER_SCHEMA = vol.Schema(
|
||||
),
|
||||
# climate current_temperature: badge + room-average vote (off unless True)
|
||||
vol.Optional("use_climate_temp"): vol.Any(bool, None),
|
||||
# Per-marker battery indicator override; absence/False keeps global policy.
|
||||
vol.Optional("hide_battery"): bool,
|
||||
vol.Optional("room_id"): vol.Any(str, None),
|
||||
# Keep in sync with DISPLAY_MODES in src/logic.ts. `ripple` is no longer
|
||||
# offered, but remains accepted while old stores migrate to icon_ripple.
|
||||
|
||||
@@ -9,6 +9,20 @@ const { page, browser } = await launch({ width: 1200, height: 900 }, 1, [], { ha
|
||||
const out = {};
|
||||
try {
|
||||
await installBatteryFixture(page);
|
||||
out.approvedShadowIsAppliedAtRenderedScale = await page.evaluate(() => {
|
||||
const icon = window.__card.shadowRoot.querySelector('[data-id="d_temp"] ha-icon.device-battery-icon');
|
||||
if (!icon) return false;
|
||||
const frame = icon.getBoundingClientRect().width;
|
||||
const filter = getComputedStyle(icon).filter;
|
||||
const values = [...filter.matchAll(/(-?\d+(?:\.\d+)?)px/g)].map((match) => Number(match[1]));
|
||||
const expected = [
|
||||
frame * 0.024324324324 + 0.237837837844,
|
||||
frame * 0.048648648649 + 0.875675675669,
|
||||
frame * 0.051351351351 + 0.324324324331,
|
||||
];
|
||||
return filter.includes('drop-shadow') && /0\.75|75%/.test(filter)
|
||||
&& values.length >= 3 && expected.every((value, index) => Math.abs(value - values[index]) <= 0.03);
|
||||
});
|
||||
out.nonBatteryGetsNoQuestionMark = await page.evaluate(() => {
|
||||
const node = window.__card.shadowRoot.querySelector('[data-id="d_leak"]');
|
||||
return !!node && node.getBoundingClientRect().width > 0 && !node.querySelector('.device-battery');
|
||||
@@ -48,6 +62,16 @@ try {
|
||||
await page.evaluate(() => window.__setRegistryDisabled('device', 'd_temp', null));
|
||||
await page.waitForFunction(() => window.__card.shadowRoot.querySelector('[data-id="d_temp"] .device-battery')?.dataset.state === 'normal');
|
||||
|
||||
await patchBatteryMarker(page, { hide_battery: true });
|
||||
await page.waitForFunction(() => !window.__card.shadowRoot.querySelector('[data-id="d_temp"] .device-battery'));
|
||||
out.localOptOutHidesOnlySelectedDevice = await page.evaluate(() =>
|
||||
!window.__card.shadowRoot.querySelector('[data-id="d_temp"] .device-battery')
|
||||
&& !!window.__card.shadowRoot.querySelector('[data-id="d_light1"] .device-battery'));
|
||||
await patchBatteryMarker(page, { hide_battery: 'true' });
|
||||
await page.waitForFunction(() => window.__card.shadowRoot.querySelector('[data-id="d_temp"] .device-battery'));
|
||||
out.localOptOutRequiresExactBooleanTrue = true;
|
||||
await patchBatteryMarker(page, { hide_battery: false });
|
||||
|
||||
for (const position of ['none', 'right', 'left', 'top', 'bottom']) {
|
||||
await patchBatteryMarker(page, { value_badge: position === 'none' ? { enabled: false } : {
|
||||
enabled: true, source: { kind: 'entity_state', entity_id: 'sensor.living_temp' }, position,
|
||||
@@ -109,8 +133,49 @@ try {
|
||||
return battery?.dataset.state === 'warning' && !!r && !!stage
|
||||
&& r.left >= stage.left && r.top >= stage.top && r.right <= stage.right && r.bottom <= stage.bottom;
|
||||
});
|
||||
out.previewHasLocalOptOutToggle = await page.evaluate(() => {
|
||||
const input = window.__card.shadowRoot.querySelector('hp-dialog[data-kind="marker"] #marker-hide-battery');
|
||||
return !!input && input.checked === false;
|
||||
});
|
||||
await page.evaluate(() => window.__card.shadowRoot
|
||||
.querySelector('hp-dialog[data-kind="marker"] #marker-hide-battery').click());
|
||||
await page.waitForFunction(() => !window.__card.shadowRoot.querySelector('hp-device-preview')?.shadowRoot
|
||||
?.querySelector('.device-battery'));
|
||||
out.previewOptOutHidesOnlyDraftBattery = await page.evaluate(() =>
|
||||
!window.__card.shadowRoot.querySelector('hp-device-preview')?.shadowRoot?.querySelector('.device-battery')
|
||||
&& !!window.__card.shadowRoot.querySelector('[data-id="d_light1"] .device-battery'));
|
||||
await page.evaluate(() => window.__card.shadowRoot
|
||||
.querySelector('hp-dialog[data-kind="marker"] #marker-hide-battery').click());
|
||||
await page.waitForFunction(() => window.__card.shadowRoot.querySelector('hp-device-preview')?.shadowRoot
|
||||
?.querySelector('.device-battery'));
|
||||
await page.evaluate(async () => { await window.__hpTest.close(undefined, { via: 'cancel' }); await window.__hpTest.setMode('view'); });
|
||||
|
||||
// Save/reopen is the public persistence witness; the second save restores
|
||||
// the default so the remaining #792 surface checks still see the battery.
|
||||
await page.evaluate(async () => {
|
||||
await window.__hpTest.setMode('devices');
|
||||
await window.__hpTest.openMarkerDialog('d_temp');
|
||||
});
|
||||
await page.evaluate(() => window.__card.shadowRoot
|
||||
.querySelector('hp-dialog[data-kind="marker"] #marker-hide-battery').click());
|
||||
await page.evaluate(() => window.__card.shadowRoot
|
||||
.querySelector('hp-dialog[data-kind="marker"] .dialog-action-commit [data-hp="dialog-confirm"]').click());
|
||||
await page.waitForFunction(() => !window.__card.shadowRoot.querySelector('hp-dialog[data-kind="marker"]')
|
||||
&& !window.__card.shadowRoot.querySelector('[data-id="d_temp"] .device-battery'));
|
||||
await page.evaluate(() => window.__hpTest.openMarkerDialog('d_temp'));
|
||||
await page.waitForFunction(() => window.__card.shadowRoot
|
||||
.querySelector('hp-dialog[data-kind="marker"] #marker-hide-battery')?.checked === true);
|
||||
out.savedOptOutSurvivesReopen = await page.evaluate(() =>
|
||||
!window.__card.shadowRoot.querySelector('hp-device-preview')?.shadowRoot?.querySelector('.device-battery'));
|
||||
await page.evaluate(() => window.__card.shadowRoot
|
||||
.querySelector('hp-dialog[data-kind="marker"] #marker-hide-battery').click());
|
||||
await page.evaluate(() => window.__card.shadowRoot
|
||||
.querySelector('hp-dialog[data-kind="marker"] .dialog-action-commit [data-hp="dialog-confirm"]').click());
|
||||
await page.waitForFunction(() => !window.__card.shadowRoot.querySelector('hp-dialog[data-kind="marker"]')
|
||||
&& !!window.__card.shadowRoot.querySelector('[data-id="d_temp"] .device-battery'));
|
||||
await page.evaluate(() => window.__hpTest.setMode('view'));
|
||||
out.defaultRestoredAfterSecondSave = true;
|
||||
|
||||
await page.evaluate(async () => {
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const secondary = document.createElement('houseplan-space-card');
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/** #806: 200 painted battery icons, using the existing static and camera budgets. */
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1440, height: 1000 });
|
||||
const out = {};
|
||||
try {
|
||||
const measured = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const longTaskWindow = () => {
|
||||
const entries = [];
|
||||
if (!PerformanceObserver.supportedEntryTypes?.includes('longtask')) {
|
||||
return { stop: async () => ({ supported: false, count: 0, maxMs: 0, totalMs: 0 }) };
|
||||
}
|
||||
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
|
||||
observer.observe({ type: 'longtask', buffered: false });
|
||||
return {
|
||||
stop: async () => {
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
entries.push(...observer.takeRecords());
|
||||
observer.disconnect();
|
||||
const durations = entries.map((entry) => entry.duration);
|
||||
return {
|
||||
supported: true,
|
||||
count: durations.length,
|
||||
maxMs: durations.length ? Math.max(...durations) : 0,
|
||||
totalMs: durations.reduce((sum, duration) => sum + duration, 0),
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const states = { ...card.hass.states };
|
||||
const markers = Object.keys(card.hass.devices).map((id) => ({
|
||||
id, binding: `device:${id}`, hidden: true,
|
||||
}));
|
||||
const layout = {};
|
||||
for (let index = 0; index < 200; index++) {
|
||||
const id = `battery-perf-${index}`;
|
||||
const entityId = `sensor.battery_perf_${index}`;
|
||||
window.__addRegistryEntity(entityId, null, String(20 + (index % 81)));
|
||||
Object.assign(card.hass.entities[entityId], {
|
||||
device_class: 'battery',
|
||||
disabled_by: null,
|
||||
});
|
||||
states[entityId] = {
|
||||
entity_id: entityId,
|
||||
state: String(20 + (index % 81)),
|
||||
attributes: { device_class: 'battery', unit_of_measurement: '%' },
|
||||
};
|
||||
markers.push({
|
||||
id,
|
||||
binding: `entity:${entityId}`,
|
||||
space: 'f1',
|
||||
display: 'badge',
|
||||
value_badge: { enabled: false },
|
||||
});
|
||||
layout[id] = {
|
||||
s: 'f1',
|
||||
x: 0.025 + (index % 20) * 0.05,
|
||||
y: 0.05 + Math.floor(index / 20) * 0.095,
|
||||
};
|
||||
}
|
||||
card.hass = { ...card.hass, states };
|
||||
|
||||
const staticTasks = longTaskWindow();
|
||||
const staticStarted = performance.now();
|
||||
await window.__hpTest.setServerConfig((cfg) => ({
|
||||
...cfg,
|
||||
settings: { ...cfg.settings, show_device_battery: true, volumetric_view: false },
|
||||
markers,
|
||||
}));
|
||||
await window.__hpTest.setLayout((current) => ({ ...current, ...layout }));
|
||||
await frame();
|
||||
const staticMs = performance.now() - staticStarted;
|
||||
const staticLongTasks = await staticTasks.stop();
|
||||
|
||||
const roots = [...card.shadowRoot.querySelectorAll('[data-hp="device"] .device-battery')];
|
||||
const filteredIcons = roots.filter((root) => {
|
||||
const icon = root.querySelector('ha-icon.device-battery-icon');
|
||||
return icon && getComputedStyle(icon).filter.includes('drop-shadow');
|
||||
});
|
||||
const stage = card.shadowRoot.querySelector('.stage');
|
||||
const camera = card.shadowRoot.querySelector(
|
||||
'[data-hp-live-viewbox="camera"], [data-hp-live-viewbox="floor"], .zoomwrap > svg',
|
||||
);
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const beforeViewBox = camera?.getAttribute('viewBox') || '';
|
||||
const cameraTasks = longTaskWindow();
|
||||
const cameraStarted = performance.now();
|
||||
const pointerId = 806;
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
clientX: rect.left + 220, clientY: rect.top + 220, button: 0, buttons: 1,
|
||||
bubbles: true, composed: true, pointerId, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
for (let index = 0; index < 20; index++) {
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 225 + index * 3, clientY: rect.top + 223 + index,
|
||||
button: 0, buttons: 1, bubbles: true, composed: true,
|
||||
pointerId, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
}
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', {
|
||||
clientX: rect.left + 285, clientY: rect.top + 243, button: 0, buttons: 0,
|
||||
bubbles: true, composed: true, pointerId, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
stage.dispatchEvent(new WheelEvent('wheel', {
|
||||
deltaY: -120,
|
||||
clientX: rect.left + rect.width / 2,
|
||||
clientY: rect.top + rect.height / 2,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
await window.__hpTest.settled();
|
||||
await frame();
|
||||
const cameraMs = performance.now() - cameraStarted;
|
||||
const cameraLongTasks = await cameraTasks.stop();
|
||||
const afterViewBox = camera?.getAttribute('viewBox') || '';
|
||||
|
||||
return {
|
||||
roots: roots.length,
|
||||
filteredIcons: filteredIcons.length,
|
||||
staticMs,
|
||||
staticLongTasks,
|
||||
cameraMs,
|
||||
cameraLongTasks,
|
||||
cameraMoved: !!beforeViewBox && beforeViewBox !== afterViewBox,
|
||||
rootsAfterCamera: card.shadowRoot.querySelectorAll('[data-hp="device"] .device-battery').length,
|
||||
};
|
||||
});
|
||||
|
||||
const checks = {};
|
||||
checks.rendersExactly200ShadowedIndicators = measured.roots === 200 && measured.filteredIcons === 200;
|
||||
checks.staticFrameStaysWithinExistingBudget = measured.staticMs <= 3400
|
||||
&& (!measured.staticLongTasks.supported || (measured.staticLongTasks.maxMs <= 3000
|
||||
&& measured.staticLongTasks.count <= 30 && measured.staticLongTasks.totalMs <= 12000));
|
||||
checks.panZoomStaysWithinExistingBudget = measured.cameraMoved && measured.cameraMs <= 500
|
||||
&& measured.rootsAfterCamera === 200
|
||||
&& (!measured.cameraLongTasks.supported || (measured.cameraLongTasks.maxMs <= 150
|
||||
&& measured.cameraLongTasks.count <= 3 && measured.cameraLongTasks.totalMs <= 300));
|
||||
Object.assign(out, checks);
|
||||
out.metrics = measured;
|
||||
checkAll(checks);
|
||||
} catch (error) {
|
||||
out.error = String(error?.stack || error);
|
||||
checkAll({ batteryShadowPerformanceCompleted: false });
|
||||
}
|
||||
await finish(browser, out);
|
||||
@@ -122,6 +122,8 @@ const out = await page.evaluate(async () => {
|
||||
q('.hpf-iconclear').click(); await upd();
|
||||
o.clearResetsIcon = c._markerDialog.icon === '';
|
||||
o.displaySelectWithHint = !!q('#marker-display.hpf-select') && !!q('#marker-display')?.closest('.hpf-field')?.querySelector('.hpf-hint');
|
||||
o.hideBatteryIsAnAppearanceToggle = !!q('#marker-hide-battery')
|
||||
&& !!q('#marker-hide-battery')?.closest('.hpf-toggle.hpf-compact');
|
||||
o.badgeBlock = !!q('.markerbadgegroup.hpf-block .hpf-sub h4') && !!q('#marker-value-badge')?.closest('.hpf-toggle.hpf-compact');
|
||||
if (!q('#marker-value-badge').checked && !q('#marker-value-badge').disabled) { q('#marker-value-badge').click(); await upd(); }
|
||||
o.badgePositionIsASegment = qa('input[name="marker-value-badge-position"]').length === 4;
|
||||
@@ -178,6 +180,10 @@ const out = await page.evaluate(async () => {
|
||||
c._closeMarkerDialog(); await upd();
|
||||
c._openMarkerDialog(lamp); await upd();
|
||||
o.saveDisabledWhenClean = saveBtn().disabled === true && statusText() === '';
|
||||
q('#marker-hide-battery').click(); await upd();
|
||||
o.hideBatteryWritesOwnKeyAndMakesDirty = c._markerDialog.hideBattery === true && saveBtn().disabled === false;
|
||||
q('#marker-hide-battery').click(); await upd();
|
||||
o.hideBatteryRevertsToClean = c._markerDialog.hideBattery === false && saveBtn().disabled === true;
|
||||
const editName0 = c._markerDialog.name;
|
||||
input(q('#marker-name'), `${editName0} x`); await upd();
|
||||
o.saveEnabledWhenDirtyWithoutDuplicateStatus = saveBtn().disabled === false && statusText() === '';
|
||||
|
||||
@@ -232,9 +232,13 @@ values come only from active state rows. Entity-bound markers explicitly capture
|
||||
their physical sensor siblings in the render snapshot, so battery-only ticks
|
||||
invalidate the correct frame without changing the functional roster or actions.
|
||||
The default-on shared `settings.show_device_battery` switch does not depend on
|
||||
face static/live policy; active LED representations omit the indicator. The
|
||||
face static/live policy; exact `marker.hide_battery:true` adds a per-marker
|
||||
O(1) gate before the battery resolver, while absence/false inherits the global
|
||||
choice. Active LED representations omit the indicator. The
|
||||
passive out-of-flow frame uses one built-in HA MDI icon at the three designer
|
||||
control sizes, without separate artwork delivery. Zigbee routes and captions have separate stacking levels: endpoint cores
|
||||
control sizes, without separate artwork delivery. A continuously sized CSS
|
||||
drop-shadow is composited on that existing icon; it adds no DOM, observer or
|
||||
registry scan. Zigbee routes and captions have separate stacking levels: endpoint cores
|
||||
remain above routes, captions remain above their batteries.
|
||||
|
||||
Attachments are staged in `up_*`, promoted into `<config>/houseplan/files/<id>/`
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Battery icons now keep their colour readable over light and mixed plan
|
||||
backgrounds with a soft size-aware shadow. Each device also has a **Hide
|
||||
battery status on plan** switch, so one indicator can be suppressed without
|
||||
disabling the default-on global battery display for every device
|
||||
([#806](https://github.com/Matysh/houseplan-card/issues/806)).
|
||||
|
||||
## v1.80.0-beta.5 — 2026-10-06
|
||||
|
||||
- Battery-powered devices now show a compact charge indicator: green at 60–100%,
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Иконки заряда лучше читаются на светлом и неоднородном фоне благодаря мягкой
|
||||
тени, которая масштабируется вместе с батарейкой. В настройках каждого
|
||||
устройства появился переключатель **«Скрыть отображение заряда на плане»**:
|
||||
он скрывает только выбранное устройство, не отключая глобальную индикацию
|
||||
остальных ([#806](https://github.com/Matysh/houseplan-card/issues/806)).
|
||||
|
||||
## v1.80.0-beta.5 — 2026-10-06
|
||||
|
||||
- У батарейных устройств появилась компактная индикация заряда: зелёная при
|
||||
|
||||
@@ -134,6 +134,13 @@ older backends preserve it through the unknown-settings policy. There is no
|
||||
model/store migration and no persisted charge or per-marker source selection:
|
||||
the own-device source and its current state are resolved from HA at runtime.
|
||||
|
||||
`marker.hide_battery` is an optional strict boolean added by #806. Only exact
|
||||
`true` suppresses the passive battery for that marker; absence and `false`
|
||||
inherit the global default-on policy. The device dialog stores the default as
|
||||
absence. Full backup/import and the privacy-safe support projection retain the
|
||||
boolean. Older clients ignore it, so no model/store version migration is
|
||||
required. It does not select a battery entity or persist charge state.
|
||||
|
||||
## 2.5D View setting (#649)
|
||||
|
||||
`settings.volumetric_view` is an optional global boolean for the whole
|
||||
|
||||
@@ -11,6 +11,10 @@ No separate runtime SVG files, icon dependency or asset-serving route is added.
|
||||
Scope size rules to `.device-battery-icon`; the main device glyph's `ha-icon`
|
||||
size must not leak into this independent frame. Demo fixtures use the same MDI
|
||||
paths so pixel tests do not substitute an emoji or an arbitrary test symbol.
|
||||
The #806 shadow is interpolated from the approved 19px control
|
||||
`(.7px 1.8px 1.3px)` to the 56px control `(1.6px 3.6px 3.2px)`, with a 75%
|
||||
black alpha in between. Keep it on the existing icon and reserve the blurred
|
||||
extent in preview fit bounds; do not add a wrapper, hit target or observer.
|
||||
|
||||
The owner also approved a size-budget recalibration. Fresh source builds move
|
||||
initial View gzip from 301037 to 302532 bytes (+1495); the existing rolling
|
||||
@@ -311,6 +315,10 @@ npm run audit:config -- path/to/houseplan-config.json
|
||||
# Reproducible synthetic large-house report (seven measured samples + warm-up).
|
||||
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
|
||||
|
||||
# #806: one-room browser stress with 200 painted battery shadows. It applies
|
||||
# the existing 3400 ms static, 500 ms pan+zoom and 150 ms camera Long Task limits.
|
||||
node demo/smoke_device_battery_performance.mjs
|
||||
|
||||
# Hidden isometric profile; diagnostic only outside exact-SHA Linux CI.
|
||||
npm run benchmark:large-house-isometric -- --samples=7 --warmups=1 --output=artifacts/performance/isometric-local.json
|
||||
|
||||
|
||||
@@ -46,6 +46,10 @@ unknown. Двоичный `on` означает low, `off` normal. Неизве
|
||||
ждёт изменения основного датчика или пересборки roster.
|
||||
|
||||
`settings.show_device_battery` отсутствует — включено, exact false — выключено.
|
||||
`marker.hide_battery:true` сильнее глобального default-on и скрывает батарею
|
||||
только этого маркера; отсутствие/false сохраняет общее поведение. Тот же
|
||||
предикат используется в View, 2.5D, preview и static space card, поэтому draft
|
||||
диалога не расходится с сохранённым представлением.
|
||||
Батарея намеренно независима от static/live policy (`static_icon`,
|
||||
`value_static_icon`, `live_states:false`); прочие diagnostic gates не меняются.
|
||||
Lifecycle hidden/disabled/orphaned и виртуальные маркеры не оживляют индикатор;
|
||||
@@ -56,6 +60,9 @@ user-hidden design preview сохраняет реальный дизайн. LED
|
||||
`ha-icon`: `mdi:battery`, `mdi:battery-30`, `mdi:battery-outline`,
|
||||
`mdi:battery-unknown`; отличие заполнения жёлтой и внутренний `?` разрешены.
|
||||
Цвета, размеры фрейма и отступы берутся из макета, отдельные SVG не поставляются.
|
||||
На сам `ha-icon` наложена одобренная мягкая `drop-shadow`: контрольные точки
|
||||
0.7/1.8/1.3 px при фрейме 19 px и 1.6/3.6/3.2 px при 56 px, между ними —
|
||||
непрерывное масштабирование. Тень пассивна и не расширяет hit area.
|
||||
|
||||
Свидетели: `test/device-battery.test.mjs`, `test/device-presentation.test.mjs`,
|
||||
`test/render-device-snapshot.test.mjs`; browser/golden — матрица батарей #792.
|
||||
|
||||
+5
-1
@@ -1431,7 +1431,11 @@ information appears above it.
|
||||
|
||||
**General settings → Display → Show device battery status** is on by default
|
||||
and applies installation-wide, including static space cards and the editor
|
||||
preview. Full configuration export/import preserves the setting.
|
||||
preview. In an individual device's **Appearance** settings, **Hide battery
|
||||
status on plan** suppresses only that device; it is off by default and has no
|
||||
effect on the ordinary value badge. Battery icons use a soft size-aware shadow
|
||||
to stay legible over light and mixed plan backgrounds. Full configuration
|
||||
export/import preserves both settings.
|
||||
|
||||
### Icon changes by state
|
||||
|
||||
|
||||
@@ -1590,7 +1590,11 @@ Glow и заливка «Свет» продолжают работать по
|
||||
|
||||
Общие настройки → **Отображение → Показывать заряд устройств** включено по
|
||||
умолчанию. Переключатель сохраняется для всей установки, включая карточки
|
||||
пространств и предпросмотр, и переносится с полным экспортом настроек.
|
||||
пространств и предпросмотр. В разделе **«Внешний вид»** настроек отдельного
|
||||
устройства можно включить **«Скрыть отображение заряда на плане»** — это скроет
|
||||
только его батарейку, не обычный бейдж со значением. По умолчанию локальное
|
||||
скрытие выключено. Мягкая масштабируемая тень сохраняет читаемость иконки на
|
||||
светлом и неоднородном фоне. Обе настройки переносятся с полным экспортом.
|
||||
|
||||
Цвет числа Zigbee LQI под маркером плавно меняется от красного при слабом
|
||||
сигнале к зелёному при сильном. Числовое значение и градиент LQI-заливки
|
||||
|
||||
@@ -6,6 +6,17 @@ export function deviceBatteryGeometry(shellDiameter: number): { frame: number; g
|
||||
return { frame: 33 + (d - 56) * 23 / 40, gap: 4 + (d - 56) * 3 / 40 };
|
||||
}
|
||||
|
||||
/** Approved #806 shadow control points, scaled continuously with the MDI frame. */
|
||||
export function deviceBatteryShadow(frame: number): { x: number; y: number; blur: number } {
|
||||
const size = Number.isFinite(frame) ? Math.max(0, frame) : 0;
|
||||
if (size === 0) return { x: 0, y: 0, blur: 0 };
|
||||
return {
|
||||
x: size * 0.024324324324 + 0.237837837844,
|
||||
y: size * 0.048648648649 + 0.875675675669,
|
||||
blur: size * 0.051351351351 + 0.324324324331,
|
||||
};
|
||||
}
|
||||
|
||||
export interface DeviceBatteryFaceBounds {
|
||||
left: number;
|
||||
right: number;
|
||||
@@ -19,11 +30,16 @@ export function withDeviceBatteryBounds(
|
||||
shellDiameter: number,
|
||||
): DeviceBatteryFaceBounds {
|
||||
const { frame, gap } = deviceBatteryGeometry(shellDiameter);
|
||||
const shadow = deviceBatteryShadow(frame);
|
||||
// CSS drop-shadow blur is a standard deviation. Reserve three sigmas in the
|
||||
// preview fit so the approved soft edge is never cut by its overflow guard.
|
||||
const spread = shadow.blur * 3;
|
||||
const center = (shell.top + shell.bottom) / 2;
|
||||
const batteryLeft = shell.right + gap;
|
||||
return {
|
||||
left: shell.left,
|
||||
right: shell.right + gap + frame,
|
||||
top: Math.min(shell.top, center - frame / 2),
|
||||
bottom: Math.max(shell.bottom, center + frame / 2),
|
||||
left: Math.min(shell.left, batteryLeft + shadow.x - spread),
|
||||
right: Math.max(shell.right, batteryLeft + frame + shadow.x + spread),
|
||||
top: Math.min(shell.top, center - frame / 2 + shadow.y - spread),
|
||||
bottom: Math.max(shell.bottom, center + frame / 2 + shadow.y + spread),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,3 +8,12 @@ export function writeDeviceBatterySetting(settings: { show_device_battery?: unkn
|
||||
if (on) delete settings.show_device_battery;
|
||||
else settings.show_device_battery = false;
|
||||
}
|
||||
|
||||
export function showMarkerBatteryOf(marker: { hide_battery?: unknown } | null | undefined): boolean {
|
||||
return marker?.hide_battery !== true;
|
||||
}
|
||||
|
||||
/** Default false is represented by absence, keeping old marker configs byte-small. */
|
||||
export function markerBatteryFields(hidden: boolean): { hide_battery: true } | Record<string, never> {
|
||||
return hidden ? { hide_battery: true } : {};
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
createDeviceBatteryContext, resolveDeviceBattery,
|
||||
type DeviceBatteryContext, type ResolvedDeviceBattery,
|
||||
} from './device-battery';
|
||||
import { showMarkerBatteryOf } from './device-battery-settings';
|
||||
import {
|
||||
resolveDeviceValueBadge, resolveValueSource, valueBadgeSourceKey,
|
||||
type ResolvedValueBadge,
|
||||
@@ -756,7 +757,7 @@ export function resolveDevicePresentation(
|
||||
vacuumLiveRequested: d.marker?.vacuum?.live === true,
|
||||
});
|
||||
const { effectiveHidden, visual } = policy;
|
||||
const battery = options.showBattery !== false && !effectiveHidden
|
||||
const battery = options.showBattery !== false && showMarkerBatteryOf(d.marker) && !effectiveHidden
|
||||
? resolveDeviceBattery(d, options.batteryContext
|
||||
|| createDeviceBatteryContext(hass, options.registryHass || hass)) : null;
|
||||
|
||||
|
||||
@@ -636,6 +636,12 @@ export function renderMarkerDialog(this: HouseplanEditorRuntime): TemplateResult
|
||||
</select>`,
|
||||
hint: t(DISPLAY_HINT_KEYS[d.display]),
|
||||
})}
|
||||
${toggleRow({
|
||||
id: 'marker-hide-battery', compact: true, icon: 'mdi:battery-off-outline',
|
||||
title: t('marker.hide_battery'),
|
||||
checked: d.hideBattery,
|
||||
onChange: (hidden) => (this.host._markerDialog = { ...d, hideBattery: hidden }),
|
||||
})}
|
||||
${d.display === 'icon_ripple'
|
||||
? html`${field({
|
||||
label: t('marker.activity_color'),
|
||||
|
||||
@@ -2174,6 +2174,7 @@ export class HouseplanCard extends LitElement {
|
||||
originalHasValueSource: boolean;
|
||||
originalValueSource: ValueBadgeSource | null | undefined;
|
||||
useClimateTemp: boolean; // badge + room-average vote from climate current_temperature
|
||||
hideBattery: boolean; // per-marker override; global setting remains authoritative
|
||||
model: string;
|
||||
link: string;
|
||||
description: string;
|
||||
|
||||
@@ -112,7 +112,9 @@ import {
|
||||
} from './space-dialog';
|
||||
import { rememberSpaceDialogBaseline, spaceDialogProblems } from './editors/space-form-state';
|
||||
import { generalProblems, moonDraftOf, rememberGeneralBaseline, writeMoonSetting } from './editors/general-form-state';
|
||||
import { showDeviceBatteryOf, writeDeviceBatterySetting } from './device-battery-settings';
|
||||
import {
|
||||
markerBatteryFields, showDeviceBatteryOf, writeDeviceBatterySetting,
|
||||
} from './device-battery-settings';
|
||||
import { openMoonStatus } from './editors/moon-status';
|
||||
import { rememberRoomBaseline } from './editors/room-form-state';
|
||||
import { forgetMarkerBaseline, rememberMarkerBaseline } from './editors/marker-form-state';
|
||||
@@ -615,7 +617,7 @@ export interface HouseplanEditorHostPort {
|
||||
_layout: DeviceLayout;
|
||||
readonly _layoutRev: number;
|
||||
_logicalViewCenter: () => { x: number; y: number; } | null;
|
||||
_markerDialog: { devId?: string; uploadId?: string; name: string; binding: string; bindingMode: "virtual" | "ha"; bindingOpen: boolean; showEntities: boolean; bindingFilter: string; icon: string; autoIcon: string; display: DeviceDisplayMode; rippleColor: string; rippleSize: number; size: number; angle: number; tapAction: string; tapActionTouched: boolean; originalHasTapAction: boolean; originalTapAction: string | null | undefined; tapHintAnnouncement: string; toggleEntity: string; toggleEntityTouched: boolean; originalHasToggleEntity: boolean; originalToggleEntity: string | null | undefined; tapTarget: string; tapConfirm: boolean; runFilter: string; controls: string[]; controlsFilter: string; glowRadius: string; lightRole: "auto" | "always" | "never"; lightRoleTouched: boolean; originalHasIsLight: boolean; originalIsLight: boolean | null | undefined; lightEntity: string; lightEntityTouched: boolean; originalHasLightEntity: boolean; originalLightEntity: string | null | undefined; glowMode: "auto" | "color" | "fixed"; glowColor: string; glowBrightness: number; glowColorDrafted: boolean; glowBrightnessDrafted: boolean; glowTouched: boolean; originalHasGlowColor: boolean; originalGlowColor: { c: string; bri?: number | null; } | null | undefined; valueBadgeEnabled: boolean; valueBadgeSource: ValueBadgeSource | null; valueBadgePosition: ValueBadgePosition; valueBadgeTouched: boolean; originalHasValueBadge: boolean; originalValueBadge: MarkerValueBadge | null | undefined; valueSource: ValueBadgeSource | null; valueSourceTouched: boolean; originalHasValueSource: boolean; originalValueSource: ValueBadgeSource | null | undefined; useClimateTemp: boolean; model: string; link: string; description: string; pdfs: PdfRef[]; room: string; roomTouched: boolean; radar: RadarEditorDraft | null; radarEligible: boolean; radarTouched: boolean; radarRemove: boolean; hideFromPlan: boolean; busy: boolean; } | null;
|
||||
_markerDialog: { devId?: string; uploadId?: string; name: string; binding: string; bindingMode: "virtual" | "ha"; bindingOpen: boolean; showEntities: boolean; bindingFilter: string; icon: string; autoIcon: string; display: DeviceDisplayMode; rippleColor: string; rippleSize: number; size: number; angle: number; tapAction: string; tapActionTouched: boolean; originalHasTapAction: boolean; originalTapAction: string | null | undefined; tapHintAnnouncement: string; toggleEntity: string; toggleEntityTouched: boolean; originalHasToggleEntity: boolean; originalToggleEntity: string | null | undefined; tapTarget: string; tapConfirm: boolean; runFilter: string; controls: string[]; controlsFilter: string; glowRadius: string; lightRole: "auto" | "always" | "never"; lightRoleTouched: boolean; originalHasIsLight: boolean; originalIsLight: boolean | null | undefined; lightEntity: string; lightEntityTouched: boolean; originalHasLightEntity: boolean; originalLightEntity: string | null | undefined; glowMode: "auto" | "color" | "fixed"; glowColor: string; glowBrightness: number; glowColorDrafted: boolean; glowBrightnessDrafted: boolean; glowTouched: boolean; originalHasGlowColor: boolean; originalGlowColor: { c: string; bri?: number | null; } | null | undefined; valueBadgeEnabled: boolean; valueBadgeSource: ValueBadgeSource | null; valueBadgePosition: ValueBadgePosition; valueBadgeTouched: boolean; originalHasValueBadge: boolean; originalValueBadge: MarkerValueBadge | null | undefined; valueSource: ValueBadgeSource | null; valueSourceTouched: boolean; originalHasValueSource: boolean; originalValueSource: ValueBadgeSource | null | undefined; useClimateTemp: boolean; hideBattery: boolean; model: string; link: string; description: string; pdfs: PdfRef[]; room: string; roomTouched: boolean; radar: RadarEditorDraft | null; radarEligible: boolean; radarTouched: boolean; radarRemove: boolean; hideFromPlan: boolean; busy: boolean; } | null;
|
||||
_markerPreviewDevicesMemo: { base: readonly DevItem[]; preview: DevItem; devices: readonly DevItem[]; } | null;
|
||||
_markerPreviewMemo: { key: string; device: DevItem | null; } | null;
|
||||
_markers: Marker[];
|
||||
@@ -7292,6 +7294,7 @@ public _openMarkerDialog(d?: DevItem): void {
|
||||
originalHasValueSource: hasValueSource,
|
||||
originalValueSource: marker?.value_source,
|
||||
useClimateTemp: d.marker?.use_climate_temp === true,
|
||||
hideBattery: d.marker?.hide_battery === true,
|
||||
glowRadius: Number(d.marker?.glow_radius_cm) > 0
|
||||
? String(this.host._imperial
|
||||
? Math.round((Number(d.marker!.glow_radius_cm) / 30.48) * 10) / 10
|
||||
@@ -7334,7 +7337,7 @@ public _openMarkerDialog(d?: DevItem): void {
|
||||
valueBadgeTouched: false, originalHasValueBadge: false, originalValueBadge: undefined,
|
||||
valueSource: null, valueSourceTouched: false,
|
||||
originalHasValueSource: false, originalValueSource: undefined,
|
||||
useClimateTemp: false, glowRadius: '', model: '',
|
||||
useClimateTemp: false, hideBattery: false, glowRadius: '', model: '',
|
||||
link: '', description: '', pdfs: [], room: '', roomTouched: false,
|
||||
radar: null, radarEligible: false, radarTouched: false, radarRemove: false,
|
||||
hideFromPlan: false, busy: false,
|
||||
@@ -7626,6 +7629,7 @@ public async _saveMarker(): Promise<void> {
|
||||
...this._markerValueBadgeFields(dlg),
|
||||
...this._markerValueSourceFields(dlg),
|
||||
use_climate_temp: dlg.useClimateTemp ? true : null,
|
||||
...markerBatteryFields(dlg.hideBattery),
|
||||
glow_radius_cm: (() => {
|
||||
const v = strictNumber(dlg.glowRadius);
|
||||
if (v == null || v <= 0) return null;
|
||||
@@ -11638,6 +11642,7 @@ public _markerDraft(d: NonNullable<HouseplanEditorHostPort['_markerDialog']>): M
|
||||
...this._markerValueBadgeFields(d),
|
||||
...this._markerValueSourceFields(d),
|
||||
use_climate_temp: d.useClimateTemp ? true : null,
|
||||
...markerBatteryFields(d.hideBattery),
|
||||
glow_radius_cm: (() => {
|
||||
const value = strictNumber(d.glowRadius);
|
||||
if (value == null || value <= 0) return null;
|
||||
|
||||
@@ -832,6 +832,7 @@
|
||||
"room.unnamed": "Unbenannter Raum",
|
||||
"marker.use_climate_temp": "Geräte-Temperatur im Raum berücksichtigen",
|
||||
"marker.use_climate_temp_tip": "Fügt die aktuelle Temperatur einer Klimaanlage oder eines Thermostats zum Raumdurchschnitt hinzu. Das externe Wert-Label unten separat konfigurieren.",
|
||||
"marker.hide_battery": "Batteriestatus auf dem Grundriss ausblenden",
|
||||
"marker.value_badge_title": "Wert-Label",
|
||||
"marker.value_badge.help": "Zeigt einen ausgewählten Wert neben dem Symbol an. Dies beeinflusst nicht die Raumkennzahlen, Licht, Glanz oder die Tippaktion.",
|
||||
"marker.value_badge.help.aria": "Hilfe: Gerätewert-Badge",
|
||||
|
||||
@@ -832,6 +832,7 @@
|
||||
"room.unnamed": "Unnamed room",
|
||||
"marker.use_climate_temp": "Include the device temperature in the room",
|
||||
"marker.use_climate_temp_tip": "Adds an air conditioner or thermostat current_temperature to the room average. Configure the external value badge separately below.",
|
||||
"marker.hide_battery": "Hide battery status on plan",
|
||||
"marker.value_badge_title": "Value badge",
|
||||
"marker.value_badge.help": "Shows one selected value next to the icon. It does not affect room metrics, light, Glow or the tap action.",
|
||||
"marker.value_badge.help.aria": "Help: device value badge",
|
||||
|
||||
@@ -832,6 +832,7 @@
|
||||
"room.unnamed": "Pièce sans nom",
|
||||
"marker.use_climate_temp": "Inclure la température de l’appareil dans la pièce",
|
||||
"marker.use_climate_temp_tip": "Ajoute la valeur current_temperature d’un climatiseur ou thermostat à la moyenne de la pièce. Configurez séparément le badge de valeur externe ci-dessous.",
|
||||
"marker.hide_battery": "Masquer l’état de la batterie sur le plan",
|
||||
"marker.value_badge_title": "Badge de valeur",
|
||||
"marker.value_badge.help": "Affiche une valeur sélectionnée à côté de l’icône. Cela n’affecte pas les mesures de la pièce, la lumière, le halo ni l’action au toucher.",
|
||||
"marker.value_badge.help.aria": "Aide : badge de valeur de l’appareil",
|
||||
|
||||
@@ -832,6 +832,7 @@
|
||||
"room.unnamed": "Комната без имени",
|
||||
"marker.use_climate_temp": "Учитывать температуру устройства в комнате",
|
||||
"marker.use_climate_temp_tip": "Добавляет current_temperature кондиционера или термостата в среднюю температуру комнаты. Внешний бейдж со значением настраивается отдельно ниже.",
|
||||
"marker.hide_battery": "Скрыть отображение заряда на плане",
|
||||
"marker.value_badge_title": "Бейдж со значением",
|
||||
"marker.value_badge.help": "Показывает одно выбранное значение рядом со значком. Не влияет на комнатные показатели, свет, Glow и действие по нажатию.",
|
||||
"marker.value_badge.help.aria": "Подсказка: бейдж со значением устройства",
|
||||
|
||||
@@ -253,6 +253,9 @@ export const devicesStyles = css`
|
||||
.device-battery > ha-icon.device-battery-icon {
|
||||
/* Override the main device glyph's .55 sizing, not its geometry. */
|
||||
--mdc-icon-size: var(--battery-frame);
|
||||
--battery-shadow-x: calc(var(--battery-frame) * .024324324324 + .237837837844px);
|
||||
--battery-shadow-y: calc(var(--battery-frame) * .048648648649 + .875675675669px);
|
||||
--battery-shadow-blur: calc(var(--battery-frame) * .051351351351 + .324324324331px);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
@@ -263,6 +266,8 @@ export const devicesStyles = css`
|
||||
line-height: 0;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(var(--battery-shadow-x) var(--battery-shadow-y)
|
||||
var(--battery-shadow-blur) rgb(0 0 0 / 75%));
|
||||
}
|
||||
.device-shell:not(.with-values):not(.text-shell) .device-shell-frame {
|
||||
border-radius: 50%;
|
||||
|
||||
@@ -251,6 +251,9 @@ export interface Marker {
|
||||
* nothing changes without an explicit tick (owner's spec, 2026-08-03).
|
||||
*/
|
||||
use_climate_temp?: boolean | null;
|
||||
/** Per-device override for the default-on battery diagnostic. Only exact
|
||||
* true hides it; absent/false preserve the global setting. */
|
||||
hide_battery?: boolean;
|
||||
}
|
||||
|
||||
/** One saved answer to "this exact map of this exact source lives here" (#162). */
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { deviceBatteryGeometry, withDeviceBatteryBounds } from '../test-build/device-battery-geometry.js';
|
||||
import {
|
||||
deviceBatteryGeometry, deviceBatteryShadow, withDeviceBatteryBounds,
|
||||
} from '../test-build/device-battery-geometry.js';
|
||||
|
||||
test('#792 battery geometry uses outer-shell diameter and exact designer control points', () => {
|
||||
for (const [diameter, frame, gap] of [[32, 19, 2], [56, 33, 4], [96, 56, 7]]) {
|
||||
@@ -17,6 +19,26 @@ test('#792 battery geometry uses outer-shell diameter and exact designer control
|
||||
}
|
||||
});
|
||||
|
||||
test('#806 approved battery shadow scales continuously through the two reviewed sizes', () => {
|
||||
const close = (actual, expected) => assert.ok(Math.abs(actual - expected) < 1e-9, `${actual} != ${expected}`);
|
||||
for (const [frame, x, y, blur] of [
|
||||
[19, .7, 1.8, 1.3],
|
||||
[56, 1.6, 3.6, 3.2],
|
||||
]) {
|
||||
const shadow = deviceBatteryShadow(frame);
|
||||
close(shadow.x, x);
|
||||
close(shadow.y, y);
|
||||
close(shadow.blur, blur);
|
||||
}
|
||||
assert.deepEqual(deviceBatteryShadow(0), { x: 0, y: 0, blur: 0 });
|
||||
assert.deepEqual(deviceBatteryShadow(NaN), { x: 0, y: 0, blur: 0 });
|
||||
for (let frame = 19; frame < 56; frame++) {
|
||||
const before = deviceBatteryShadow(frame);
|
||||
const after = deviceBatteryShadow(frame + 1);
|
||||
assert.ok(after.x > before.x && after.y > before.y && after.blur > before.blur);
|
||||
}
|
||||
});
|
||||
|
||||
test('#792 fit extent adds the battery to the whole asymmetric capsule without moving the shell', () => {
|
||||
for (const shell of [
|
||||
{ left: -28, right: 28, top: -28, bottom: 28 },
|
||||
@@ -28,9 +50,13 @@ test('#792 fit extent adds the battery to the whole asymmetric capsule without m
|
||||
const copy = { ...shell };
|
||||
const bounds = withDeviceBatteryBounds(shell, 56);
|
||||
assert.deepEqual(shell, copy, 'the frame is read-only');
|
||||
assert.equal(bounds.left, shell.left);
|
||||
assert.equal(bounds.right, shell.right + 4 + 33);
|
||||
assert.equal(bounds.top, shell.top);
|
||||
assert.equal(bounds.bottom, shell.bottom);
|
||||
const { frame, gap } = deviceBatteryGeometry(56);
|
||||
const shadow = deviceBatteryShadow(frame);
|
||||
const spread = shadow.blur * 3;
|
||||
const center = (shell.top + shell.bottom) / 2;
|
||||
assert.equal(bounds.left, Math.min(shell.left, shell.right + gap + shadow.x - spread));
|
||||
assert.equal(bounds.right, Math.max(shell.right, shell.right + gap + frame + shadow.x + spread));
|
||||
assert.equal(bounds.top, Math.min(shell.top, center - frame / 2 + shadow.y - spread));
|
||||
assert.equal(bounds.bottom, Math.max(shell.bottom, center + frame / 2 + shadow.y + spread));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { showDeviceBatteryOf, writeDeviceBatterySetting } from '../test-build/device-battery-settings.js';
|
||||
import {
|
||||
markerBatteryFields, showDeviceBatteryOf, showMarkerBatteryOf, writeDeviceBatterySetting,
|
||||
} from '../test-build/device-battery-settings.js';
|
||||
import { forgetGeneralBaseline, generalDirty, rememberGeneralBaseline } from '../test-build/editors/general-form-state.js';
|
||||
|
||||
test('#792 AC9: only exact false disables the installation-wide battery indicator', () => {
|
||||
@@ -53,3 +55,34 @@ test('#792 AC9: the general battery setting has complete RU/EN/DE/FR wording', (
|
||||
assert.equal(read('ru')['gs.show_device_battery'], 'Показывать заряд устройств');
|
||||
assert.equal(read('en')['gs.show_device_battery'], 'Show device battery status');
|
||||
});
|
||||
|
||||
test('#806 AC2: only exact marker true hides one battery and false is stored as absence', () => {
|
||||
for (const marker of [undefined, null, {}, { hide_battery: false },
|
||||
{ hide_battery: undefined }, { hide_battery: null },
|
||||
{ hide_battery: 'true' }, { hide_battery: 1 }]) {
|
||||
assert.equal(showMarkerBatteryOf(marker), true, JSON.stringify(marker));
|
||||
}
|
||||
assert.equal(showMarkerBatteryOf({ hide_battery: true }), false);
|
||||
assert.deepEqual(markerBatteryFields(false), {});
|
||||
assert.deepEqual(markerBatteryFields(true), { hide_battery: true });
|
||||
});
|
||||
|
||||
test('#806 AC3: the per-device battery opt-out has complete RU/EN/DE/FR wording', () => {
|
||||
const read = (lang) => JSON.parse(readFileSync(new URL(`../src/i18n/${lang}.json`, import.meta.url), 'utf8'));
|
||||
for (const lang of ['ru', 'en', 'de', 'fr']) {
|
||||
assert.ok(read(lang)['marker.hide_battery']?.trim(), `${lang}: marker.hide_battery`);
|
||||
}
|
||||
assert.equal(read('ru')['marker.hide_battery'], 'Скрыть отображение заряда на плане');
|
||||
assert.equal(read('en')['marker.hide_battery'], 'Hide battery status on plan');
|
||||
});
|
||||
|
||||
test('#806 AC1/AC2: production CSS and dialog use the reviewed path', () => {
|
||||
const css = readFileSync(new URL('../src/styles/devices.styles.ts', import.meta.url), 'utf8');
|
||||
assert.match(css, /--battery-shadow-x:\s*calc\(var\(--battery-frame\) \* \.024324324324 \+ \.237837837844px\)/);
|
||||
assert.match(css, /--battery-shadow-y:\s*calc\(var\(--battery-frame\) \* \.048648648649 \+ \.875675675669px\)/);
|
||||
assert.match(css, /--battery-shadow-blur:\s*calc\(var\(--battery-frame\) \* \.051351351351 \+ \.324324324331px\)/);
|
||||
assert.match(css, /filter:\s*drop-shadow\([\s\S]*rgb\(0 0 0 \/ 75%\)\)/);
|
||||
|
||||
const dialog = readFileSync(new URL('../src/editors/marker-dialog.ts', import.meta.url), 'utf8');
|
||||
assert.match(dialog, /id:\s*'marker-hide-battery'[\s\S]*title:\s*t\('marker\.hide_battery'\)[\s\S]*checked:\s*d\.hideBattery/);
|
||||
});
|
||||
|
||||
@@ -80,6 +80,11 @@ test('#792 battery is independent of static face and live-state policies', () =>
|
||||
const hidden = device({ userHidden: true, marker: { id: 'd1', binding: 'device:d1', hidden: true } });
|
||||
assert.equal(resolveDevicePresentation(ha, hidden, options).battery, null);
|
||||
assert.equal(resolveDevicePresentation(ha, hidden, { ...options, designPreview: true }).battery?.state, 'warning');
|
||||
const batteryHidden = device({ marker: { id: 'd1', binding: 'device:d1', hide_battery: true } });
|
||||
assert.equal(resolveDevicePresentation(ha, batteryHidden, options).battery, null);
|
||||
assert.equal(resolveDevicePresentation(ha, batteryHidden, { ...options, designPreview: true }).battery, null);
|
||||
const malformedOptOut = device({ marker: { id: 'd1', binding: 'device:d1', hide_battery: 'true' } });
|
||||
assert.equal(resolveDevicePresentation(ha, malformedOptOut, options).battery?.state, 'warning');
|
||||
for (const kind of ['ha_disabled', 'orphaned']) {
|
||||
assert.equal(resolveDevicePresentation(ha, device({ bindingStatus: { kind } }), options).battery, null);
|
||||
}
|
||||
|
||||
@@ -185,7 +185,8 @@ test('#631 general settings: busy and raw inputs are transient, stored values ar
|
||||
const markerDraft = (patch = {}) => ({
|
||||
binding: 'light.kitchen', bindingMode: 'ha', name: 'Lamp', icon: 'mdi:lamp',
|
||||
tapAction: 'toggle', tapTarget: '', valueBadgeTouched: false, valueBadgeEnabled: false,
|
||||
valueBadgeSource: null, glowRadius: null, originalBinding: 'light.kitchen', ...patch,
|
||||
valueBadgeSource: null, glowRadius: null, hideBattery: false,
|
||||
originalBinding: 'light.kitchen', ...patch,
|
||||
});
|
||||
|
||||
test('#631 marker dialog: exactly the declared transient keys are ignored, Touched/original are not', () => {
|
||||
@@ -204,6 +205,7 @@ test('#631 marker dialog: exactly the declared transient keys are ignored, Touch
|
||||
assert.equal(markerDirty(host, markerDraft({ [k]: v })), false, k);
|
||||
}
|
||||
assert.equal(markerDirty(host, markerDraft({ valueBadgeTouched: true })), true);
|
||||
assert.equal(markerDirty(host, markerDraft({ hideBattery: true })), true);
|
||||
assert.equal(markerDirty(host, markerDraft({ originalBinding: 'light.hall' })), true);
|
||||
assert.equal(markerDirty(host, markerDraft({ name: 'Lamp 2' })), true);
|
||||
assert.equal(markerDraftKey(markerDraft(transient)), markerDraftKey(base));
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""#806 AC2/AC3: strict optional marker opt-out and support projection."""
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
from pure_imports import HOUSEPLAN_ROOT, load_pure
|
||||
|
||||
validation = load_pure("hp_validation_marker_hide_battery", HOUSEPLAN_ROOT / "validation.py")
|
||||
support = load_pure(
|
||||
"custom_components.houseplan.support_package", HOUSEPLAN_ROOT / "support_package.py",
|
||||
)
|
||||
|
||||
|
||||
def _config(marker):
|
||||
return {
|
||||
"spaces": [],
|
||||
"settings": {},
|
||||
"markers": [{"id": "m1", "binding": "virtual", **marker}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [True, False])
|
||||
def test_marker_hide_battery_preserves_explicit_booleans(value):
|
||||
out = validation.CONFIG_SCHEMA(copy.deepcopy(_config({"hide_battery": value})))
|
||||
assert out["markers"][0]["hide_battery"] is value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["false", "true", "yes", 0, 1, None, [], {}])
|
||||
def test_marker_hide_battery_rejects_non_booleans(bad):
|
||||
with pytest.raises(vol.Invalid):
|
||||
validation.CONFIG_SCHEMA(_config({"hide_battery": bad}))
|
||||
|
||||
|
||||
def test_marker_hide_battery_omission_stays_absent_without_migration():
|
||||
out = validation.CONFIG_SCHEMA(_config({"name": "Virtual"}))
|
||||
assert "hide_battery" not in out["markers"][0]
|
||||
|
||||
|
||||
def test_support_projection_carries_the_boolean_without_private_marker_data():
|
||||
for value in (True, False):
|
||||
out = support._project_marker(
|
||||
support._Pseudonyms("battery"),
|
||||
{"id": "m1", "binding": "virtual", "hide_battery": value, "private": "drop"},
|
||||
)
|
||||
assert out["hide_battery"] is value
|
||||
assert "private" not in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["true", 1, None, [], {}])
|
||||
def test_support_projection_omits_malformed_hide_battery(bad):
|
||||
out = support._project_marker(
|
||||
support._Pseudonyms("battery"),
|
||||
{"id": "m1", "binding": "virtual", "hide_battery": bad},
|
||||
)
|
||||
assert "hide_battery" not in out
|
||||
Reference in New Issue
Block a user