From 82348597e8e43999d7447b864dcf3994df8aca6c Mon Sep 17 00:00:00 2001 From: Sergey Boltonosov Date: Thu, 15 Nov 2018 23:51:00 +0700 Subject: [PATCH 1/7] Fix number with units for repeated step --- js/ractive-legalform.js | 50 +++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/js/ractive-legalform.js b/js/ractive-legalform.js index bbb6d50..5a2f8c7 100644 --- a/js/ractive-legalform.js +++ b/js/ractive-legalform.js @@ -132,6 +132,8 @@ if (isComputed) return; + this.onChangeAmount(newValue, oldValue, keypath); + var isEmpty = newValue === null || newValue === undefined || (typeof(newValue) === 'string' && !newValue.trim().length); //consider evalueted expressions, that have only spaces, as empty @@ -200,6 +202,28 @@ }, 10); }, + /** + * Handle change of amount options from singular to plural, and backwords. + * @param {mixed} newValue + * @param {mixed} oldValue + * @param {string} keypath + */ + onChangeAmount: function(newValue, oldValue, keypath) { + var key = keypath.replace(/\.amount$/, ''); + var meta = this.get('meta.' + key); + var isAmount = typeof meta !== 'undefined' && + typeof meta.plural !== 'undefined' && + typeof meta.singular !== 'undefined'; + + if (!isAmount) return; + + var oldOptions = meta[newValue == 1 ? 'plural' : 'singular']; + var newOptions = meta[newValue == 1 ? 'singular' : 'plural']; + var index = oldOptions ? oldOptions.indexOf(this.get(key + '.unit')) : -1; + + if (newOptions && index !== -1) this.set(key + '.unit', newOptions[index]); + }, + /** * We do not use computed for expression field itself, to avoid escaping dots in template, * because in computed properties dots are just parts of name, and do not represent nested objects. @@ -230,6 +254,7 @@ var ractive = this; var name = unescapeDots(keypath.replace(this.suffix.repeater, '')); var value = ractive.get(name); + var tmpl = typeof this.defaults[name] !== 'undefined' ? this.defaults[name][0] : {}; var repeater = newValue; var stepCount = value.length; @@ -238,7 +263,7 @@ else if (repeater > stepCount) { var addLength = repeater - stepCount; for (var i = 0; i < addLength; i++) { - value.push({}); + value.push($.extend(true, {}, tmpl)); } } @@ -334,36 +359,13 @@ * Initialize special field types */ initField: function (key, meta) { - var amountFields = []; - if (meta.type === 'amount') { - amountFields.push(key + this.suffix.amount); this.initAmountField(key, meta); } else if (meta.type === 'external_data') { this.initExternalData($.extend({name: key}, meta)); } else if (meta.external_source) { this.initExternalSource($.extend({name: key}, meta)); } - - this.initAmountChange(amountFields); - }, - - /** - * Init change of amount options from singular to plural, and backwords. - * This can not be processed in base form change observer, as it needs fields names. - * @param {array} fields All amount fields' names - */ - initAmountChange: function(fields) { - if (!fields.length) return; - - this.observe(fields.join(' '), function(newValue, oldValue, keypath) { - var key = keypath.replace(/\.amount$/, ''); - var oldOptions = this.get('meta.' + key + '.' + (newValue == 1 ? 'plural' : 'singular')); - var newOptions = this.get('meta.' + key + '.' + (newValue == 1 ? 'singular' : 'plural')); - var index = oldOptions ? oldOptions.indexOf(this.get(key + '.unit')) : -1; - - if (newOptions && index !== -1) this.set(key + '.unit', newOptions[index]); - }, {defer: true}); }, /** From 13255497c5dad3c2f2e8fe9671823923a8cbdae8 Mon Sep 17 00:00:00 2001 From: Minstel Date: Sun, 2 Dec 2018 22:13:44 +0700 Subject: [PATCH 2/7] Fix parseNumber in case of 3 numbers after decimal dot --- js/lib/parse-number.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/js/lib/parse-number.js b/js/lib/parse-number.js index 4f45efe..1c8d145 100644 --- a/js/lib/parse-number.js +++ b/js/lib/parse-number.js @@ -3,7 +3,7 @@ if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { module.exports = parseNumber; } -var numberRegexp = new RegExp('^(?:((?:\\d{1,3}(?:\\.\\d{3})+|\\d+)(?:,\\d{1,})?)|((?:\\d{1,3}(?:,\\d{3})+|\\d+)(?:\\.\\d{1,})?))$'); +var numberRegexp = new RegExp('^(?:((?:\\d{1,3}(?:\\.\\d{3})+|\\d+)(,\\d{1,})?)|((?:\\d{1,3}(?:,\\d{3})+|\\d+)(\\.\\d{1,})?))$'); var dotRegexp = /\./g; var commaRegexp = /,/g; @@ -23,7 +23,9 @@ function parseNumber(number) { var match = number.match(numberRegexp); if (!match) return null; - var isDecimalComma = typeof match[1] !== 'undefined'; + var isDecimalComma = + typeof match[2] !== 'undefined' || + (typeof match[3] !== 'undefined' && typeof match[4] === 'undefined'); number = isDecimalComma ? number.replace(dotRegexp, '').replace(',', '.') : From ac091cb58ee0a5d27e3e745c021f5fcb343c74b2 Mon Sep 17 00:00:00 2001 From: Sergey Boltonosov Date: Sat, 24 Nov 2018 00:07:50 +0700 Subject: [PATCH 3/7] Fix validation of group fields --- js/legalform-validation.js | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/js/legalform-validation.js b/js/legalform-validation.js index 3d30acb..a9005fd 100644 --- a/js/legalform-validation.js +++ b/js/legalform-validation.js @@ -2,10 +2,11 @@ * Validation for LegalForm */ (function($) { - function LegalFormValidation() { + function LegalFormValidation(isTestEnv) { this.ractive = null; this.el = null; this.elWizard = null; + this.isTestEnv = !!isTestEnv; //Fields for custom validation var textFields = 'input[type="text"], input[type="number"], input[type="email"], textarea'; @@ -228,25 +229,26 @@ } // Implement validation for group checkboxes - if (meta.type === 'group') { + if (meta.type === 'group' && $input.attr('multiple')) { const checkBoxId = $input.attr('data-id'); - const allCheckboxes = $("[data-id='" + checkBoxId + "']"); + const $allCheckboxes = $('[data-id="' + checkBoxId + '"]'); const isRequired = !$input.closest('.form-group').find('label > span').length ? false : $input.closest('.form-group').find('label > span')[0].className === 'required' ? true : false; - let checked = 0; - - for (var i = 0; i < allCheckboxes.length; i++) { - if (allCheckboxes[i].checked) { - checked++; - } else if (allCheckboxes[i].type !== 'radio') { - $(allCheckboxes[i]).prop('required', false); + if (isRequired && this.isTestEnv) { + $allCheckboxes.prop('required', false); + } else { + let checked = 0; + for (var i = 0; i < $allCheckboxes.length; i++) { + if ($allCheckboxes[i].checked) checked++; } - } - if (isRequired && checked === 0) { - $input.get(0).setCustomValidity(error); - return; + if (isRequired) $allCheckboxes.prop('required', !checked); + + if (isRequired && checked === 0) { + $input.get(0).setCustomValidity(error); + return; + } } } From 4a2c3d56a8dd9c384e88f8cdca70964f022b7691 Mon Sep 17 00:00:00 2001 From: Sergey Boltonosov Date: Fri, 16 Nov 2018 00:31:59 +0700 Subject: [PATCH 4/7] Cast money to float --- js/legalform-calc.js | 2 ++ js/ractive-legalform.js | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/js/legalform-calc.js b/js/legalform-calc.js index 00a0f05..a79ef17 100644 --- a/js/legalform-calc.js +++ b/js/legalform-calc.js @@ -55,6 +55,8 @@ function LegalFormCalc($) { if (type === 'group' && field.multiple) { value = typeof(value) !== 'undefined' ? [value] : []; + } else if (type === 'money' && value) { + value = parseFloat(value); } addGroupedData(data, step.group, field.name, value); diff --git a/js/ractive-legalform.js b/js/ractive-legalform.js index cbe8b18..57a5354 100644 --- a/js/ractive-legalform.js +++ b/js/ractive-legalform.js @@ -132,6 +132,8 @@ if (isComputed) return; + this.onChangeMoney(newValue, oldValue, keypath); + var isEmpty = newValue === null || newValue === undefined || (typeof(newValue) === 'string' && !newValue.trim().length); //consider evalueted expressions, that have only spaces, as empty @@ -207,6 +209,23 @@ }, 10); }, + /** + * Cast money to float + * @param {string} newValue + * @param {string} oldValue + * @param {string} keypath + */ + onChangeMoney: function(newValue, oldValue, keypath) { + var meta = this.get('meta.' + keypath); + var isMoney = typeof meta !== 'undefined' && + typeof meta.type !== 'undefined' && + meta.type === 'money'; + + if (isMoney && newValue) { + this.set(keypath, parseFloat(newValue)); + } + }, + /** * We do not use computed for expression field itself, to avoid escaping dots in template, * because in computed properties dots are just parts of name, and do not represent nested objects. From c950439647aee2a30456b0e59ea6e010acb63a66 Mon Sep 17 00:00:00 2001 From: Sergey Boltonosov Date: Sun, 25 Nov 2018 23:40:12 +0700 Subject: [PATCH 5/7] Make required fields optional in editor --- js/legalform-html.js | 18 ++++++++++-------- js/legalform-validation.js | 8 +++++--- js/legalform.js | 10 +++++----- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/js/legalform-html.js b/js/legalform-html.js index cf63e56..0346ae5 100644 --- a/js/legalform-html.js +++ b/js/legalform-html.js @@ -22,16 +22,18 @@ function LegalFormHtml($) { }; this.model = null; - this.isTestEnv = false; + this.disableRequiredFields = false; /** * Build form html - * @param {array} definition Form definition - * @param {boolean} isTestEnv If we're building form for testing purposes - * @return {string} Form html + * @param {array} definition Form definition + * @param {object} builderOptions Additional options for buildong form html + * @return {string} Form html */ - this.build = function(definition, isTestEnv) { - self.isTestEnv = !!isTestEnv; + this.build = function(definition, builderOptions) { + if (typeof builderOptions === 'undefined') builderOptions = {}; + + self.disableRequiredFields = !!builderOptions.disableRequiredFields; self.model = (new FormModel(definition)).getModel(); var lines = []; @@ -165,7 +167,7 @@ function LegalFormHtml($) { var type = self.model.getFieldType(data); var excl = mode === 'build' ? 'data-mask;' : - (self.isTestEnv ? 'required;' : ''); + (self.disableRequiredFields ? 'required;' : ''); switch (type) { case 'number': @@ -335,7 +337,7 @@ function LegalFormHtml($) { var more = value === null ? {checked: data.value} : {name: data.value, value: value}; attrs = $.extend(attrs, more); - if (self.isTestEnv) { + if (self.disableRequiredFields) { excl += ';required;'; } } else { diff --git a/js/legalform-validation.js b/js/legalform-validation.js index a9005fd..b749ff5 100644 --- a/js/legalform-validation.js +++ b/js/legalform-validation.js @@ -2,11 +2,13 @@ * Validation for LegalForm */ (function($) { - function LegalFormValidation(isTestEnv) { + function LegalFormValidation(builderOptions) { + if (typeof builderOptions === 'undefined') builderOptions = {}; + this.ractive = null; this.el = null; this.elWizard = null; - this.isTestEnv = !!isTestEnv; + this.disableRequiredFields = !!builderOptions.disableRequiredFields; //Fields for custom validation var textFields = 'input[type="text"], input[type="number"], input[type="email"], textarea'; @@ -235,7 +237,7 @@ const isRequired = !$input.closest('.form-group').find('label > span').length ? false : $input.closest('.form-group').find('label > span')[0].className === 'required' ? true : false; - if (isRequired && this.isTestEnv) { + if (isRequired && this.disableRequiredFields) { $allCheckboxes.prop('required', false); } else { let checked = 0; diff --git a/js/legalform.js b/js/legalform.js index a2671a8..6ffc495 100644 --- a/js/legalform.js +++ b/js/legalform.js @@ -12,13 +12,13 @@ function LegalForm($) { /** * Build form html - * @param {array} definition Form definition - * @param {boolean} isTestEnv If we're building form for testing purposes - * @return {string} Form html + * @param {array} definition Form definition + * @param {object} builderOptions Additional options for buildong form html + * @return {string} Form html */ - this.build = function(definition, isTestEnv) { + this.build = function(definition, builderOptions) { var handler = new LegalFormHtml($); - return handler.build(definition, isTestEnv); + return handler.build(definition, builderOptions); } /** From 85c3435bf38ec9ff7287dcb434f8e265d0c2acc0 Mon Sep 17 00:00:00 2001 From: Sergey Boltonosov Date: Mon, 26 Nov 2018 00:50:51 +0700 Subject: [PATCH 6/7] Fix tests --- spec/legalform.build.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/legalform.build.spec.js b/spec/legalform.build.spec.js index 781c9d3..abc74e1 100644 --- a/spec/legalform.build.spec.js +++ b/spec/legalform.build.spec.js @@ -1484,7 +1484,7 @@ describe("building a LegalForm for legalform model", function() { } ]; - var form = new LegalForm(jQuery).build(definition, true); + var form = new LegalForm(jQuery).build(definition, {disableRequiredFields: true}); expect(form).toMatchHtml(`
From a0acab6dab11cdd534a29009419f83e640a7997b Mon Sep 17 00:00:00 2001 From: Minstel Date: Sun, 2 Dec 2018 17:22:34 +0700 Subject: [PATCH 7/7] Expressions for repeated steps --- js/legalform-calc.js | 11 ++- js/lib/ractive-dynamic-computed.js | 71 +++++++++++++++++++ js/lib/repeated-step-expression.js | 22 ++++++ js/ractive-legalform.js | 84 +++++++++++++++++++++-- spec/lib/repeated-step-expression.spec.js | 42 ++++++++++++ 5 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 js/lib/ractive-dynamic-computed.js create mode 100644 js/lib/repeated-step-expression.js create mode 100644 spec/lib/repeated-step-expression.spec.js diff --git a/js/legalform-calc.js b/js/legalform-calc.js index a79ef17..71313b2 100644 --- a/js/legalform-calc.js +++ b/js/legalform-calc.js @@ -90,7 +90,7 @@ function LegalFormCalc($) { data[name + '-validation'] = expandCondition(field.validation, step.group || '', true); } - if (type === 'expression') { + if (type === 'expression' && !step.repeater) { setComputedForExpression(name, step, field, data); } else if (type === 'external_data' || field.external_source) { setComputedForExternalUrls(name, step, field, data); @@ -157,6 +157,15 @@ function LegalFormCalc($) { meta.yearly = !!(typeof field.yearly !== 'undefined' && field.yearly); } + if (type === 'expression' && step.repeater) { + var expression = {}; + var name = (step.group ? step.group + '.' : '') + field.name; + var key = name + '-expression'; + + setComputedForExpression(name, step, field, expression); + meta.expressionTmpl = expression[key]; + } + addGroupedData(data, step.group, field.name, meta); }); diff --git a/js/lib/ractive-dynamic-computed.js b/js/lib/ractive-dynamic-computed.js new file mode 100644 index 0000000..2bae9d7 --- /dev/null +++ b/js/lib/ractive-dynamic-computed.js @@ -0,0 +1,71 @@ +/** + * Dynamic addition and removing of computed properties in current used version of Ractive (0.9.13) is not supported out of the box. + * So we use this object for that purpose. It uses code, extracted from ractive, and simplified to only cover our needs + * (that is only support computed properties, given as strings). + */ + +if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = RactiveDynamicComputed; +} + +function RactiveDynamicComputed() { + var self = this; + + this.dotRegExp = /\./g; + this.computedVarRegExp = /\$\{([^\}]+)\}/g; + + /** + * Remove computed property from existing rative instance + * @param {object} ractive + * @param {string} key + */ + this.remove = function(ractive, key) { + var escapedKey = key.replace(dotRegExp, '\\.'); + + delete ractive.computed[key]; + delete ractive.viewmodel.computations[escapedKey]; + } + + /** + * Add computed expression to existing ractive instance + * @param {object} ractive + * @param {string} key + * @param {string} value + */ + this.add = function(ractive, key, value) { + var signature = getComputationSignature(ractive, key, value); + + ractive.computed[key] = value; + ractive.viewmodel.compute(key, signature); + } + + function getComputationSignature(ractive, key, signature) { + if (typeof signature !== 'string') { + throw 'Unable to dynamically add computed property with value of type ' + (typeof signature); + } + + var getter = createFunctionFromString(signature, ractive); + var getterString = signature; + + return { + getter: getter, + setter: undefined, + getterString: getterString, + setterString: undefined, + getterUseStack: undefined + }; + } + + function createFunctionFromString(str, bindTo) { + var hasThis; + + var functionBody = 'return (' + str.replace(self.computedVarRegExp, function (match, keypath) { + hasThis = true; + return ("__ractive.get(\"" + keypath + "\")"); + }) + ');'; + + if (hasThis) { functionBody = "var __ractive = this; " + functionBody; } + var fn = new Function( functionBody ); + return hasThis ? fn.bind( bindTo ) : fn; + } +} diff --git a/js/lib/repeated-step-expression.js b/js/lib/repeated-step-expression.js new file mode 100644 index 0000000..4242923 --- /dev/null +++ b/js/lib/repeated-step-expression.js @@ -0,0 +1,22 @@ + +if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = tmplToExpression; +} + +var dotRegExp = /\./g; + +/** + * Insert repeated step index into expression + * @param {object} step + * @param {string} expression + * @return {string} + */ +function tmplToExpression(expressionTmpl, group, idx) { + var prefix = group + '.'; + prefix = prefix.replace(dotRegExp, '\\.'); + + var prefixRegExp = new RegExp('\\$\\{' + prefix, 'g'); + var replacement = '${' + group + '[' + idx + '].'; + + return expressionTmpl.replace(prefixRegExp, replacement); +} diff --git a/js/ractive-legalform.js b/js/ractive-legalform.js index 5e48dfe..7b8a140 100644 --- a/js/ractive-legalform.js +++ b/js/ractive-legalform.js @@ -20,6 +20,11 @@ */ validation: null, + /** + * Expressions used in repeated steps + */ + repeatedStepExpressions: {}, + /** * Suffixes in keypath names that determine their special behaviour * @type {Object} @@ -204,7 +209,7 @@ //Use timeout because of some ractive bug: expressions, that depend on setting key, may be not updated, or can even cause an error setTimeout(function() { ractive.set(setName, newValue); - + if (newValue) { $(input).parent().removeClass('is-empty'); } @@ -284,15 +289,21 @@ var repeater = newValue; var stepCount = value.length; - if (!repeater) value.length = 0; - else if (repeater < stepCount) value = value.slice(0, repeater); - else if (repeater > stepCount) { + if (!repeater && stepCount) { + this.removeRepeatedStepExpression(name, 0, stepCount); + value.length = 0; + } else if (repeater < stepCount) { + this.removeRepeatedStepExpression(name, repeater, stepCount); + value = value.slice(0, repeater); + } else if (repeater > stepCount) { var addLength = repeater - stepCount; for (var i = 0; i < addLength; i++) { value.push($.extend(true, {}, tmpl)); } } + this.addRepeatedStepExpression(name, 0, value.length); + ractive.set(name, value); var meta = ractive.get('meta'); @@ -303,6 +314,69 @@ ractive.set('meta', meta); }, + /** + * Save repeated step expression tmpl to cache on ractive init + * @param {string} keypath + * @param {string} expressionTmpl + */ + cacheExpressionTmpl: function(keypath, expressionTmpl) { + var parts = keypath.split('.0.'); + if (parts.length !== 2) return; // Step is not repeatable (shouldn't happen) or has nested arrays (can't be, just in case) + + var group = parts[0]; + var fieldName = parts[1]; + var cache = this.repeatedStepExpressions; + + if (typeof cache[group] === 'undefined') cache[group] = {}; + cache[group][fieldName] = expressionTmpl; + }, + + /** + * Create computed expression dynamically for repeated step + * @param {string} group + * @param {int} fromStepIdx + * @param {int} stepCount + */ + addRepeatedStepExpression: function(group, fromStepIdx, stepCount) { + var expressionTmpls = this.repeatedStepExpressions[group]; + if (typeof expressionTmpls === 'undefined' || !expressionTmpls) return; + + for (var idx = fromStepIdx; idx < stepCount; idx++) { + var prefix = group + '[' + idx + ']'; + + for (var key in expressionTmpls) { + var keypath = prefix + '.' + key + this.suffix.expression; + var value = this.get(keypath); + + if (typeof value !== 'undefined') continue; + + var tmpl = expressionTmpls[key]; + var expression = tmplToExpression(tmpl, group, idx); + this.ractiveDynamicComputed.add(this, keypath, expression); + } + } + }, + + /** + * Remove computed expressions for repeated steps + * @param {string} group + * @param {int} fromStepIdx + * @param {int} stepCount + */ + removeRepeatedStepExpression: function(group, fromStepIdx, stepCount) { + var expressionTmpls = this.repeatedStepExpressions[group]; + if (typeof expressionTmpls === 'undefined' || !expressionTmpls) return; + + for (var idx = fromStepIdx; idx < stepCount; idx++) { + var prefix = group + '[' + idx + ']'; + + for (var key in expressionTmpls) { + var keypath = prefix + '.' + key + this.suffix.expression; + this.ractiveDynamicComputed.remove(this, keypath); + } + } + }, + /** * Show / hide likert questions */ @@ -877,6 +951,8 @@ setByKeyPath(ractive.defaults, key, today); } else if (meta.type === "date") { setByKeyPath(ractive.defaults, key, ""); + } else if (meta.type === 'expression' && typeof meta.expressionTmpl !== 'undefined') { + ractive.cacheExpressionTmpl(key, meta.expressionTmpl); } }); diff --git a/spec/lib/repeated-step-expression.spec.js b/spec/lib/repeated-step-expression.spec.js new file mode 100644 index 0000000..2848ff1 --- /dev/null +++ b/spec/lib/repeated-step-expression.spec.js @@ -0,0 +1,42 @@ +'use strict'; + +describe("test inserting repeated step index into expression", function() { + var tmplToExpression = require('../../js/lib/repeated-step-expression'); + + function usePlaceholderProvider() { + return [ + { + note: 'insert indexes for "foo" step', + group: 'foo', + expression: '${foo.bar} + ${baz.bar} + "test" + "foo." + ${foo.zoo} + ${test.foo.zoo}', + expected: '${foo[2].bar} + ${baz.bar} + "test" + "foo." + ${foo[2].zoo} + ${test.foo.zoo}' + }, + { + note: 'insert indexes for "foo.bar.baz" step', + group: 'foo.bar.baz', + expression: '${foo.bar.baz.prop1} + ${baz.bar} + "test" + "foo.bar.baz." + ${foo.bar.baz.prop2} + ${test.foo.bar.baz.zoo}', + expected: '${foo.bar.baz[2].prop1} + ${baz.bar} + "test" + "foo.bar.baz." + ${foo.bar.baz[2].prop2} + ${test.foo.bar.baz.zoo}' + }, + { + note: 'return expression as it is if step group is not set', + group: '', + expression: '${foo.bar} + ${baz.bar} + "test" + "foo." + ${foo.zoo} + ${test.foo.zoo}', + expected: '${foo.bar} + ${baz.bar} + "test" + "foo." + ${foo.zoo} + ${test.foo.zoo}' + }, + { + note: 'return expression as it is if step group is not found in expression', + group: 'not_used', + expression: '${foo.bar} + ${baz.bar} + "test" + "foo." + ${foo.zoo} + ${test.foo.zoo}', + expected: '${foo.bar} + ${baz.bar} + "test" + "foo." + ${foo.zoo} + ${test.foo.zoo}' + }, + ]; + } + + usePlaceholderProvider().forEach(function(spec) { + it(spec.note, function() { + var result = tmplToExpression(spec.expression, spec.group, 2); + + expect(result).toBe(spec.expected); + }); + }); +});