Separate more JS util logic to be unit tested (#705)

* Move featureClassNames to js util

* Add tests for featureClassNames

* Strip empty feature class names

* Move canToggleDomain to js util

* Add tests for canToggleDomain

* Refactor canToggleDomain to ensure boolean return

* Switch to chai assert for richer syntax options

* Move canToggleState to js util

* Tests for canToggleState

* Enable linting for mocha tests

* Move stateCardType to js util

* Add tests for stateCardType

* Move stateMoreInfoType to js util

* Tests for stateMoreInfoType

* Include mdn Array includes polyfill
This commit is contained in:
Adam Mills 2017-12-03 23:56:16 -05:00 committed by Paulus Schoutsen
parent c1e7f4cc77
commit a723c62f4f
23 changed files with 340 additions and 106 deletions

View File

@ -0,0 +1,11 @@
export default function canToggleDomain(hass, domain) {
const services = hass.config.services[domain];
if (!services) { return false; }
if (domain === 'lock') {
return 'lock' in services;
} else if (domain === 'cover') {
return 'open_cover' in services;
}
return 'turn_on' in services;
}

View File

@ -0,0 +1,11 @@
import canToggleDomain from './can_toggle_domain.js';
import computeStateDomain from './compute_state_domain.js';
export default function canToggleState(hass, stateObj) {
const domain = computeStateDomain(stateObj);
if (domain === 'group') {
return stateObj.state === 'on' || stateObj.state === 'off';
}
return canToggleDomain(hass, domain);
}

View File

@ -0,0 +1,10 @@
// Expects classNames to be an object mapping feature-bit -> className
export default function featureClassNames(stateObj, classNames) {
if (!stateObj || !stateObj.attributes.supported_features) return '';
const features = stateObj.attributes.supported_features;
return Object.keys(classNames).map(feature => (
(features & feature) !== 0 ? classNames[feature] : ''
)).filter(attr => attr !== '').join(' ');
}

View File

@ -0,0 +1,31 @@
import canToggleState from './can_toggle_state.js';
import computeStateDomain from './compute_state_domain.js';
const DOMAINS_WITH_CARD = [
'climate',
'cover',
'configurator',
'input_select',
'input_number',
'input_text',
'media_player',
'scene',
'script',
'weblink',
];
export default function stateCardType(hass, stateObj) {
if (stateObj.state === 'unavailable') {
return 'display';
}
const domain = computeStateDomain(stateObj);
if (DOMAINS_WITH_CARD.includes(domain)) {
return domain;
} else if (canToggleState(hass, stateObj) &&
stateObj.attributes.control !== 'hidden') {
return 'toggle';
}
return 'display';
}

View File

@ -0,0 +1,23 @@
import computeStateDomain from './compute_state_domain.js';
const DOMAINS_WITH_MORE_INFO = [
'alarm_control_panel', 'automation', 'camera', 'climate', 'configurator',
'cover', 'fan', 'group', 'history_graph', 'light', 'lock', 'media_player', 'script',
'sun', 'updater', 'vacuum', 'input_datetime',
];
const HIDE_MORE_INFO = [
'input_select', 'scene', 'input_number', 'input_text'
];
export default function stateMoreInfoType(stateObj) {
const domain = computeStateDomain(stateObj);
if (DOMAINS_WITH_MORE_INFO.includes(domain)) {
return domain;
}
if (HIDE_MORE_INFO.includes(domain)) {
return 'hidden';
}
return 'default';
}

View File

@ -1,3 +1,4 @@
import 'mdn-polyfills/Array.prototype.includes';
import 'unfetch/polyfill'; import 'unfetch/polyfill';
import objAssign from 'es6-object-assign'; import objAssign from 'es6-object-assign';

View File

@ -7,11 +7,16 @@
*/ */
import attributeClassNames from './common/util/attribute_class_names.js'; import attributeClassNames from './common/util/attribute_class_names.js';
import canToggleDomain from './common/util/can_toggle_domain.js';
import canToggleState from './common/util/can_toggle_state.js';
import computeStateDomain from './common/util/compute_state_domain.js'; import computeStateDomain from './common/util/compute_state_domain.js';
import computeStateDisplay from './common/util/compute_state_display.js'; import computeStateDisplay from './common/util/compute_state_display.js';
import featureClassNames from './common/util/feature_class_names.js';
import formatDate from './common/util/format_date.js'; import formatDate from './common/util/format_date.js';
import formatDateTime from './common/util/format_date_time.js'; import formatDateTime from './common/util/format_date_time.js';
import formatTime from './common/util/format_time.js'; import formatTime from './common/util/format_time.js';
import stateCardType from './common/util/state_card_type.js';
import stateMoreInfoType from './common/util/state_more_info_type.js';
window.hassUtil = window.hassUtil || {}; window.hassUtil = window.hassUtil || {};
@ -21,8 +26,13 @@ const language = navigator.languages ?
window.fecha.masks.haDateTime = window.fecha.masks.shortTime + ' ' + window.fecha.masks.mediumDate; window.fecha.masks.haDateTime = window.fecha.masks.shortTime + ' ' + window.fecha.masks.mediumDate;
window.hassUtil.attributeClassNames = attributeClassNames; window.hassUtil.attributeClassNames = attributeClassNames;
window.hassUtil.canToggleDomain = canToggleDomain;
window.hassUtil.canToggleState = canToggleState;
window.hassUtil.computeDomain = computeStateDomain; window.hassUtil.computeDomain = computeStateDomain;
window.hassUtil.computeStateDisplay = computeStateDisplay; window.hassUtil.computeStateDisplay = computeStateDisplay;
window.hassUtil.featureClassNames = featureClassNames;
window.hassUtil.formatDate = dateObj => formatDate(dateObj, language); window.hassUtil.formatDate = dateObj => formatDate(dateObj, language);
window.hassUtil.formatDateTime = dateObj => formatDateTime(dateObj, language); window.hassUtil.formatDateTime = dateObj => formatDateTime(dateObj, language);
window.hassUtil.formatTime = dateObj => formatTime(dateObj, language); window.hassUtil.formatTime = dateObj => formatTime(dateObj, language);
window.hassUtil.stateCardType = stateCardType;
window.hassUtil.stateMoreInfoType = stateMoreInfoType;

View File

@ -15,7 +15,7 @@
"dev-watch": "npm run gulp watch_ru_all gen-service-worker", "dev-watch": "npm run gulp watch_ru_all gen-service-worker",
"dev-es5": "npm run gulp ru_all_es5 gen-service-worker-es5", "dev-es5": "npm run gulp ru_all_es5 gen-service-worker-es5",
"dev-watch-es5": "npm run gulp watch_ru_all_es5 gen-service-worker-es5", "dev-watch-es5": "npm run gulp watch_ru_all_es5 gen-service-worker-es5",
"lint_js": "eslint src panels js --ext js,html", "lint_js": "eslint src panels js test-mocha --ext js,html",
"lint_html": "ls -1 src/home-assistant.html panels/**/ha-panel-*.html | xargs polymer lint --input", "lint_html": "ls -1 src/home-assistant.html panels/**/ha-panel-*.html | xargs polymer lint --input",
"mocha": "node_modules/.bin/mocha --opts test-mocha/mocha.opts", "mocha": "node_modules/.bin/mocha --opts test-mocha/mocha.opts",
"test": "npm run lint_js && npm run lint_html && npm run mocha" "test": "npm run lint_js && npm run lint_html && npm run mocha"
@ -29,6 +29,7 @@
"babel-plugin-transform-react-jsx": "^6.24.1", "babel-plugin-transform-react-jsx": "^6.24.1",
"babel-preset-es2015": "^6.24.1", "babel-preset-es2015": "^6.24.1",
"bower": "^1.8.2", "bower": "^1.8.2",
"chai": "^4.1.2",
"css-slam": "^2.0.2", "css-slam": "^2.0.2",
"del": "^3.0.0", "del": "^3.0.0",
"es6-object-assign": "^1.1.0", "es6-object-assign": "^1.1.0",
@ -58,6 +59,7 @@
"gulp-vinyl-zip": "^2.1.0", "gulp-vinyl-zip": "^2.1.0",
"home-assistant-js-websocket": "^1.1.2", "home-assistant-js-websocket": "^1.1.2",
"html-minifier": "^3.5.6", "html-minifier": "^3.5.6",
"mdn-polyfills": "^5.5.0",
"merge-stream": "^1.0.1", "merge-stream": "^1.0.1",
"mocha": "^4.0.1", "mocha": "^4.0.1",
"parse5": "^3.0.3", "parse5": "^3.0.3",

View File

@ -11,67 +11,8 @@ window.hassUtil.DEFAULT_ICON = 'mdi:bookmark';
window.hassUtil.OFF_STATES = ['off', 'closed', 'unlocked']; window.hassUtil.OFF_STATES = ['off', 'closed', 'unlocked'];
window.hassUtil.DOMAINS_WITH_CARD = [
'climate',
'cover',
'configurator',
'input_select',
'input_number',
'input_text',
'media_player',
'scene',
'script',
'weblink',
];
window.hassUtil.DOMAINS_WITH_MORE_INFO = [
'alarm_control_panel', 'automation', 'camera', 'climate', 'configurator',
'cover', 'fan', 'group', 'history_graph', 'light', 'lock', 'media_player', 'script',
'sun', 'updater', 'vacuum', 'input_datetime',
];
window.hassUtil.DOMAINS_WITH_NO_HISTORY = ['camera', 'configurator', 'history_graph', 'scene']; window.hassUtil.DOMAINS_WITH_NO_HISTORY = ['camera', 'configurator', 'history_graph', 'scene'];
window.hassUtil.HIDE_MORE_INFO = [
'input_select', 'scene', 'input_number', 'input_text'
];
// Expects featureClassNames to be an object mapping feature-bit -> className
window.hassUtil.featureClassNames = function (stateObj, featureClassNames) {
if (!stateObj || !stateObj.attributes.supported_features) return '';
var features = stateObj.attributes.supported_features;
return Object.keys(featureClassNames).map(function (feature) {
return (features & feature) !== 0 ? featureClassNames[feature] : '';
}).join(' ');
};
window.hassUtil.canToggleState = function (hass, stateObj) {
var domain = window.hassUtil.computeDomain(stateObj);
if (domain === 'group') {
return stateObj.state === 'on' || stateObj.state === 'off';
}
return window.hassUtil.canToggleDomain(hass, domain);
};
window.hassUtil.canToggleDomain = function (hass, domain) {
var turnOnService;
var services = hass.config.services[domain];
if (domain === 'lock') {
turnOnService = 'lock';
} else if (domain === 'cover') {
turnOnService = 'open_cover';
} else {
turnOnService = 'turn_on';
}
return services && turnOnService in services;
};
// Update root's child element to be newElementTag replacing another existing child if any. // Update root's child element to be newElementTag replacing another existing child if any.
// Copy attributes into the child element. // Copy attributes into the child element.
window.hassUtil.dynamicContentUpdater = function (root, newElementTag, attributes) { window.hassUtil.dynamicContentUpdater = function (root, newElementTag, attributes) {
@ -133,34 +74,6 @@ window.hassUtil.relativeTime.tests = [
7, 'day', 7, 'day',
]; ];
window.hassUtil.stateCardType = function (hass, stateObj) {
if (stateObj.state === 'unavailable') {
return 'display';
}
var domain = window.hassUtil.computeDomain(stateObj);
if (window.hassUtil.DOMAINS_WITH_CARD.indexOf(domain) !== -1) {
return domain;
} else if (window.hassUtil.canToggleState(hass, stateObj) &&
stateObj.attributes.control !== 'hidden') {
return 'toggle';
}
return 'display';
};
window.hassUtil.stateMoreInfoType = function (stateObj) {
var domain = window.hassUtil.computeDomain(stateObj);
if (window.hassUtil.DOMAINS_WITH_MORE_INFO.indexOf(domain) !== -1) {
return domain;
}
if (window.hassUtil.HIDE_MORE_INFO.indexOf(domain) !== -1) {
return 'hidden';
}
return 'default';
};
window.hassUtil.domainIcon = function (domain, state) { window.hassUtil.domainIcon = function (domain, state) {
switch (domain) { switch (domain) {
case 'alarm_control_panel': case 'alarm_control_panel':

View File

@ -1,6 +1,6 @@
import attributeClassNames from '../../../js/common/util/attribute_class_names'; import { assert } from 'chai';
const assert = require('assert'); import attributeClassNames from '../../../js/common/util/attribute_class_names';
describe('attributeClassNames', () => { describe('attributeClassNames', () => {
const attrs = ['mock_attr1', 'mock_attr2']; const attrs = ['mock_attr1', 'mock_attr2'];

View File

@ -0,0 +1,39 @@
import { assert } from 'chai';
import canToggleDomain from '../../../js/common/util/can_toggle_domain';
describe('canToggleDomain', () => {
const hass = {
config: {
services: {
light: {
turn_on: null, // Service keys only need to be present for test
turn_off: null,
},
lock: {
lock: null,
unlock: null,
},
sensor: {
custom_service: null,
},
},
},
};
it('Detects lights toggle', () => {
assert.isTrue(canToggleDomain(hass, 'light'));
});
it('Detects locks toggle', () => {
assert.isTrue(canToggleDomain(hass, 'lock'));
});
it('Detects sensors do not toggle', () => {
assert.isFalse(canToggleDomain(hass, 'sensor'));
});
it('Detects binary sensors do not toggle', () => {
assert.isFalse(canToggleDomain(hass, 'binary_sensor'));
});
});

View File

@ -0,0 +1,40 @@
import { assert } from 'chai';
import canToggleState from '../../../js/common/util/can_toggle_state';
describe('canToggleState', () => {
const hass = {
config: {
services: {
light: {
turn_on: null, // Service keys only need to be present for test
turn_off: null,
},
},
},
};
it('Detects lights toggle', () => {
const stateObj = {
entity_id: 'light.bla',
state: 'on',
};
assert.isTrue(canToggleState(hass, stateObj));
});
it('Detects group with toggle', () => {
const stateObj = {
entity_id: 'group.bla',
state: 'on',
};
assert.isTrue(canToggleState(hass, stateObj));
});
it('Detects group without toggle', () => {
const stateObj = {
entity_id: 'group.devices',
state: 'home',
};
assert.isFalse(canToggleState(hass, stateObj));
});
});

View File

@ -1,6 +1,6 @@
import computeDomain from '../../../js/common/util/compute_domain'; import { assert } from 'chai';
const assert = require('assert'); import computeDomain from '../../../js/common/util/compute_domain';
describe('computeDomain', () => { describe('computeDomain', () => {
it('Returns domains', () => { it('Returns domains', () => {

View File

@ -1,6 +1,6 @@
import computeStateDisplay from '../../../js/common/util/compute_state_display'; import { assert } from 'chai';
const assert = require('assert'); import computeStateDisplay from '../../../js/common/util/compute_state_display';
describe('computeStateDisplay', () => { describe('computeStateDisplay', () => {
const haLocalize = function (namespace, message, ...args) { const haLocalize = function (namespace, message, ...args) {
@ -156,7 +156,7 @@ describe('computeStateDisplay', () => {
}); });
it('Localizes custom state', () => { it('Localizes custom state', () => {
const altHaLocalize = function (namespace, message, ...args) { const altHaLocalize = function () {
// No matches can be found // No matches can be found
return null; return null;
}; };

View File

@ -1,6 +1,6 @@
import computeStateDomain from '../../../js/common/util/compute_state_domain.js'; import { assert } from 'chai';
const assert = require('assert'); import computeStateDomain from '../../../js/common/util/compute_state_domain.js';
describe('computeStateDomain', () => { describe('computeStateDomain', () => {
it('Detects sensor domain', () => { it('Detects sensor domain', () => {

View File

@ -0,0 +1,56 @@
import { assert } from 'chai';
import featureClassNames from '../../../js/common/util/feature_class_names';
describe('featureClassNames', () => {
const classNames = {
1: 'has-feature_a',
2: 'has-feature_b',
4: 'has-feature_c',
8: 'has-feature_d',
};
it('Skips null states', () => {
const stateObj = null;
assert.strictEqual(
featureClassNames(stateObj, classNames),
''
);
});
it('Matches no features', () => {
const stateObj = {
attributes: {
supported_features: 64,
},
};
assert.strictEqual(
featureClassNames(stateObj, classNames),
''
);
});
it('Matches one feature', () => {
const stateObj = {
attributes: {
supported_features: 72,
},
};
assert.strictEqual(
featureClassNames(stateObj, classNames),
'has-feature_d'
);
});
it('Matches two features', () => {
const stateObj = {
attributes: {
supported_features: 73,
},
};
assert.strictEqual(
featureClassNames(stateObj, classNames),
'has-feature_a has-feature_d'
);
});
});

View File

@ -1,6 +1,6 @@
import formatDate from '../../../js/common/util/format_date'; import { assert } from 'chai';
const assert = require('assert'); import formatDate from '../../../js/common/util/format_date';
describe('formatDate', () => { describe('formatDate', () => {
const dateObj = new Date( const dateObj = new Date(

View File

@ -1,6 +1,6 @@
import formatDateTime from '../../../js/common/util/format_date_time'; import { assert } from 'chai';
const assert = require('assert'); import formatDateTime from '../../../js/common/util/format_date_time';
describe('formatDateTime', () => { describe('formatDateTime', () => {
const dateObj = new Date( const dateObj = new Date(

View File

@ -1,6 +1,6 @@
import formatTime from '../../../js/common/util/format_time'; import { assert } from 'chai';
const assert = require('assert'); import formatTime from '../../../js/common/util/format_time';
describe('formatTime', () => { describe('formatTime', () => {
const dateObj = new Date( const dateObj = new Date(

View File

@ -1,6 +1,6 @@
import { hasLocation } from '../../../js/common/util/location'; import { assert } from 'chai';
const assert = require('assert'); import { hasLocation } from '../../../js/common/util/location';
describe('hasLocation', () => { describe('hasLocation', () => {
it('flags states with location', () => { it('flags states with location', () => {

View File

@ -0,0 +1,55 @@
import { assert } from 'chai';
import stateCardType from '../../../js/common/util/state_card_type';
describe('stateCardType', () => {
const hass = {
config: {
services: {
light: {
turn_on: null, // Service keys only need to be present for test
turn_off: null,
},
},
},
};
it('Returns display for unavailable states', () => {
const stateObj = {
state: 'unavailable',
};
assert.strictEqual(stateCardType(hass, stateObj), 'display');
});
it('Returns media_player for media_player states', () => {
const stateObj = {
entity_id: 'media_player.bla',
};
assert.strictEqual(stateCardType(hass, stateObj), 'media_player');
});
it('Returns toggle for states that can toggle', () => {
const stateObj = {
entity_id: 'light.bla',
attributes: {},
};
assert.strictEqual(stateCardType(hass, stateObj), 'toggle');
});
it('Returns display for states with hidden control', () => {
const stateObj = {
entity_id: 'light.bla',
attributes: {
control: 'hidden',
},
};
assert.strictEqual(stateCardType(hass, stateObj), 'display');
});
it('Returns display for entities that cannot toggle', () => {
const stateObj = {
entity_id: 'sensor.bla',
};
assert.strictEqual(stateCardType(hass, stateObj), 'display');
});
});

View File

@ -0,0 +1,28 @@
import { assert } from 'chai';
import stateMoreInfoType from '../../../js/common/util/state_more_info_type';
describe('stateMoreInfoType', () => {
it('Returns media_player for media_player states', () => {
const stateObj = {
entity_id: 'media_player.bla',
};
assert.strictEqual(stateMoreInfoType(stateObj), 'media_player');
});
it('Returns hidden for input_select states', () => {
const stateObj = {
entity_id: 'input_select.bla',
attributes: {},
};
assert.strictEqual(stateMoreInfoType(stateObj), 'hidden');
});
it('Returns default for switch states', () => {
const stateObj = {
entity_id: 'switch.bla',
attributes: {},
};
assert.strictEqual(stateMoreInfoType(stateObj), 'default');
});
});

View File

@ -1707,7 +1707,7 @@ chai@^3.5.0:
deep-eql "^0.1.3" deep-eql "^0.1.3"
type-detect "^1.0.0" type-detect "^1.0.0"
chai@^4.0.2: chai@^4.0.2, chai@^4.1.2:
version "4.1.2" version "4.1.2"
resolved "https://registry.yarnpkg.com/chai/-/chai-4.1.2.tgz#0f64584ba642f0f2ace2806279f4f06ca23ad73c" resolved "https://registry.yarnpkg.com/chai/-/chai-4.1.2.tgz#0f64584ba642f0f2ace2806279f4f06ca23ad73c"
dependencies: dependencies:
@ -5194,6 +5194,10 @@ md5@^2.2.1:
crypt "~0.0.1" crypt "~0.0.1"
is-buffer "~1.1.1" is-buffer "~1.1.1"
mdn-polyfills@^5.5.0:
version "5.5.0"
resolved "https://registry.yarnpkg.com/mdn-polyfills/-/mdn-polyfills-5.5.0.tgz#b8ce237a0a7cbae66c56a6fdd0334b659360d364"
media-typer@0.3.0: media-typer@0.3.0:
version "0.3.0" version "0.3.0"
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"