filtering: hiding is an explicit per-device flag (docs/FILTERING.md)

Agreed with the owner: whether a device is on the plan is a CHECKBOX
('Hide device from plan', every kind incl. virtual), not a runtime
algorithm. The old filter survives only as the SEEDER of those flags.

- marker.hidden is the flag; hidden devices are BUILT (room LQI counts
  them — owner's decision) but rendered only in the device editor with
  'Show hidden' on, ghosted. They cast no glow and no light fill: an
  invisible device casts no visible light (owner's decision).
- seedHiddenBindings(): non-physical devices (excluded domains, Group,
  scene, bridge, myheat children, grouped lamps) in bound areas WITHOUT a
  marker. The editing client materialises them into hidden:true stub
  markers, sets settings.filter_seeded, retires settings.show_all, and
  strips fresh-hidden ids from the red-dot list. Unticking the checkbox
  keeps a hidden:false marker — the seeder never revisits a marked device,
  so the user's decision is final. New non-physical devices hide silently;
  physical ones keep the red-dot flow.
- legacy configs (no filter_seeded) keep the OLD behaviour verbatim —
  runtime filter, shared show_all, hidden-means-gone — until an editing
  client materialises them, so a read-only tablet never sees a half-state.
- 'Show all' is renamed 'Show hidden' and is LOCAL to the tab; the shared
  settings.show_all retires with the runtime filter.
- 'Remove from plan' disappears for auto/entity devices (the checkbox is
  the way); a virtual device's Delete remains a real deletion.
- docs/FILTERING.md is the source of truth for the mechanism.

Tests: seeder/seeded/legacy/lights units (146), smoke_hidden_flag with 12
assertions (68 smokes). Inventory: 146 / 51 / 43 / 68.
This commit is contained in:
Matysh
2026-07-29 11:35:35 +03:00
parent 996a7442ec
commit 694e1e9a3b
13 changed files with 457 additions and 101 deletions
+62 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue , areaClimate, areaClimateMap, litLightEntity } from '../test-build/devices.js';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue , areaClimate, areaClimateMap, litLightEntity, seedHiddenBindings } from '../test-build/devices.js';
import { compileIconRules, iconFor } from '../test-build/rules.js';
/** Minimal fake hass around the pieces buildDevices reads. */
@@ -524,3 +524,64 @@ test('litLightEntity: one truth for "this thing is shining"', () => {
'switch.wall',
);
});
// ---------- explicit hide-from-plan flags (docs/FILTERING.md) ----------
test('seedHiddenBindings: non-physical devices in bound areas, unmarked only', () => {
const h = mkHass({ devices: {
lamp: dev('lamp', 'Lamp', 'Bulb', 'living'),
bridge: dev('bridge', 'Z2M Bridge', 'Bridge', 'living'),
grp: dev('grp', 'All lights', 'Group', 'living'),
scn: dev('scn', 'Evening', 'Scene switch', 'living'),
faraway: dev('faraway', 'Bridge 2', 'Bridge', 'garage'), // unbound area
}});
const ctx = { hass: h, areaToSpace: { living: 'f1' }, markers: [], settings: {},
excluded: new Set(['hacs']), firstSpaceId: 'f1' };
assert.deepEqual(seedHiddenBindings(ctx).sort(), ['device:bridge', 'device:grp', 'device:scn']);
// a marker of ANY kind — even hidden: false — is the user's decision
const marked = { ...ctx, markers: [
{ id: 'x', binding: 'device:bridge', hidden: false },
{ id: 'y', binding: 'device:grp', hidden: true },
] };
assert.deepEqual(seedHiddenBindings(marked), ['device:scn'], 'marked devices are never revisited');
});
test('seeded config: hidden is a flag, not an absence', () => {
const h = mkHass({ devices: {
lamp: dev('lamp', 'Lamp', 'Bulb', 'living'),
plug: dev('plug', 'Plug', 'Socket', 'living'),
}});
const res = buildDevices(baseCtx(h, {
settings: { filter_seeded: true },
markers: [{ id: 'hplug', binding: 'device:plug', hidden: true }],
}));
const plug = res.find((d) => d.bindingRef === 'plug');
assert.ok(plug, 'the hidden device is BUILT — room LQI still counts it');
assert.equal(plug.hidden, true, 'and flagged for the renderer');
assert.ok(!res.find((d) => d.id === 'lamp').hidden);
});
test('legacy config (no filter_seeded): the runtime filter still applies', () => {
const h = mkHass({ devices: {
lamp: dev('lamp', 'Lamp', 'Bulb', 'living'),
bridge: dev('bridge', 'Hub', 'Bridge', 'living'),
}});
const res = buildDevices(baseCtx(h));
assert.deepEqual(res.map((d) => d.id), ['lamp'], 'a read-only client on an old config sees the old behaviour');
// and a hidden marker still removes the device entirely, as before
const res2 = buildDevices(baseCtx(h, { markers: [{ id: 'x', binding: 'device:lamp', hidden: true }] }));
assert.deepEqual(res2.map((d) => d.id), []);
});
test('hidden lights cast no visible light, but count toward LQI', () => {
const h = mkHass({
devices: {},
states: { 'light.a': { state: 'on', attributes: {} } },
});
const devices = [
{ area: 'living', entities: ['light.a'], hidden: true },
];
assert.equal(areaLights(h, devices, 'living'), 'none', 'an invisible device casts no visible light');
assert.equal(areaLightStats(h, devices, 'living'), null);
});