From dfe1e9c340ace4a77de10a8cbff538e219111e91 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Mon, 19 Dec 2016 18:38:41 -0400 Subject: [PATCH] [WIP]: wet/dry JSON transformation improvements Don't review yet! Signed-off-by: Juan Cruz Viotti --- .eslintrc.yml | 2 +- lib/configuration.js | 145 +++++ lib/connector.js | 125 ++++ lib/connectors/formats/json.js | 62 ++ lib/connectors/json.js | 33 + lib/domain.js | 92 +++ lib/engine/configuration.js | 214 ------- lib/index.js | 19 +- lib/jsontemplate/README.markdown | 54 -- lib/jsontemplate/index.js | 131 ---- lib/jsontemplate/regexes.js | 113 ---- lib/jsontemplate/string.js | 173 ----- lib/mapping.js | 312 +++++++++ lib/properties.js | 189 ++++++ lib/state.js | 171 +++++ lib/template.js | 186 ++++++ lib/type.js | 104 +++ package.json | 7 +- tests/configuration.spec.js | 237 +++++++ tests/connector.spec.js | 188 ++++++ tests/domain.spec.js | 155 +++++ .../configuration/bidirectional.spec.js | 152 ----- .../fixtures/resinos-v1/dry.json | 9 - .../fixtures/resinos-v1/schema.json | 52 -- .../fixtures/resinos-v1/wet-extra.json | 25 - .../fixtures/resinos-v1/wet.json | 21 - tests/engine/configuration/unmatch.spec.js | 130 ---- .../fixtures/resinos-v1-ethernet/data.json | 4 +- .../fixtures/resinos-v1-ethernet/image.img | Bin 33554432 -> 33554432 bytes .../fixtures/resinos-v1-ethernet/schema.json | 179 +++--- .../fixtures/resinos-v1-wifi/schema.json | 179 +++--- .../fixtures/resinos-v2/schema.json | 75 +-- tests/integration/read.spec.js | 10 +- tests/integration/write.spec.js | 207 +++--- tests/jsontemplate/bidirectional.spec.js | 112 ---- .../fixtures/matches/resinos-v1-ethernet.json | 37 -- .../fixtures/matches/resinos-v1-wifi.json | 44 -- tests/jsontemplate/matches.spec.js | 36 -- .../jsontemplate/string/bidirectional.spec.js | 253 -------- .../jsontemplate/string/deinterpolate.spec.js | 86 --- tests/jsontemplate/string/interpolate.spec.js | 95 --- tests/mapping.spec.js | 592 ++++++++++++++++++ tests/properties.spec.js | 351 +++++++++++ tests/state.spec.js | 135 ++++ tests/template.spec.js | 429 +++++++++++++ tests/type.spec.js | 166 +++++ tests/visuals/cli/flatten.spec.js | 241 ------- .../cli/transpile-question-when.spec.js | 346 ---------- tests/visuals/cli/transpile-question.spec.js | 351 ----------- visuals/cli.js | 271 -------- 50 files changed, 3978 insertions(+), 3322 deletions(-) create mode 100644 lib/configuration.js create mode 100644 lib/connector.js create mode 100644 lib/connectors/formats/json.js create mode 100644 lib/connectors/json.js create mode 100644 lib/domain.js delete mode 100644 lib/engine/configuration.js delete mode 100644 lib/jsontemplate/README.markdown delete mode 100644 lib/jsontemplate/index.js delete mode 100644 lib/jsontemplate/regexes.js delete mode 100644 lib/jsontemplate/string.js create mode 100644 lib/mapping.js create mode 100644 lib/properties.js create mode 100644 lib/state.js create mode 100644 lib/template.js create mode 100644 lib/type.js create mode 100644 tests/configuration.spec.js create mode 100644 tests/connector.spec.js create mode 100644 tests/domain.spec.js delete mode 100644 tests/engine/configuration/bidirectional.spec.js delete mode 100644 tests/engine/configuration/fixtures/resinos-v1/dry.json delete mode 100644 tests/engine/configuration/fixtures/resinos-v1/schema.json delete mode 100644 tests/engine/configuration/fixtures/resinos-v1/wet-extra.json delete mode 100644 tests/engine/configuration/fixtures/resinos-v1/wet.json delete mode 100644 tests/engine/configuration/unmatch.spec.js delete mode 100644 tests/jsontemplate/bidirectional.spec.js delete mode 100644 tests/jsontemplate/fixtures/matches/resinos-v1-ethernet.json delete mode 100644 tests/jsontemplate/fixtures/matches/resinos-v1-wifi.json delete mode 100644 tests/jsontemplate/matches.spec.js delete mode 100644 tests/jsontemplate/string/bidirectional.spec.js delete mode 100644 tests/jsontemplate/string/deinterpolate.spec.js delete mode 100644 tests/jsontemplate/string/interpolate.spec.js create mode 100644 tests/mapping.spec.js create mode 100644 tests/properties.spec.js create mode 100644 tests/state.spec.js create mode 100644 tests/template.spec.js create mode 100644 tests/type.spec.js delete mode 100644 tests/visuals/cli/flatten.spec.js delete mode 100644 tests/visuals/cli/transpile-question-when.spec.js delete mode 100644 tests/visuals/cli/transpile-question.spec.js delete mode 100644 visuals/cli.js diff --git a/.eslintrc.yml b/.eslintrc.yml index db8a6541..4ed0cca9 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -180,7 +180,7 @@ rules: no-useless-concat: - error no-useless-escape: - - error + - off no-void: - error no-warning-comments: diff --git a/lib/configuration.js b/lib/configuration.js new file mode 100644 index 00000000..1dbd9a94 --- /dev/null +++ b/lib/configuration.js @@ -0,0 +1,145 @@ +/* + * Copyright 2016 Resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/** + * @module Reconfix.Configuration + */ + +const _ = require('lodash'); +const State = require('./state'); + +/** + * @summary Generate configuration + * @function + * @public + * + * @param {Object} schema - schema + * @param {Object} state - state + * @returns {Object} configuration + * + * @example + * const configuration = Configuration.generate({ + * config_json: { + * connector: { + * type: 'json' + * path: [ 'config.json' ], + * partition: { + * primary: 4, + * logical: 1 + * } + * }, + * properties: { + * network: { + * ssid: { + * type: [ 'string' ], + * mapping: [ + * [ 'network', 'default', 'ssid' ] + * ] + * } + * } + * } + * } + * }, { + * network: { + * ssid: 'mynetwork' + * } + * }); + * + * console.log(configuration); + * > { + * > config_json: { + * > network: { + * > default: { + * > ssid: "mynetwork" + * > } + * > } + * > } + * > } + */ +exports.generate = (schema, state) => { + return _.mapValues(schema, (entity) => { + return State.compile(entity.properties, state); + }); +}; + +/** + * @summary Extract configuration + * @function + * @public + * + * @param {Object} schema - schema + * @param {Object} configuration - configuration + * @returns {Object} state + * + * @example + * const state = Configuration.extract({ + * config_json: { + * connector: { + * type: 'json' + * path: [ 'config.json' ], + * partition: { + * primary: 4, + * logical: 1 + * } + * }, + * properties: { + * network: { + * ssid: { + * type: [ 'string' ], + * mapping: [ + * [ 'network', 'default', 'ssid' ] + * ] + * } + * } + * } + * } + * }, { + * config_json: { + * network: { + * default: { + * ssid: "mynetwork" + * } + * } + * } + * }); + * + * console.log(state); + * > { + * > tainted: [], + * > result: { + * > network: { + * > ssid: 'mynetwork' + * > } + * > } + * > } + */ +exports.extract = (schema, configuration) => { + return _.reduce(schema, (accumulator, entity, name) => { + try { + return _.merge(accumulator, { + result: State.decompile(entity.properties, _.get(configuration, name)) + }); + } catch (error) { + accumulator.tainted.push(name); + return accumulator; + } + }, { + tainted: [], + result: {} + }); +}; diff --git a/lib/connector.js b/lib/connector.js new file mode 100644 index 00000000..7492b50a --- /dev/null +++ b/lib/connector.js @@ -0,0 +1,125 @@ +/* + * Copyright 2016 Resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/** + * @module Reconfix.Connector + */ + +const _ = require('lodash'); + +/** + * @summary Built-in connectors + * @type Object + * @constant + * @public + */ +exports.BUILTIN_CONNECTORS = { + json: require('./connectors/json') +}; + +/** + * @summary Get the type of a connector + * @function + * @private + * + * @param {Object} connector - connector + * @returns {String} type - type + * + * @example + * const type = Connector.getType({ + * type: 'json', + * path: [ 'config.txt' ], + * partition: { + * primary: 1 + * } + * }); + */ +exports.getType = (connector) => { + return _.get(connector, 'type'); +}; + +/** + * @summary Get the options of a connector + * @function + * @private + * + * @param {Object} connector - connector + * @returns {Object} options - options + * + * @example + * const options = Connector.getOptions({ + * type: 'json', + * path: [ 'config.txt' ], + * partition: { + * primary: 1 + * } + * }); + * + * console.log(options); + * > { + * > path: [ 'config.txt' ], + * > partition: { + * > primary: 1 + * > } + * > } + */ +exports.getOptions = (connector) => { + return _.omit(connector, [ 'type' ]); +}; + +/** + * @summary Set data using a connector + * @function + * @public + * + * @param {Object} connector - connector + * @param {Object} data - data + * @param {Object} options - options + * @param {Object} options.connectors - available connectors + * @returns {Promise} + * + * @example + * Connector.set({ + * type: 'json', + * path: [ 'config.json' ], + * partition: { + * primary: 1 + * } + * }, { + * foo: 'bar' + * }, { + * connectors: Connector.BUILTIN_CONNECTORS + * }).then(() => { + * console.log('Done!'); + * }); + */ +exports.set = (connector, data, options) => { + const type = exports.getType(connector); + const executor = _.get(options.connectors, [ type, 'set' ]); + + if (!_.has(options.connectors, type)) { + throw new Error(`Unknown connector type: "${type}"`); + } + + if (!_.isFunction(executor)) { + throw new Error(`Invalid connector type: "${type}", "set" is not a function`); + } + + const connectorOptions = exports.getOptions(connector); + return executor(connectorOptions, data); +}; diff --git a/lib/connectors/formats/json.js b/lib/connectors/formats/json.js new file mode 100644 index 00000000..e6547175 --- /dev/null +++ b/lib/connectors/formats/json.js @@ -0,0 +1,62 @@ +/* + * Copyright 2016 Resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/** + * @module Reconfix.Connectors.Formats.Json + */ + +const _ = require('lodash'); + +/** + * @summary Decode the contents of a JSON string + * @function + * @public + * + * @param {String} string - json string + * @returns {Object} object + * + * @example + * const object = Json.decode('{"foo":"bar"}'); + * console.log(object.foo); + * > 'bar' + */ +exports.decode = _.unary(JSON.parse); + +/** + * @summary Encode a JSON object as an JSON string + * @function + * @public + * + * @param {Object} object - object + * @returns {String} json string + * + * @example + * const string = json.encode({ + * mysection: { + * foo: 'bar' + * } + * }); + * + * console.log(string); + * > { + * > mysection: { + * > foo: 'bar' + * > } + * > } + */ +exports.encode = _.partialRight(JSON.stringify, null, 2); diff --git a/lib/connectors/json.js b/lib/connectors/json.js new file mode 100644 index 00000000..74ed4b7d --- /dev/null +++ b/lib/connectors/json.js @@ -0,0 +1,33 @@ +/* + * Copyright 2016 Resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/** + * @module Reconfix.Connectors.Json + */ + +const Bluebird = require('bluebird'); +const Json = require('./formats/json'); + +exports.set = (options, data) => { + console.log('Writing...'); + console.log('Options:'); + console.log(Json.encode(options)); + console.log('Data:'); + console.log(Json.encode(data)); + return Bluebird.resolve(); +}; diff --git a/lib/domain.js b/lib/domain.js new file mode 100644 index 00000000..10f66d0d --- /dev/null +++ b/lib/domain.js @@ -0,0 +1,92 @@ +/* + * Copyright 2016 Resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/** + * @module Reconfix.Domain + */ + +const _ = require('lodash'); + +/** + * @summary Mask an object with a domain + * @function + * @public + * + * @param {Object} object - object + * @param {Array[]} domain - domain + * @returns {Object} resulting object + * + * @example + * const object = Domain.mask({ + * foo: 1, + * bar: 2, + * baz: 3 + * }, [ + * [ 'foo' ], + * [ 'baz' ] + * ]); + * + * console.log(object); + * > { foo: 1, baz: 3 } + */ +exports.mask = (object, domain) => { + return _.reduce(domain, (accumulator, path) => { + const value = _.get(object, path); + + if (_.isUndefined(value)) { + return accumulator; + } + + return _.set(accumulator, path, value); + }, {}); +}; + +/** + * @summary Get the domain from mapping + * @function + * @public + * + * @param {Object[]} mapping - mapping + * @returns {Array[]} domain + * + * @example + * const domain = Domain.getFromMapping([ + * { + * value: true, + * template: { + * foo: 1 + * } + * }, + * { + * value: false, + * template: { + * bar: 1 + * } + * } + * ]); + * + * console.log(domain); + * > [ [ 'foo' ], [ 'bar' ] ] + */ +exports.getFromMapping = (mapping) => { + return _.uniqWith(_.reduce(mapping, (accumulator, choice) => { + return _.concat(accumulator, _.map(_.keys(choice.template), (key) => { + return [ key ]; + })); + }, []), _.isEqual); +}; diff --git a/lib/engine/configuration.js b/lib/engine/configuration.js deleted file mode 100644 index b256aec1..00000000 --- a/lib/engine/configuration.js +++ /dev/null @@ -1,214 +0,0 @@ -/* - * Copyright 2016 Resin.io - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/** - * @module Reconfix.Engine.Configuration - */ - -const _ = require('lodash'); -const jsontemplate = require('../jsontemplate'); - -/** - * @summary Safely merge an object to a certain path - * @function - * @private - * - * @param {Object} destination - destination object - * @param {(String|String[])} path - object path - * @param {Object} source - source object - * - * @example - * const x = {}; - * const y = { - * name: 'John Doe' - * }; - * - * mergePath(x, [ 'data', 'foo' ], y); - * - * console.log(x); - * > { - * > data: { - * > foo: { - * > name: 'John Doe' - * > } - * > } - * > } - */ -const mergePath = (destination, path, source) => { - if (!_.has(destination, path)) { - _.set(destination, path, {}); - } - - _.merge(_.get(destination, path), source); -}; - -/** - * @summary Get all unique domain filenames - * @function - * @private - * - * @param {Array[]} domain - domain - * @returns {Array[]} unique domain filenames - * - * @example - * const filenames = getDomainFilenames([ - * [ 'foo', 'bar' ] - * [ 'foo', 'baz' ] - * ]); - * - * console.log(filenames); - * > [ 'foo' ] - */ -const getDomainFilenames = (domain) => { - return _.uniqBy(_.map(domain, (domainPath) => { - return _.initial(domainPath); - }), _.isEqual); -}; - -/** - * @summary Generate configuration - * @function - * @public - * - * @param {Object} schema - configuration schema - * @param {Object} settings - user settings - * @param {Object} [options] - options - * @param {Object} [options.defaults] - default data - * @returns {Object} configuration - * - * @example - * const result = configuration.generate([ - * { - * template: { - * gpu_mem_1024: '{{gpuMem1024}}' - * }, - * domain: [ - * [ 'config_txt', 'gpu_mem_1024' ] - * ] - * } - * ], { - * gpuMem1024: 64 - * }); - * - * console.log(result); - * > { - * > config_txt: { - * > gpu_mem_1024: 64 - * > } - * > } - */ -exports.generate = (schema, settings, options) => { - options = options || {}; - _.defaults(options, { - defaults: {} - }); - - return _.reduce(schema, (accumulator, correspondence) => { - const domainFilenamesPaths = getDomainFilenames(correspondence.domain); - - _.each(domainFilenamesPaths, (domainFilenamePath) => { - if (correspondence.choice) { - correspondence.template = _.find(correspondence.choice, { - value: _.get(settings, correspondence.property) - }).template; - } - - const configuration = jsontemplate.compile(correspondence.template, settings); - - mergePath(accumulator, domainFilenamePath, _.attempt(() => { - if (correspondence.choice) { - return configuration; - } - - const current = _.get(options.defaults, domainFilenamePath, {}); - return _.merge(current, configuration); - })); - }); - - return accumulator; - }, {}); -}; - -/** - * @summary Extract configuration - * @function - * @public - * - * @param {Object} schema - configuration schema - * @param {Object} configuration - configuration object - * @returns {Object} user settings - * - * @example - * const settings = configuration.extract([ - * { - * template: { - * gpu_mem_1024: '{{gpuMem1024}}' - * }, - * domain: [ - * [ 'config_txt', 'gpu_mem_1024' ] - * ] - * } - * ], { - * config_txt: { - * gpu_mem_1024: 64 - * } - * }); - * - * console.log(settings); - * > { - * > gpuMem1024: 64 - * > } - */ -exports.extract = (schema, configuration) => { - return _.reduce(schema, (accumulator, correspondence) => { - const domainFilenamesPaths = getDomainFilenames(correspondence.domain); - - _.each(domainFilenamesPaths, (domainFilenamePath) => { - const domain = _.get(configuration, domainFilenamePath); - - if (correspondence.choice) { - const matches = _.filter(correspondence.choice, (choice) => { - return jsontemplate.matches(choice.template, domain); - }); - - if (matches.length !== 1) { - throw new Error([ - 'The current state doesn\'t match the schema.', - '', - 'Current configuration:', - '', - JSON.stringify(domain, null, 2), - '', - 'Schema choices:', - '', - JSON.stringify(correspondence.choice, null, 2) - ].join('\n')); - } - - const match = _.first(matches); - - _.set(accumulator, correspondence.property, match.value); - correspondence.template = match.template; - } - - _.merge(accumulator, jsontemplate.decompile(correspondence.template, domain)); - }); - - return accumulator; - }, {}); -}; diff --git a/lib/index.js b/lib/index.js index 070b78b6..d7ca6a1f 100644 --- a/lib/index.js +++ b/lib/index.js @@ -22,7 +22,7 @@ const _ = require('lodash'); const filesystem = require('./engine/filesystem'); -const configuration = require('./engine/configuration'); +const configuration = require('./configuration'); /** * @summary Read image configuration @@ -40,8 +40,8 @@ const configuration = require('./engine/configuration'); * }); */ exports.readConfiguration = (schema, image) => { - return filesystem.readImageConfiguration(schema.files, image) - .then(_.partial(configuration.extract, schema.mapper)); + return filesystem.readImageConfiguration(schema, image) + .then(_.partial(configuration.extract, schema)); }; /** @@ -64,11 +64,16 @@ exports.readConfiguration = (schema, image) => { * }); */ exports.writeConfiguration = (schema, object, image) => { - return filesystem.readImageConfiguration(schema.files, image).then((current) => { - const data = configuration.generate(schema.mapper, object, { - defaults: current + return filesystem.readImageConfiguration(schema, image).then((data) => { + + _.assignWith(data, configuration.generate(schema, object), (objectValue, sourceValue) => { + if (_.isUndefined(objectValue)) { + return objectValue; + } + + return sourceValue; }); - return filesystem.writeImageConfiguration(schema.files, image, data); + return filesystem.writeImageConfiguration(schema, image, data); }); }; diff --git a/lib/jsontemplate/README.markdown b/lib/jsontemplate/README.markdown deleted file mode 100644 index f282e12a..00000000 --- a/lib/jsontemplate/README.markdown +++ /dev/null @@ -1,54 +0,0 @@ -JSONTemplate -============ - -> Bidirectional JSON-based templating engine - -JSONTemplate is a templating engine that operates on JSON objects, providing -the unique benefit of allowing bidirectional transformations, that is, from -template and data to a result, and from a template and result to the original -data. - -For example, consider the following template: - -```json -{ - "foo": "My name is {{name}}" -} -``` - -Notice the use of double curly braces to denote string interpolation. - -In order to compile this template, we need a `name` value. This is an example -data object that can be used to compile the above template: - -```json -{ - "name": "John Doe" -} -``` - -The compilation result looks like this: - -```json -{ - "foo": "My name is John Doe" -} -``` - -Now consider that we have the compilation result and the template, and we want -to be able to determine what was the original data used to compile it. - -JSONTemplate will realise `"My name is John Doe"` was compiled from `"My name -is {{name}}"`, and therefore that `name` equals `John Doe`. Using this -information, JSONTemplate will "decompile" the template and return back the -following object to the user, which unsurprisingly equals the "data" object: - -```json -{ - "name": "John Doe" -} -``` - -The example objects contain one key and a single interpolation, but on real -templates, there can be complex nesting levels and multiple interpolations -(even many per property). diff --git a/lib/jsontemplate/index.js b/lib/jsontemplate/index.js deleted file mode 100644 index 05202134..00000000 --- a/lib/jsontemplate/index.js +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright 2016 Resin.io - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/** - * @module Reconfix.JSONTemplate - */ - -const _ = require('lodash'); -const string = require('./string'); - -/** - * @summary Compile a JSON template - * @function - * @public - * - * @param {Object} template - json template - * @param {Object} data - template data - * @returns {Object} compilation result - * - * @example - * const result = jsontemplate.compile({ - * greeting: 'Hello, {{name}}!' - * }, { - * name: 'John Doe' - * }); - * - * console.log(result); - * > { - * > greeting: 'Hello, John Doe!' - * > } - */ -exports.compile = (template, data) => { - return _.mapValues(template, (value) => { - if (_.isPlainObject(value)) { - return exports.compile(value, data); - } - - if (_.isString(value)) { - return string.interpolate(value, data); - } - - return value; - }); -}; - -/** - * @summary Decompile a JSON template - * @function - * @public - * - * @param {Object} template - json template - * @param {Object} result - compilation result - * @returns {Object} template data - * - * @example - * const data = jsontemplate.decompile({ - * greeting: 'Hello, {{name}}!' - * }, { - * greeting: 'Hello, John Doe!' - * }); - * - * console.log(data); - * > { - * > name: 'John Doe' - * > } - */ -exports.decompile = (template, result) => { - return _.reduce(template, (data, value, key) => { - const stringValue = _.get(result, key); - - if (_.isPlainObject(value)) { - _.merge(data, exports.decompile(value, stringValue)); - } - - if (_.isString(value)) { - _.merge(data, string.deinterpolate(value, stringValue)); - } - - return data; - }, {}); -}; - -/** - * @summary Check if a compiled object matches a template - * @function - * @public - * - * @param {Object} template - template object - * @param {Object} object - compiled object - * @returns {Boolean} whether object matches template - * - * @example - * if (jsontemplate.matches({ - * foo: '{{bar}}' - * }, } - * foo: 'bar' - * )) { - * console.log('This is a match!'); - * } - */ -exports.matches = (template, object) => { - const data = exports.decompile(template, object); - - try { - return _.isEqual(exports.compile(template, data), object); - } catch (error) { - - // TODO: Terrible way to match the error. - // Use an error code instead. - if (_.startsWith(error.message, 'Missing variable')) { - return false; - } - - throw error; - } -}; diff --git a/lib/jsontemplate/regexes.js b/lib/jsontemplate/regexes.js deleted file mode 100644 index f49170c0..00000000 --- a/lib/jsontemplate/regexes.js +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright 2016 Resin.io - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/** - * @module Reconfix.JSONTemplate.Regexes - */ - -const _ = require('lodash'); - -/** - * @summary RegExp string portions - * @type {Object} - * @constant - * @public - */ -const REGEX = { - capturingType: '((\\w+):)?', - nonCapturingType: '(?:\\w+:)?', - property: '([\\w$_\\.\\[\\]]+)', - openDelimiters: '{{', - closeDelimiters: '}}' -}; - -/** - * @summary Unbounded interpolation RegExp - * @type RegExp - * @constant - * @public - */ -exports.UNBOUNDED_INTERPOLATION = new RegExp([ - REGEX.openDelimiters, - REGEX.capturingType, - REGEX.property, - REGEX.closeDelimiters -].join(''), 'g'); - -/** - * @summary Lodash template interpolation RegExp - * @type RegExp - * @constant - * @public - * - * We need to make a special regular expression without a capturing - * group on the type section, since `_.template` will get confused - * if there is more than one capturing group. - */ -exports.TEMPLATE_INTERPOLATION = new RegExp([ - REGEX.openDelimiters, - REGEX.nonCapturingType, - REGEX.property, - REGEX.closeDelimiters -].join(''), 'g'); - -/** - * @summary Bounded interpolation RegExp - * @type RegExp - * @constant - * @pblic - */ -exports.BOUNDED_INTERPOLATION = new RegExp([ - '^', - REGEX.openDelimiters, - REGEX.capturingType, - REGEX.property, - REGEX.closeDelimiters, - '$' -].join('')); - -/** - * @summary Execute interpolation regex - * @function - * @public - * - * @param {RegExp} regex - interpolation regex - * @param {String} template - template string - * @returns {Object} interpolation details - * - * @example - * const interpolation = regexes.execute(regexes.BOUNDED_INTERPOLATION, '{{string:name}}'); - * - * console.log(interpolation.type); - * > 'string' - * - * console.log(interpolation.property); - * > 'name' - */ -exports.execute = (regex, template) => { - - // Reset global RegExp index - // See: http://stackoverflow.com/a/11477448/1641422 - regex.lastIndex = 0; - - const matches = regex.exec(template); - return { - type: _.nth(matches, 2), - property: _.nth(matches, 3) - }; -}; diff --git a/lib/jsontemplate/string.js b/lib/jsontemplate/string.js deleted file mode 100644 index 385c2198..00000000 --- a/lib/jsontemplate/string.js +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Copyright 2016 Resin.io - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/** - * @module Reconfix.JSONTemplate.String - */ - -const _ = require('lodash'); -const regexes = require('./regexes'); - -/** - * @summary Transform value to another type - * @function - * @private - * - * @param {String} type - new type - * @param {*} value - value to cast - * @returns {*} casted value - * - * @example - * console.log(transformValue('number', '21')); - * > 21 - */ -const transformValue = (type, value) => { - const castFunctions = { - number: parseFloat, - string: String - }; - - const result = _.get(castFunctions, type, _.identity)(value); - - if (_.isNaN(result)) { - throw new Error(`Can't convert ${value} to ${type}`); - } - - return result; -}; - -/** - * @summary Interpolate a string - * @function - * @public - * - * @description - * The gist of this function is: `(template, data) => string` - * - * @param {String} template - template - * @param {Object} data - data - * @returns {*} interpolated result - * - * @example - * console.log(string.interpolate('Hello, {{name}}!', { - * name: 'John Doe' - * })); - * > 'Hello, John Doe!' - */ -exports.interpolate = (template, data) => { - if (regexes.BOUNDED_INTERPOLATION.test(template)) { - const interpolation = regexes.execute(regexes.BOUNDED_INTERPOLATION, template); - const value = _.get(data, interpolation.property); - - if (_.isUndefined(value) || _.isNull(value)) { - throw new Error(`Missing variable ${interpolation.property}`); - } - - return transformValue(interpolation.type, value); - } - - try { - return _.template(template, { - interpolate: regexes.TEMPLATE_INTERPOLATION - })(data); - - // This is a terrible way to intercept an undefined - // variable error to give it a better message, but - // sadly its the best we can to still be able to re-use - // the `_.template` functionality. - } catch (error) { - const undefinedExpression = _.nth(/(.*) is not defined/.exec(error.message), 1); - - if (undefinedExpression) { - error.message = `Missing variable ${undefinedExpression}`; - } - - throw error; - } -}; - -/** - * @summary Create a single property object - * @function - * @private - * - * @param {String} key - object key - * @param {*} value - object value - * @returns {Object} single property object - * - * @example - * console.log(createSinglePropertyObject('foo', 'bar')); - * > { foo: 'bar' } - * - * console.log(createSinglePropertyObject('foo.baz', 'bar')); - * > { foo: { bar: 'baz' } } - */ -const createSinglePropertyObject = (key, value) => { - const object = {}; - - // `_.set` ensures that if `key` is a path - // (e.g: `foo.bar.baz`), it will be expanded correctly. - _.set(object, key, value); - - return object; -}; - -/** - * @summary Deinterpolate a string - * @function - * @public - * - * @description - * The gist of this function is: `(template, string) => data` - * - * @param {String} template - template - * @param {*} string - interpolated string - * @returns {Object} template data - * - * @example - * console.log(string.deinterpolate('Hello, {{name}}!', 'Hello, John Doe!'); - * > { - * > name: 'John Doe' - * > } - */ -exports.deinterpolate = (template, string) => { - if (regexes.BOUNDED_INTERPOLATION.test(template)) { - const interpolation = regexes.execute(regexes.BOUNDED_INTERPOLATION, template); - return createSinglePropertyObject( - interpolation.property, - transformValue(interpolation.type, string) - ); - } - - const templateRegexString = template.replace(regexes.UNBOUNDED_INTERPOLATION, '(.+)'); - const templateRegex = new RegExp(templateRegexString); - const allExpressions = template.match(regexes.UNBOUNDED_INTERPOLATION); - const allValues = _.tail(templateRegex.exec(string)); - - return _.reduce(_.zip(allExpressions, allValues), (data, pair) => { - const interpolation = regexes.execute(regexes.UNBOUNDED_INTERPOLATION, _.first(pair)); - const value = _.last(pair); - - if (_.isUndefined(value)) { - throw new Error(`No match for '${interpolation.property}'`); - } - - _.set(data, interpolation.property, transformValue(interpolation.type, value)); - return data; - }, {}); -}; diff --git a/lib/mapping.js b/lib/mapping.js new file mode 100644 index 00000000..10382925 --- /dev/null +++ b/lib/mapping.js @@ -0,0 +1,312 @@ +/* + * Copyright 2016 Resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/** + * @module Reconfix.Mapping + */ + +const _ = require('lodash'); +const Domain = require('./domain'); +const Template = require('./template'); + +/** + * @summary Get relationships for value + * @function + * @private + * + * @param {Array} mapping - mapping + * @param {*} value - value + * @returns {Object[]} relationships that apply to value + * + * @example + * const relationships = Mapping.getRelationshipsForValue([ + * { + * value: 'foo', + * template: { + * foo: 1 + * } + * }, + * { + * value: 'bar', + * template: { + * bar: 1 + * } + * } + * ], 'foo'); + * + * console.log(relationships); + * > { value: 'foo', template: { foo: 1 } } + */ +exports.getRelationshipsForValue = (mapping, value) => { + return _.filter(mapping, (relationship) => { + return _.isEqual(relationship.value, value); + }); +}; + +/** + * @summary Get the value associated with a template + * @function + * @private + * + * @param {Array} mapping - mapping + * @param {Object} template - template + * @returns {*} value + * + * @example + * const value = Mapping.getTemplateValue([ + * { + * value: 'foo', + * template: { + * foo: 1 + * } + * }, + * { + * value: 'bar', + * template: { + * bar: 1 + * } + * } + * ], { + * foo: 1 + * }); + * + * console.log(value); + * > 'foo' + */ +exports.getTemplateValue = (mapping, template) => { + const values = _.chain(mapping) + .filter((relationship) => { + return _.isEqual(template, relationship.template); + }) + .map('value') + .uniqWith(_.isEqual) + .value(); + + if (_.size(values) > 1) { + throw new Error(`Ambiguous duplicated template: ${JSON.stringify(template)}`); + } + + return _.first(values); +}; + +/** + * @summary Map a value to an object + * @function + * @public + * + * @param {Array[]} mapping - mapping + * @param {*} value - value + * @returns {Object} mapped object + * + * @throws Will throw if value applies to more than one relationship + * + * @example + * const object = Mapping.map([ + * [ 'foo' ] + * ], 'bar'); + * + * console.log(object); + * > { foo: 'bar' } + * + * @example + * const object = Mapping.map([ + * [ 'foo' ], + * [ 'bar' ], + * [ 'baz' ] + * ], '3'); + * + * console.log(object); + * > { foo: 3, bar: 3, baz: 3 } + */ +exports.map = (mapping, value) => { + const relationships = exports.getRelationshipsForValue(mapping, value); + + if (_.size(relationships) > 1) { + throw new Error(`Ambiguous mapping for value: ${value}`); + } + + return _.reduce(mapping, (accumulator, relationship) => { + if (_.isPlainObject(relationship)) { + if (_.isEqual(relationship.value, value)) { + return _.assign(accumulator, relationship.template); + } + + return accumulator; + } + + if (!_.isNull(value)) { + return _.set(accumulator, relationship, value); + } + + return accumulator; + }, {}); +}; + +/** + * @summary Unmap a value from an object + * @function + * @public + * + * @param {Array[]} mapping - mapping + * @param {Object} object - object + * @returns {*} value + * + * @throws Will throw if there is an unmapping ambiguity + * @throws Will throw if no match was found + * + * @example + * const value = Mapping.unmap([ + * [ 'foo' ] + * ], { + * foo: 'bar' + * }); + * + * console.log(value); + * > 'bar' + * + * @example + * const value = Mapping.map([ + * [ 'foo' ], + * [ 'bar' ], + * [ 'baz' ] + * ], { + * foo: 3, + * bar: 3, + * baz: 3 + * }); + * + * console.log(value); + * > 3 + */ +exports.unmap = (mapping, object) => { + const matchingTemplates = Template.getHighestDegreeMatchingTemplates( + Domain.mask(object, Domain.getFromMapping(mapping)), + _.map(_.filter(mapping, _.isPlainObject), 'template') + ); + + const templateValues = _.map(matchingTemplates, _.partial(exports.getTemplateValue, mapping)); + + const directValues = _.map(_.reject(mapping, _.isPlainObject), (relationship) => { + + // We return `null` instead of `undefined` since + // `undefined` is not a valid JSON keyword + return _.get(object, relationship, null); + + }); + + const values = _.uniqWith(_.concat(templateValues, directValues), _.isEqual); + + if (_.size(values) > 1) { + throw new Error(`Ambiguous values: ${values}`); + } + + if (_.isEmpty(values)) { + throw new Error('No match found'); + } + + return _.first(values); +}; + +/** + * @summary Check if a mapping is a template mapping + * @function + * @public + * + * @description + * This function will return false is mapping is mixed. + * + * @param {(Array[]|Object[])} mapping - mapping + * @returns {Boolean} whether the mapping is a direct mapping + * + * @example + * if (Mapping.isTemplateMapping([ + * { + * value: 1, + * template: { + * foo: true + * } + * }, + * { + * value: 0, + * template: { + * foo: false + * } + * } + * ])) { + * console.log('This is a template mapping'); + * } + */ +exports.isTemplateMapping = (mapping) => { + return _.every(_.map(mapping, _.isPlainObject)); +}; + +/** + * @summary Check if a mapping is a direct mapping + * @function + * @public + * + * @description + * This function will return false is mapping is mixed. + * + * @param {(Array[]|Object[])} mapping - mapping + * @returns {Boolean} whether the mapping is a direct mapping + * + * @example + * if (Mapping.isDirectMapping([ + * [ 'foo' ] + * ])) { + * console.log('This is a direct mapping'); + * } + */ +exports.isDirectMapping = (mapping) => { + return _.every(_.map(mapping, _.isArray)); +}; + +/** + * @summary Check if a mapping is a mixed mapping + * @function + * @public + * + * @param {(Array[]|Object[])} mapping - mapping + * @returns {Boolean} whether the mapping is a mixed mapping + * + * @example + * if (Mapping.isMixedMapping([ + * [ 'foo' ], + * { + * value: 1, + * template: { + * foo: true + * } + * }, + * { + * value: 0, + * template: { + * foo: false + * } + * } + * ])) { + * console.log('This is a mixed mapping'); + * } + */ +exports.isMixedMapping = (mapping) => { + return _.every([ + _.some(_.map(mapping, _.isArray)), + _.some(_.map(mapping, _.isPlainObject)) + ]); +}; diff --git a/lib/properties.js b/lib/properties.js new file mode 100644 index 00000000..0a7b7f4d --- /dev/null +++ b/lib/properties.js @@ -0,0 +1,189 @@ +/* + * Copyright 2016 Resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/** + * @module Reconfix.Properties + */ + +const _ = require('lodash'); +const Mapping = require('./mapping'); + +/** + * @summary Check if a property is a leaf property + * @function + * @public + * + * @param {Object} property - property + * @returns {Boolean} whether the property is a leaf property + * + * @example + * if (Properties.isLeafProperty({ + * type: [ 'number' ], + * mapping: [ + * [ 'foo' ] + * ] + * })) { + * console.log('This is a leaf property'); + * } + */ +exports.isLeafProperty = (property) => { + + // There might be a non-leaf property containing a + // children called "type", so we check the actual + // type of "type" to protect ourselves from that ambiguity + return _.isArray(_.get(property, 'type')); + +}; + +/** + * @summary List all property paths + * @function + * @private + * + * @param {Object} properties - properties + * @returns {Array[]} property paths + * + * @example + * const paths = Properties.listPropertyPaths({ + * foo: { + * type: [ 'number' ] + * }, + * bar: { + * type: [ 'number' ] + * }, + * baz: { + * type: [ 'number' ] + * } + * }); + * + * console.log(paths); + * > [ [ 'foo' ], [ 'bar' ], [ 'baz' ] ] + */ +exports.listPropertyPaths = (properties) => { + return _.reduce(properties, (accumulator, property, name) => { + return _.concat(accumulator, _.attempt(() => { + if (exports.isLeafProperty(property)) { + return [ [ name ] ]; + } + + return _.map(exports.listPropertyPaths(property), (path) => { + return _.concat([ name ], path); + }); + })); + }, []); +}; + +/** + * @summary Get mapping from property + * @function + * @private + * + * @param {Object} properties - properties + * @param {Array[]} property - property path + * @returns {Array[]} mapping + * + * @example + * const mapping = Properties.getPropertyMapping({ + * foo: { + * type: [ 'string' ], + * mapping: [ + * [ 'bar' ] + * ] + * } + * }, [ + * 'foo' + * ]); + * + * console.log(mapping); + * > [ [ 'bar' ] ] + */ +exports.getPropertyMapping = (properties, property) => { + return _.get(properties, _.concat(property, [ 'mapping' ])); +}; + +/** + * @summary Get all property paths + * @function + * @public + * + * @description + * This function is wrapper around `Properties.listPropertyPaths()`, which + * makes sure the returned properties are in an order such that Reconfix + * can operate on top of them without conflicts. + * + * @param {Object} properties - properties + * @returns {Array[]} property paths + * + * @example + * const paths = Properties.getPropertyPaths({ + * foo: { + * type: [ 'number' ], + * mapping: [ + * [ 'value1' ] + * ] + * }, + * bar: { + * type: [ 'number' ], + * mapping: [ + * { + * value: 1, + * template: { + * bar: true + * } + * }, + * { + * value: 0, + * template: { + * bar: false + * } + * } + * ] + * } + * }); + * + * console.log(paths); + * > [ [ 'bar' ], [ 'foo' ] ] + */ +exports.getPropertyPaths = (properties) => { + return exports.listPropertyPaths(properties).sort((property1, property2) => { + const mapping1 = exports.getPropertyMapping(properties, property1); + const mapping2 = exports.getPropertyMapping(properties, property2); + + // Prefer mixed mappings above everything + + if (Mapping.isMixedMapping(mapping1)) { + return -1; + } + + if (Mapping.isMixedMapping(mapping2)) { + return 1; + } + + // Prefer template mappings above direct mappings + + if (Mapping.isTemplateMapping(mapping1)) { + return -1; + } + + if (Mapping.isTemplateMapping(mapping2)) { + return 1; + } + + return 0; + }); +}; diff --git a/lib/state.js b/lib/state.js new file mode 100644 index 00000000..8d3bc3e5 --- /dev/null +++ b/lib/state.js @@ -0,0 +1,171 @@ +/* + * Copyright 2016 Resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/** + * @module Reconfix.State + */ + +const _ = require('lodash'); +const Mapping = require('./mapping'); +const Properties = require('./properties'); +const Type = require('./type'); + +/** + * @summary Check property value type + * @function + * @private + * + * @param {String[]} path - property path + * @param {String[]} type - property type + * @param {*} value - property type + * + * @throws Will throw if there is a type mismatch + * + * @example + * checkPropertyValueType([ 'my', 'property' ], [ 'number' ], 3); + */ +const checkPropertyValueType = (path, type, value) => { + + // Every value is optional by default, therefore we don't + // try to match "undefined" or "null" with the property types + if (!_.isNull(value) && !_.isUndefined(value) && !Type.matchesSomeType(type, value)) { + throw new Error(`Type mismatch for "${path}": expected ${type}, but got "${value}"`); + } + +}; + +/** + * @summary Utility to reduce properties to an object + * @function + * @private + * + * @param {Object} properties - properties + * @param {Function} callback - callback (accumulator, property, path) + * @returns {Object} reduced object + * + * @example + * const object = reducePropertiesToObject({ + * foo: { + * type: [ 'number' ], + * mapping: [ + * [ 'bar' ] + * ] + * } + * }, (accumulator, property, path) => { + * + * _.defaults(accumulator, { + * numberOfProperties: 0 + * }); + * + * + * accumulator.numberOfProperties += 1; + * return accumulator; + * }); + * + * console.log(object); + * > { numberOfProperties: 1 } + */ +const reducePropertiesToObject = (properties, callback) => { + const propertyPaths = Properties.getPropertyPaths(properties); + return _.reduce(propertyPaths, (accumulator, propertyPath) => { + const property = _.get(properties, propertyPath); + return callback(accumulator, property, propertyPath); + }, {}); +}; + +/** + * @summary Compile state with a set of properties + * @function + * @public + * + * @param {Object} properties - properties + * @param {Object} state - state + * @returns {Object} compiled state + * + * @throws Will throw if there is a type mismatch + * + * @example + * const object = State.compile({ + * property1: { + * type: [ 'string' ], + * mapping: [ + * [ 'foo' ] + * ] + * }, + * property2: { + * type: [ 'string' ], + * mapping: [ + * [ 'baz' ] + * ] + * } + * }, { + * property1: 'hello', + * property2: 'world' + * }); + * + * console.log(object); + * > { foo: 'hello', bar: 'world' } + */ +exports.compile = (properties, state) => { + return reducePropertiesToObject(properties, (accumulator, property, path) => { + const value = _.get(state, path); + checkPropertyValueType(path, property.type, value); + return _.merge(accumulator, Mapping.map(property.mapping, value)); + }); +}; + +/** + * @summary Deompile state from a set of properties + * @function + * @public + * + * @param {Object} properties - properties + * @param {Object} source - source + * @returns {Object} state + * + * @throws Will throw if there is a type mismatch + * + * @example + * const state = State.decompile({ + * property1: { + * type: [ 'string' ], + * mapping: [ + * [ 'foo' ] + * ] + * }, + * property2: { + * type: [ 'string' ], + * mapping: [ + * [ 'baz' ] + * ] + * } + * }, { + * foo: 'hello', + * bar: 'world' + * }); + * + * console.log(state); + * > { property1: 'hello', property2: 'world' } + */ +exports.decompile = (properties, source) => { + return reducePropertiesToObject(properties, (accumulator, property, path) => { + const value = Mapping.unmap(property.mapping, source); + checkPropertyValueType(path, property.type, value); + return _.set(accumulator, path, value); + }); +}; diff --git a/lib/template.js b/lib/template.js new file mode 100644 index 00000000..d5f1f416 --- /dev/null +++ b/lib/template.js @@ -0,0 +1,186 @@ +/* + * Copyright 2016 Resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/** + * @module Reconfix.Template + */ + +const _ = require('lodash'); +const Type = require('./type'); + +/** + * @summary "Type wildcard" regular expression + * @type String + * @constant + * @private + */ +const REGEX_TYPE_WILDCARD = /^\[\[([a-z|]+)\]\]$/; + +/** + * @summary Check if a string represents a "type wildcard" + * @function + * @private + * + * @param {String} string - string + * @returns {Boolean} whether the string represents a "type wildcard" + * + * @example + * if (Template.isTypeWildcard('[[string]]')) { + * console.log('This is a type wildcard'); + * } + */ +exports.isTypeWildcard = (string) => { + return REGEX_TYPE_WILDCARD.test(string); +}; + +/** + * @summary Get "type wildcard" type + * @function + * @private + * + * @param {String} string - string + * @returns {(String[]|Undefined)} wildcard type, if any + * + * @example + * const type = Template.getWildcardType('[[string|number]]'); + * console.log(type); + * > [ 'string', 'number' ] + */ +exports.getWildcardType = (string) => { + const type = _.nth(REGEX_TYPE_WILDCARD.exec(string), 1); + + if (_.isUndefined(type)) { + return; + } + + return _.split(type, '|'); +}; + +/** + * @summary Match a template with an object + * @function + * @public + * + * @param {Object} object - object + * @param {Object} template - template + * @returns {Boolean} whether the template matches the object + * + * @example + * Template.matches({ + * foo: { + * bar: 'baz' + * } + * }, { + * foo: { + * bar: '[[string]]' + * } + * }); + * > true + */ +exports.matches = (object, template) => { + return _.every(template, (value, key) => { + const objectValue = _.get(object, key); + + if (exports.isTypeWildcard(value)) { + const wildcardType = exports.getWildcardType(value); + return Type.matchesSomeType(wildcardType, objectValue); + } + + if (_.isArray(value)) { + return _.isEmpty(_.differenceWith(objectValue, value, _.isEqual)); + } + + if (_.isPlainObject(value)) { + return exports.matches(objectValue, value); + } + + return _.isEqual(objectValue, value); + }); +}; + +/** + * @summary Get template degree + * @function + * @public + * + * @description + * The degree of a template is calculated based on + * its number of keys. + * + * @param {Object} template - mplate + * @returns {Number} template degree + * + * @example + * const degree = Template.getTemplateDegree({ + * foo: { + * bar: { + * baz: 'qux' + * } + * } + * }); + * + * console.log(degree); + * > 3 + */ +exports.getTemplateDegree = (template) => { + return _.reduce(template, (accumulator, value) => { + const nestedKeys = _.isPlainObject(value) + ? exports.getTemplateDegree(value) : 0; + + return accumulator + nestedKeys + 1; + }, 0); +}; + +/** + * @summary Get highest degree matching templates for object + * @function + * @public + * + * @param {Object} object - object + * @param {Object[]} templates - templates + * @returns {Object[]} highest degree matching templates + * + * @example + * const result = Template.getHighestDegreeMatchingTemplates({ + * foo: 1, + * bar: 2, + * baz: 3 + * }, [ + * { + * foo: 1 + * }, + * { + * foo: 1, + * bar: 2 + * } + * ]); + * + * console.log(result); + * > { foo: 1, bar: 2 } + */ +exports.getHighestDegreeMatchingTemplates = (object, templates) => { + const matchingTemplates = _.filter(templates, _.partial(exports.matches, object)); + + const highestMatchingDegree = _.reduce(matchingTemplates, (accumulator, template) => { + return _.max([ exports.getTemplateDegree(template), accumulator ]); + }, 0); + + return _.filter(matchingTemplates, (template) => { + return exports.getTemplateDegree(template) === highestMatchingDegree; + }); +}; diff --git a/lib/type.js b/lib/type.js new file mode 100644 index 00000000..56ed628d --- /dev/null +++ b/lib/type.js @@ -0,0 +1,104 @@ +/* + * Copyright 2016 Resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/** + * @module Reconfix.Type + */ + +const _ = require('lodash'); + +/** + * @summary Type check functions by type name + * @type Object + * @constant + * @private + */ +const TYPE_CHECKS = { + number: _.isNumber, + string: _.isString, + boolean: _.isBoolean, + object: _.isPlainObject, + array: _.isArray +}; + +/** + * @summary Supported types + * @type String[] + * @constant + * @private + */ +const VALID_TYPES = _.keys(TYPE_CHECKS); + +/** + * @summary Check if a type is a valid type + * @function + * @public + * + * @param {String} type - type + * @returns {Boolean} whether the type is valid + * + * @example + * if (Type.isValidType('string')) { + * console.log('"string" is a valid type'); + * } + */ +exports.isValidType = _.partial(_.includes, VALID_TYPES); + +/** + * @summary Check if a value matches a type + * @function + * @public + * + * @param {String} type - type + * @param {String} value - value + * @returns {Boolean} whether `value` matches the type + * + * @throws Will throw if `type` is invalid + * + * @example + * if (Type.matchesType('number', 3)) { + * console.log('3 is a number'); + * } + */ +exports.matchesType = (type, value) => { + if (!exports.isValidType(type)) { + throw new Error(`Invalid type: ${type}`); + } + + return _.invoke(TYPE_CHECKS, type, value); +}; + +/** + * @summary Check if a value matches any type inside a list + * @function + * @public + * + * @param {String[]} types - types + * @param {String} value - value + * @returns {Boolean} whether `value` matches any type + * + * @example + * if (Type.matchesSomeType([ 'number', 'string' ], 3)) { + * console.log('3 matches some type'); + * } + */ +exports.matchesSomeType = (types, value) => { + return _.some(types, (type) => { + return exports.matchesType(type, value); + }); +}; diff --git a/package.json b/package.json index 0b060ea8..d6e4557f 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,9 @@ "url": "git://github.com/resin-io/reconfix.git" }, "scripts": { - "test": "npm run lint && npm run unit", - "unit": "ava tests", + "test": "npm run lint && npm run unit:mocha && npm run unit:ava", + "unit:ava": "ava tests/integration", + "unit:mocha": "mocha -R spec tests/*.spec.js", "lint": "eslint lib tests" }, "keywords": [ @@ -28,7 +29,9 @@ "license": "Apache-2.0", "devDependencies": { "ava": "^0.16.0", + "chai": "^3.5.0", "eslint": "^3.6.1", + "mocha": "^3.2.0", "tmp": "0.0.29" }, "dependencies": { diff --git a/tests/configuration.spec.js b/tests/configuration.spec.js new file mode 100644 index 00000000..f9018e7e --- /dev/null +++ b/tests/configuration.spec.js @@ -0,0 +1,237 @@ +/* + * Copyright 2016 Resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const chai = require('chai'); +const Configuration = require('../lib/configuration'); + +describe('Configuration', function() { + + describe('bidirectional property', function() { + + const bidirectional = (title, schema, state, source) => { + it(`.generate() ${title}`, function() { + chai.expect(Configuration.generate(schema, state)).to.deep.equal(source); + }); + + it(`.extract() ${title}`, function() { + chai.expect(Configuration.extract(schema, source)).to.deep.equal({ + tainted: [], + result: state + }); + }); + }; + + bidirectional('should process a single simple mapping on a single file', { + foo: { + properties: { + bar: { + type: [ 'number' ], + mapping: [ + [ 'baz' ] + ] + } + } + } + }, { + bar: 3 + }, { + foo: { + baz: 3 + } + }); + + bidirectional('should process a multiple simple mappings on multiple files', { + foo: { + properties: { + name: { + type: [ 'string' ], + mapping: [ + [ 'fullname' ] + ] + }, + job: { + type: [ 'string' ], + mapping: [ + [ 'job', 'title' ] + ] + } + } + }, + bar: { + properties: { + age: { + type: [ 'number' ], + mapping: [ + [ 'person', 'age' ] + ] + } + } + } + }, { + name: 'John Doe', + job: 'Software Engineer', + age: 35 + }, { + foo: { + fullname: 'John Doe', + job: { + title: 'Software Engineer' + } + }, + bar: { + person: { + age: 35 + } + } + }); + + }); + + describe('.extract()', function() { + + it('should add a file to the "tainted" list if something failed', function() { + chai.expect(Configuration.extract({ + foo: { + properties: { + bar: { + type: [ 'number' ], + mapping: [ + [ 'xxx' ] + ] + } + } + }, + bar: { + properties: { + baz: { + type: [ 'string' ], + mapping: [ + [ 'yyy' ] + ] + } + } + } + }, { + foo: { + xxx: 'foo' + }, + bar: { + yyy: 'bar' + } + })).to.deep.equal({ + tainted: [ 'foo' ], + result: { + baz: 'bar' + } + }); + }); + + it('should be able to mark more than one file as tainted', function() { + chai.expect(Configuration.extract({ + foo: { + properties: { + bar: { + type: [ 'number' ], + mapping: [ + [ 'xxx' ] + ] + } + } + }, + bar: { + properties: { + baz: { + type: [ 'string' ], + mapping: [ + [ 'yyy' ], + [ 'zzz' ] + ] + } + } + }, + success: { + properties: { + value: { + type: [ 'string' ], + mapping: [ + [ 'the', 'value' ] + ] + } + } + } + }, { + foo: { + xxx: 'foo' + }, + bar: { + yyy: 'bar', + zzz: 'qux' + }, + success: { + the: { + value: 'success' + } + } + })).to.deep.equal({ + tainted: [ 'foo', 'bar' ], + result: { + value: 'success' + } + }); + }); + + it('should return an empty object if everything is tainted', function() { + chai.expect(Configuration.extract({ + foo: { + properties: { + bar: { + type: [ 'number' ], + mapping: [ + [ 'xxx' ] + ] + } + } + }, + bar: { + properties: { + baz: { + type: [ 'string' ], + mapping: [ + [ 'yyy' ], + [ 'zzz' ] + ] + } + } + } + }, { + foo: { + xxx: 'foo' + }, + bar: { + yyy: 'bar', + zzz: 'qux' + } + })).to.deep.equal({ + tainted: [ 'foo', 'bar' ], + result: {} + }); + }); + + }); + +}); diff --git a/tests/connector.spec.js b/tests/connector.spec.js new file mode 100644 index 00000000..ab68cf22 --- /dev/null +++ b/tests/connector.spec.js @@ -0,0 +1,188 @@ +/* + * Copyright 2016 Resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const _ = require('lodash'); +const Bluebird = require('bluebird'); +const chai = require('chai'); +const sinon = require('sinon'); +const Connector = require('../lib/connector'); + +describe('Connector', function() { + + describe('.BUILTIN_CONNECTORS', function() { + + it('should not be empty', function() { + chai.expect(_.isEmpty(Connector.BUILTIN_CONNECTORS)).to.be.false; + }); + + it('should be a plain object', function() { + chai.expect(_.isPlainObject(Connector.BUILTIN_CONNECTORS)).to.be.true; + }); + + it('should contain objects with an "set" function', function() { + _.each(Connector.BUILTIN_CONNECTORS, (connector) => { + chai.expect(_.isFunction(connector.set)).to.be.true; + }); + }); + + }); + + describe('.getType()', function() { + + it('should return the type of the connector', function() { + chai.expect(Connector.getType({ + type: 'json', + path: [ 'config.txt' ], + partition: { + primary: 1 + } + })).to.equal('json'); + }); + + }); + + describe('.getOptions()', function() { + + it('should return the connector options', function() { + chai.expect(Connector.getOptions({ + type: 'json', + path: [ 'config.txt' ], + partition: { + primary: 1 + } + })).to.deep.equal({ + path: [ 'config.txt' ], + partition: { + primary: 1 + } + }); + }); + + it('should return an empty object if the connect has no options', function() { + chai.expect(Connector.getOptions({ + type: 'null' + })).to.deep.equal({}); + }); + + }); + + describe('.set()', function() { + + it('should throw if the connector type does not exist', function() { + chai.expect(() => { + Connector.set({ + type: 'foobar', + url: 'foobar.com' + }, { + foo: { + bar: 'baz' + } + }, { + connectors: {} + }); + }).to.throw('Unknown connector type: "foobar"'); + }); + + it('should throw if the connector type does not contain an set function', function() { + chai.expect(() => { + Connector.set({ + type: 'foobar', + url: 'foobar.com' + }, { + foo: { + bar: 'baz' + } + }, { + connectors: { + foobar: { + set: 1 + } + } + }); + }).to.throw('Invalid connector type: "foobar", "set" is not a function'); + }); + + it('should pass the options and the data to the connector', function(done) { + const fakeExecutor = sinon.stub(); + fakeExecutor.returns(Bluebird.resolve()); + + Connector.set({ + type: 'stub', + option1: 'value1', + option2: 'value2' + }, { + foo: { + bar: { + baz: 1 + } + } + }, { + connectors: { + stub: { + set: fakeExecutor + } + } + }).finally(() => { + chai.expect(fakeExecutor.callCount).to.equal(1); + chai.expect(fakeExecutor.firstCall.args).to.deep.equal([ + { + option1: 'value1', + option2: 'value2' + }, + { + foo: { + bar: { + baz: 1 + } + } + } + ]); + done(); + }); + }); + + it('should be rejected if the executor rejects', function(done) { + const fakeExecutor = sinon.stub(); + fakeExecutor.returns(Bluebird.reject(new Error('Executor error'))); + + Connector.set({ + type: 'stub', + option1: 'value1', + option2: 'value2' + }, { + foo: { + bar: { + baz: 1 + } + } + }, { + connectors: { + stub: { + set: fakeExecutor + } + } + }).catch((error) => { + chai.expect(error).to.be.an.instanceof(Error); + chai.expect(error.message).to.equal('Executor error'); + done(); + }); + }); + + }); + +}); diff --git a/tests/domain.spec.js b/tests/domain.spec.js new file mode 100644 index 00000000..4c5bc4b8 --- /dev/null +++ b/tests/domain.spec.js @@ -0,0 +1,155 @@ +/* + * Copyright 2016 Resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const chai = require('chai'); +const Domain = require('../lib/domain'); + +describe('Domain', function() { + + describe('.mask()', function() { + + it('should return an empty object if the path list is empty', function() { + chai.expect(Domain.mask({ + foo: { + bar: 'baz' + } + }, [])).to.deep.equal({}); + }); + + it('should ignore invalid paths', function() { + chai.expect(Domain.mask({ + foo: 1, + bar: 2, + baz: 3 + }, [ + [ 'bar' ], + [ 'qux' ] + ])).to.deep.equal({ + bar: 2 + }); + }); + + it('should mask an object with a single path', function() { + chai.expect(Domain.mask({ + foo: 1, + bar: 2, + baz: 3 + }, [ + [ 'bar' ] + ])).to.deep.equal({ + bar: 2 + }); + }); + + it('should mask an object with multiple paths', function() { + chai.expect(Domain.mask({ + foo: 1, + bar: 2, + baz: 3 + }, [ + [ 'foo' ], + [ 'baz' ] + ])).to.deep.equal({ + foo: 1, + baz: 3 + }); + }); + + it('should mask an object with a single nested path', function() { + chai.expect(Domain.mask({ + foo: { + bar: { + qux: { + foo: 3 + }, + baz: 1 + } + } + }, [ + [ 'foo', 'bar', 'baz' ] + ])).to.deep.equal({ + foo: { + bar: { + baz: 1 + } + } + }); + }); + + }); + + describe('.getFromMapping()', function() { + + it('should return an empty array for a simple mapping', function() { + chai.expect(Domain.getFromMapping([ + [ 'foo', 'bar' ], + [ 'foo', 'baz' ] + ])).to.deep.equal([]); + }); + + it('should avoid duplicates from a set of choices', function() { + chai.expect(Domain.getFromMapping([ + { + value: true, + template: { + foo: { + bar: 1 + } + } + }, + { + value: false, + template: { + foo: { + bar: 2 + } + } + } + ])).to.deep.equal([ [ 'foo' ] ]); + }); + + it('should get the domain of a set of choices with multi-key templates', function() { + chai.expect(Domain.getFromMapping([ + { + value: true, + template: { + foo: { + bar: 1 + } + } + }, + { + value: false, + template: { + foo: { + bar: 1 + }, + baz: { + qux: 5 + } + } + } + ])).to.deep.equal([ + [ 'foo' ], + [ 'baz' ] + ]); + }); + + }); + +}); diff --git a/tests/engine/configuration/bidirectional.spec.js b/tests/engine/configuration/bidirectional.spec.js deleted file mode 100644 index a56fe78d..00000000 --- a/tests/engine/configuration/bidirectional.spec.js +++ /dev/null @@ -1,152 +0,0 @@ -/* - * Copyright 2016 Resin.io - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const ava = require('ava'); -const path = require('path'); -const configuration = require('../../../lib/engine/configuration'); - -const testFixture = (name) => { - const fixturePath = path.join(__dirname, 'fixtures', name); - const files = { - dry: require(path.join(fixturePath, 'dry.json')), - wet: require(path.join(fixturePath, 'wet.json')), - wetExtra: require(path.join(fixturePath, 'wet-extra.json')), - schema: require(path.join(fixturePath, 'schema.json')) - }; - - ava.test(`(${name}) should generate configuration`, (test) => { - test.deepEqual(configuration.generate(files.schema, files.dry), files.wet); - }); - - ava.test(`(${name}) should extract settings`, (test) => { - test.deepEqual(configuration.extract(files.schema, files.wet), files.dry); - }); - - ava.test(`(${name}) should ignore extra settings when extracting`, (test) => { - test.deepEqual(configuration.extract(files.schema, files.wetExtra), files.dry); - }); -}; - -testFixture('resinos-v1'); - -ava.test('.generate() should preserve custom defaults values', (test) => { - - /* eslint-disable camelcase */ - - test.deepEqual(configuration.generate([ - { - template: { - gpu_mem_1024: '{{gpuMem1024}}' - }, - domain: [ - [ 'config_txt', 'gpu_mem_1024' ] - ] - } - ], { - gpuMem1024: 64 - }, { - defaults: { - config_txt: { - gpu_mem_1024: 32, - foo: 'bar', - bar: 'baz' - } - } - }), { - config_txt: { - gpu_mem_1024: 64, - foo: 'bar', - bar: 'baz' - } - }); - - /* eslint-enable camelcase */ - -}); - -ava.test('.generate() should override custom default values in choices', (test) => { - - /* eslint-disable camelcase */ - - test.deepEqual(configuration.generate([ - { - property: [ 'network', 'type' ], - domain: [ - [ 'network_config', 'service_home_ethernet' ], - [ 'network_config', 'service_home_wifi' ] - ], - choice: [ - { - value: 'ethernet', - template: { - service_home_ethernet: { - type: 'ethernet', - nameservers: '8.8.8.8,8.8.4.4' - } - } - }, - { - value: 'wifi', - template: { - service_home_ethernet: { - type: 'ethernet', - nameservers: '8.8.8.8,8.8.4.4' - }, - service_home_wifi: { - hidden: true, - type: 'wifi', - name: '{{network.ssid}}', - passphrase: '{{network.key}}', - nameservers: '8.8.8.8,8.8.4.4' - } - } - } - ] - } - ], { - network: { - type: 'ethernet' - } - }, { - defaults: { - network_config: { - service_home_ethernet: { - type: 'ethernet', - nameservers: '8.8.8.8,8.8.4.4' - }, - service_home_wifi: { - hidden: true, - type: 'wifi', - name: 'resin', - passphrase: 'secret', - nameservers: '8.8.8.8,8.8.4.4' - } - } - } - }), { - network_config: { - service_home_ethernet: { - type: 'ethernet', - nameservers: '8.8.8.8,8.8.4.4' - } - } - }); - - /* eslint-enable camelcase */ - -}); diff --git a/tests/engine/configuration/fixtures/resinos-v1/dry.json b/tests/engine/configuration/fixtures/resinos-v1/dry.json deleted file mode 100644 index c6105dcf..00000000 --- a/tests/engine/configuration/fixtures/resinos-v1/dry.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "gpuMem1024": 64, - "appUpdatePollInterval": 60000, - "network": { - "type": "wifi", - "ssid": "resin", - "key": "secret" - } -} diff --git a/tests/engine/configuration/fixtures/resinos-v1/schema.json b/tests/engine/configuration/fixtures/resinos-v1/schema.json deleted file mode 100644 index de86ad84..00000000 --- a/tests/engine/configuration/fixtures/resinos-v1/schema.json +++ /dev/null @@ -1,52 +0,0 @@ -[ - { - "template": { - "gpu_mem_1024": "{{gpuMem1024}}" - }, - "domain": [ - [ "config_txt", "gpu_mem_1024" ] - ] - }, - { - "template": { - "appUpdatePollInterval": "{{appUpdatePollInterval}}" - }, - "domain": [ - [ "config_json", "appUpdatePollInterval" ] - ] - }, - { - "property": [ "network", "type" ], - "domain": [ - [ "network_config", "service_home_ethernet" ], - [ "network_config", "service_home_wifi" ] - ], - "choice": [ - { - "value": "ethernet", - "template": { - "service_home_ethernet": { - "type": "ethernet", - "nameservers": "8.8.8.8,8.8.4.4" - } - } - }, - { - "value": "wifi", - "template": { - "service_home_ethernet": { - "type": "ethernet", - "nameservers": "8.8.8.8,8.8.4.4" - }, - "service_home_wifi": { - "hidden": true, - "type": "wifi", - "name": "{{network.ssid}}", - "passphrase": "{{network.key}}", - "nameservers": "8.8.8.8,8.8.4.4" - } - } - } - ] - } -] diff --git a/tests/engine/configuration/fixtures/resinos-v1/wet-extra.json b/tests/engine/configuration/fixtures/resinos-v1/wet-extra.json deleted file mode 100644 index 95244245..00000000 --- a/tests/engine/configuration/fixtures/resinos-v1/wet-extra.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "config_txt": { - "gpu_mem_1024": 64, - "gpu_mem": 16, - "dtparam": "spi=on", - "device_tree_overlay": "w1-gpio-pullup-overlay.dtb" - }, - "config_json": { - "userId": 9999, - "appUpdatePollInterval": 60000 - }, - "network_config": { - "service_home_ethernet": { - "type": "ethernet", - "nameservers": "8.8.8.8,8.8.4.4" - }, - "service_home_wifi": { - "hidden": true, - "type": "wifi", - "name": "resin", - "passphrase": "secret", - "nameservers": "8.8.8.8,8.8.4.4" - } - } -} diff --git a/tests/engine/configuration/fixtures/resinos-v1/wet.json b/tests/engine/configuration/fixtures/resinos-v1/wet.json deleted file mode 100644 index 1b9d2191..00000000 --- a/tests/engine/configuration/fixtures/resinos-v1/wet.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "config_txt": { - "gpu_mem_1024": 64 - }, - "config_json": { - "appUpdatePollInterval": 60000 - }, - "network_config": { - "service_home_ethernet": { - "type": "ethernet", - "nameservers": "8.8.8.8,8.8.4.4" - }, - "service_home_wifi": { - "hidden": true, - "type": "wifi", - "name": "resin", - "passphrase": "secret", - "nameservers": "8.8.8.8,8.8.4.4" - } - } -} diff --git a/tests/engine/configuration/unmatch.spec.js b/tests/engine/configuration/unmatch.spec.js deleted file mode 100644 index 21b52999..00000000 --- a/tests/engine/configuration/unmatch.spec.js +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright 2016 Resin.io - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const ava = require('ava'); -const configuration = require('../../../lib/engine/configuration'); - -ava.test('.extract() throw if current data does not match the schema', (test) => { - - /* eslint-disable camelcase */ - - const schema = [ - { - property: [ 'networkType' ], - domain: [ - [ 'network_config', 'service_home_ethernet' ], - [ 'network_config', 'service_home_wifi' ] - ], - choice: [ - { - value: 'ethernet', - template: { - service_home_ethernet: { - type: 'ethernet', - nameservers: '8.8.8.8,8.8.4.4' - } - } - }, - { - value: 'wifi', - template: { - service_home_ethernet: { - type: 'ethernet', - nameservers: '8.8.8.8,8.8.4.4' - }, - service_home_wifi: { - hidden: true, - type: 'wifi', - name: '{{networkSsid}}', - passphrase: '{{networkKey}}', - nameservers: '8.8.8.8,8.8.4.4' - } - } - } - ] - } - ]; - - const wet = { - network_config: { - service_home_ethernet: { - type: 'ethernet', - nameservers: '8.8.8.8,8.8.4.4' - }, - service_work_wifi: { - type: 'wifi', - name: 'resin', - passphrase: 'secret', - nameservers: '8.8.8.8' - } - } - }; - - /* eslint-enable camelcase */ - - test.throws(() => { - configuration.extract(schema, wet); - }, [ - 'The current state doesn\'t match the schema.', - '', - 'Current configuration:', - '', - '{', - ' "service_home_ethernet": {', - ' "type": "ethernet",', - ' "nameservers": "8.8.8.8,8.8.4.4"', - ' },', - ' "service_work_wifi": {', - ' "type": "wifi",', - ' "name": "resin",', - ' "passphrase": "secret",', - ' "nameservers": "8.8.8.8"', - ' }', - '}', - '', - 'Schema choices:', - '', - '[', - ' {', - ' "value": "ethernet",', - ' "template": {', - ' "service_home_ethernet": {', - ' "type": "ethernet",', - ' "nameservers": "8.8.8.8,8.8.4.4"', - ' }', - ' }', - ' },', - ' {', - ' "value": "wifi",', - ' "template": {', - ' "service_home_ethernet": {', - ' "type": "ethernet",', - ' "nameservers": "8.8.8.8,8.8.4.4"', - ' },', - ' "service_home_wifi": {', - ' "hidden": true,', - ' "type": "wifi",', - ' "name": "{{networkSsid}}",', - ' "passphrase": "{{networkKey}}",', - ' "nameservers": "8.8.8.8,8.8.4.4"', - ' }', - ' }', - ' }', - ']' - ].join('\n')); -}); diff --git a/tests/integration/fixtures/resinos-v1-ethernet/data.json b/tests/integration/fixtures/resinos-v1-ethernet/data.json index 6c3d3baa..87ce567f 100644 --- a/tests/integration/fixtures/resinos-v1-ethernet/data.json +++ b/tests/integration/fixtures/resinos-v1-ethernet/data.json @@ -1,5 +1,7 @@ { "gpuMem": 16, "appUpdatePollInterval": "60000", - "networkType": "ethernet" + "networkType": "ethernet", + "networkSsid": null, + "networkKey": null } diff --git a/tests/integration/fixtures/resinos-v1-ethernet/image.img b/tests/integration/fixtures/resinos-v1-ethernet/image.img index 7476632e0125dc83059b0248c98b179bac32abad..6efc17753a8562656c858a9def1f578d7100991e 100644 GIT binary patch delta 3922 zcmeI!_g7PA9LMoof&&x;K@lei;vyOeBN0bJ2tu_(ZEMw5tF5(lwOZTSYAx#5g$hBG zxFCXii=sG*d+)s!75Bou>E{Edt-tiNe*pKK_v^X$W^tawdF~S*AMX**L=h)Zi?fuH z(o#msii@~PIVmr0;w}}$Lp-ITc!{@ElFH&ERm4}SN;Ro2HKeB05g24#k>l=UtQciC z_p>Nbj+sueO1fKWJEQBrzLsgMtoFC1*nBgM^OSts=1gNPB|j%G(-_u4?Zx{qjRU=_ z;4CgBPNm{lS`~=j$+u;^TY zjryRzs2}`Sy3{Yh*HobG#RBL8=8WqqG@P4nt{?#I7N~ALJAytSksYZAs# zNoo;4;!kRm01`;*kRVc*)Fbst1JaNL6AfuZ8WSyPLPCg+gpx24P9lh&7)VnRNt%)7 zqy=e7jKoBuh?zta3yC4ENUY+(I-68);+uw}FL7Kgr7grQ}QUzTL}&9_-N zzrJFcPpQ;*o_O6{ZHEGGxFr>2TNRK@CXy5~iA*M`#73r&sbm_NPG*oal1?xU1=%x6 z2FWC|$ZRr)%q8>4e6oNnB#X#mvV<%p%gAz)MOKiNB%7=vt4R)7L)MaYWIfqHHj-T8 zAe+c$vW09V+sJmZgX|=`$ZoQS>?QliesX{uB!|dha)cZu$H;MVf}A9$$Z2whoF(VT zd2)eVB$r4YxlFE*t0bRXBiG3da+BO5x5*uHm)s-w$pcbA9+F4oF?m9sl4s;Oc|l&1 TSL8K$L*6Qmg6u-2LhC;P;Qe)i delta 3941 zcmeI!_g9lw9LMoof*>e}3Mvj%#BDSIL~te{LDJd~}+s(ZYuUua@ zV>z>PU~ZuDcV)T7s(n5gaYpa|{4Cp8LE9_d;t0$(&Q&JI=4@ki6_cBvZEV^>JB0I3 zjD!5A;S8I_rqN)==AjAEXf$TW%qKWiIW}|9JMuZ09qMV(WK9nGT+>sd%Of}@!eEHD z8VovXXiStYJiJ+iF3KDhs2qaLUy>V-Z) zz0rs0BlI!qgZiRR(5I*$`V93)NoW8Xhz6n0(O@(L4Mks|FVR(K_Z5#=El z+JrWvEodv+hPI;}XeZi*cB4ILFWQIpqXXz5I)o0RBj_ml2^~Yn(Ft@CokFM4&*%&~ zi_W3*=mN?|7tt^1S9A$oMpw{P^c%W{uA|@44RjOTLbp)?x`Xbbd+0uTfF7bp=rMYN zo}y>yIeLK#$187@hKL){5_eL9co0ugl6aA#ZM~R3|k^O%hCMkq}aw)FE|AJyM@EAPq?)q9ct-6QU;u5=z2IIB7~E zNHY>iqDXVng0v*9NNduD7>S9PNi?w#D~Ta(Nvv|=KB9bLE7!jkkUF`SW<*$t>y@%n zyl`(TEYxcBLm#BOOjRnXlb$o;Y|6Lk$A^z~?Yn)`rYuMM%!G`o9pW;xyJpUsmg($l z@3`ecmkp^{!@w?nRjs%{(G`MRNe!Ld%hphBif(LnTk%l51jYXXRlEenOAz~imY_?8 zv^=jA1%{ImWF!&tEg3~dlQCp08AryG31lKkC27i;=Ve#z{JBql^(v#$)KKM(%Bgn` zQge!K(Jd>^2)5%X`RWvo|9)C?yDHi}I|K19N<};4F@E~jHrknw!*`U$8P>wCoXX-l ziZMD)J2>9r3{17FLH_BlHrAN3m3+CecFx2{fA6fF`9+6jr%HP{{c>;ZGU3qW77}i< zghz#%BXkD6Uat$sep+WT=uJ9ngvn}+)<=a#>Z5WeC0sb8&C9SWU%w_cUDB}LyEN{j zy^kA=DtvXu?|?pE!~M_^L&^TPQ7TJ6Zh zgrq*5`Xt(t#w91E_8XAg&(y5cvU)t^@gsI+#&KOTWSyd8&XUG+&T z$gwLhiA*Nnk?%=5$si8$1DQgml4&H9OeZr4)};)oAScNwa+>^1&XBX@963)ekbH8H{6c;um&j#u zgn-q{ce { ava.test(`(${fixtureName}) should read settings`, (test) => { @@ -34,7 +35,10 @@ _.each([ const data = require(path.join(fixturePath, 'data.json')); return reconfix.readConfiguration(schema, imagePath).then((configuration) => { - test.deepEqual(configuration, data); + test.deepEqual(_.omitBy(configuration, _.isUndefined), { + tainted: [], + result: data + }); }); }); diff --git a/tests/integration/write.spec.js b/tests/integration/write.spec.js index 966be689..60432509 100644 --- a/tests/integration/write.spec.js +++ b/tests/integration/write.spec.js @@ -20,12 +20,13 @@ const ava = require('ava'); const Bluebird = require('bluebird'); const tmp = Bluebird.promisifyAll(require('tmp')); const path = require('path'); -const imagefs = require('resin-image-fs'); const fs = require('fs'); const rindle = require('rindle'); const filesystem = require('../../lib/engine/filesystem'); const reconfix = require('../../lib'); +// const imagefs = require('resin-image-fs'); + const createTemporaryFileFromFile = (file) => { return tmp.fileAsync().tap((temporaryFilePath) => { const stream = fs.createReadStream(file) @@ -55,7 +56,10 @@ ava.test('should switch an ethernet resin image into a wifi one', (test) => { }).then(() => { return reconfix.readConfiguration(files.ethernet.schema, imagePath); }).then((settings) => { - test.deepEqual(settings, files.wifi.data); + test.deepEqual(settings, { + tainted: [], + result: files.wifi.data + }); }); }); }); @@ -81,7 +85,10 @@ ava.test('should switch a wifi resin image into an ethernet one', (test) => { }).then(() => { return reconfix.readConfiguration(files.wifi.schema, imagePath); }).then((settings) => { - test.deepEqual(settings, files.ethernet.data); + test.deepEqual(settings, { + tainted: [], + result: files.ethernet.data + }); }); }); }); @@ -121,100 +128,100 @@ ava.test('should extend the current values instead of overwriting', (test) => { }); -ava.test('should be able to modify a fileset', (test) => { - const fixturesPath = path.join(__dirname, 'fixtures'); - const schema = require(path.join(fixturesPath, 'resinos-v2', 'schema.json')); - const fixtureImage = path.join(fixturesPath, 'resinos-v2', 'image.img'); - - const readFiles = (image) => { - return Bluebird.props({ - cellular: imagefs.readFile({ - image: image, - partition: schema.files.system_connections.location.partition, - path: path.join(schema.files.system_connections.location.path, 'cellular') - }), - ethernet: imagefs.readFile({ - image: image, - partition: schema.files.system_connections.location.partition, - path: path.join(schema.files.system_connections.location.path, 'ethernet') - }), - wifi: imagefs.readFile({ - image: image, - partition: schema.files.system_connections.location.partition, - path: path.join(schema.files.system_connections.location.path, 'wifi') - }) - }); - }; - - return createTemporaryFileFromFile(fixtureImage).then((imagePath) => { - return readFiles(imagePath).then((files) => { - test.deepEqual(files, { - cellular: '[connection]\nname=cellular\n', - ethernet: '[connection]\nname=ethernet\n', - wifi: '[connection]\nname=wifi\n' - }); - - return reconfix.writeConfiguration(schema, { - cellularConnectionName: 'newcellular', - ethernetConnectionName: 'newethernet', - wifiConnectionName: 'newwifi' - }, imagePath); - }).then(() => { - return readFiles(imagePath); - }).then((files) => { - test.deepEqual(files, { - cellular: '[connection]\nname=newcellular', - ethernet: '[connection]\nname=newethernet', - wifi: '[connection]\nname=newwifi' - }); - }); - }); -}); - -ava.test('should not override custom properties inside a fileset', (test) => { - const fixturesPath = path.join(__dirname, 'fixtures'); - const schema = require(path.join(fixturesPath, 'resinos-v2', 'schema.json')); - const fixtureImage = path.join(fixturesPath, 'resinos-v2', 'image.img'); - - const readFiles = (image) => { - return Bluebird.props({ - cellular: imagefs.readFile({ - image: image, - partition: schema.files.system_connections.location.partition, - path: path.join(schema.files.system_connections.location.path, 'cellular') - }), - ethernet: imagefs.readFile({ - image: image, - partition: schema.files.system_connections.location.partition, - path: path.join(schema.files.system_connections.location.path, 'ethernet') - }), - wifi: imagefs.readFile({ - image: image, - partition: schema.files.system_connections.location.partition, - path: path.join(schema.files.system_connections.location.path, 'wifi') - }) - }); - }; - - return createTemporaryFileFromFile(fixtureImage).then((imagePath) => { - return imagefs.writeFile({ - image: imagePath, - partition: schema.files.system_connections.location.partition, - path: path.join(schema.files.system_connections.location.path, 'cellular') - }, '[connection]\nname=cellular\nfoo=bar\nbar=baz').then(() => { - return reconfix.writeConfiguration(schema, { - cellularConnectionName: 'newcellular', - ethernetConnectionName: 'newethernet', - wifiConnectionName: 'newwifi' - }, imagePath); - }).then(() => { - return readFiles(imagePath); - }).then((files) => { - test.deepEqual(files, { - cellular: '[connection]\nname=newcellular\nfoo=bar\nbar=baz', - ethernet: '[connection]\nname=newethernet', - wifi: '[connection]\nname=newwifi' - }); - }); - }); -}); +// ava.test('should be able to modify a fileset', (test) => { + // const fixturesPath = path.join(__dirname, 'fixtures'); + // const schema = require(path.join(fixturesPath, 'resinos-v2', 'schema.json')); + // const fixtureImage = path.join(fixturesPath, 'resinos-v2', 'image.img'); + + // const readFiles = (image) => { + // return Bluebird.props({ + // cellular: imagefs.readFile({ + // image: image, + // partition: schema.files.system_connections.location.partition, + // path: path.join(schema.files.system_connections.location.path, 'cellular') + // }), + // ethernet: imagefs.readFile({ + // image: image, + // partition: schema.files.system_connections.location.partition, + // path: path.join(schema.files.system_connections.location.path, 'ethernet') + // }), + // wifi: imagefs.readFile({ + // image: image, + // partition: schema.files.system_connections.location.partition, + // path: path.join(schema.files.system_connections.location.path, 'wifi') + // }) + // }); + // }; + + // return createTemporaryFileFromFile(fixtureImage).then((imagePath) => { + // return readFiles(imagePath).then((files) => { + // test.deepEqual(files, { + // cellular: '[connection]\nname=cellular\n', + // ethernet: '[connection]\nname=ethernet\n', + // wifi: '[connection]\nname=wifi\n' + // }); + + // return reconfix.writeConfiguration(schema, { + // cellularConnectionName: 'newcellular', + // ethernetConnectionName: 'newethernet', + // wifiConnectionName: 'newwifi' + // }, imagePath); + // }).then(() => { + // return readFiles(imagePath); + // }).then((files) => { + // test.deepEqual(files, { + // cellular: '[connection]\nname=newcellular', + // ethernet: '[connection]\nname=newethernet', + // wifi: '[connection]\nname=newwifi' + // }); + // }); + // }); +// }); + +// ava.test('should not override custom properties inside a fileset', (test) => { + // const fixturesPath = path.join(__dirname, 'fixtures'); + // const schema = require(path.join(fixturesPath, 'resinos-v2', 'schema.json')); + // const fixtureImage = path.join(fixturesPath, 'resinos-v2', 'image.img'); + + // const readFiles = (image) => { + // return Bluebird.props({ + // cellular: imagefs.readFile({ + // image: image, + // partition: schema.files.system_connections.location.partition, + // path: path.join(schema.files.system_connections.location.path, 'cellular') + // }), + // ethernet: imagefs.readFile({ + // image: image, + // partition: schema.files.system_connections.location.partition, + // path: path.join(schema.files.system_connections.location.path, 'ethernet') + // }), + // wifi: imagefs.readFile({ + // image: image, + // partition: schema.files.system_connections.location.partition, + // path: path.join(schema.files.system_connections.location.path, 'wifi') + // }) + // }); + // }; + + // return createTemporaryFileFromFile(fixtureImage).then((imagePath) => { + // return imagefs.writeFile({ + // image: imagePath, + // partition: schema.files.system_connections.location.partition, + // path: path.join(schema.files.system_connections.location.path, 'cellular') + // }, '[connection]\nname=cellular\nfoo=bar\nbar=baz').then(() => { + // return reconfix.writeConfiguration(schema, { + // cellularConnectionName: 'newcellular', + // ethernetConnectionName: 'newethernet', + // wifiConnectionName: 'newwifi' + // }, imagePath); + // }).then(() => { + // return readFiles(imagePath); + // }).then((files) => { + // test.deepEqual(files, { + // cellular: '[connection]\nname=newcellular\nfoo=bar\nbar=baz', + // ethernet: '[connection]\nname=newethernet', + // wifi: '[connection]\nname=newwifi' + // }); + // }); + // }); +// }); diff --git a/tests/jsontemplate/bidirectional.spec.js b/tests/jsontemplate/bidirectional.spec.js deleted file mode 100644 index 4dc45c30..00000000 --- a/tests/jsontemplate/bidirectional.spec.js +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright 2016 Resin.io - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const ava = require('ava'); -const jsontemplate = require('../../lib/jsontemplate'); - -const testBidirectionalCompilation = (title, template, data, result) => { - ava.test(`.compile() should compile ${title}`, (test) => { - test.deepEqual(jsontemplate.compile(template, data), result); - }); - - ava.test(`.decompile() should decompile ${title}`, (test) => { - test.deepEqual(jsontemplate.decompile(template, result), data); - }); -}; - -testBidirectionalCompilation('a single top-level independent string property', { - person: '{{name}}' -}, { - name: 'John Doe' -}, { - person: 'John Doe' -}); - -testBidirectionalCompilation('a single top-level dependent string property', { - greeting: 'Hello, {{name}}' -}, { - name: 'John Doe' -}, { - greeting: 'Hello, John Doe' -}); - -testBidirectionalCompilation('a single nested independent string property', { - data: { - person: '{{name}}' - } -}, { - name: 'John Doe' -}, { - data: { - person: 'John Doe' - } -}); - -testBidirectionalCompilation('a single top-level independent number property', { - magicNumber: '{{age}}' -}, { - age: 17 -}, { - magicNumber: 17 -}); - -testBidirectionalCompilation('a single top-level dependent number property', { - age: 'My age is {{age}}' -}, { - age: '21' -}, { - age: 'My age is 21' -}); - -testBidirectionalCompilation('multiple independent properties', { - profile: { - fullName: '{{name}}', - age: '{{age}}', - jobTitle: '{{job}}' - } -}, { - name: 'John Doe', - age: '42', - job: 'Software Engineer' -}, { - profile: { - fullName: 'John Doe', - age: '42', - jobTitle: 'Software Engineer' - } -}); - -testBidirectionalCompilation('multiple nested independent properties', { - profile: { - fullName: '{{person.name}}', - age: '{{person.age}}', - jobTitle: '{{person.job}}' - } -}, { - person: { - name: 'John Doe', - age: '42', - job: 'Software Engineer' - } -}, { - profile: { - fullName: 'John Doe', - age: '42', - jobTitle: 'Software Engineer' - } -}); diff --git a/tests/jsontemplate/fixtures/matches/resinos-v1-ethernet.json b/tests/jsontemplate/fixtures/matches/resinos-v1-ethernet.json deleted file mode 100644 index 1fbf3307..00000000 --- a/tests/jsontemplate/fixtures/matches/resinos-v1-ethernet.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "object": { - "service_home_ethernet": { - "type": "ethernet", - "nameservers": "8.8.8.8,8.8.4.4" - } - }, - "choices": [ - { - "title": "ethernet", - "matches": true, - "template": { - "service_home_ethernet": { - "type": "ethernet", - "nameservers": "8.8.8.8,8.8.4.4" - } - } - }, - { - "title": "wifi", - "matches": false, - "template": { - "service_home_ethernet": { - "type": "ethernet", - "nameservers": "8.8.8.8,8.8.4.4" - }, - "service_home_wifi": { - "hidden": true, - "type": "wifi", - "name": "{{network.ssid}}", - "passphrase": "{{network.key}}", - "nameservers": "8.8.8.8,8.8.4.4" - } - } - } - ] -} diff --git a/tests/jsontemplate/fixtures/matches/resinos-v1-wifi.json b/tests/jsontemplate/fixtures/matches/resinos-v1-wifi.json deleted file mode 100644 index e3187085..00000000 --- a/tests/jsontemplate/fixtures/matches/resinos-v1-wifi.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "object": { - "service_home_ethernet": { - "type": "ethernet", - "nameservers": "8.8.8.8,8.8.4.4" - }, - "service_home_wifi": { - "hidden": true, - "type": "wifi", - "name": "resin", - "passphrase": "secret", - "nameservers": "8.8.8.8,8.8.4.4" - } - }, - "choices": [ - { - "title": "ethernet", - "matches": false, - "template": { - "service_home_ethernet": { - "type": "ethernet", - "nameservers": "8.8.8.8,8.8.4.4" - } - } - }, - { - "title": "wifi", - "matches": true, - "template": { - "service_home_ethernet": { - "type": "ethernet", - "nameservers": "8.8.8.8,8.8.4.4" - }, - "service_home_wifi": { - "hidden": true, - "type": "wifi", - "name": "{{network.ssid}}", - "passphrase": "{{network.key}}", - "nameservers": "8.8.8.8,8.8.4.4" - } - } - } - ] -} diff --git a/tests/jsontemplate/matches.spec.js b/tests/jsontemplate/matches.spec.js deleted file mode 100644 index d1e31d61..00000000 --- a/tests/jsontemplate/matches.spec.js +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2016 Resin.io - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const ava = require('ava'); -const path = require('path'); -const _ = require('lodash'); -const jsontemplate = require('../../lib/jsontemplate'); -const fixturesPath = path.join(__dirname, 'fixtures', 'matches'); - -const testFixture = (name) => { - const fixture = require(path.join(fixturesPath, `${name}.json`)); - - _.each(fixture.choices, (choice) => { - ava.test(`.matches() (${name}) should be ${choice.matches} for ${choice.title}`, (test) => { - test.is(jsontemplate.matches(choice.template, fixture.object), choice.matches); - }); - }); -}; - -testFixture('resinos-v1-wifi'); -testFixture('resinos-v1-ethernet'); diff --git a/tests/jsontemplate/string/bidirectional.spec.js b/tests/jsontemplate/string/bidirectional.spec.js deleted file mode 100644 index c71fb43f..00000000 --- a/tests/jsontemplate/string/bidirectional.spec.js +++ /dev/null @@ -1,253 +0,0 @@ -/* - * Copyright 2016 Resin.io - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const ava = require('ava'); -const _ = require('lodash'); -const string = require('../../../lib/jsontemplate/string'); - -_.each([ - - // ------------------------------------------------------------------- - // No interpolation - // ------------------------------------------------------------------- - - { - template: 'Hello world', - data: {}, - result: 'Hello world' - }, - - // ------------------------------------------------------------------- - // Top level string interpolation - // ------------------------------------------------------------------- - - { - template: '{{name}}', - data: { - name: 'John Doe' - }, - result: 'John Doe' - }, - { - template: 'Hello, {{name}}', - data: { - name: 'John Doe' - }, - result: 'Hello, John Doe' - }, - { - template: 'Hello, {{name}}!', - data: { - name: 'John Doe' - }, - result: 'Hello, John Doe!' - }, - { - template: 'Foo{{name}}Foo', - data: { - name: 'John Doe' - }, - result: 'FooJohn DoeFoo' - }, - { - template: 'Foo{{word}}Foo', - data: { - word: 'Foo' - }, - result: 'FooFooFoo' - }, - - // ------------------------------------------------------------------- - // Top level number interpolation - // ------------------------------------------------------------------- - - { - template: '{{number}}', - data: { - number: 0 - }, - result: 0 - }, - { - template: '{{age}}', - data: { - age: 17 - }, - result: 17 - }, - { - template: '{{age}}', - data: { - age: 21.5 - }, - result: 21.5 - }, - { - template: '{{number}}', - data: { - number: -14 - }, - result: -14 - }, - { - template: '{{number}}', - data: { - number: 5.0 - }, - result: 5.0 - }, - - // ------------------------------------------------------------------- - // Top level boolean interpolation - // ------------------------------------------------------------------- - - { - template: '{{bool}}', - data: { - bool: true - }, - result: true - }, - { - template: '{{bool}}', - data: { - bool: false - }, - result: false - }, - - // ------------------------------------------------------------------- - // Special characters in key - // ------------------------------------------------------------------- - - /* eslint-disable camelcase */ - - { - template: '{{$name}}', - data: { - $name: 'John Doe' - }, - result: 'John Doe' - }, - { - template: '{{full_name}}', - data: { - full_name: 'John Doe' - }, - result: 'John Doe' - }, - - /* eslint-enable camelcase */ - - // ------------------------------------------------------------------- - // Nested string interpolation - // ------------------------------------------------------------------- - - { - template: '{{foo.bar.baz.name}}', - data: { - foo: { - bar: { - baz: { - name: 'John Doe' - } - } - } - }, - result: 'John Doe' - }, - - // ------------------------------------------------------------------- - // Nested number interpolation - // ------------------------------------------------------------------- - - { - template: '{{foo.bar.baz.age}}', - data: { - foo: { - bar: { - baz: { - age: 21 - } - } - } - }, - result: 21 - }, - - // ------------------------------------------------------------------- - // Multiple interpolations - // ------------------------------------------------------------------- - - { - template: 'Hello, I\'m {{name}} and I\'m {{number:age}} years old', - data: { - name: 'John Doe', - age: 43 - }, - result: 'Hello, I\'m John Doe and I\'m 43 years old' - }, - { - template: 'These are {{person1.name}} and {{person2.name}}', - data: { - person1: { - name: 'John Doe' - }, - person2: { - name: 'Jane Doe' - } - }, - result: 'These are John Doe and Jane Doe' - }, - - // ------------------------------------------------------------------- - // String <-> Number casting - // ------------------------------------------------------------------- - - { - template: '{{number:age}}', - data: { - age: 43 - }, - result: 43 - }, - { - template: 'Foo {{number:age}}', - data: { - age: 43 - }, - result: 'Foo 43' - } - -], (testCase) => { - - ava.test(`.interpolate() should interpolate ${testCase.template}`, (test) => { - test.deepEqual(string.interpolate( - testCase.template, - testCase.data - ), testCase.result); - }); - - ava.test(`.deinterpolate() should deinterpolate ${testCase.result}`, (test) => { - test.deepEqual(string.deinterpolate( - testCase.template, - testCase.result - ), testCase.data); - }); - -}); diff --git a/tests/jsontemplate/string/deinterpolate.spec.js b/tests/jsontemplate/string/deinterpolate.spec.js deleted file mode 100644 index 93f1ea75..00000000 --- a/tests/jsontemplate/string/deinterpolate.spec.js +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright 2016 Resin.io - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const ava = require('ava'); -const string = require('../../../lib/jsontemplate/string'); - -ava.test('.deinterpolate() should throw if strings do not match', (test) => { - test.throws(() => { - string.deinterpolate('Hello {{name}}!', 'Hi John Doe!'); - }, 'No match for \'name\''); -}); - -ava.test('.deinterpolate() should throw if interpolation result is missing', (test) => { - test.throws(() => { - string.deinterpolate('Hello {{name}}!', 'Hi !'); - }, 'No match for \'name\''); -}); - -ava.test('.deinterpolate() should accept a number type on an independent string', (test) => { - test.deepEqual(string.deinterpolate('{{number:age}}', '21'), { - age: 21 - }); -}); - -ava.test('.deinterpolate() should accept a number type on a dependent string', (test) => { - test.deepEqual(string.deinterpolate('I am {{number:age}} years old', 'I am 21 years old'), { - age: 21 - }); -}); - -ava.test('.deinterpolate() should parse a float from an independent string', (test) => { - test.deepEqual(string.deinterpolate('{{number:foo}}', '21.123'), { - foo: 21.123 - }); -}); - -ava.test('.deinterpolate() should parse a float from an dependent string', (test) => { - test.deepEqual(string.deinterpolate('Foo {{number:foo}} Foo', 'Foo 21.123 Foo'), { - foo: 21.123 - }); -}); - -ava.test('.deinterpolate() should be able to cast a zero', (test) => { - test.deepEqual(string.deinterpolate('{{number:foo}}', '0'), { - foo: 0 - }); -}); - -ava.test('.deinterpolate() should be able to cast a negative number', (test) => { - test.deepEqual(string.deinterpolate('{{number:foo}}', '-5'), { - foo: -5 - }); -}); - -ava.test('.deinterpolate() should throw if independent string casted to number becomes NaN', (test) => { - test.throws(() => { - string.deinterpolate('{{number:age}}', 'foo'); - }, 'Can\'t convert foo to number'); -}); - -ava.test('.deinterpolate() should throw if dependent string casted to number becomes NaN', (test) => { - test.throws(() => { - string.deinterpolate('I am {{number:age}} years old', 'I am foo years old'); - }, 'Can\'t convert foo to number'); -}); - -ava.test('.deinterpolate() should accept a string type', (test) => { - test.deepEqual(string.deinterpolate('{{string:age}}', 21), { - age: '21' - }); -}); diff --git a/tests/jsontemplate/string/interpolate.spec.js b/tests/jsontemplate/string/interpolate.spec.js deleted file mode 100644 index 3cc26c56..00000000 --- a/tests/jsontemplate/string/interpolate.spec.js +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright 2016 Resin.io - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const ava = require('ava'); -const string = require('../../../lib/jsontemplate/string'); - -ava.test('.interpolate() should cast positive integer to string if interpolation has context', (test) => { - test.deepEqual(string.interpolate('My age is {{age}}', { - age: 21 - }), 'My age is 21'); -}); - -ava.test('.interpolate() should cast negative integer to string if interpolation has context', (test) => { - test.deepEqual(string.interpolate('The temperature is {{temperature}}', { - temperature: -5 - }), 'The temperature is -5'); -}); - -ava.test('.interpolate() should cast positive float to string if interpolation has context', (test) => { - test.deepEqual(string.interpolate('Foo {{bar}} baz', { - bar: 5.1 - }), 'Foo 5.1 baz'); -}); - -ava.test('.interpolate() should cast negative float to string if interpolation has context', (test) => { - test.deepEqual(string.interpolate('Foo {{bar}} baz', { - bar: -3.3 - }), 'Foo -3.3 baz'); -}); - -ava.test('.interpolate() should cast true to string if interpolation has context', (test) => { - test.deepEqual(string.interpolate('Foo {{bool}} baz', { - bool: true - }), 'Foo true baz'); -}); - -ava.test('.interpolate() should cast false to string if interpolation has context', (test) => { - test.deepEqual(string.interpolate('Foo {{bool}} baz', { - bool: false - }), 'Foo false baz'); -}); - -ava.test('.interpolate() should throw if a referenced variable does not exist', (test) => { - test.throws(() => { - string.interpolate('{{foo}}', {}); - }, 'Missing variable foo'); -}); - -ava.test('.interpolate() should throw if a referenced variable is null', (test) => { - test.throws(() => { - string.interpolate('{{foo}}', { - foo: null - }); - }, 'Missing variable foo'); -}); - -ava.test('.interpolate() should throw if a referenced nested variable does not exist', (test) => { - test.throws(() => { - string.interpolate('{{foo.bar.baz}}', {}); - }, 'Missing variable foo.bar.baz'); -}); - -ava.test('.interpolate() should ignore unused data variables', (test) => { - const result = string.interpolate('{{foo}} {{bar}}', { - foo: 'FOO', - bar: 'BAR', - baz: 'BAZ', - data: { - hello: 'world' - } - }); - - test.deepEqual(result, 'FOO BAR'); -}); - -ava.test('.interpolate() should be able to force a string type on a dependent string', (test) => { - test.deepEqual(string.interpolate('{{string:age}}', { - age: 43 - }), '43'); -}); diff --git a/tests/mapping.spec.js b/tests/mapping.spec.js new file mode 100644 index 00000000..860b8aba --- /dev/null +++ b/tests/mapping.spec.js @@ -0,0 +1,592 @@ +/* + * Copyright 2016 Resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const chai = require('chai'); +const Mapping = require('../lib/mapping'); + +describe('Mapping', function() { + + describe('bidirectional property', function() { + + const bidirectional = (title, map, object, value) => { + it(`.map() ${title}`, function() { + chai.expect(Mapping.map(map, value)).to.deep.equal(object); + }); + + it(`.unmap() ${title}`, function() { + chai.expect(Mapping.unmap(map, object)).to.deep.equal(value); + }); + }; + + bidirectional('should process a single simple mapping', [ + [ 'foo' ] + ], { + foo: 3 + }, 3); + + bidirectional('should process a single nested mapping', [ + [ 'foo', 'bar', 'baz' ] + ], { + foo: { + bar: { + baz: 3 + } + } + }, 3); + + bidirectional('should process multiple simple mappings', [ + [ 'foo' ], + [ 'bar' ] + ], { + foo: 'baz', + bar: 'baz' + }, 'baz'); + + bidirectional('should process multiple non-scalar mappings', [ + [ 'foo' ], + [ 'bar' ] + ], { + foo: { + hello: 'world' + }, + bar: { + hello: 'world' + } + }, { + hello: 'world' + }); + + bidirectional('should process a boolean template mapping', [ + { + value: true, + template: { + foo: 1 + } + }, + { + value: false, + template: { + foo: 2 + } + } + ], { + foo: 2 + }, false); + + bidirectional('should process a template and direct mapping', [ + [ 'foo', 'bar', 'baz' ], + { + value: 'johndoe', + template: { + name: 'John' + } + }, + { + value: 'janedoe', + template: { + name: 'Jane' + } + } + ], { + name: 'John', + foo: { + bar: { + baz: 'johndoe' + } + } + }, 'johndoe'); + + bidirectional('should disambiguate matching templates based on templates degrees', [ + { + value: 'foo', + template: { + foo: 1 + } + }, + { + value: 'foobar', + template: { + foo: 1, + bar: 2 + } + } + ], { + foo: 1, + bar: 2 + }, 'foobar'); + }); + + describe('.map()', function() { + + it('should ignore null mappings', function() { + chai.expect(Mapping.map([ + [ 'foo' ] + ], null)).to.deep.equal({}); + }); + + it('should throw if more than one template matches the value', function() { + chai.expect(() => { + Mapping.map([ + { + value: 1, + template: { + foo: 1 + } + }, + { + value: 1, + template: { + foo: 5 + } + } + ], 1); + }).to.throw('Ambiguous mapping for value: 1'); + }); + + it('should do nothing if no template matches the value', function() { + chai.expect(Mapping.map([ + { + value: 1, + template: { + foo: 1 + } + }, + { + value: 2, + template: { + foo: 2 + } + } + ], 5)).to.deep.equal({}); + }); + + }); + + describe('.unmap()', function() { + + it('should return null if the mapping path leads to undefined', function() { + chai.expect(Mapping.unmap([ + [ 'foo' ] + ], { + foo: undefined + })).to.deep.equal(null); + }); + + it('should throw if there is an unmapping ambiguity', function() { + chai.expect(() => { + Mapping.unmap([ + [ 'foo' ], + [ 'bar' ] + ], { + foo: 3, + bar: 5 + }); + }).to.throw('Ambiguous values: 3,5'); + }); + + it('should throw if there was no template match', function() { + chai.expect(() => { + Mapping.unmap([ + { + value: 1, + template: { + foo: 1 + } + }, + { + value: 2, + template: { + foo: 2 + } + } + ], { + foo: 3 + }); + }).to.throw('No match found'); + }); + + it('should throw if there were multiple template matches with the same degree', function() { + chai.expect(() => { + Mapping.unmap([ + { + value: 1, + template: { + foo: 1 + } + }, + { + value: 2, + template: { + bar: 2 + } + } + ], { + foo: 1, + bar: 2 + }); + }).to.throw('Ambiguous values: 1,2'); + }); + + it('should throw if there are equal templates pointing to different values', function() { + chai.expect(() => { + Mapping.unmap([ + { + value: 1, + template: { + foo: 1 + } + }, + { + value: 2, + template: { + foo: 1 + } + } + ], { + foo: 1 + }); + }).to.throw('Ambiguous duplicated template: {"foo":1}'); + }); + + it('should not throw if there are equal templates pointing to equal values', function() { + chai.expect(Mapping.unmap([ + { + value: 1, + template: { + foo: 1 + } + }, + { + value: 1, + template: { + foo: 1 + } + } + ], { + foo: 1 + })).to.deep.equal(1); + }); + + }); + + describe('.getRelationshipsForValue()', function() { + + it('should be able to find no matching relationship', function() { + chai.expect(Mapping.getRelationshipsForValue([ + { + value: 'foo', + template: { + foo: 1 + } + }, + { + value: 'bar', + template: { + bar: 2 + } + } + ], 'qux')).to.deep.equal([]); + }); + + it('should ignore direct mappings', function() { + chai.expect(Mapping.getRelationshipsForValue([ + [ 'foo', 'bar' ], + [ 'bar', 'baz' ], + { + value: 'foo', + template: { + foo: 1 + } + }, + { + value: 'bar', + template: { + bar: 2 + } + } + ], 'foo')).to.deep.equal([ + { + value: 'foo', + template: { + foo: 1 + } + } + ]); + }); + + it('should be able to find a single matching relationship', function() { + chai.expect(Mapping.getRelationshipsForValue([ + { + value: true, + template: { + foo: 1 + } + }, + { + value: false, + template: { + foo: 2 + } + } + ], true)).to.deep.equal([ + { + value: true, + template: { + foo: 1 + } + } + ]); + }); + + it('should be able to find multiple matching relationships', function() { + chai.expect(Mapping.getRelationshipsForValue([ + { + value: 'foo', + template: { + foo: 1 + } + }, + { + value: 'foo', + template: { + foo: 2 + } + }, + { + value: 'bar', + template: { + bar: 1 + } + } + ], 'foo')).to.deep.equal([ + { + value: 'foo', + template: { + foo: 1 + } + }, + { + value: 'foo', + template: { + foo: 2 + } + } + ]); + }); + + }); + + describe('.getTemplateValue()', function() { + + it('should get the value of an un-ambiguous template', function() { + chai.expect(Mapping.getTemplateValue([ + { + value: 'foo', + template: { + foo: 1 + } + }, + { + value: 'bar', + template: { + bar: 1 + } + } + ], { + foo: 1 + })).to.deep.equal('foo'); + }); + + it('should get the value of an ambiguous template that always points to the same value', function() { + chai.expect(Mapping.getTemplateValue([ + { + value: 'foo', + template: { + foo: 1 + } + }, + { + value: 'foo', + template: { + foo: 1 + } + } + ], { + foo: 1 + })).to.deep.equal('foo'); + }); + + it('should throw if the mapping is ambiguous', function() { + chai.expect(() => { + Mapping.getTemplateValue([ + { + value: 'foo', + template: { + foo: 1 + } + }, + { + value: 'bar', + template: { + foo: 1 + } + } + ], { + foo: 1 + }); + }).to.throw('Ambiguous duplicated template: {"foo":1}'); + }); + + }); + + describe('.isTemplateMapping()', function() { + + it('should return true if mapping is templated based', function() { + chai.expect(Mapping.isTemplateMapping([ + { + value: 1, + template: { + foo: 'bar' + } + }, + { + value: 2, + template: { + foo: 'baz' + } + } + ])).to.be.true; + }); + + it('should return false if mapping is mixed', function() { + chai.expect(Mapping.isTemplateMapping([ + [ 'foo' ], + { + value: 1, + template: { + foo: 'bar' + } + }, + { + value: 2, + template: { + foo: 'baz' + } + } + ])).to.be.false; + }); + + it('should return false if mapping is direct', function() { + chai.expect(Mapping.isTemplateMapping([ + [ 'foo' ] + ])).to.be.false; + }); + + }); + + describe('.isDirectMapping()', function() { + + it('should return false if mapping is templated based', function() { + chai.expect(Mapping.isDirectMapping([ + { + value: 1, + template: { + foo: 'bar' + } + }, + { + value: 2, + template: { + foo: 'baz' + } + } + ])).to.be.false; + }); + + it('should return false if mapping is mixed', function() { + chai.expect(Mapping.isDirectMapping([ + [ 'foo' ], + { + value: 1, + template: { + foo: 'bar' + } + }, + { + value: 2, + template: { + foo: 'baz' + } + } + ])).to.be.false; + }); + + it('should return true if mapping is direct', function() { + chai.expect(Mapping.isDirectMapping([ + [ 'foo' ] + ])).to.be.true; + }); + + }); + + describe('.isMixedMapping()', function() { + + it('should return false if mapping is templated based', function() { + chai.expect(Mapping.isMixedMapping([ + { + value: 1, + template: { + foo: 'bar' + } + }, + { + value: 2, + template: { + foo: 'baz' + } + } + ])).to.be.false; + }); + + it('should return true if mapping is mixed', function() { + chai.expect(Mapping.isMixedMapping([ + [ 'foo' ], + { + value: 1, + template: { + foo: 'bar' + } + }, + { + value: 2, + template: { + foo: 'baz' + } + } + ])).to.be.true; + }); + + it('should return false if mapping is direct', function() { + chai.expect(Mapping.isMixedMapping([ + [ 'foo' ] + ])).to.be.false; + }); + + }); + +}); diff --git a/tests/properties.spec.js b/tests/properties.spec.js new file mode 100644 index 00000000..8e3d317e --- /dev/null +++ b/tests/properties.spec.js @@ -0,0 +1,351 @@ +/* + * Copyright 2016 Resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const chai = require('chai'); +const Properties = require('../lib/properties'); + +describe('Properties', function() { + + describe('.isLeafProperty()', function() { + + it('should return true if property is a leaf property', function() { + chai.expect(Properties.isLeafProperty({ + type: [ 'number' ] + })).to.be.true; + }); + + it('should return false if property is not leaf property', function() { + chai.expect(Properties.isLeafProperty({ + foo: { + type: [ 'number' ] + } + })).to.be.false; + }); + + it('should return false if property is not leaf property but contains a "type" child', function() { + chai.expect(Properties.isLeafProperty({ + type: { + type: [ 'number' ] + } + })).to.be.false; + }); + + }); + + describe('.listPropertyPaths()', function() { + + it('should be able to list a single property', function() { + chai.expect(Properties.listPropertyPaths({ + foo: { + type: [ 'number' ] + } + })).to.deep.equal([ + [ 'foo' ] + ]); + }); + + it('should be able to list a multiple properties', function() { + chai.expect(Properties.listPropertyPaths({ + foo: { + type: [ 'number' ] + }, + bar: { + type: [ 'number' ] + }, + baz: { + type: [ 'number' ] + } + })).to.deep.equal([ + [ 'foo' ], + [ 'bar' ], + [ 'baz' ] + ]); + }); + + it('should be able to list a single nested property', function() { + chai.expect(Properties.listPropertyPaths({ + foo: { + bar: { + baz: { + type: [ 'number' ] + } + } + } + })).to.deep.equal([ + [ 'foo', 'bar', 'baz' ] + ]); + }); + + it('should be able to list multiple nested property', function() { + chai.expect(Properties.listPropertyPaths({ + qux: { + type: [ 'boolean' ] + }, + foo: { + hello: { + type: [ 'string' ] + }, + bar: { + baz: { + type: [ 'number' ] + } + } + } + })).to.deep.equal([ + [ 'qux' ], + [ 'foo', 'hello' ], + [ 'foo', 'bar', 'baz' ] + ]); + }); + + }); + + describe('.getPropertyMapping()', function() { + + it('should return a direct mapping', function() { + chai.expect(Properties.getPropertyMapping({ + foo: { + type: [ 'string' ], + mapping: [ + [ 'option1' ] + ] + } + }, [ + 'foo' + ])).to.deep.equal([ + [ 'option1' ] + ]); + }); + + it('should return a nested direct mapping', function() { + chai.expect(Properties.getPropertyMapping({ + foo: { + bar: { + type: [ 'string' ], + mapping: [ + [ 'option1' ] + ] + } + } + }, [ + 'foo', + 'bar' + ])).to.deep.equal([ + [ 'option1' ] + ]); + }); + + it('should return undefined is property does not exist', function() { + chai.expect(Properties.getPropertyMapping({ + foo: { + type: [ 'string' ], + mapping: [ + [ 'option1' ] + ] + } + }, [ + 'foo', + 'bar' + ])).to.deep.equal(undefined); + }); + + }); + + describe('.getPropertyPaths()', function() { + + it('should keep direct mappings in order', function() { + chai.expect(Properties.getPropertyPaths({ + foo: { + type: [ 'string' ], + mapping: [ + [ 'option1' ] + ] + }, + bar: { + type: [ 'string' ], + mapping: [ + [ 'option2' ] + ] + }, + baz: { + type: [ 'string' ], + mapping: [ + [ 'option3' ] + ] + } + })).to.deep.equal([ + [ 'foo' ], + [ 'bar' ], + [ 'baz' ] + ]); + }); + + it('should put template based properties first', function() { + chai.expect(Properties.getPropertyPaths({ + foo: { + type: [ 'string' ], + mapping: [ + [ 'option1' ] + ] + }, + bar: { + type: [ 'string' ], + mapping: [ + { + value: 'hello', + template: { + greeting: 'formal' + } + }, + { + value: 'hey', + template: { + greeting: 'informal' + } + } + ] + }, + baz: { + type: [ 'string' ], + mapping: [ + [ 'option2' ] + ] + } + })).to.deep.equal([ + [ 'bar' ], + [ 'foo' ], + [ 'baz' ] + ]); + }); + + it('should put multiple template based properties first', function() { + chai.expect(Properties.getPropertyPaths({ + foo: { + type: [ 'string' ], + mapping: [ + [ 'option1' ] + ] + }, + bar: { + type: [ 'string' ], + mapping: [ + { + value: 'hello', + template: { + greeting: 'formal' + } + }, + { + value: 'hey', + template: { + greeting: 'informal' + } + } + ] + }, + baz: { + type: [ 'string' ], + mapping: [ + [ 'option2' ] + ] + }, + qux: { + type: [ 'string' ], + mapping: [ + { + value: 1, + template: { + foo: true + } + }, + { + value: 0, + template: { + foo: false + } + } + ] + } + })).to.deep.equal([ + [ 'bar' ], + [ 'qux' ], + [ 'foo' ], + [ 'baz' ] + ]); + }); + + it('should put mixed based properties first', function() { + chai.expect(Properties.getPropertyPaths({ + foo: { + type: [ 'string' ], + mapping: [ + [ 'option1' ] + ] + }, + bar: { + type: [ 'string' ], + mapping: [ + { + value: 'hello', + template: { + greeting: 'formal' + } + }, + { + value: 'hey', + template: { + greeting: 'informal' + } + } + ] + }, + baz: { + type: [ 'string' ], + mapping: [ + [ 'option2' ] + ] + }, + qux: { + type: [ 'string' ], + mapping: [ + [ 'foo' ], + { + value: 1, + template: { + foo: true + } + }, + { + value: 0, + template: { + foo: false + } + } + ] + } + })).to.deep.equal([ + [ 'qux' ], + [ 'bar' ], + [ 'foo' ], + [ 'baz' ] + ]); + }); + + }); + +}); diff --git a/tests/state.spec.js b/tests/state.spec.js new file mode 100644 index 00000000..68042fe1 --- /dev/null +++ b/tests/state.spec.js @@ -0,0 +1,135 @@ +/* + * Copyright 2016 Resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const chai = require('chai'); +const state = require('../lib/state'); + +describe('State', function() { + + describe('bidirectional property', function() { + + const bidirectional = (title, property, settings, object) => { + it(`.compile() ${title}`, function() { + chai.expect(state.compile(property, settings)).to.deep.equal(object); + }); + + it(`.decompile() ${title}`, function() { + chai.expect(state.decompile(property, object)).to.deep.equal(settings); + }); + }; + + bidirectional('should compile a single simple mapping', { + bar: { + type: [ 'number' ], + mapping: [ + [ 'baz' ] + ] + } + }, { + bar: 3 + }, { + baz: 3 + }); + + bidirectional('should compile a single complex mapping', { + property: { + type: [ 'number' ], + mapping: [ + [ 'foo', 'bar' ], + [ 'baz' ] + ] + } + }, { + property: 13 + }, { + foo: { + bar: 13 + }, + baz: 13 + }); + + bidirectional('should compile multiple simple mappings', { + foo: { + type: [ 'number' ], + mapping: [ + [ 'xxx' ] + ] + }, + bar: { + type: [ 'string' ], + mapping: [ + [ 'yyy' ] + ] + }, + baz: { + type: [ 'string' ], + mapping: [ + [ 'zzz' ] + ] + } + }, { + foo: 7, + bar: 'hello', + baz: 'world' + }, { + xxx: 7, + yyy: 'hello', + zzz: 'world' + }); + + }); + + describe('.compile()', function() { + + it('should throw if there is a type mismatch', function() { + chai.expect(() => { + state.compile({ + bar: { + type: [ 'number' ], + mapping: [ + [ 'baz' ] + ] + } + }, { + bar: 'foo' + }); + }).to.throw('Type mismatch for "bar": expected number, but got "foo"'); + }); + + }); + + describe('.decompile()', function() { + + it('should throw if there is a type mismatch', function() { + chai.expect(() => { + state.decompile({ + bar: { + type: [ 'number' ], + mapping: [ + [ 'baz' ] + ] + } + }, { + baz: 'foo' + }); + }).to.throw('Type mismatch for "bar": expected number, but got "foo"'); + }); + + }); + +}); diff --git a/tests/template.spec.js b/tests/template.spec.js new file mode 100644 index 00000000..349ce3cb --- /dev/null +++ b/tests/template.spec.js @@ -0,0 +1,429 @@ +/* + * Copyright 2016 Resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const chai = require('chai'); +const Template = require('../lib/template'); + +describe('Template', function() { + + describe('.isTypeWildcard()', function() { + + it('should return false if type wildcard is empty', function() { + chai.expect(Template.isTypeWildcard('[[]]')).to.be.false; + }); + + it('should return true if string is a type wildcard', function() { + chai.expect(Template.isTypeWildcard('[[string]]')).to.be.true; + }); + + it('should return false if string is lacking a set of brackets', function() { + chai.expect(Template.isTypeWildcard('[string]')).to.be.false; + }); + + it('should return false if string contains spaces', function() { + chai.expect(Template.isTypeWildcard('[[str ing]]')).to.be.false; + }); + + it('should return false if string contains numbers', function() { + chai.expect(Template.isTypeWildcard('[[number123]]')).to.be.false; + }); + + it('should return false if string contains non-alphanumeric characters', function() { + chai.expect(Template.isTypeWildcard('[[my-type$]]')).to.be.false; + }); + + it('should return false if string has outer leading space', function() { + chai.expect(Template.isTypeWildcard(' [[string]]')).to.be.false; + }); + + it('should return false if string has inner leading space', function() { + chai.expect(Template.isTypeWildcard('[[ string]]')).to.be.false; + }); + + it('should return false if string has outer trailing space', function() { + chai.expect(Template.isTypeWildcard('[[string]] ')).to.be.false; + }); + + it('should return false if string has inner trailing space', function() { + chai.expect(Template.isTypeWildcard('[[string ]]')).to.be.false; + }); + + it('should accept a | separated list of types', function() { + chai.expect(Template.isTypeWildcard('[[string|number|object]]')).to.be.true; + }); + + }); + + describe('.getWildcardType()', function() { + + it('should return undefined given an invalid wildcard', function() { + chai.expect(Template.getWildcardType('foobar')).to.be.undefined; + }); + + it('should extract a single valid wildcard type', function() { + chai.expect(Template.getWildcardType('[[number]]')).to.deep.equal([ 'number' ]); + }); + + it('should extract a multiple valid wildcard types', function() { + chai.expect(Template.getWildcardType('[[number|string|object]]')).to.deep.equal([ + 'number', + 'string', + 'object' + ]); + }); + + }); + + describe('.matches()', function() { + + it('should return true if subset matches', function() { + chai.expect(Template.matches({ + foo: { + bar: 2, + baz: { + qux: 5 + } + } + }, { + foo: { + bar: 2 + } + })).to.be.true; + }); + + it('should return false if the template is a superset of the data', function() { + chai.expect(Template.matches({ + foo: { + bar: 2 + } + }, { + foo: { + bar: 2 + }, + baz: { + qux: 5 + } + })).to.be.false; + }); + + it('should return true if subset does not match', function() { + chai.expect(Template.matches({ + foo: { + bar: 2, + baz: { + qux: 5 + } + } + }, { + foo: { + qux: 2 + } + })).to.be.false; + }); + + it('should return false if an array is a subset of the other', function() { + chai.expect(Template.matches({ + foo: { + bar: [ 1, 2, 3 ] + } + }, { + foo: { + bar: [ 1, 2 ] + } + })).to.be.false; + }); + + it('should return false if array do not match', function() { + chai.expect(Template.matches({ + foo: { + bar: [ 1, 2, 3 ] + } + }, { + foo: { + bar: [ 0, 1, 2 ] + } + })).to.be.false; + }); + + it('should return true if arrays match entirely', function() { + chai.expect(Template.matches({ + foo: { + bar: [ 1, 2, 3 ] + } + }, { + foo: { + bar: [ 1, 2, 3 ] + } + })).to.be.true; + }); + + it('should throw if a wildcard type is invalid', function() { + chai.expect(() => { + Template.matches({ + foo: { + bar: 'baz' + } + }, { + foo: { + bar: '[[foo]]' + } + }); + }).to.throw('Invalid type: foo'); + }); + + it('should return false if a wildcard gets matched with "undefined"', function() { + chai.expect(Template.matches({ + foo: { + bar: undefined + } + }, { + foo: { + bar: '[[string]]' + } + })).to.be.false; + }); + + it('should return true if a string wildcard matches', function() { + chai.expect(Template.matches({ + foo: { + bar: 'foo bar' + } + }, { + foo: { + bar: '[[string]]' + } + })).to.be.true; + }); + + it('should return true if a string wildcard does not match', function() { + chai.expect(Template.matches({ + foo: { + bar: 1 + } + }, { + foo: { + bar: '[[string]]' + } + })).to.be.false; + }); + + it('should return true if a number wildcard matches', function() { + chai.expect(Template.matches({ + foo: { + bar: 3 + } + }, { + foo: { + bar: '[[number]]' + } + })).to.be.true; + }); + + it('should return true if a number wildcard does not match', function() { + chai.expect(Template.matches({ + foo: { + bar: '3' + } + }, { + foo: { + bar: '[[number]]' + } + })).to.be.false; + }); + + it('should return true if a boolean wildcard matches', function() { + chai.expect(Template.matches({ + foo: { + bar: false + } + }, { + foo: { + bar: '[[boolean]]' + } + })).to.be.true; + }); + + it('should return false if a boolean wildcard does not match', function() { + chai.expect(Template.matches({ + foo: { + bar: 'true' + } + }, { + foo: { + bar: '[[boolean]]' + } + })).to.be.false; + }); + + it('should return true if an object wildcard matches', function() { + chai.expect(Template.matches({ + foo: { + bar: { + qux: 1, + hello: { + world: 3 + } + } + } + }, { + foo: { + bar: '[[object]]' + } + })).to.be.true; + }); + + it('should return false if an object wildcard does not match', function() { + chai.expect(Template.matches({ + foo: { + bar: [ 1, 2, 3 ] + } + }, { + foo: { + bar: '[[object]]' + } + })).to.be.false; + }); + + it('should return true if an array wildcard matches', function() { + chai.expect(Template.matches({ + foo: { + bar: [ 1, 2, 3 ] + } + }, { + foo: { + bar: '[[array]]' + } + })).to.be.true; + }); + + it('should return false if an array wildcard does not match', function() { + chai.expect(Template.matches({ + foo: { + bar: { + baz: [ 1, 2, 3 ] + } + } + }, { + foo: { + bar: '[[array]]' + } + })).to.be.false; + }); + + }); + + describe('.getTemplateDegree()', function() { + + it('should return zero for an empty template', function() { + chai.expect(Template.getTemplateDegree({})).to.equal(0); + }); + + it('should calculate the degree of a simple template', function() { + chai.expect(Template.getTemplateDegree({ + foo: 1, + bar: 2 + })).to.equal(2); + }); + + it('should calculate the degree of a nested template', function() { + chai.expect(Template.getTemplateDegree({ + one: { + two: { + three: 3 + }, + four: { + five: 5, + six: [ 6 ], + seven: { + eight: 8 + } + } + } + })).to.equal(8); + }); + + }); + + describe('.getHighestDegreeMatchingTemplates()', function() { + + it('should analyse two templates with equal degrees', function() { + chai.expect(Template.getHighestDegreeMatchingTemplates({ + foo: 2 + }, [ + { + foo: 1 + }, + { + foo: 2 + } + ])).to.deep.equal([ + { + foo: 2 + } + ]); + }); + + it('should analyse two matching templates with different degrees', function() { + chai.expect(Template.getHighestDegreeMatchingTemplates({ + foo: 1, + bar: 2 + }, [ + { + foo: 1 + }, + { + foo: 1, + bar: 2 + } + ])).to.deep.equal([ + { + foo: 1, + bar: 2 + } + ]); + }); + + it('should analyse two matching templates with equal degrees', function() { + chai.expect(Template.getHighestDegreeMatchingTemplates({ + foo: 1, + bar: 2, + baz: 3 + }, [ + { + foo: 1, + bar: 2 + }, + { + bar: 2, + baz: 3 + } + ])).to.deep.equal([ + { + foo: 1, + bar: 2 + }, + { + bar: 2, + baz: 3 + } + ]); + }); + + }); + +}); diff --git a/tests/type.spec.js b/tests/type.spec.js new file mode 100644 index 00000000..eaf6344e --- /dev/null +++ b/tests/type.spec.js @@ -0,0 +1,166 @@ +/* + * Copyright 2016 Resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const chai = require('chai'); +const Type = require('../lib/type'); + +describe('Type', function() { + + describe('.isValidType()', function() { + + it('should return true for "string"', function() { + chai.expect(Type.isValidType('string')).to.be.true; + }); + + it('should return false for "STRING"', function() { + chai.expect(Type.isValidType('STRING')).to.be.false; + }); + + it('should return false for "String"', function() { + chai.expect(Type.isValidType('String')).to.be.false; + }); + + it('should return true for "number"', function() { + chai.expect(Type.isValidType('number')).to.be.true; + }); + + it('should return true for "boolean"', function() { + chai.expect(Type.isValidType('boolean')).to.be.true; + }); + + it('should return true for "object"', function() { + chai.expect(Type.isValidType('object')).to.be.true; + }); + + it('should return true for "array"', function() { + chai.expect(Type.isValidType('object')).to.be.true; + }); + + }); + + describe('.matchesType()', function() { + + it('should throw if type is invalid', function() { + chai.expect(() => { + Type.matchesType('foo', 'bar'); + }).to.throw('Invalid type: foo'); + }); + + it('should match a negative integer as a number', function() { + chai.expect(Type.matchesType('number', -3)).to.be.true; + }); + + it('should match a negative float as a number', function() { + chai.expect(Type.matchesType('number', -5.8)).to.be.true; + }); + + it('should match a positive integer as a number', function() { + chai.expect(Type.matchesType('number', 3)).to.be.true; + }); + + it('should match a positive float as a number', function() { + chai.expect(Type.matchesType('number', 5.8)).to.be.true; + }); + + it('should match a zero as a number', function() { + chai.expect(Type.matchesType('number', 0)).to.be.true; + }); + + it('should match an empty string as a string', function() { + chai.expect(Type.matchesType('string', '')).to.be.true; + }); + + it('should match a single character string as a string', function() { + chai.expect(Type.matchesType('string', 'a')).to.be.true; + }); + + it('should match a multiple character string as a string', function() { + chai.expect(Type.matchesType('string', 'foo bar')).to.be.true; + }); + + it('should match true as a boolean', function() { + chai.expect(Type.matchesType('boolean', true)).to.be.true; + }); + + it('should match false as a boolean', function() { + chai.expect(Type.matchesType('boolean', false)).to.be.true; + }); + + it('should not match undefined as a boolean', function() { + chai.expect(Type.matchesType('boolean', undefined)).to.be.false; + }); + + it('should not match null as a boolean', function() { + chai.expect(Type.matchesType('boolean', undefined)).to.be.false; + }); + + it('should not match a string as a number', function() { + chai.expect(Type.matchesType('number', '567')).to.be.false; + }); + + it('should not match a number as a string', function() { + chai.expect(Type.matchesType('string', 567)).to.be.false; + }); + + it('should match an empty object as an object', function() { + chai.expect(Type.matchesType('object', {})).to.be.true; + }); + + it('should match an object as an object', function() { + chai.expect(Type.matchesType('object', { + foo: 2 + })).to.be.true; + }); + + it('should not match an empty array as an object', function() { + chai.expect(Type.matchesType('object', [])).to.be.false; + }); + + it('should not match an empty object as an array', function() { + chai.expect(Type.matchesType('array', {})).to.be.false; + }); + + it('should match an empty array as an array', function() { + chai.expect(Type.matchesType('array', [])).to.be.true; + }); + + it('should match an empty array as an array', function() { + chai.expect(Type.matchesType('array', [ 1, 2, 3 ])).to.be.true; + }); + + }); + + describe('.matchesSomeType()', function() { + + it('should return true if one type matches', function() { + chai.expect(Type.matchesSomeType([ + 'string', + 'number' + ], 3)).to.be.true; + }); + + it('should return false if no types match', function() { + chai.expect(Type.matchesSomeType([ + 'string', + 'number' + ], true)).to.be.false; + }); + + }); + +}); diff --git a/tests/visuals/cli/flatten.spec.js b/tests/visuals/cli/flatten.spec.js deleted file mode 100644 index 1e8cc739..00000000 --- a/tests/visuals/cli/flatten.spec.js +++ /dev/null @@ -1,241 +0,0 @@ -/* - * Copyright 2016 Resin.io - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const ava = require('ava'); -const cli = require('../../../visuals/cli'); - -ava.test('should flatten a list without nested questions', (test) => { - test.deepEqual(cli.flatten([ - { - title: 'Network Type', - name: 'networkType', - type: 'list', - choices: [ - { - title: 'Wifi', - name: 'wifi' - }, - { - title: 'Ethernet', - name: 'ethernet' - } - ] - } - ]), [ - { - title: 'Network Type', - name: 'networkType', - type: 'list', - choices: [ - { - title: 'Wifi', - name: 'wifi' - }, - { - title: 'Ethernet', - name: 'ethernet' - } - ] - } - ]); -}); - -ava.test('should flatten a list with single-level nested questions', (test) => { - test.deepEqual(cli.flatten([ - { - title: 'Network Type', - name: 'networkType', - type: 'list', - choices: [ - { - title: 'Wifi', - name: 'wifi', - questions: [ - { - title: 'Wifi SSID', - name: 'networkSsid', - type: 'text' - }, - { - title: 'Wifi Key', - name: 'networkKey', - type: 'password' - } - ] - }, - { - title: 'Ethernet', - name: 'ethernet' - } - ] - } - ]), [ - { - title: 'Network Type', - name: 'networkType', - type: 'list', - choices: [ - { - title: 'Wifi', - name: 'wifi' - }, - { - title: 'Ethernet', - name: 'ethernet' - } - ] - }, - { - title: 'Wifi SSID', - name: 'networkSsid', - type: 'text', - when: { - networkType: 'wifi' - } - }, - { - title: 'Wifi Key', - name: 'networkKey', - type: 'password', - when: { - networkType: 'wifi' - } - } - ]); -}); - -ava.test('should flatten a list with two-level nested questions', (test) => { - test.deepEqual(cli.flatten([ - { - title: 'Network Type', - name: 'networkType', - type: 'list', - choices: [ - { - title: 'Wifi', - name: 'wifi', - questions: [ - { - title: 'Wifi SSID', - name: 'networkSsid', - type: 'text' - }, - { - title: 'Wifi Key', - name: 'networkKey', - type: 'password' - }, - { - title: 'Wifi Type', - name: 'networkWifiType', - type: 'list', - choices: [ - { - title: 'WEP', - name: 'wep' - }, - { - title: 'WPA', - name: 'wpa' - }, - { - title: 'WPA2', - name: 'wpa2', - questions: [ - { - title: 'Wlan Interface', - name: 'wlanInterface', - type: 'text' - } - ] - } - ] - } - ] - }, - { - title: 'Ethernet', - name: 'ethernet' - } - ] - } - ]), [ - { - title: 'Network Type', - name: 'networkType', - type: 'list', - choices: [ - { - title: 'Wifi', - name: 'wifi' - }, - { - title: 'Ethernet', - name: 'ethernet' - } - ] - }, - { - title: 'Wifi SSID', - name: 'networkSsid', - type: 'text', - when: { - networkType: 'wifi' - } - }, - { - title: 'Wifi Key', - name: 'networkKey', - type: 'password', - when: { - networkType: 'wifi' - } - }, - { - title: 'Wifi Type', - name: 'networkWifiType', - type: 'list', - choices: [ - { - title: 'WEP', - name: 'wep' - }, - { - title: 'WPA', - name: 'wpa' - }, - { - title: 'WPA2', - name: 'wpa2' - } - ], - when: { - networkType: 'wifi' - } - }, - { - title: 'Wlan Interface', - name: 'wlanInterface', - type: 'text', - when: { - networkType: 'wifi', - networkWifiType: 'wpa2' - } - } - ]); -}); diff --git a/tests/visuals/cli/transpile-question-when.spec.js b/tests/visuals/cli/transpile-question-when.spec.js deleted file mode 100644 index 152a6715..00000000 --- a/tests/visuals/cli/transpile-question-when.spec.js +++ /dev/null @@ -1,346 +0,0 @@ -/* - * Copyright 2016 Resin.io - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const ava = require('ava'); -const _ = require('lodash'); -const cli = require('../../../visuals/cli'); - -_.attempt(() => { - const question = cli.transpileQuestion({ - title: 'Wifi SSID', - name: 'ssid', - type: 'text', - when: { - networkType: 'wifi' - } - }); - - ava.test('(string property) should return false for an empty object', (test) => { - test.false(question.when({})); - }); - - ava.test('(string property) should return true if it matches', (test) => { - test.true(question.when({ - networkType: 'wifi' - })); - }); - - ava.test('(string property) should return true if is a subset', (test) => { - test.true(question.when({ - networkType: 'wifi', - wifiKey: 'foo bar' - })); - }); - - ava.test('(string property) should return false if is not a subset', (test) => { - test.false(question.when({ - wifiKey: 'foo bar' - })); - }); - - ava.test('(string property) should return true if it does not match', (test) => { - test.false(question.when({ - networkType: 'ethernet' - })); - }); -}); - -_.attempt(() => { - const question = cli.transpileQuestion({ - title: 'HDMI', - name: 'hdmi', - type: 'checkbox', - when: { - enableScreen: true - } - }); - - ava.test('(boolean property) should return false for an empty object', (test) => { - test.false(question.when({})); - }); - - ava.test('(boolean property) should return true if true', (test) => { - test.true(question.when({ - enableScreen: true - })); - }); - - ava.test('(boolean property) should return false if false', (test) => { - test.false(question.when({ - enableScreen: false - })); - }); - - ava.test('(boolean property) should return false if 0', (test) => { - test.false(question.when({ - enableScreen: 0 - })); - }); - - ava.test('(boolean property) should return false if 1', (test) => { - test.false(question.when({ - enableScreen: 0 - })); - }); - - ava.test('(boolean property) should return false if undefined', (test) => { - test.false(question.when({ - enableScreen: undefined - })); - }); - - ava.test('(boolean property) should return false if null', (test) => { - test.false(question.when({ - enableScreen: undefined - })); - }); -}); - -_.attempt(() => { - const question = cli.transpileQuestion({ - title: 'HDMI', - name: 'hdmi', - type: 'checkbox', - when: { - capabilities: [ - 'screen', - 'interactive', - 'touch' - ] - } - }); - - ava.test('(string array property) should return false if empty object', (test) => { - test.false(question.when({})); - }); - - ava.test('(string array property) should return true if it matches', (test) => { - test.true(question.when({ - capabilities: [ - 'screen', - 'interactive', - 'touch' - ] - })); - }); - - ava.test('(string array property) should return false if subset', (test) => { - test.false(question.when({ - capabilities: [ - 'screen', - 'touch' - ] - })); - }); - - ava.test('(string array property) should return true if superset', (test) => { - test.true(question.when({ - capabilities: [ - 'screen', - 'interactive', - 'touch', - 'blink', - 'battery' - ] - })); - }); - -}); - -_.attempt(() => { - const question = cli.transpileQuestion({ - title: 'HDMI', - name: 'hdmi', - type: 'checkbox', - when: { - screen: { - type: 'led', - manufacturer: { - name: 'Samsung', - serial: 'xxxxxxx' - } - } - } - }); - - ava.test('(nested object property) should return false if empty object', (test) => { - test.false(question.when({})); - }); - - ava.test('(nested object property) should return true if it matches', (test) => { - test.true(question.when({ - screen: { - type: 'led', - manufacturer: { - name: 'Samsung', - serial: 'xxxxxxx' - } - } - })); - }); - - ava.test('(nested object property) should return false if subset', (test) => { - test.false(question.when({ - screen: { - manufacturer: { - name: 'Samsung', - serial: 'xxxxxxx' - } - } - })); - }); - - ava.test('(nested object property) should return true if superset', (test) => { - test.true(question.when({ - screen: { - type: 'led', - foo: 'bar', - manufacturer: { - foo: 'bar', - name: 'Samsung', - serial: 'xxxxxxx' - } - }, - foo: 'bar' - })); - }); - -}); - -_.attempt(() => { - const question = cli.transpileQuestion({ - title: 'HDMI', - name: 'hdmi', - type: 'checkbox', - when: { - capabilities: [ - { - name: 'screen' - }, - { - name: 'interactive' - }, - { - name: 'touch' - } - ] - } - }); - - ava.test('(object array property) should return false if empty object', (test) => { - test.false(question.when({})); - }); - - ava.test('(object array property) should return true if it matches', (test) => { - test.true(question.when({ - capabilities: [ - { - name: 'screen' - }, - { - name: 'interactive' - }, - { - name: 'touch' - } - ] - })); - }); - - ava.test('(object array property) should return false if subset', (test) => { - test.false(question.when({ - capabilities: [ - { - name: 'screen' - }, - { - name: 'interactive' - } - ] - })); - }); - - ava.test('(object array property) should return true if superset', (test) => { - test.true(question.when({ - capabilities: [ - { - name: 'screen' - }, - { - name: 'interactive' - }, - { - name: 'touch' - }, - { - name: 'blink' - }, - { - name: 'battery' - } - ] - })); - }); - -}); - -_.attempt(() => { - const question = cli.transpileQuestion({ - title: 'Wifi SSID', - name: 'ssid', - type: 'text', - when: { - enableWifi: true, - network: true, - networkType: 'wifi' - } - }); - - ava.test('(multiple string property) should return true if it matches', (test) => { - test.true(question.when({ - enableWifi: true, - network: true, - networkType: 'wifi' - })); - }); - - ava.test('(multiple string property) should return true if is a subset', (test) => { - test.true(question.when({ - enableWifi: true, - network: true, - networkType: 'wifi', - enableFoo: false, - wifiKey: 'secret' - })); - }); - - ava.test('(multiple string property) should return false if is not a subset', (test) => { - test.false(question.when({ - enableWifi: true, - networkType: 'wifi' - })); - }); - - ava.test('(multiple string property) should return true if it does not match', (test) => { - test.false(question.when({ - enableWifi: true, - network: false, - networkType: 'wifi' - })); - }); -}); diff --git a/tests/visuals/cli/transpile-question.spec.js b/tests/visuals/cli/transpile-question.spec.js deleted file mode 100644 index dbe328c6..00000000 --- a/tests/visuals/cli/transpile-question.spec.js +++ /dev/null @@ -1,351 +0,0 @@ -/* - * Copyright 2016 Resin.io - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const ava = require('ava'); -const _ = require('lodash'); -const cli = require('../../../visuals/cli'); - -ava.test('should throw if type is not recognised', (test) => { - test.throws(() => { - cli.transpileQuestion({ - title: 'Foo', - name: 'foo', - type: 'foo' - }); - }, 'Unknown question type: foo'); -}); - -ava.test('should throw if question has no title', (test) => { - test.throws(() => { - cli.transpileQuestion({ - name: 'foo', - type: 'text' - }); - }, 'Invalid question title: undefined'); -}); - -ava.test('should throw if question has an invalid title', (test) => { - test.throws(() => { - cli.transpileQuestion({ - title: [ 'foo', 'bar' ], - name: 'foo', - type: 'text' - }); - }, 'Invalid question title: foo,bar'); -}); - -ava.test('should throw if question has no name', (test) => { - test.throws(() => { - cli.transpileQuestion({ - title: 'Foo', - type: 'text' - }); - }, 'Invalid question name: undefined'); -}); - -ava.test('should throw if question has an invalid name', (test) => { - test.throws(() => { - cli.transpileQuestion({ - title: 'Foo', - name: [ 'foo', 'bar' ], - type: 'text' - }); - }, 'Invalid question name: foo,bar'); -}); - -ava.test('it should transpile a basic text question', (test) => { - test.deepEqual(cli.transpileQuestion({ - title: 'Wifi SSID', - name: 'ssid', - type: 'text' - }), { - message: 'Wifi SSID', - name: 'ssid', - type: 'input' - }); -}); - -ava.test('it should transpile a basic password question', (test) => { - test.deepEqual(cli.transpileQuestion({ - title: 'Wifi Key', - name: 'key', - type: 'password' - }), { - message: 'Wifi Key', - name: 'key', - type: 'password' - }); -}); - -ava.test('it should transpile a basic number question', (test) => { - const question = cli.transpileQuestion({ - title: 'Update Poll Interval', - name: 'updatePollInterval', - type: 'number' - }); - - test.deepEqual(_.omitBy(question, _.isFunction), { - message: 'Update Poll Interval', - name: 'updatePollInterval', - type: 'input' - }); -}); - -_.each([ - { - value: '123', - expected: true - }, - { - value: '0', - expected: true - }, - { - value: '-1', - expected: true - }, - { - value: '3.5', - expected: true - }, - { - value: '.5', - expected: true - }, - { - value: '-10.3', - expected: true - }, - { - value: 5, - expected: true - }, - { - value: 'foo', - expected: 'Invalid number' - } -], (testCase) => { - ava.test(`it should return ${testCase.expected} for ${testCase.value} number validation`, (test) => { - const question = cli.transpileQuestion({ - title: 'Update Poll Interval', - name: 'updatePollInterval', - type: 'number' - }); - - test.is(question.validate(testCase.value), testCase.expected); - }); -}); - -_.each([ - { - value: '123', - expected: 123 - }, - { - value: '0', - expected: 0 - }, - { - value: '-1', - expected: -1 - }, - { - value: '3.5', - expected: 3.5 - }, - { - value: '.5', - expected: 0.5 - }, - { - value: '-10.3', - expected: -10.3 - } -], (testCase) => { - ava.test(`it should return ${testCase.expected} for ${testCase.value} number filter`, (test) => { - const question = cli.transpileQuestion({ - title: 'Update Poll Interval', - name: 'updatePollInterval', - type: 'number' - }); - - test.is(question.filter(testCase.value), testCase.expected); - }); -}); - -ava.test('it should transpile a basic editor question', (test) => { - test.deepEqual(cli.transpileQuestion({ - title: 'Welcome message', - name: 'welcome', - type: 'editor' - }), { - message: 'Welcome message', - name: 'welcome', - type: 'editor' - }); -}); - -ava.test('it should allow a default text value', (test) => { - test.deepEqual(cli.transpileQuestion({ - title: 'Wifi SSID', - name: 'ssid', - type: 'text', - default: 'mynetwork' - }), { - message: 'Wifi SSID', - name: 'ssid', - type: 'input', - default: 'mynetwork' - }); -}); - -ava.test('it should allow a default password value', (test) => { - test.deepEqual(cli.transpileQuestion({ - title: 'Wifi Key', - name: 'key', - type: 'password', - default: 'secret' - }), { - message: 'Wifi Key', - name: 'key', - type: 'password', - default: 'secret' - }); -}); - -ava.test('it should allow a default number value', (test) => { - const question = cli.transpileQuestion({ - title: 'Update Poll Interval', - name: 'updatePollInterval', - type: 'number', - default: 60000 - }); - - test.deepEqual(_.omitBy(question, _.isFunction), { - message: 'Update Poll Interval', - name: 'updatePollInterval', - type: 'input', - default: 60000 - }); -}); - -ava.test('it should allow a default editor value', (test) => { - test.deepEqual(cli.transpileQuestion({ - title: 'Welcome message', - name: 'welcome', - type: 'editor', - default: 'Welcome!' - }), { - message: 'Welcome message', - name: 'welcome', - type: 'editor', - default: 'Welcome!' - }); -}); - -ava.test('it should transpile a basic list question', (test) => { - test.deepEqual(cli.transpileQuestion({ - title: 'Network Type', - name: 'networkType', - type: 'list', - choices: [ - { - title: 'Wifi', - name: 'wifi' - }, - { - title: 'Ethernet', - name: 'ethernet' - } - ] - }), { - message: 'Network Type', - name: 'networkType', - type: 'list', - choices: [ - { - name: 'Wifi', - value: 'wifi' - }, - { - name: 'Ethernet', - value: 'ethernet' - } - ] - }); -}); - -ava.test('it should allow a default list value', (test) => { - test.deepEqual(cli.transpileQuestion({ - title: 'Network Type', - name: 'networkType', - type: 'list', - default: 'wifi', - choices: [ - { - title: 'Wifi', - name: 'wifi' - }, - { - title: 'Ethernet', - name: 'ethernet' - } - ] - }), { - message: 'Network Type', - name: 'networkType', - type: 'list', - default: 'wifi', - choices: [ - { - name: 'Wifi', - value: 'wifi' - }, - { - name: 'Ethernet', - value: 'ethernet' - } - ] - }); -}); - -ava.test('it should transpile a basic checkbox question', (test) => { - test.deepEqual(cli.transpileQuestion({ - title: 'Enable HDMI', - name: 'hdmi', - type: 'checkbox' - }), { - message: 'Enable HDMI', - name: 'hdmi', - type: 'checkbox' - }); -}); - -ava.test('it should allow a default checkbox value', (test) => { - test.deepEqual(cli.transpileQuestion({ - title: 'Enable HDMI', - name: 'hdmi', - type: 'checkbox', - default: false - }), { - message: 'Enable HDMI', - name: 'hdmi', - type: 'checkbox', - default: false - }); -}); diff --git a/visuals/cli.js b/visuals/cli.js deleted file mode 100644 index 83ea7f44..00000000 --- a/visuals/cli.js +++ /dev/null @@ -1,271 +0,0 @@ -/* - * Copyright 2016 Resin.io - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/** - * @module Reconfix.Visuals.CLI - */ - -const _ = require('lodash'); -const inquirer = require('inquirer'); - -/** - * @summary Reconfix to Inquirer input type map - * @type Object - * @constant - * @private - */ -const INQUIRER_TYPE_MAP = { - text: 'input', - password: 'password', - editor: 'editor', - list: 'list', - checkbox: 'checkbox', - - // InquirerJS doesn't support a "number" - // input so we fallback to a text field. - number: 'input' - -}; - -/** - * @summary Check if an object is a subset of another object - * @function - * @private - * - * @param {Object} subset - subset - * @param {Object} object - object - * @returns {Boolean} whether the object is a subset of the other object - * - * @example - * if (isSubset({ - * foo: 'bar' - * }, { - * foo: 'bar' - * bar: 'baz' - * })) { - * console.log('The first object is a subet of the other one'); - * } - */ -const isSubset = (subset, object) => { - return _.every(subset, (value, key) => { - const objectValue = _.get(object, key); - - if (_.isArray(value)) { - return _.differenceWith(value, objectValue, _.isEqual).length === 0; - } - - if (_.isPlainObject(value)) { - return isSubset(value, objectValue); - } - - return _.isEqual(value, objectValue); - }); -}; - -/** - * @summary Transpile a Reconfix question to an InquirerJS question - * @function - * @private - * - * @param {Object} question - question - * @returns {Object} inquirer question - * - * @example - * const question = cli.transpileQuestion({ - * title: 'Wifi SSID', - * name: 'ssid', - * type: 'text', - * when: { - * networkType: 'wifi' - * } - * }); - */ -exports.transpileQuestion = (question) => { - if (!question.title || !_.isString(question.title)) { - throw new Error(`Invalid question title: ${question.title}`); - } - - if (!question.name || !_.isString(question.name)) { - throw new Error(`Invalid question name: ${question.name}`); - } - - const type = _.get(INQUIRER_TYPE_MAP, question.type); - - if (!type) { - throw new Error(`Unknown question type: ${question.type}`); - } - - if (question.type === 'number') { - question.validate = (input) => { - if (!/^-?(\d+\.?\d*)$|(\d*\.?\d+)$/.test(input)) { - return 'Invalid number'; - } - - return true; - }; - - question.filter = parseFloat; - } - - if (question.when) { - question.whenFunction = _.partial(isSubset, question.when); - } - - if (question.choices) { - question.choices = _.map(question.choices, (choice) => { - return { - name: choice.title, - value: choice.name - }; - }); - } - - return _.omitBy({ - message: question.title, - name: question.name, - type: type, - default: question.default, - choices: question.choices, - validate: question.validate, - filter: question.filter, - when: question.whenFunction - }, _.isUndefined); -}; - -/** - * @summary Flatten a nested reconfix set of questions - * @function - * @private - * - * @param {Object[]} questions - questions - * @returns {Object[]} nested questions - * - * @example - * const result = cli.flatten([ - * { - * title: 'Network Type', - * name: 'networkType', - * type: 'list', - * choices: [ - * { - * title: 'Wifi', - * name: 'wifi', - * questions: [ - * { - * title: 'Wifi SSID', - * name: 'networkSsid', - * type: 'text' - * }, - * { - * title: 'Wifi Key', - * name: 'networkKey', - * type: 'password' - * } - * ] - * }, - * { - * title: 'Ethernet', - * name: 'ethernet' - * } - * ] - * } - * ]); - */ -exports.flatten = (questions) => { - return _.reduce(questions, (accumulator, question) => { - const childrenQuestions = []; - - const flattenChoices = (composedQuestion, when) => { - when = when || {}; - - composedQuestion.choices = _.map(composedQuestion.choices, (choice) => { - if (!_.isEmpty(choice.questions)) { - _.each(choice.questions, (choiceQuestion) => { - choiceQuestion.when = choiceQuestion.when || {}; - _.merge(choiceQuestion.when, when); - choiceQuestion.when[composedQuestion.name] = choice.name; - childrenQuestions.push(choiceQuestion); - - if (choiceQuestion.type === 'list') { - flattenChoices(choiceQuestion, choiceQuestion.when); - } - }); - } - - return _.omit(choice, 'questions'); - }); - }; - - if (question.type === 'list') { - flattenChoices(question); - } - - return _.concat(accumulator, [ question ], childrenQuestions); - }, []); -}; - -/** - * @summary Run a set of questions - * @function - * @public - * - * @param {Object[]} questions - questions - * @param {Object} [defaults={}] - default answers - * @fulfil {Object} - answers - * @returns {Promise} - * - * @example - * cli.run([ - * { - * title: 'Network Type', - * name: 'networkType', - * type: 'list', - * choices: [ - * { - * title: 'Wifi', - * name: 'wifi', - * questions: [ - * { - * title: 'Wifi SSID', - * name: 'networkSsid', - * type: 'text' - * }, - * { - * title: 'Wifi Key', - * name: 'networkKey', - * type: 'password' - * } - * ] - * }, - * { - * title: 'Ethernet', - * name: 'ethernet' - * } - * ] - * } - * ]).then((answers) => { - * console.log(answers); - * }); - */ -exports.run = (questions, defaults) => { - defaults = defaults || {}; - return inquirer.prompt(_.map(exports.flatten(questions), (question) => { - question.default = _.get(defaults, question.name) || question.default; - return exports.transpileQuestion(question); - })); -};