fix: seven small honesty fixes from the 29.08 audit (#369)

(a) documented: deleting a vacuum marker erases its server trail at once
and a re-added marker starts from scratch (VACUUM.md + both USER-GUIDEs).
(b) smoothVacPath reports dropped non-finite segments — one console warn
per call with the count — instead of hiding the whole trail silently on a
broken calibration matrix. (c) room climate (#317) now reaches legacy
markers whose exported config carries an ABSENT area key rather than an
explicit null: `== null` where the placement is decided. (d) the armed
furniture preview follows Shift without mouse movement — window
keydown/keyup listeners live exactly as long as the palette is armed,
detached at every palette teardown. (e) only the primary mouse button
places decor/furniture: a right or middle click with an armed tool is a
no-op, touch/pen untouched. (f) a device whose registry entities were ALL
deliberately disabled by the user no longer glows as an alive controller —
the #318 entityless-active rule now requires a genuinely empty roster.
(g) furniture-pack author corrected to Sergey Matyunin (Сергей Матюнин)
per the owner's decision — LICENSE.md, README.md, pack.json,
docs/FURNITURE.md, the provenance check in generate-furniture-assets and
its unit; the source archive bytes are unchanged and the README notes the
romanisation fix.

Proofs: units for (b)/(c)/(f) including the #318 regression pair; new
smoke_furniture_polish for (d)/(e) with listener add/remove counters and
both mouse buttons; five registry mutants, one per code change.

Issue: #369
User-Visible: yes
This commit is contained in:
Codex
2026-08-29 11:29:42 +03:00
parent a041934bc2
commit 8ee1c91fff
38 changed files with 551 additions and 212 deletions
+45
View File
@@ -1189,3 +1189,48 @@ test('an incidental cover does not hijack another whole-device role on info tap'
assert.equal(result.sourceKind, 'device_role');
assert.deepEqual(result.visualSources.map((source) => source.eid), ['media_player.screen']);
});
test('issue 369г: устройство с полностью отключёнными сущностями не выглядит живым', () => {
const h = hass({
'light.wall_group': state('light.wall_group', 'on', { friendly_name: 'Wall lights' }),
}, {
'light.wall_group': {
entity_id: 'light.wall_group', device_id: 'lights', platform: 'demo',
},
});
// (i) истинно безростерный active-контроллер — по-прежнему available (#318)
const bare = device({
id: 'bare', name: 'Bare controller', entities: [], primary: null,
bindingRef: 'bare',
bindingStatus: { kind: 'active', enabledEntityIds: [], allEntityIds: [] },
marker: { id: 'bare', binding: 'device:bare', tap_action: 'toggle', controls: ['light.wall_group'] },
});
assert.equal(resolveDevicePresentation(h, bare, options).visual.availability, 'available');
// (ii) реестр непуст, но всё отключено пользователем — НЕ available
const optedOut = device({
id: 'opted', name: 'Opted-out device', entities: [], primary: null,
bindingRef: 'opted',
bindingStatus: {
kind: 'active', enabledEntityIds: [], allEntityIds: ['switch.opted_main'],
},
marker: { id: 'opted', binding: 'device:opted', tap_action: 'toggle', controls: ['light.wall_group'] },
});
assert.notEqual(
resolveDevicePresentation(h, optedOut, options).visual.availability, 'available',
'ростер, сознательно выключенный пользователем, — не свидетельство жизни',
);
// (iii) ha_disabled — как и раньше, не available
const disabled = device({
id: 'disabled', name: 'Disabled device', entities: [], primary: null,
bindingRef: 'disabled',
bindingStatus: {
kind: 'ha_disabled', reason: 'device_disabled',
enabledEntityIds: [], allEntityIds: ['switch.d_main'],
},
marker: { id: 'disabled', binding: 'device:disabled', tap_action: 'toggle', controls: ['light.wall_group'] },
});
// ha_disabled гасится на уровне lifecycle: маркер скрыт и нейтрален целиком
const disabledResult = resolveDevicePresentation(h, disabled, options);
assert.equal(disabledResult.effectiveHidden, true);
assert.equal(disabledResult.visual.status, 'neutral');
});
+23
View File
@@ -2120,3 +2120,26 @@ test('HA-disabled marker is built only as an inert service ghost', () => {
assert.deepEqual(ghost.entities, [], 'stale state cannot feed any runtime consumer');
assert.deepEqual(ghost.allEntities, ['switch.valve']);
});
test('issue 369в: легаси-маркер без ключа area размещается в комнате как с явным null', () => {
const hass = {
devices: { legacy: { id: 'legacy', name: 'Legacy thermometer', area_id: null } },
entities: { 'sensor.legacy_temperature': { device_id: 'legacy', platform: 'demo' } },
states: {
'sensor.legacy_temperature': { state: '19', attributes: { device_class: 'temperature' } },
},
};
// ключ area ОТСУТСТВУЕТ (экспорт/легаси), а не явный null
const absent = [{ id: 'legacy', binding: 'device:legacy', space: 'f1', room_id: 'shed' }];
const map = roomClimateMap(hass, undefined, absent);
assert.deepEqual(map.get('@room/f1/shed'), { temp: 19, hum: null },
'undefined area == null area: комната получает голос');
// явный null даёт тот же ключ (регресс #317)
const explicit = [{ id: 'legacy', binding: 'device:legacy', space: 'f1', room_id: 'shed', area: null }];
assert.deepEqual(roomClimateMap(hass, undefined, explicit).get('@room/f1/shed'),
{ temp: 19, hum: null });
// строковая area по-прежнему побеждает (регресс-защита)
const areaMarker = [{ id: 'legacy', binding: 'device:legacy', space: 'f1', room_id: 'shed', area: 'kitchen' }];
assert.deepEqual(roomClimateMap(hass, undefined, areaMarker).get('kitchen'),
{ temp: 19, hum: null });
});
+1 -1
View File
@@ -44,7 +44,7 @@ test('front-view menu artwork is reachable only through the lazy editor graph',
});
test('release provenance is normalized to the repository MIT grant', () => {
assert.equal(MANIFEST.author, 'Sergey Matyushin (Matysh)');
assert.equal(MANIFEST.author, 'Sergey Matyunin (Matysh)');
assert.equal(MANIFEST.license, 'MIT');
const readme = fs.readFileSync(
path.join(ROOT, 'assets', 'furniture', 'houseplan-0.3.0', 'README.md'), 'utf8');
+18
View File
@@ -458,3 +458,21 @@ test('readVacTelemetry keeps numeric zero map_index as map id (HP-1540-02)', ()
const t = readVacTelemetry({ vacuum_position: { x: 1, y: 2 }, map_index: 0 });
assert.equal(t.mapId, '0');
});
test('smoothVacPath предупреждает один раз о нефинитных сегментах (#369б)', () => {
const warnings = [];
const warn = (message) => warnings.push(message);
const bad = [
[[0, 0], [NaN, 10], [20, 20]],
[[0, 0], [10, Infinity]],
[[0, 0], [10, 10], [20, 0]],
];
const out = smoothVacPath(bad, 17.5, warn);
assert.equal(out.length, 1, 'валидный сегмент выжил');
assert.equal(warnings.length, 1, 'один warn на вызов, не на сегмент');
assert.match(warnings[0], /2 segment/);
assert.match(warnings[0], /non-finite/);
const clean = smoothVacPath([[[0, 0], [10, 10]]], 17.5, warn);
assert.equal(clean.length, 1);
assert.equal(warnings.length, 1, 'чистый путь не шумит');
});