Files
houseplan-card/test/device-pulse.test.mjs
T
Claude 99e1765d22 feat: режим «Значение + статичный значок» (#588)
Пятый вариант «Отображение»: маркер показывает выбранное значение и при этом
никогда не меняет цвет — ни по состоянию, ни по тревоге, ни по недоступности,
ни под цвет RGB-света, ни по активности.

Четыре прежних режима задавали одним выбором две независимые вещи: что
нарисовано внутри маркера и красится ли он состоянием. Поэтому вместо
сравнения с одним токеном появились два производных предиката рядом с
`normalizeDeviceDisplay` — `displayWantsValue` и `displayIsNeutral`, — и их
спрашивают политика, слой пульсации, внешний бейдж, редактор и три ветки
живого слоя пылесоса.

Две ловушки, из-за которых режим не сводится к одной строке в словаре:

- быстрый путь «статичному маркеру источники не нужны» (`sourceDetails: false`)
  — это основной путь рендера плана, карточки пространства и PDF. Он оставлен
  только режиму без значения: иначе число пропало бы именно на плане и
  осталось в предпросмотре редактора;
- три ветки живого пылесоса сравнивают режим строкой и не читают политику,
  поэтому зелёная политика их не гарантирует. Свидетель держит две
  конфигурации карты: сопоставленная доказывает puck и след, несопоставленная
  — бейдж маршрута (при совпадающей калибровке маршрут `ready`, и бейджа не
  было бы ни в одном режиме). Бейдж проверяется при наполненном буфере
  позиций, иначе его отсутствие объяснялось бы первой веткой.

Потолок initial View перецентрирован 291_700 → 292_400 без изменения общего
бюджета: измеренный факт 291 346 Б оставлял под прежним центром 354 Б —
внутри шумовой полосы метрики.

Эталон: в `device-icon-state-table-{light,dark}` включённая RGB-лампа
переведена в новый режим. Кадры обязаны разойтись; приёмка — отдельным
коммитом класса D с полного линуксового артефакта Validate.

Issue: #588
User-Visible: yes
2026-09-18 13:40:57 +03:00

115 lines
4.8 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { resolveDevicePulse } from '../test-build/device-pulse.js';
const visual = (status = 'neutral', activity = 'none') => ({
availability: 'available', status, activity,
});
const resolve = (overrides = {}) => resolveDevicePulse({
display: 'icon_ripple', visual: visual(), semanticActivity: 'none',
liveStates: true, effectiveHidden: false, now: 1_000,
...overrides,
});
test('pulse priority is hidden/static, alarm, finite event, continuous state', () => {
assert.equal(resolve({ effectiveHidden: true, visual: visual('alarm') }).kind, 'none');
assert.equal(resolve({ display: 'static_icon', visual: visual('alarm') }).kind, 'none');
// #588 AC4: значение внутри маркера не возвращает ему ни тревожную пульсацию,
// ни обычную — нейтральность принадлежит цвету, а не лицу.
assert.equal(resolve({ display: 'value_static_icon', visual: visual('alarm') }).kind, 'none');
assert.equal(resolve({
display: 'value_static_icon', semanticActivity: 'presence',
}).kind, 'none');
assert.equal(resolve({ display: 'badge', visual: visual('alarm') }).kind, 'alarm');
assert.equal(resolve({
shortReason: 'event', shortExpiresAt: 2_000, semanticActivity: 'running',
}).kind, 'short');
assert.equal(resolve({ semanticActivity: 'presence' }).kind, 'continuous');
});
test('ordinary activity belongs only to icon plus activity mode', () => {
for (const display of ['badge', 'value']) {
assert.equal(resolve({ display, semanticActivity: 'running' }).kind, 'none');
}
assert.equal(resolve({ display: 'icon_ripple', semanticActivity: 'running' }).kind, 'continuous');
assert.equal(resolve({ liveStates: false, semanticActivity: 'running' }).kind, 'none');
});
test('critical alarm survives live-state and face-mode gates but not disabled binding', () => {
assert.equal(resolve({ liveStates: false, visual: visual('alarm') }).kind, 'alarm');
for (const display of ['badge', 'value']) {
assert.equal(resolve({ display, liveStates: false, visual: visual('alarm') }).kind, 'alarm');
}
assert.equal(resolve({ bindingUnavailable: true, visual: visual('alarm') }).kind, 'none');
});
test('reduced motion replaces ordinary waves with a dot and keeps alarm semantic', () => {
const ordinary = resolve({
semanticActivity: 'running', reducedMotion: true,
color: '#123456', diameterScale: 5,
});
assert.equal(ordinary.animated, false);
assert.equal(ordinary.reducedMotionIndicator, 'dot');
assert.equal(ordinary.color, '#123456');
assert.equal(ordinary.diameterScale, 1);
const short = resolve({
shortReason: 'event', shortExpiresAt: 2_000, reducedMotion: true,
});
assert.equal(short.kind, 'short');
assert.equal(short.animated, false);
assert.equal(short.reducedMotionIndicator, 'dot');
assert.equal(short.diameterScale, 1);
const alarm = resolve({
visual: visual('alarm'), reducedMotion: true,
color: '#123456', diameterScale: 5,
});
assert.equal(alarm.kind, 'alarm');
assert.equal(alarm.animated, false);
assert.equal(alarm.reducedMotionIndicator, 'none');
assert.equal(alarm.color, '#F0410C');
assert.equal(alarm.diameterScale, 1.5);
});
test('package defaults map presence, running and neutral transition to distinct colors', () => {
const presence = resolve({ semanticActivity: 'presence' });
assert.equal(presence.color, '#1DC21D');
assert.equal(presence.diameterScale, 1.5);
assert.equal(resolve({ semanticActivity: 'running' }).color, '#F0A00C');
assert.equal(resolve({
semanticActivity: 'transition', visual: visual('neutral', 'transition'),
}).color, '#0C82F0');
assert.equal(resolve({
semanticActivity: 'presence', color: '#123456', diameterScale: 4,
}).color, '#123456');
});
test('expired finite runtime never leaves a static ring', () => {
const pulse = resolve({ shortReason: 'event', shortExpiresAt: 999 });
assert.equal(pulse.kind, 'none');
});
test('inactive projection retains configured preview colour and diameter', () => {
const pulse = resolve({
color: '#123456', diameterScale: 4.25,
});
assert.equal(pulse.kind, 'none');
assert.equal(pulse.color, '#123456');
assert.equal(pulse.diameterScale, 4.25);
});
test('unknown and unavailable device state cannot retain an activity pulse', () => {
for (const kind of ['short', 'continuous']) {
const pulse = resolveDevicePulse({
display: 'icon_ripple',
visual: { availability: 'unavailable', status: 'neutral', activity: 'transition' },
semanticActivity: 'transition',
shortReason: kind === 'short' ? 'transition' : null,
shortExpiresAt: kind === 'short' ? Date.now() + 1_000 : null,
liveStates: true,
effectiveHidden: false,
});
assert.equal(pulse.kind, 'none');
}
});