diff --git a/js/common/util/can_toggle_domain.js b/js/common/util/can_toggle_domain.js new file mode 100644 index 0000000000..48e41e8297 --- /dev/null +++ b/js/common/util/can_toggle_domain.js @@ -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; +} diff --git a/js/common/util/can_toggle_state.js b/js/common/util/can_toggle_state.js new file mode 100644 index 0000000000..c7859cedc5 --- /dev/null +++ b/js/common/util/can_toggle_state.js @@ -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); +} diff --git a/js/common/util/feature_class_names.js b/js/common/util/feature_class_names.js new file mode 100644 index 0000000000..73262ab06b --- /dev/null +++ b/js/common/util/feature_class_names.js @@ -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(' '); +} diff --git a/js/common/util/state_card_type.js b/js/common/util/state_card_type.js new file mode 100644 index 0000000000..73d2378f2a --- /dev/null +++ b/js/common/util/state_card_type.js @@ -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'; +} diff --git a/js/common/util/state_more_info_type.js b/js/common/util/state_more_info_type.js new file mode 100644 index 0000000000..ddd7aaad96 --- /dev/null +++ b/js/common/util/state_more_info_type.js @@ -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'; +} diff --git a/js/compatibility.js b/js/compatibility.js index b3edbda996..744ff1e49b 100644 --- a/js/compatibility.js +++ b/js/compatibility.js @@ -1,3 +1,4 @@ +import 'mdn-polyfills/Array.prototype.includes'; import 'unfetch/polyfill'; import objAssign from 'es6-object-assign'; diff --git a/js/util.js b/js/util.js index a8e5fe10af..34af033a52 100644 --- a/js/util.js +++ b/js/util.js @@ -7,11 +7,16 @@ */ 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 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 formatDateTime from './common/util/format_date_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 || {}; @@ -21,8 +26,13 @@ const language = navigator.languages ? window.fecha.masks.haDateTime = window.fecha.masks.shortTime + ' ' + window.fecha.masks.mediumDate; window.hassUtil.attributeClassNames = attributeClassNames; +window.hassUtil.canToggleDomain = canToggleDomain; +window.hassUtil.canToggleState = canToggleState; window.hassUtil.computeDomain = computeStateDomain; window.hassUtil.computeStateDisplay = computeStateDisplay; +window.hassUtil.featureClassNames = featureClassNames; window.hassUtil.formatDate = dateObj => formatDate(dateObj, language); window.hassUtil.formatDateTime = dateObj => formatDateTime(dateObj, language); window.hassUtil.formatTime = dateObj => formatTime(dateObj, language); +window.hassUtil.stateCardType = stateCardType; +window.hassUtil.stateMoreInfoType = stateMoreInfoType; diff --git a/package.json b/package.json index 1bba80db6a..cc5dd94276 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "dev-watch": "npm run gulp watch_ru_all gen-service-worker", "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", - "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", "mocha": "node_modules/.bin/mocha --opts test-mocha/mocha.opts", "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-preset-es2015": "^6.24.1", "bower": "^1.8.2", + "chai": "^4.1.2", "css-slam": "^2.0.2", "del": "^3.0.0", "es6-object-assign": "^1.1.0", @@ -58,6 +59,7 @@ "gulp-vinyl-zip": "^2.1.0", "home-assistant-js-websocket": "^1.1.2", "html-minifier": "^3.5.6", + "mdn-polyfills": "^5.5.0", "merge-stream": "^1.0.1", "mocha": "^4.0.1", "parse5": "^3.0.3", diff --git a/src/util/hass-util.html b/src/util/hass-util.html index 5b5e78b35a..818b59b000 100644 --- a/src/util/hass-util.html +++ b/src/util/hass-util.html @@ -11,67 +11,8 @@ window.hassUtil.DEFAULT_ICON = 'mdi:bookmark'; 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.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. // Copy attributes into the child element. window.hassUtil.dynamicContentUpdater = function (root, newElementTag, attributes) { @@ -133,34 +74,6 @@ window.hassUtil.relativeTime.tests = [ 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) { switch (domain) { case 'alarm_control_panel': diff --git a/test-mocha/common/util/attribute_class_names_test.js b/test-mocha/common/util/attribute_class_names_test.js index 9aaeea1940..309dd83b35 100644 --- a/test-mocha/common/util/attribute_class_names_test.js +++ b/test-mocha/common/util/attribute_class_names_test.js @@ -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', () => { const attrs = ['mock_attr1', 'mock_attr2']; diff --git a/test-mocha/common/util/can_toggle_domain_test.js b/test-mocha/common/util/can_toggle_domain_test.js new file mode 100644 index 0000000000..a357cd5c0e --- /dev/null +++ b/test-mocha/common/util/can_toggle_domain_test.js @@ -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')); + }); +}); diff --git a/test-mocha/common/util/can_toggle_state_test.js b/test-mocha/common/util/can_toggle_state_test.js new file mode 100644 index 0000000000..21e5dd852c --- /dev/null +++ b/test-mocha/common/util/can_toggle_state_test.js @@ -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)); + }); +}); diff --git a/test-mocha/common/util/compute_domain.js b/test-mocha/common/util/compute_domain.js index a4668c6687..708f5ff215 100644 --- a/test-mocha/common/util/compute_domain.js +++ b/test-mocha/common/util/compute_domain.js @@ -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', () => { it('Returns domains', () => { diff --git a/test-mocha/common/util/compute_state_display.js b/test-mocha/common/util/compute_state_display.js index 00596e0742..93d9c0600d 100644 --- a/test-mocha/common/util/compute_state_display.js +++ b/test-mocha/common/util/compute_state_display.js @@ -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', () => { const haLocalize = function (namespace, message, ...args) { @@ -156,7 +156,7 @@ describe('computeStateDisplay', () => { }); it('Localizes custom state', () => { - const altHaLocalize = function (namespace, message, ...args) { + const altHaLocalize = function () { // No matches can be found return null; }; diff --git a/test-mocha/common/util/compute_state_domain.js b/test-mocha/common/util/compute_state_domain.js index a8d173db43..38620bd60b 100644 --- a/test-mocha/common/util/compute_state_domain.js +++ b/test-mocha/common/util/compute_state_domain.js @@ -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', () => { it('Detects sensor domain', () => { diff --git a/test-mocha/common/util/feature_class_names_test.js b/test-mocha/common/util/feature_class_names_test.js new file mode 100644 index 0000000000..11ea64b7c8 --- /dev/null +++ b/test-mocha/common/util/feature_class_names_test.js @@ -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' + ); + }); +}); diff --git a/test-mocha/common/util/format_date.js b/test-mocha/common/util/format_date.js index b1a1df09ce..817fd9bb5a 100644 --- a/test-mocha/common/util/format_date.js +++ b/test-mocha/common/util/format_date.js @@ -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', () => { const dateObj = new Date( diff --git a/test-mocha/common/util/format_date_time.js b/test-mocha/common/util/format_date_time.js index fe98276f96..94540a0236 100644 --- a/test-mocha/common/util/format_date_time.js +++ b/test-mocha/common/util/format_date_time.js @@ -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', () => { const dateObj = new Date( diff --git a/test-mocha/common/util/format_time.js b/test-mocha/common/util/format_time.js index 86c6784dc0..1e36c3e5d9 100644 --- a/test-mocha/common/util/format_time.js +++ b/test-mocha/common/util/format_time.js @@ -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', () => { const dateObj = new Date( diff --git a/test-mocha/common/util/location.js b/test-mocha/common/util/location.js index b563352b3c..fefc90e6af 100644 --- a/test-mocha/common/util/location.js +++ b/test-mocha/common/util/location.js @@ -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', () => { it('flags states with location', () => { diff --git a/test-mocha/common/util/state_card_type_test.js b/test-mocha/common/util/state_card_type_test.js new file mode 100644 index 0000000000..76fe75a526 --- /dev/null +++ b/test-mocha/common/util/state_card_type_test.js @@ -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'); + }); +}); diff --git a/test-mocha/common/util/state_more_info_type_test.js b/test-mocha/common/util/state_more_info_type_test.js new file mode 100644 index 0000000000..b9e5fa4fed --- /dev/null +++ b/test-mocha/common/util/state_more_info_type_test.js @@ -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'); + }); +}); diff --git a/yarn.lock b/yarn.lock index 213d5f46f9..3de8b1f60b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1707,7 +1707,7 @@ chai@^3.5.0: deep-eql "^0.1.3" type-detect "^1.0.0" -chai@^4.0.2: +chai@^4.0.2, chai@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chai/-/chai-4.1.2.tgz#0f64584ba642f0f2ace2806279f4f06ca23ad73c" dependencies: @@ -5194,6 +5194,10 @@ md5@^2.2.1: crypt "~0.0.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: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"