diff --git a/build_data.js b/build_data.js index 50d282f7a3..c9066ad39a 100644 --- a/build_data.js +++ b/build_data.js @@ -294,7 +294,7 @@ function generatePresets(tstrings, faIcons, tnpIcons) { validate(file, preset, presetSchema); tstrings.presets[id] = { - name: preset.name, + name: [preset.name].concat(preset.aliases || []).join(','), terms: (preset.terms || []).join(',') }; @@ -351,14 +351,17 @@ function generateTranslations(fields, presets, tstrings) { var tags = p.tags || {}; var keys = Object.keys(tags); + var tagsDescription = keys.map(function(k) { return k + '=' + tags[k]; }).join(', '); + if (keys.length) { - preset['name#'] = keys.map(function(k) { return k + '=' + tags[k]; }).join(', '); + preset['name#'] = `Name for '${tagsDescription}'. Aliases may be added after the name, separated by commas.`; } if (p.searchable !== false) { + preset['terms#'] = `Related terms beside the name and aliases with which the preset for '${tagsDescription}' should be found, separated by commas. '<>' if none are eligible.`; if (p.terms && p.terms.length) { - preset['terms#'] = 'terms: ' + p.terms.join(); + preset['terms#'] += ' English terms: ' + p.terms.join(); } - preset.terms = ''; + preset.terms = '<>'; } else { delete preset.terms; } @@ -652,8 +655,28 @@ function writeEnJson(tstrings) { var imagery = YAML.load(data[1]); var community = YAML.load(data[2]); + function transifexToPreset(transifex) { + var names = transifex.name.trim().split(/\s*,+\s*/); + var preset = {}; + preset.name = names[0]; + if(names.length > 1) preset.aliases = names.slice(1); + // terms stay Transifex'ied (comma separated string) + if(transifex.terms.length) preset.terms = transifex.terms; + return preset; + } + + var tstringPresets = {}; + for (const id of Object.keys(tstrings.presets)) { + tstringPresets[id] = transifexToPreset(tstrings.presets[id]); + } + var enjsonPresetStrings = { + categories: tstrings.categories, + fields: tstrings.fields, + presets: tstringPresets + }; + var enjson = core; - enjson.en.presets = tstrings; + enjson.en.presets = enjsonPresetStrings; enjson.en.imagery = imagery.en.imagery; enjson.en.community = community.en; diff --git a/data/presets/README.md b/data/presets/README.md index a923b79cd0..dabb2ff0cf 100644 --- a/data/presets/README.md +++ b/data/presets/README.md @@ -88,6 +88,18 @@ A features can only match one preset even if its tags and geometry could technic This property is required. There is no default. +##### `aliases` + +A list of aliases beside the name for this preset. Optional. + +Aliases, like names, use [title case](https://en.wikipedia.org/wiki/Letter_case#Title_case). + +This may be displayed alongside `name` in the result list when searching for presets. + +##### `terms` + +A list of related terms beside the name and aliases with which the preset should be found. Optional. + ##### `addTags` The tags that are added to the feature when selecting this preset. Defaults to `tags`. If needed, this property will typically be a superset of `tags`. diff --git a/data/presets/schema/preset.json b/data/presets/schema/preset.json index 22e1164001..b36b0c6ec5 100644 --- a/data/presets/schema/preset.json +++ b/data/presets/schema/preset.json @@ -63,8 +63,15 @@ "description": "The URL of a remote image that is more specific than 'icon'", "type": "string" }, + "aliases": { + "description": "English aliases / synonyms", + "type": "array", + "items": { + "type": "string" + } + }, "terms": { - "description": "English synonyms or related terms", + "description": "English related terms / keywords", "type": "array", "items": { "type": "string" diff --git a/data/update_locales.js b/data/update_locales.js index b9f716b60b..ffd9da8b77 100644 --- a/data/update_locales.js +++ b/data/update_locales.js @@ -92,18 +92,27 @@ function getResource(resource, callback) { } else { if (resource === 'presets') { - // remove terms that were not really translated + // remove terms that were not really translated, transform names back to name+aliases var presets = (result.presets && result.presets.presets) || {}; for (const key of Object.keys(presets)) { var preset = presets[key]; - if (!preset.terms) continue; - preset.terms = preset.terms.replace(/<.*>/, '').trim(); - if (!preset.terms) { - delete preset.terms; - if (!Object.keys(preset).length) { - delete presets[key]; + if (preset.terms) { + preset.terms = preset.terms.replace(/<.*>/, '').trim(); + if (!preset.terms) { + delete preset.terms; } } + if (preset.name) { + var names = preset.name.trim().split(/\s*,+\s*/); + preset.name = names[0]; + if(names.length > 1) { + preset.aliases = names.slice(1); + } + } + + if (!Object.keys(preset).length) { + delete presets[key]; + } } } diff --git a/modules/presets/collection.js b/modules/presets/collection.js index d2ab15d98f..20c4578312 100644 --- a/modules/presets/collection.js +++ b/modules/presets/collection.js @@ -1,4 +1,4 @@ -import { utilArrayUniq, utilEditDistance } from '../util'; +import { utilEditDistance, utilArrayMapTruthy } from '../util'; export function presetCollection(collection) { @@ -42,7 +42,7 @@ export function presetCollection(collection) { }, search: function(value, geometry, countryCode) { - if (!value) return this; + if (!value) return []; value = value.toLowerCase(); @@ -58,26 +58,48 @@ export function presetCollection(collection) { return index === 0; } - function sortNames(a, b) { - var aCompare = (a.suggestion ? a.originalName : a.name()).toLowerCase(); - var bCompare = (b.suggestion ? b.originalName : b.name()).toLowerCase(); + function getMatchWord(match) { + if (match.alias) return match.alias; + if (match.term) return match.term; + if (match.tagValue) return match.tagValue; + if (match.preset.suggestion) return match.preset.originalName; + return match.preset.name(); + } + + function sortMatches(a, b) { + var aCompare = getMatchWord(a).toLowerCase(); + var bCompare = getMatchWord(b).toLowerCase(); - // priority if search string matches preset name exactly - #4325 + // priority if search string matches word exactly - #4325 if (value === aCompare) return -1; if (value === bCompare) return 1; + // priority for smaller edit distance + var i = a.fuzziness - b.fuzziness; + if (i !== 0) return i; + // priority for higher matchScore - var i = b.originalScore - a.originalScore; + i = b.preset.originalScore - a.preset.originalScore; if (i !== 0) return i; - // priority if search string appears earlier in preset name + // priority if search string appears earlier in word i = aCompare.indexOf(value) - bCompare.indexOf(value); if (i !== 0) return i; - // priority for shorter preset names + // priority for shorter words return aCompare.length - bCompare.length; } + // take those presets from the source array for which the given function + // returns a match and sort the resulting list of matches + function take(array, fn) { + return utilArrayMapTruthy(array, function (val, i) { + var r = fn.call(null, val, i, array); + if (r) array[i] = null; + return r; + }).sort(sortMatches); + } + var pool = this.collection; if (countryCode) { pool = pool.filter(function(a) { @@ -93,81 +115,102 @@ export function presetCollection(collection) { }); // matches value to preset.name - var leading_name = searchable - .filter(function(a) { - return leading(a.name().toLowerCase()); - }).sort(sortNames); + var leading_name = take(searchable, function(a) { + if (leading(a.name().toLowerCase())) return { preset: a }; + }); + + // matches value to preset.aliases values + var leading_aliases = take(searchable, function(a) { + var aliases = a.aliases(); + for (var i = 0; i < aliases.length; i++) { + var alias = aliases[i]; + if (leading(alias.toLowerCase())) return { preset: a, alias: alias }; + } + }); // matches value to preset.terms values - var leading_terms = searchable - .filter(function(a) { - return (a.terms() || []).some(leading); - }); + var leading_terms = take(searchable, function(a) { + var terms = a.terms(); + for (var i = 0; i < terms.length; i++) { + var term = terms[i]; + if (leading(term)) return { preset: a, term: term }; + } + }); // matches value to preset.tags values - var leading_tag_values = searchable - .filter(function(a) { - return Object.values(a.tags || {}) - .filter(function(val) { return val !== '*'; }) - .some(leading); - }); + var leading_tag_values = take(searchable, function(a) { + var tagValues = Object.values(a.tags || []); + for (var i = 0; i < tagValues.length; i++) { + var tagValue = tagValues[i]; + if (tagValue !== '*' && leading(tagValue)) { + return { preset: a, tagValue: tagValue }; + } + } + }); - var leading_suggestions = suggestions - .filter(function(a) { - return leadingStrict(a.originalName.toLowerCase()); - }).sort(sortNames); + var leading_suggestions = take(suggestions, function(a) { + if (leadingStrict(a.originalName.toLowerCase())) return { preset: a }; + }); // finds close matches to value in preset.name - var similar_name = searchable - .map(function(a) { - return { preset: a, dist: utilEditDistance(value, a.name()) }; - }).filter(function(a) { - return a.dist + Math.min(value.length - a.preset.name().length, 0) < 3; - }).sort(function(a, b) { - return a.dist - b.dist; - }).map(function(a) { - return a.preset; - }); + var similar_name = take(searchable, function(a) { + var dist = utilEditDistance(value, a.name()); + if (dist + Math.min(value.length - a.name().length, 0) < 3) { + return { preset: a, fuzziness: dist }; + } + }); + + // finds close matches to value in preset.aliases + var similar_aliases = take(searchable, function(a) { + return utilArrayMapTruthy(a.aliases(), function(alias) { + var dist = utilEditDistance(value, alias); + if (dist + Math.min(value.length - alias.length, 0) < 3) { + return { preset: a, alias: alias, fuzziness: dist }; + } + // only keep the one match with the lowest fuzziness + }).sort(function(a,b) { return a.fuzziness - b.fuzziness; })[0]; + }); // finds close matches to value in preset.terms - var similar_terms = searchable - .filter(function(a) { - return (a.terms() || []).some(function(b) { - return utilEditDistance(value, b) + Math.min(value.length - b.length, 0) < 3; - }); - }); + var similar_terms = take(searchable, function(a) { + return utilArrayMapTruthy(a.terms(), function(term) { + var dist = utilEditDistance(value, term); + if (dist + Math.min(value.length - term.length, 0) < 3) { + return { preset: a, term: term, fuzziness: dist }; + } + // only keep the one match with the lowest fuzziness + }).sort(function(a,b) { return a.fuzziness - b.fuzziness; })[0]; + }); - var similar_suggestions = suggestions - .map(function(a) { - return { preset: a, dist: utilEditDistance(value, a.originalName.toLowerCase()) }; - }).filter(function(a) { - return a.dist + Math.min(value.length - a.preset.originalName.length, 0) < 1; - }).sort(function(a, b) { - return a.dist - b.dist; - }).map(function(a) { - return a.preset; - }); + var similar_suggestions = take(suggestions, function(a) { + var dist = utilEditDistance(value, a.originalName); + if (dist + Math.min(value.length - a.originalName.length, 0) < 1) { + return { preset: a, fuzziness: dist }; + } + }); var results = leading_name.concat( + leading_aliases, leading_suggestions, leading_terms, leading_tag_values, similar_name, + similar_aliases, similar_suggestions, similar_terms ).slice(0, maxSearchResults - 1); if (geometry) { if (typeof geometry === 'string') { - results.push(presets.fallback(geometry)); + results.push({ preset: presets.fallback(geometry) }); } else { geometry.forEach(function(geom) { - results.push(presets.fallback(geom)); + results.push({ preset: presets.fallback(geom) }); }); } } - return presetCollection(utilArrayUniq(results)); + return results; } }; diff --git a/modules/presets/preset.js b/modules/presets/preset.js index 581598bd6c..de78fe6226 100644 --- a/modules/presets/preset.js +++ b/modules/presets/preset.js @@ -140,7 +140,6 @@ export function presetPreset(id, preset, fields, visible, rawPresets) { preset.originalName = preset.name || ''; - preset.name = function() { if (preset.suggestion) { var path = id.split('/'); @@ -154,11 +153,15 @@ export function presetPreset(id, preset, fields, visible, rawPresets) { preset.originalTerms = (preset.terms || []).join(); - preset.terms = function() { return preset.t('terms', { 'default': preset.originalTerms }).toLowerCase().trim().split(/\s*,+\s*/); }; + preset.originalAliases = (preset.aliases || []); + + preset.aliases = function() { + return preset.t('aliases', { 'default': preset.originalAliases }); + }; preset.isFallback = function() { var tagCount = Object.keys(preset.tags).length; diff --git a/modules/util/array.js b/modules/util/array.js index c06e66cdef..f6c5a44289 100644 --- a/modules/util/array.js +++ b/modules/util/array.js @@ -137,3 +137,16 @@ export function utilArrayUniqBy(a, key) { }, []); } +// like Array.prototype.map(fn), only that null elements and elements for +// which the function returns something are filtered out. +// utilArrayMapTruthy([1,2,null,3,4,5], a => a%2 ? a*2 : null) == [2,6,10] +export function utilArrayMapTruthy(a, fn) { + var result = []; + for (var i = 0; i < a.length; i++) { + if (a[i] != null) { + var r = fn.call(null, a[i], i, a); + if (r != null) result.push(r); + } + } + return result; +} diff --git a/modules/util/index.js b/modules/util/index.js index 7d466c1ff3..7691820f21 100644 --- a/modules/util/index.js +++ b/modules/util/index.js @@ -6,6 +6,8 @@ export { utilArrayIntersection } from './array'; export { utilArrayUnion } from './array'; export { utilArrayUniq } from './array'; export { utilArrayUniqBy } from './array'; +export { utilArrayMapTruthy } from './array'; + export { utilAsyncMap } from './util'; export { utilCallWhenIdle } from './idle'; diff --git a/test/spec/presets/collection.js b/test/spec/presets/collection.js index 02ca8bd7d1..68324c4ed2 100644 --- a/test/spec/presets/collection.js +++ b/test/spec/presets/collection.js @@ -18,39 +18,45 @@ describe('iD.presetCollection', function() { grill: iD.presetPreset('__test/amenity/bbq', { name: 'Grill', tags: { amenity: 'bbq' }, - geometry: ['point'], - terms: [] + geometry: ['point'] }), sandpit: iD.presetPreset('__test/amenity/grit_bin', { name: 'Sandpit', + aliases: ['Grit Bin'], tags: { amenity: 'grit_bin' }, + geometry: ['point'] + }), + griffin_nest: iD.presetPreset('__test/natural/griffin_nest', { + name: 'Fantasy Bird Nest', + tags: { natural: 'griffin_nest' }, + geometry: ['point'] + }), + grillos_burgers: iD.presetPreset('__test/amenity/fast_food/burger/Grillo\'s_Burgers', { + name: 'Grillo\'s Burgers', + tags: { amenity: 'fast_food', cuisine: 'burger', name: 'Grillo\'s Burgers' }, geometry: ['point'], - terms: [] + suggestion: true }), residential: iD.presetPreset('__test/highway/residential', { name: 'Residential Area', tags: { highway: 'residential' }, - geometry: ['point', 'area'], - terms: [] + geometry: ['point', 'area'] }), grass1: iD.presetPreset('__test/landuse/grass1', { name: 'Grass', tags: { landuse: 'grass' }, - geometry: ['point', 'area'], - terms: [] + geometry: ['point', 'area'] }), grass2: iD.presetPreset('__test/landuse/grass2', { name: 'Ğṝȁß', tags: { landuse: 'ğṝȁß' }, - geometry: ['point', 'area'], - terms: [] + geometry: ['point', 'area'] }), park: iD.presetPreset('__test/leisure/park', { name: 'Park', tags: { leisure: 'park' }, geometry: ['point', 'area'], - terms: [ 'grass' ], - matchScore: 0.5 + terms: [ 'grass' ] }), parking: iD.presetPreset('__test/amenity/parking', { name: 'Parking', @@ -58,6 +64,11 @@ describe('iD.presetCollection', function() { geometry: ['point', 'area'], terms: [ 'cars' ] }), + bicycle_parking: iD.presetPreset('__test/amenity/bicycle_parking', { + name: 'Bicycle Parking', + tags: { amenity: 'bicycle_parking' }, + geometry: ['point', 'area'] + }), soccer: iD.presetPreset('__test/leisure/pitch/soccer', { name: 'Soccer Field', tags: { leisure: 'pitch', sport: 'soccer' }, @@ -69,13 +80,31 @@ describe('iD.presetCollection', function() { tags: { leisure: 'pitch', sport: 'american_football' }, geometry: ['point', 'area'], terms: ['gridiron'] + }), + tennis: iD.presetPreset('__test/leisure/pitch/tennis', { + name: 'Tennis Field', + tags: { leisure: 'pitch', sport: 'tennis' }, + geometry: ['point'], + }), + tetris: iD.presetPreset('__test/leisure/pitch/tetris', { + name: 'Tetris Field', + tags: { leisure: 'pitch', sport: 'tetris' }, + geometry: ['point'], + }), + bicycle: iD.presetPreset('__test/barrier/bicycle', { + name: 'Bicycle', + tags: { barrier: 'bicycle' }, + geometry: ['point'], + matchScore: 0.5 }) }; var c = iD.presetCollection([ p.point, p.line, p.area, p.grill, p.sandpit, p.residential, - p.grass1, p.grass2, p.park, p.parking, p.soccer, p.football + p.grass1, p.grass2, p.park, p.parking, p.soccer, p.football, + p.tennis, p.tetris, p.bicycle_parking, p.bicycle, p.grillos_burgers, + p.griffin_nest ]); describe('#item', function() { @@ -102,49 +131,79 @@ describe('iD.presetCollection', function() { }); describe('#search', function() { + + function toPreset(a) { return a.preset; } + function toName(a) { return a.preset.originalName; } + it('matches leading name', function() { - var col = c.search('resid', 'area').collection; - expect(col.indexOf(p.residential)).to.eql(0); // 1. 'Residential' (by name) + expect(c.search('resid', 'area')[0]).to.eql({ preset: p.residential }); }); - it('returns alternate matches in correct order', function() { - var col = c.search('gri', 'point').matchGeometry('point').collection; - expect(col.indexOf(p.grill), 'Grill').to.eql(0); // 1. 'Grill' (leading name) - expect(col.indexOf(p.football), 'Football').to.eql(1); // 2. 'Football' (leading term 'gridiron') - expect(col.indexOf(p.sandpit), 'Sandpit').to.eql(2); // 3. 'Sandpit' (leading tag value 'grit_bin') - expect(col.indexOf(p.grass1), 'Grass').to.be.within(3,5); // 4. 'Grass' (similar name) - expect(col.indexOf(p.grass2), 'Ğṝȁß').to.be.within(3,5); // 5. 'Ğṝȁß' (similar name) - expect(col.indexOf(p.park), 'Park').to.be.within(3,5); // 6. 'Park' (similar term 'grass') - }); + describe('sorting result list', function() { - it('sorts preset with matchScore penalty below others', function() { - var col = c.search('par', 'point').matchGeometry('point').collection; - expect(col.indexOf(p.parking), 'Parking').to.eql(0); // 1. 'Parking' (default matchScore) - expect(col.indexOf(p.park), 'Park').to.eql(1); // 2. 'Park' (low matchScore) - }); + it('returns alternate matches in correct order', function() { + var leadingMatches = c.search('gri').map(toName).slice(0, 5); + expect(leadingMatches).to.eql([ + 'Grill', // 1. leading name + 'Sandpit', // 2. leading alias 'Grit Bin' + 'Grillo\'s Burgers', // 3. leading suggestion name + 'Football Field', // 4. leading term 'gridiron' + 'Fantasy Bird Nest', // 5. leading tag value 'griffin_nest' + ]); + }); + + it('sorts matches earlier in word first', function() { + var firstNames = c.search('par').map(toName).slice(0,3); + expect(firstNames).to.eql([ + 'Park', // cause it is shorter + 'Parking', + 'Bicycle Parking' // cause it matches the word later + ]); + }); + + it('sorts less fuzzy matches above more fuzzy matches', function() { + var firstNames = c.search('terris').map(toName).slice(0,2); + expect(firstNames).to.eql([ + 'Tetris Field', // 1. fuzziness = 1 + 'Tennis Field', // 2. fuzziness = 2 + ]); + }); + + it('sorts preset with matchScore penalty below others', function() { + var firstNames = c.search('bicyc').map(toName).slice(0,2); + expect(firstNames).to.eql([ + 'Bicycle Parking', // 1. default matchScore + 'Bicycle', // 2. low matchScore + ]); + }); + + it('ignores matchScore penalty for exact name match', function() { + var firstNames = c.search('bicycle').map(toName).slice(0,2); + expect(firstNames).to.eql([ + 'Bicycle', // 1. low matchScore + 'Bicycle Parking', // 2. default matchScore + ]); + }); - it('ignores matchScore penalty for exact name match', function() { - var col = c.search('park', 'point').matchGeometry('point').collection; - expect(col.indexOf(p.park), 'Park').to.eql(0); // 1. 'Park' (low matchScore) - expect(col.indexOf(p.parking), 'Parking').to.eql(1); // 2. 'Parking' (default matchScore) }); it('considers diacritics on exact matches', function() { - var col = c.search('ğṝȁ', 'point').matchGeometry('point').collection; - expect(col.indexOf(p.grass2), 'Ğṝȁß').to.eql(0); // 1. 'Ğṝȁß' (leading name) - expect(col.indexOf(p.grass1), 'Grass').to.eql(1); // 2. 'Grass' (similar name) + var firstNames = c.matchGeometry('point').search('ğṝȁ').map(toName).slice(0,2); + expect(firstNames).to.eql([ + 'Ğṝȁß', // 1. leading name + 'Grass' // 2. similar name + ]); }); it('replaces diacritics on fuzzy matches', function() { - var col = c.search('graß', 'point').matchGeometry('point').collection; - expect(col.indexOf(p.grass1), 'Grass').to.be.within(0,1); // 1. 'Grass' (similar name) - expect(col.indexOf(p.grass2), 'Ğṝȁß').to.be.within(0,1); // 2. 'Ğṝȁß' (similar name) + var names = iD.presetCollection([p.grass1, p.grass2]).search('graß').map(toName); + expect(names).to.include.members(['Ğṝȁß', 'Grass']); }); it('includes the appropriate fallback preset', function() { - expect(c.search('foo', 'point').collection, 'point').to.include(p.point); - expect(c.search('foo', 'line').collection, 'line').to.include(p.line); - expect(c.search('foo', 'area').collection, 'area').to.include(p.area); + expect(c.search('foo', 'point').map(toPreset), 'point').to.include(p.point); + expect(c.search('foo', 'line').map(toPreset), 'line').to.include(p.line); + expect(c.search('foo', 'area').map(toPreset), 'area').to.include(p.area); }); it('excludes presets with searchable: false', function() { @@ -155,7 +214,41 @@ describe('iD.presetCollection', function() { searchable: false }); var collection = iD.presetCollection([excluded, p.point]); - expect(collection.search('excluded', 'point').collection).not.to.include(excluded); + var presets = collection.search('excluded', 'point').map(toPreset); + expect(presets).not.to.include(excluded); + }); + + describe('match properties', function() { + + it('returns term in match results', function() { + var matches = iD.presetCollection([p.soccer]).search('fußball'); + expect(matches).to.eql([ { preset: p.soccer, term: 'fußball' } ]); + }); + + it('returns alias in match results', function() { + var matches = iD.presetCollection([p.sandpit]).search('Grit Bin'); + expect(matches).to.eql([ { preset: p.sandpit, alias: 'Grit Bin' } ]); + }); + + it('returns tagValue in match results', function() { + var matches = iD.presetCollection([p.griffin_nest]).search('griffin'); + expect(matches).to.eql([ { preset: p.griffin_nest, tagValue: 'griffin_nest' } ]); + }); + + it('returns fuzziness in fuzzy name match result', function() { + var matches = iD.presetCollection([p.sandpit]).search('sendpot'); + expect(matches).to.eql([ { preset: p.sandpit, fuzziness: 2 } ]); + }); + + it('returns fuzziness and term in fuzzy term match result', function() { + var matches = iD.presetCollection([p.soccer]).search('fußback'); + expect(matches).to.eql([ { preset: p.soccer, term: 'fußball', fuzziness: 2 } ]); + }); + + it('returns fuzziness and alias in fuzzy alias match result', function() { + var matches = iD.presetCollection([p.sandpit]).search('Great Bin'); + expect(matches).to.eql([ { preset: p.sandpit, alias: 'Grit Bin', fuzziness: 2 } ]); + }); }); }); }); diff --git a/test/spec/util/array.js b/test/spec/util/array.js index dcac18b2e2..bcedbbd6ba 100644 --- a/test/spec/util/array.js +++ b/test/spec/util/array.js @@ -120,4 +120,17 @@ describe('iD.utilArray', function() { }); }); + describe('utilArrayMapTruthy', function() { + + it('ignores nulls in input array', function() { + expect(iD.utilArrayMapTruthy([0,1,null,2,null], function(a) { return a; } )) + .to.eql([0,1,2]); + }); + + it('maps truthy values', function() { + expect(iD.utilArrayMapTruthy([1,2,3,4,5], function(a) { if (a%2) return a*2; } )) + .to.eql([2,6,10]); + }); + }); + });