From 35e4195e6d2b90f2a3541852b2c8c8bac11e9c57 Mon Sep 17 00:00:00 2001 From: Gareth Bowen Date: Fri, 4 May 2018 11:19:01 +1200 Subject: [PATCH] Adds a z-score xpath calculation This makes using z-score much more flexible than the widget implementation. medic/medic-webapp#4457 --- .../enketo/OpenrosaXpathEvaluatorBinding.js | 6 +- static/js/enketo/medic-xpath-extensions.js | 16 ++ static/js/enketo/widgets/z-score.js | 42 ++-- static/js/services/enketo.js | 30 ++- static/js/services/z-score.js | 110 +++++---- tests/karma/unit/controllers/inbox.js | 1 + tests/karma/unit/services/enketo.js | 1 + tests/karma/unit/services/z-score.js | 214 +++++++----------- 8 files changed, 201 insertions(+), 219 deletions(-) create mode 100644 static/js/enketo/medic-xpath-extensions.js diff --git a/static/js/enketo/OpenrosaXpathEvaluatorBinding.js b/static/js/enketo/OpenrosaXpathEvaluatorBinding.js index 93cd3276b95..b6a390cb170 100644 --- a/static/js/enketo/OpenrosaXpathEvaluatorBinding.js +++ b/static/js/enketo/OpenrosaXpathEvaluatorBinding.js @@ -1,5 +1,7 @@ +var _ = require('underscore'); var ExtendedXpathEvaluator = require('extended-xpath'); var openrosa_xpath_extensions = require('openrosa-xpath-extensions'); +var medicExtensions = require('./medic-xpath-extensions'); var translator = require('./translator'); module.exports = function() { @@ -12,6 +14,8 @@ module.exports = function() { return evaluator.createNSResolver.apply( evaluator, arguments ); }; this.xml.jsEvaluate = function(e, contextPath, namespaceResolver, resultType, result) { + var extensions = openrosa_xpath_extensions(translator.t); + extensions.func = _.extend(extensions.func, medicExtensions.func); var evaluator = new ExtendedXpathEvaluator( function wrappedXpathEvaluator(v) { // Node requests (i.e. result types greater than 3 (BOOLEAN) @@ -23,7 +27,7 @@ module.exports = function() { var doc = contextPath.ownerDocument; return doc.evaluate(v, contextPath, namespaceResolver, wrappedResultType, result); }, - openrosa_xpath_extensions(translator.t)); + extensions); return evaluator.evaluate(e, contextPath, namespaceResolver, resultType, result); }; window.JsXPathException = diff --git a/static/js/enketo/medic-xpath-extensions.js b/static/js/enketo/medic-xpath-extensions.js new file mode 100644 index 00000000000..8945ba07f31 --- /dev/null +++ b/static/js/enketo/medic-xpath-extensions.js @@ -0,0 +1,16 @@ +var zscoreUtil; + +module.exports = { + init: function(_zscoreUtil) { + zscoreUtil = _zscoreUtil; + }, + func: { + 'z-score': function(chartId, sex, x, y) { + var result = zscoreUtil(chartId, sex, x, y); + if (!result) { + return { t: 'str', v: '' }; + } + return { t: 'num', v: result }; + } + } +}; diff --git a/static/js/enketo/widgets/z-score.js b/static/js/enketo/widgets/z-score.js index ab168650bb6..e6418711b41 100644 --- a/static/js/enketo/widgets/z-score.js +++ b/static/js/enketo/widgets/z-score.js @@ -48,27 +48,27 @@ define( function( require, exports, module ) { }; ZScoreWidget.prototype._update = function(self) { - var options = { - sex: self.group.find( '.or-appearance-zscore-sex [data-checked=true] input' ).val(), - age: self.group.find( '.or-appearance-zscore-age input' ).val(), - weight: self.group.find( '.or-appearance-zscore-weight input' ).val(), - height: self.group.find( '.or-appearance-zscore-height input' ).val() - }; - self.zScoreService(options) - .then(function(scores) { - self.group.find('.or-appearance-zscore-weight-for-age input') - .val(self._round(scores.weightForAge)) - .trigger( 'change' ); - self.group.find('.or-appearance-zscore-height-for-age input') - .val(self._round(scores.heightForAge)) - .trigger( 'change' ); - self.group.find('.or-appearance-zscore-weight-for-height input') - .val(self._round(scores.weightForHeight)) - .trigger( 'change' ); - }) - .catch(function(err) { - self.logService.error('Error calculating z-score', err); - }); + var sex = self.group.find( '.or-appearance-zscore-sex [data-checked=true] input' ).val(); + var age = self.group.find( '.or-appearance-zscore-age input' ).val(); + var weight = self.group.find( '.or-appearance-zscore-weight input' ).val(); + var height = self.group.find( '.or-appearance-zscore-height input' ).val(); + self.zScoreService().then(function(util) { + var wfa = util('weight-for-age', sex, age, weight); + self.group.find('.or-appearance-zscore-weight-for-age input') + .val(self._round(wfa)) + .trigger( 'change' ); + var hfa = util('height-for-age', sex, age, height); + self.group.find('.or-appearance-zscore-height-for-age input') + .val(self._round(hfa)) + .trigger( 'change' ); + var wfh = util('weight-for-height', sex, height, weight); + self.group.find('.or-appearance-zscore-weight-for-height input') + .val(self._round(wfh)) + .trigger( 'change' ); + }) + .catch(function(err) { + self.logService.error('Error calculating z-score', err); + }); }; ZScoreWidget.prototype.destroy = function( /* element */ ) {}; diff --git a/static/js/services/enketo.js b/static/js/services/enketo.js index f7c6f487316..b8321ac192d 100644 --- a/static/js/services/enketo.js +++ b/static/js/services/enketo.js @@ -1,6 +1,7 @@ var uuid = require('uuid/v4'), pojo2xml = require('pojo2xml'), - xpathPath = require('../modules/xpath-element-path'); + xpathPath = require('../modules/xpath-element-path'), + medicXpathExtensions = require('../enketo/medic-xpath-extensions'); /* globals EnketoForm */ angular.module('inboxServices').service('Enketo', @@ -22,7 +23,8 @@ angular.module('inboxServices').service('Enketo', TranslateFrom, UserContact, XmlForm, - XSLT + XSLT, + ZScore ) { 'use strict'; 'ngInject'; @@ -32,6 +34,17 @@ angular.module('inboxServices').service('Enketo', var FORM_ATTACHMENT_NAME = 'xml'; var REPORT_ATTACHMENT_NAME = this.REPORT_ATTACHMENT_NAME = 'content'; + var init = function() { + ZScore() + .then(function(zscoreUtil) { + medicXpathExtensions.init(zscoreUtil); + }) + .catch(function(err) { + $log.error('Error initialising zscore util', err); + }); + }; + var inited = init(); + var replaceJavarosaMediaWithLoaders = function(id, form) { form.find('img,video,audio').each(function() { var elem = $(this); @@ -324,18 +337,17 @@ angular.module('inboxServices').service('Enketo', }; this.render = function(selector, id, instanceData, editedListener) { - return getUserContact() - .then(function() { - return renderForm(selector, id, instanceData, editedListener); - }); + return $q.all([inited, getUserContact()]).then(function() { + return renderForm(selector, id, instanceData, editedListener); + }); }; this.renderContactForm = renderForm; this.renderFromXmlString = function(selector, xmlString, instanceData, editedListener) { - return Language() - .then(function(language) { - return translateXml(xmlString, language); + return $q.all([inited, Language()]) + .then(function(results) { + return translateXml(xmlString, results[1]); }) .then(transformXml) .then(function(doc) { diff --git a/static/js/services/z-score.js b/static/js/services/z-score.js index 3ba0c559bc0..2f74a09a15d 100644 --- a/static/js/services/z-score.js +++ b/static/js/services/z-score.js @@ -1,5 +1,7 @@ angular.module('inboxServices').factory('ZScore', function( + $log, + Changes, DB ) { @@ -9,44 +11,17 @@ angular.module('inboxServices').factory('ZScore', var CONFIGURATION_DOC_ID = 'zscore-charts'; var MINIMUM_Z_SCORE = -4; var MAXIMUM_Z_SCORE = 4; - var CONFIGURATIONS = [ - { - id: 'weight-for-age', - property: 'weightForAge', - required: [ 'sex', 'age', 'weight' ], - xAxis: 'age', - yAxis: 'weight' - }, - { - id: 'height-for-age', - property: 'heightForAge', - required: [ 'sex', 'age', 'height' ], - xAxis: 'age', - yAxis: 'height' - }, - { - id: 'weight-for-height', - property: 'weightForHeight', - required: [ 'sex', 'height', 'weight' ], - xAxis: 'height', - yAxis: 'weight' - } - ]; - var findChart = function(charts, id) { - for (var i = 0; i < charts.length; i++) { - if (charts[i].id === id) { - return charts[i]; + var tables; + + var findTable = function(id) { + for (var i = 0; i < tables.length; i++) { + if (tables[i].id === id) { + return tables[i]; } } }; - var hasRequiredOptions = function(configuration, options) { - return configuration.required.every(function(required) { - return !!options[required]; - }); - }; - var findClosestDataSet = function(data, key) { if (key < data[0].key || key > data[data.length - 1].key) { // the key isn't covered by the configured data points @@ -85,45 +60,66 @@ angular.module('inboxServices').factory('ZScore', return lowerIndex + MINIMUM_Z_SCORE + ratio; }; - var calculate = function(configuration, charts, options) { - if (!hasRequiredOptions(configuration, options)) { - return; - } - var chart = findChart(charts, configuration.id); - if (!chart) { - // no chart configured in the database - return; - } - var sexData = chart.data[options.sex]; - if (!sexData) { - // no data for the given sex - return; - } - var xAxisData = findClosestDataSet(sexData, options[configuration.xAxis]); + var calculate = function(data, x, y) { + var xAxisData = findClosestDataSet(data, x); if (!xAxisData) { // the key lies outside of the lookup table range return; } - return findZScore(xAxisData, options[configuration.yAxis]); + return findZScore(xAxisData, y); }; - return function(options) { - options = options || {}; + var init = function() { return DB().get(CONFIGURATION_DOC_ID) .then(function(doc) { - var result = {}; - CONFIGURATIONS.forEach(function(configuration) { - result[configuration.property] = calculate(configuration, doc.charts, options); - }); - return result; + tables = doc.charts; }) .catch(function(err) { if (err.status === 404) { - throw new Error('zscore-charts doc not found'); + return; } throw err; }); }; + + Changes({ + key: 'zscore-service', + filter: function(change) { + return change.id === CONFIGURATION_DOC_ID; + }, + callback: function(change) { + tables = change.doc && change.doc.charts; + } + }); + + return function() { + return init().then(function() { + return function(tableId, sex, x, y) { + if (!tables) { + // log an error if the z-score utility is used but not configured + $log.error('Doc "' + CONFIGURATION_DOC_ID + '" not found'); + return; + } + if (!sex || !x || !y) { + // the form may not have been filled out yet + return; + } + var table = findTable(tableId); + if (!table) { + // log an error if the z-score utility is used but not configured + $log.error('Requested z-score table not found', tableId); + return; + } + var data = table.data[sex]; + if (!data) { + $log.error('The ' + tableId + ' z-score table is not configured for ' + sex + ' children'); + // no data for the given sex + return; + } + return calculate(data, x, y); + }; + }); + }; } ); diff --git a/tests/karma/unit/controllers/inbox.js b/tests/karma/unit/controllers/inbox.js index 2890d6eb437..4f7aedd31b8 100644 --- a/tests/karma/unit/controllers/inbox.js +++ b/tests/karma/unit/controllers/inbox.js @@ -66,6 +66,7 @@ describe('InboxCtrl controller', () => { $provide.value('UserSettings', sinon.stub()); $provide.value('Tour', { getTours: () => Promise.resolve([]) }); $provide.value('RulesEngine', { init: KarmaUtils.nullPromise()() }); + $provide.value('Enketo', sinon.stub()); $provide.constant('APP_CONFIG', { name: 'name', version: 'version' diff --git a/tests/karma/unit/services/enketo.js b/tests/karma/unit/services/enketo.js index 37262e048d8..8b7ff25e03f 100644 --- a/tests/karma/unit/services/enketo.js +++ b/tests/karma/unit/services/enketo.js @@ -123,6 +123,7 @@ describe('Enketo service', function() { $provide.value('EnketoPrepopulationData', EnketoPrepopulationData); $provide.value('AddAttachment', AddAttachment); $provide.value('XmlForm', XmlForm); + $provide.value('ZScore', () => Promise.resolve(sinon.stub())); $provide.value('$q', Q); // bypass $q so we don't have to digest }); inject(function(_Enketo_) { diff --git a/tests/karma/unit/services/z-score.js b/tests/karma/unit/services/z-score.js index cf05e874e1f..15a64a97aa3 100644 --- a/tests/karma/unit/services/z-score.js +++ b/tests/karma/unit/services/z-score.js @@ -1,23 +1,26 @@ -describe('ZScore service', function() { +describe('ZScore service', () => { 'use strict'; - var service, - dbGet; + let service, + dbGet, + Changes; - beforeEach(function() { + beforeEach(() => { module('inboxApp'); dbGet = sinon.stub(); - module(function($provide) { + Changes = sinon.stub(); + module($provide => { $provide.factory('DB', KarmaUtils.mockDB({ get: dbGet })); + $provide.value('Changes', Changes); }); - inject(function(_ZScore_) { + inject(_ZScore_ => { service = _ZScore_; }); }); - describe('weightForAge calculation', function() { + describe('weightForAge calculation', () => { - var CONFIG_DOC = { + const CONFIG_DOC = { charts: [{ id: 'weight-for-age', data: { @@ -31,119 +34,89 @@ describe('ZScore service', function() { }] }; - it('returns undefined for unconfigured chart', function() { - var options = { - sex: 'male', - weight: 10, - age: 150 - }; - var configDoc = { + it('returns undefined for unconfigured chart', () => { + const configDoc = { charts: [] }; dbGet.returns(Promise.resolve(configDoc)); - return service(options).then(function(scores) { - chai.expect(scores.weightForAge).to.equal(undefined); + return service().then(util => { + const actual = util('weight-for-age', 'male', 10, 150); + chai.expect(actual).to.equal(undefined); }); }); - it('returns undefined when not given sex', function() { - var options = { - weight: 10, - age: 150 - }; - var configDoc = { + it('returns undefined when not given sex', () => { + const configDoc = { charts: [{ id: 'weight-for-age', data: {} }] }; dbGet.returns(Promise.resolve(configDoc)); - return service(options).then(function(scores) { - chai.expect(scores.weightForAge).to.equal(undefined); + return service().then(util => { + const actual = util('weight-for-age', null, 10, 150); + chai.expect(actual).to.equal(undefined); }); }); - it('returns undefined when not given weight', function() { - var options = { - sex: 'male', - age: 150 - }; - var configDoc = { + it('returns undefined when not given weight', () => { + const configDoc = { charts: [{ id: 'weight-for-age', data: {} }] }; dbGet.returns(Promise.resolve(configDoc)); - return service(options).then(function(scores) { - chai.expect(scores.weightForAge).to.equal(undefined); + return service().then(util => { + const actual = util('weight-for-age', 'male', null, 150); + chai.expect(actual).to.equal(undefined); }); }); - it('returns undefined when not given age', function() { - var options = { - sex: 'male', - weight: 10 - }; - var configDoc = { + it('returns undefined when not given age', () => { + const configDoc = { charts: [{ id: 'weight-for-age', data: {} }] }; dbGet.returns(Promise.resolve(configDoc)); - return service(options).then(function(scores) { - chai.expect(scores.weightForAge).to.equal(undefined); + return service().then(util => { + const actual = util('weight-for-age', 'male', 10, null); + chai.expect(actual).to.equal(undefined); }); }); - it('returns zscore', function() { - var options = { - sex: 'male', - weight: 25, - age: 1 - }; + it('returns zscore', () => { dbGet.returns(Promise.resolve(CONFIG_DOC)); - return service(options).then(function(scores) { - chai.expect(scores.weightForAge).to.equal(1); + return service().then(util => { + const actual = util('weight-for-age', 'male', 1, 25); + chai.expect(actual).to.equal(1); chai.expect(dbGet.callCount).to.equal(1); chai.expect(dbGet.args[0][0]).to.equal('zscore-charts'); }); }); - it('approximates zscore when weight is between data points', function() { - var options = { - sex: 'male', - weight: 25.753, - age: 1 - }; + it('approximates zscore when weight is between data points', () => { dbGet.returns(Promise.resolve(CONFIG_DOC)); - return service(options).then(function(scores) { + return service().then(util => { + const rough = util('weight-for-age', 'male', 1, 25.753); // round to 3dp to ignore tiny errors caused by floats - var actual = (scores.weightForAge * 1000) / 1000; + const actual = (rough * 1000) / 1000; chai.expect(actual).to.equal(1.753); }); }); - it('returns undefined when requested sex not configured', function() { - var options = { - sex: 'female', - weight: 25.7, - age: 1 - }; + it('returns undefined when requested sex not configured', () => { dbGet.returns(Promise.resolve(CONFIG_DOC)); - return service(options).then(function(scores) { - chai.expect(scores.weightForAge).to.equal(undefined); + return service().then(util => { + const actual = util('weight-for-age', 'female', 1, 25.7); + chai.expect(actual).to.equal(undefined); }); }); - it('returns undefined when age is below data range', function() { - var options = { - sex: 'male', - weight: 25.7, - age: 1 - }; - var configDoc = { + it('returns undefined when age is below data range', () => { + const configDoc = { charts: [{ id: 'weight-for-age', data: { @@ -155,52 +128,41 @@ describe('ZScore service', function() { }] }; dbGet.returns(Promise.resolve(configDoc)); - return service(options).then(function(scores) { - chai.expect(scores.weightForAge).to.equal(undefined); + return service().then(util => { + const actual = util('weight-for-age', 'male', 1, 25.7); + chai.expect(actual).to.equal(undefined); }); }); - it('returns undefined when age is above data range', function() { - var options = { - sex: 'male', - weight: 25.7, - age: 5 - }; + it('returns undefined when age is above data range', () => { dbGet.returns(Promise.resolve(CONFIG_DOC)); - return service(options).then(function(scores) { - chai.expect(scores.weightForAge).to.equal(undefined); + return service().then(util => { + const actual = util('weight-for-age', 'male', 5, 25.7); + chai.expect(actual).to.equal(undefined); }); }); - it('returns -4 when weight is below data range', function() { - var options = { - sex: 'male', - weight: 19, - age: 1 - }; + it('returns -4 when weight is below data range', () => { dbGet.returns(Promise.resolve(CONFIG_DOC)); - return service(options).then(function(scores) { - chai.expect(scores.weightForAge).to.equal(-4); + return service().then(util => { + const actual = util('weight-for-age', 'male', 1, 19); + chai.expect(actual).to.equal(-4); }); }); - it('returns 4 when weight is above data range', function() { - var options = { - sex: 'male', - weight: 29, - age: 1 - }; + it('returns 4 when weight is above data range', () => { dbGet.returns(Promise.resolve(CONFIG_DOC)); - return service(options).then(function(scores) { - chai.expect(scores.weightForAge).to.equal(4); + return service().then(util => { + const actual = util('weight-for-age', 'male', 1, 29); + chai.expect(actual).to.equal(4); }); }); }); - describe('heightForAge calculation', function() { + describe('heightForAge calculation', () => { - var CONFIG_DOC = { + const CONFIG_DOC = { charts: [{ id: 'height-for-age', data: { @@ -214,24 +176,20 @@ describe('ZScore service', function() { }] }; - it('returns zscore', function() { - var options = { - sex: 'male', - height: 56.1, - age: 1 - }; + it('returns zscore', () => { dbGet.returns(Promise.resolve(CONFIG_DOC)); - return service(options).then(function(scores) { - chai.expect(scores.heightForAge).to.equal(1.5); + return service().then(util => { + const actual = util('height-for-age', 'male', 1, 56.1); + chai.expect(actual).to.equal(1.5); chai.expect(dbGet.callCount).to.equal(1); chai.expect(dbGet.args[0][0]).to.equal('zscore-charts'); }); }); }); - describe('weightForHeight calculation', function() { + describe('weightForHeight calculation', () => { - var CONFIG_DOC = { + const CONFIG_DOC = { charts: [{ id: 'weight-for-height', data: { @@ -245,24 +203,20 @@ describe('ZScore service', function() { }] }; - it('returns zscore', function() { - var options = { - sex: 'male', - height: 45.1, - weight: 26.5 - }; + it('returns zscore', () => { dbGet.returns(Promise.resolve(CONFIG_DOC)); - return service(options).then(function(scores) { - chai.expect(scores.weightForHeight).to.equal(2.5); + return service().then(util => { + const actual = util('weight-for-height', 'male', 45.1, 26.5); + chai.expect(actual).to.equal(2.5); chai.expect(dbGet.callCount).to.equal(1); chai.expect(dbGet.args[0][0]).to.equal('zscore-charts'); }); }); }); - describe('cic test cases', function() { + describe('cic test cases', () => { - var CONFIG_DOC = { + const CONFIG_DOC = { charts: [{ id: 'height-for-age', data: { @@ -293,18 +247,16 @@ describe('ZScore service', function() { }] }; - it('#563', function() { - var options = { - sex: 'male', - age: 1072, - height: 83, - weight: 11.704545 - }; + it('#563', () => { + const sex = 'male'; + const age = 1072; + const height = 83; + const weight = 11.704545; dbGet.returns(Promise.resolve(CONFIG_DOC)); - return service(options).then(function(scores) { - chai.expect(scores.heightForAge).to.equal(-3.424135113048216); - chai.expect(scores.weightForAge).to.equal(-1.6321967559943587); - chai.expect(scores.weightForHeight).to.equal(0.6880010384215982); + return service().then(util => { + chai.expect(util('height-for-age', sex, age, height)).to.equal(-3.424135113048216); + chai.expect(util('weight-for-age', sex, age, weight)).to.equal(-1.6321967559943587); + chai.expect(util('weight-for-height', sex, height, weight)).to.equal(0.6880010384215982); }); }); });