mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-18 18:40:47 -04:00
slorp
This commit is contained in:
@@ -1,127 +1,177 @@
|
||||
// Exercise contracts without starting the app or connecting to Home Assistant.
|
||||
// Exercise metadata discovery and writes without opening any network connection.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { buildEntity, buildCommand } = require('./entityHelpers');
|
||||
const { matchesFilter } = require('./capabilities');
|
||||
const { createActions } = require('./actions');
|
||||
const { createLocks, resolveItem } = require('./locks');
|
||||
const { createHaCommand } = require('../operatorCommandService/commands/ha');
|
||||
const { services } = require('./testFixtures/services');
|
||||
const { assertValidConfig, normalizeConfig } = require('../../configuration/validation');
|
||||
|
||||
const user = { role: 'user', mode: 'open' };
|
||||
const raw = (state, attributes = {}) => ({ state, attributes });
|
||||
const entity = (id, state, attributes = {}, config = {}) => buildEntity({ id, ...config }, raw(state, attributes), services);
|
||||
const getField = (item, service, key) => item.actions.find((action) => action.service === service)?.fields.find((field) => field.key === key);
|
||||
function harness(items, snapshot) {
|
||||
const calls = [];
|
||||
const config = { enabled: true, items };
|
||||
const locked = new Set();
|
||||
const ha = {
|
||||
enabled: true, isConnected: () => true,
|
||||
getServiceDescriptions: () => services,
|
||||
getRawEntitySnapshot: (id) => snapshot[id],
|
||||
callHomeAssistantService: async (...args) => { calls.push(args); },
|
||||
};
|
||||
return { config, ha, calls, locked, actions: createActions({ getConfig: () => config, ha, locks: { isLocked: (id) => locked.has(id) } }) };
|
||||
}
|
||||
|
||||
test('sensor states, live constraints, and unknown button states are preserved', () => {
|
||||
const sensor = buildEntity({ id: 'sensor.humidity' }, raw('46.3', { unit_of_measurement: '%', friendly_name: 'Humidity' }));
|
||||
test('sensor values and configured colors remain independent of writable capabilities', () => {
|
||||
const sensor = entity('sensor.humidity', '46.3', { unit_of_measurement: '%', friendly_name: 'Humidity' }, { color: '#cc4488' });
|
||||
assert.equal(sensor.state, '46.3');
|
||||
assert.equal(sensor.unit, '%');
|
||||
assert.equal(sensor.type, 'readOnly');
|
||||
assert.equal(sensor.name, 'Humidity');
|
||||
assert.equal(buildEntity({ id: 'button.bell' }, raw('unknown')).available, true);
|
||||
assert.equal(buildEntity({ id: 'button.bell' }, raw('unavailable')).available, false);
|
||||
assert.equal(buildEntity({ id: 'button.bell' }, null).available, false);
|
||||
assert.equal(buildEntity({ id: 'switch.fan', readOnly: true }, raw('on')).type, 'readOnly');
|
||||
assert.equal(sensor.color, '#cc4488');
|
||||
assert.deepEqual(sensor.actions, []);
|
||||
assert.equal(entity('button.bell', 'unknown').available, true);
|
||||
assert.equal(entity('button.bell', 'unavailable').available, false);
|
||||
assert.equal(buildEntity({ id: 'button.bell' }, null, services).available, false);
|
||||
assert.deepEqual(entity('fan.room', 'on', { supported_features: 15 }, { readOnly: true }).actions, []);
|
||||
});
|
||||
|
||||
test('domain dispatch handles native entities and helpers without room-light payloads', async () => {
|
||||
const cases = [
|
||||
['light.lamp', 'on', {}, 'turn_on', {}],
|
||||
['switch.fan', 'off', {}, 'turn_off', {}],
|
||||
['input_boolean.enabled', 'on', {}, 'turn_on', {}],
|
||||
['number.speed', 0.3, { min: 0, max: 1, step: 0.1 }, 'set_value', { value: 0.3 }],
|
||||
['input_number.speed', '2', { min: 0, max: 5, step: 1 }, 'set_value', { value: 2 }],
|
||||
['text.message', 'hello', { min: 0, max: 20 }, 'set_value', { value: 'hello' }],
|
||||
['input_text.message', '', { min: 0, max: 20 }, 'set_value', { value: '' }],
|
||||
['select.mode', 'Quiet', { options: ['Quiet'] }, 'select_option', { option: 'Quiet' }],
|
||||
['input_select.mode', 'Quiet', { options: ['Quiet'] }, 'select_option', { option: 'Quiet' }],
|
||||
['button.bell', 'press', {}, 'press', {}],
|
||||
['input_button.bell', 'press', {}, 'press', {}],
|
||||
];
|
||||
// Each domain is exercised through the allowlist and action service rather
|
||||
// than merely asserting that an internal mapping table contains an entry.
|
||||
for (const [id, value, attributes, service, data] of cases) {
|
||||
const h = harness([{ id }], { [id]: raw('unknown', attributes) });
|
||||
if (!id.includes('button')) h.ha.getRawEntitySnapshot = () => raw('off', attributes);
|
||||
await h.actions.act(id, value, user);
|
||||
assert.deepEqual(h.calls, [[id.split('.')[0], service, { entity_id: id, ...data }]]);
|
||||
}
|
||||
test('fan fields follow live features, percentages, options, and irregular state names', () => {
|
||||
const fan = entity('fan.room', 'on', { supported_features: 15, percentage: 50, percentage_step: 25,
|
||||
oscillating: true, current_direction: 'reverse', preset_mode: 'auto', preset_modes: ['auto', 'sleep'] });
|
||||
assert.equal(getField(fan, 'set_percentage', 'percentage').step, 25);
|
||||
assert.equal(getField(fan, 'set_percentage', 'percentage').state, 50);
|
||||
assert.equal(getField(fan, 'oscillate', 'oscillating').state, true);
|
||||
assert.equal(getField(fan, 'set_direction', 'direction').state, 'reverse');
|
||||
assert.deepEqual(getField(fan, 'set_preset_mode', 'preset_mode').options.map((option) => option.value), ['auto', 'sleep']);
|
||||
assert.ok(!entity('fan.simple', 'off', { supported_features: 1 }).actions.some((action) => action.service === 'oscillate'));
|
||||
assert.deepEqual(buildCommand(fan, 'fan.oscillate', { oscillating: false }), { domain: 'fan', service: 'oscillate', data: { oscillating: false, entity_id: 'fan.room' } });
|
||||
});
|
||||
|
||||
test('invalid values fail before an outbound request', () => {
|
||||
const number = buildEntity({ id: 'number.speed' }, raw('0', { min: 0, max: 1, step: 0.1 }));
|
||||
for (const value of ['', ' ', null, false, {}, Infinity, 'NaN', 2, 0.15]) assert.throws(() => buildCommand(number, value));
|
||||
const text = buildEntity({ id: 'text.code' }, raw('ab', { min: 2, max: 4, pattern: '[a-z]+' }));
|
||||
for (const value of ['', 'abcde', 123]) assert.throws(() => buildCommand(text, value));
|
||||
// Pattern interpretation belongs to HA, even when metadata supplies one.
|
||||
assert.equal(buildCommand(text, '12').data.value, '12');
|
||||
assert.throws(() => buildCommand(buildEntity({ id: 'select.mode' }, raw('Quiet', { options: ['Quiet'] })), 'Other'));
|
||||
assert.throws(() => buildCommand(buildEntity({ id: 'sensor.state' }, raw('ok')), 'on'), /read-only/);
|
||||
test('climate ranges use entity limits, nested fields, companion values, and live modes', () => {
|
||||
const climate = entity('climate.room', 'heat_cool', { supported_features: 10, min_temp: 16, max_temp: 28,
|
||||
target_temp_step: 0.5, target_temp_low: 18, target_temp_high: 24, hvac_modes: ['off', 'heat_cool'], fan_modes: ['auto', 'low'], fan_mode: 'auto' });
|
||||
const low = getField(climate, 'set_temperature', 'target_temp_low');
|
||||
assert.deepEqual([low.min, low.max, low.step, low.required], [16, 28, 0.5, true]);
|
||||
assert.equal(getField(climate, 'set_temperature', 'temperature'), undefined);
|
||||
assert.equal(getField(climate, 'set_hvac_mode', 'hvac_mode').state, 'heat_cool');
|
||||
assert.equal(getField(climate, 'set_temperature', 'hvac_mode').hidden, true);
|
||||
assert.throws(() => buildCommand(climate, 'climate.set_temperature', { target_temp_low: 19 }), /required/);
|
||||
const command = buildCommand(climate, 'climate.set_temperature', { target_temp_low: 19, target_temp_high: 25 });
|
||||
assert.deepEqual(command.data, { entity_id: 'climate.room', target_temp_low: 19, target_temp_high: 25 });
|
||||
assert.throws(() => buildCommand(climate, 'climate.set_temperature', { target_temp_low: 10, target_temp_high: 25 }), /range/);
|
||||
});
|
||||
|
||||
test('permissions, locks, availability and allowlist are authoritative', async () => {
|
||||
test('covers, media players, vacuums, and lights use the same service discovery', () => {
|
||||
const cover = entity('cover.blind', 'open', { supported_features: 15, current_position: 40 });
|
||||
assert.equal(getField(cover, 'set_cover_position', 'position').state, 40);
|
||||
assert.equal(buildCommand(cover, 'cover.close_cover', {}).service, 'close_cover');
|
||||
const player = entity('media_player.room', 'playing', { supported_features: 4 | 8 | 2048 | 512 | 1 | 16384,
|
||||
volume_level: 0.3, is_volume_muted: false, source: 'Radio', source_list: ['Radio', 'TV'] });
|
||||
assert.equal(getField(player, 'volume_set', 'volume_level').state, 0.3);
|
||||
assert.equal(getField(player, 'select_source', 'source').options.length, 2);
|
||||
assert.ok(player.actions.some((action) => action.service === 'media_play_pause'));
|
||||
assert.ok(player.unsupported.includes('Play media'));
|
||||
const vacuum = entity('vacuum.robot', 'docked', { supported_features: 8192 | 16, fan_speed: 'quiet', fan_speed_list: ['quiet', 'max'] });
|
||||
assert.equal(buildCommand(vacuum, 'vacuum.return_to_base', {}).service, 'return_to_base');
|
||||
assert.equal(getField(vacuum, 'set_fan_speed', 'fan_speed').state, 'quiet');
|
||||
const light = entity('light.room', 'on', { brightness: 128, rgb_color: [20, 40, 60], supported_color_modes: ['rgb'] });
|
||||
assert.equal(getField(light, 'turn_on', 'brightness_pct').state, 50);
|
||||
assert.equal(getField(light, 'turn_on', 'rgb_color').type, 'color');
|
||||
assert.equal(getField(light, 'turn_on', 'color_temp_kelvin'), undefined);
|
||||
assert.deepEqual(buildCommand(light, 'light.turn_on', { rgb_color: [1, 2, 3] }).data, { entity_id: 'light.room', rgb_color: [1, 2, 3] });
|
||||
});
|
||||
|
||||
test('new integration domains work from selectors without a domain implementation', () => {
|
||||
const custom = entity('custom.device', 'active', { level: 2, mode: 'eco' });
|
||||
assert.equal(custom.actions.length, 1);
|
||||
assert.equal(custom.actions[0].fields[1].options[0].label, 'Economy');
|
||||
assert.deepEqual(buildCommand(custom, 'custom.adjust', { level: 3, mode: 'eco' }).data, { entity_id: 'custom.device', level: 3, mode: 'eco' });
|
||||
assert.throws(() => buildCommand(custom, 'custom.reload', {}), /not available/);
|
||||
const fan = entity('fan.room', 'on');
|
||||
assert.equal(buildCommand(fan, 'custom.fan_reset', {}).domain, 'custom');
|
||||
assert.ok(!custom.actions.some((action) => action.service === 'fan_reset'));
|
||||
});
|
||||
|
||||
test('filter semantics preserve OR, nested AND, attributes, and domain boundaries', () => {
|
||||
assert.equal(matchesFilter({ supported_features: [[1, 2], 8] }, 'fan', { supported_features: 1 }), false);
|
||||
assert.equal(matchesFilter({ supported_features: [[1, 2], 8] }, 'fan', { supported_features: 3 }), true);
|
||||
assert.equal(matchesFilter({ supported_features: [[1, 2], 8] }, 'fan', { supported_features: 8 }), true);
|
||||
assert.equal(matchesFilter({ attribute: { supported_color_modes: ['rgb'] } }, 'light', { supported_color_modes: ['hs'] }), false);
|
||||
assert.equal(matchesFilter([{ domain: 'fan' }, { domain: 'light' }], 'light', {}), true);
|
||||
assert.equal(matchesFilter({ domain: 'fan' }, 'switch', {}), false);
|
||||
});
|
||||
|
||||
test('simple helpers use metadata and entity-specific limits without room-light dispatch', () => {
|
||||
const number = entity('number.speed', '0', { min: 0, max: 1, step: 0.1 });
|
||||
assert.equal(getField(number, 'set_value', 'value').type, 'number');
|
||||
for (const value of ['', null, false, {}, Infinity, 2]) assert.throws(() => buildCommand(number, 'number.set_value', { value }));
|
||||
assert.equal(buildCommand(number, 'number.set_value', { value: '0.3' }).data.value, 0.3);
|
||||
const text = entity('text.code', 'ab', { min: 2, max: 4 });
|
||||
for (const value of ['', 'abcde', 123]) assert.throws(() => buildCommand(text, 'text.set_value', { value }));
|
||||
assert.equal(buildCommand(text, 'text.set_value', { value: '12' }).data.value, '12');
|
||||
const select = entity('select.mode', 'Quiet', { options: ['Quiet', 'Normal'] });
|
||||
assert.throws(() => buildCommand(select, 'select.select_option', { option: 'Other' }));
|
||||
assert.equal(buildCommand(entity('button.bell', 'unknown'), 'button.press').service, 'press');
|
||||
});
|
||||
|
||||
test('permissions, allowlist, locks, availability, and service payloads remain authoritative', async () => {
|
||||
const h = harness([{ id: 'switch.fan' }], { 'switch.fan': raw('on') });
|
||||
await assert.rejects(h.actions.act('switch.room_only', 'off', user), /Unknown/);
|
||||
await assert.rejects(h.actions.act('switch.room_only', 'switch.turn_off', {}, user), /Unknown/);
|
||||
for (const actor of [{ role: 'spectator', mode: 'open' }, { role: 'user', mode: 'admin' }, { role: 'admin', mode: 'lockdown' }]) {
|
||||
await assert.rejects(h.actions.act('switch.fan', 'off', actor));
|
||||
await assert.rejects(h.actions.act('switch.fan', 'switch.turn_off', {}, actor));
|
||||
}
|
||||
await assert.rejects(h.actions.act('switch.fan', 'switch.turn_off', { entity_id: 'switch.other' }, user), /Unknown action field/);
|
||||
await assert.rejects(h.actions.act('switch.fan', 'custom.reload', {}, user), /not available/);
|
||||
h.locked.add('switch.fan');
|
||||
await assert.rejects(h.actions.act('switch.fan', 'off', user), /locked/);
|
||||
await assert.rejects(h.actions.act('switch.fan', 'switch.turn_off', {}, user), /locked/);
|
||||
assert.equal(h.calls.length, 0);
|
||||
await h.actions.act('switch.fan', 'off', { role: 'admin', mode: 'open' });
|
||||
await h.actions.act('switch.fan', 'off', { role: 'lockdown', mode: 'lockdown' });
|
||||
await h.actions.act('switch.fan', 'switch.turn_off', {}, { role: 'admin', mode: 'open' });
|
||||
assert.deepEqual(h.calls[0], ['switch', 'turn_off', { entity_id: 'switch.fan' }]);
|
||||
h.ha.isConnected = () => false;
|
||||
await assert.rejects(h.actions.act('switch.fan', 'off', { role: 'admin', mode: 'open' }), /offline/);
|
||||
h.config.enabled = false;
|
||||
await assert.rejects(h.actions.act('switch.fan', 'off', user), /disabled/);
|
||||
await assert.rejects(h.actions.act('switch.fan', 'switch.turn_off', {}, { role: 'admin', mode: 'open' }), /offline/);
|
||||
});
|
||||
|
||||
test('idle ignores user locks, preserves no-op items, and isolates failures', async () => {
|
||||
test('idle invokes discovered actions, ignores locks, and isolates per-item failures', async () => {
|
||||
const h = harness([
|
||||
{ id: 'switch.keep' },
|
||||
{ id: 'number.bad', idleAction: 'set', idleValue: '200' },
|
||||
{ id: 'switch.fan', idleAction: 'set', idleValue: 'off' },
|
||||
// An empty optional text box is omitted by the schema-driven admin form.
|
||||
{ id: 'input_text.message', idleAction: 'set' },
|
||||
{ id: 'input_button.stop', idleAction: 'press' },
|
||||
{ id: 'sensor.humidity', idleAction: 'set', idleValue: '0' },
|
||||
{ id: 'switch.readonly', readOnly: true, idleAction: 'set', idleValue: 'off' },
|
||||
{ id: 'number.bad', idleAction: 'set_value', idleValue: '200' },
|
||||
{ id: 'switch.fan', idleAction: 'turn_off' },
|
||||
{ id: 'text.message', idleAction: 'set_value' },
|
||||
{ id: 'button.stop', idleAction: 'press' },
|
||||
{ id: 'climate.room', idleAction: 'climate.set_temperature', idleValue: '{"target_temp_low":18,"target_temp_high":24}' },
|
||||
{ id: 'switch.readonly', readOnly: true, idleAction: 'turn_off' },
|
||||
], {
|
||||
'number.bad': raw('0', { min: 0, max: 10 }), 'switch.fan': raw('on'),
|
||||
'input_text.message': raw('Welcome', { min: 0, max: 30 }), 'input_button.stop': raw('unknown'),
|
||||
'text.message': raw('Welcome', { min: 0, max: 30 }), 'button.stop': raw('unknown'),
|
||||
'climate.room': raw('heat_cool', { supported_features: 2, min_temp: 16, max_temp: 28 }),
|
||||
});
|
||||
h.locked.add('switch.fan');
|
||||
const result = await h.actions.runIdleActions();
|
||||
assert.equal(result.results[0].ok, false);
|
||||
assert.equal(result.results.filter((entry) => entry.ok).length, 3);
|
||||
assert.deepEqual(h.calls.map((call) => call[2]), [{ entity_id: 'switch.fan' }, { entity_id: 'input_text.message', value: '' }, { entity_id: 'input_button.stop' }]);
|
||||
assert.equal(result.results.filter((entry) => entry.ok).length, 4);
|
||||
assert.deepEqual(h.calls.map((call) => call[2]), [{ entity_id: 'switch.fan' }, { value: '', entity_id: 'text.message' },
|
||||
{ entity_id: 'button.stop' }, { target_temp_low: 18, target_temp_high: 24, entity_id: 'climate.room' }]);
|
||||
});
|
||||
|
||||
test('an outstanding service response does not block later commands', async () => {
|
||||
test('actions do not wait for an earlier response to send a later change', async () => {
|
||||
const h = harness([{ id: 'switch.fan' }], { 'switch.fan': raw('off') });
|
||||
const completions = [];
|
||||
h.ha.callHomeAssistantService = () => new Promise((resolve) => { completions.push(resolve); });
|
||||
const first = h.actions.act('switch.fan', 'on', user);
|
||||
const second = h.actions.act('switch.fan', 'off', user);
|
||||
// Both commands reach HA before either response arrives.
|
||||
const first = h.actions.act('switch.fan', 'switch.turn_on', {}, user);
|
||||
const second = h.actions.act('switch.fan', 'switch.turn_off', {}, user);
|
||||
assert.equal(completions.length, 2);
|
||||
completions.forEach((finish) => finish());
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
test('locks are process-local and names resolve without guessing', () => {
|
||||
test('locks remain process-local and names resolve without guessing', () => {
|
||||
const locks = createLocks();
|
||||
locks.setLocked('number.fan', true);
|
||||
assert.equal(locks.isLocked('number.fan'), true);
|
||||
// A fresh service starts unlocked instead of restoring previous moderation.
|
||||
assert.equal(createLocks().isLocked('number.fan'), false);
|
||||
locks.setLocked('number.fan', false);
|
||||
assert.equal(locks.isLocked('number.fan'), false);
|
||||
@@ -129,19 +179,17 @@ test('locks are process-local and names resolve without guessing', () => {
|
||||
assert.equal(resolveItem(items.slice(0, 1), 'FAN SPEED').id, 'number.fan');
|
||||
assert.equal(resolveItem(items, 'NUMBER.FAN').id, 'number.fan');
|
||||
assert.throws(() => resolveItem(items, 'Fan speed'), /number.fan, number.other/);
|
||||
assert.throws(() => resolveItem(items, 'Fan'), /No activity/);
|
||||
});
|
||||
|
||||
test('command preserves multiword names and exposes lock status', async () => {
|
||||
test('command names and the normal schema configuration flow remain unchanged', async () => {
|
||||
const changes = [];
|
||||
const replies = [];
|
||||
const handler = createHaCommand({ config: { commands: { prefix: 'rs' } }, homeAssistantActivitiesService: {
|
||||
setLocked: (name, locked) => { changes.push([name, locked]); return { id: 'number.fan', name }; },
|
||||
getState: () => ({ items: [{ id: 'number.fan', name: 'Fan speed', locked: true }] }),
|
||||
} });
|
||||
const message = { reply: (reply) => { replies.push(reply.content); } };
|
||||
await handler(message, ['lock', 'Fan', 'speed']);
|
||||
await handler(message, ['status']);
|
||||
await handler({ reply() {} }, ['lock', 'Fan', 'speed']);
|
||||
assert.deepEqual(changes, [['Fan speed', true]]);
|
||||
assert.match(replies[1], /Fan speed \(number.fan\): locked/);
|
||||
const config = normalizeConfig({ homeAssistantActivities: { enabled: true, items: [{ id: 'fan.room', color: '#ab12Cd', idleAction: 'turn_off' }] } });
|
||||
assertValidConfig(config);
|
||||
config.homeAssistantActivities.items[0].color = 'red';
|
||||
assert.throws(() => assertValidConfig(config));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user