diff --git a/css/80_app.css b/css/80_app.css index d684f9b59..296359eb2 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -4167,6 +4167,10 @@ svg.mouseclick use.right { background: #ffb; } +.mode-save .error-section { + background: #ffa5a5; +} + .mode-save .warning-section .changeset-list button { border-left: 1px solid #ccc; } diff --git a/data/core.yaml b/data/core.yaml index 39ee8def3..c5435603d 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -324,6 +324,7 @@ en: cancel: Cancel changes: "{count} Changes" download_changes: Download osmChange file + errors: Errors warnings: Warnings modified: Modified deleted: Deleted diff --git a/dist/locales/en.json b/dist/locales/en.json index ceaa2675c..064f48da2 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -408,6 +408,7 @@ "cancel": "Cancel", "changes": "{count} Changes", "download_changes": "Download osmChange file", + "errors": "Errors", "warnings": "Warnings", "modified": "Modified", "deleted": "Deleted", diff --git a/modules/core/context.js b/modules/core/context.js index 21f24d2e6..53385070d 100644 --- a/modules/core/context.js +++ b/modules/core/context.js @@ -21,7 +21,8 @@ import { rendererBackground, rendererFeatures, rendererMap } from '../renderer'; import { services } from '../services'; import { uiInit } from '../ui/init'; import { utilDetect } from '../util/detect'; -import { utilCallWhenIdle, utilKeybinding, utilRebind } from '../util'; +import { utilCallWhenIdle, utilKeybinding, utilRebind, utilStringQs } from '../util'; + export var areaKeys = {}; @@ -470,6 +471,18 @@ export function coreContext() { features = rendererFeatures(context); presets = presetIndex(); + if (services.maprules && utilStringQs(window.location.hash).validations) { + var validations = utilStringQs(window.location.hash).validations; + d3_json(validations, function (err, mapcss) { + if (err) return; + services.maprules.init(context.presets().areaKeys()); + _each(mapcss, function(mapcssSelector) { + return services.maprules.addRule(mapcssSelector); + }); + context.validationRules = true; + }); + } + map = rendererMap(context); context.mouse = map.mouse; context.extent = map.extent; @@ -488,9 +501,16 @@ export function coreContext() { background.init(); features.init(); - presets.init(); - areaKeys = presets.areaKeys(); - + if (utilStringQs(window.location.hash).presets) { + var external = utilStringQs(window.location.hash).presets; + presets.fromExternal(external, function(externalPresets) { + context.presets = function() { return externalPresets; }; // default + external presets... + areaKeys = presets.areaKeys(); + }); + } else { + presets.init(); + areaKeys = presets.areaKeys(); + } return utilRebind(context, dispatch, 'on'); } diff --git a/modules/core/history.js b/modules/core/history.js index a07474041..db837d45c 100644 --- a/modules/core/history.js +++ b/modules/core/history.js @@ -281,9 +281,9 @@ export function coreHistory(context) { validate: function(changes) { - return _flatten( - _map(Validations, function(fn) { return fn()(changes, _stack[_index].graph); }) - ); + return _flatten(_map(Validations, function(fn) { + return fn()(changes, _stack[_index].graph); + })); }, diff --git a/modules/presets/collection.js b/modules/presets/collection.js index f16960173..cddd64f3a 100644 --- a/modules/presets/collection.js +++ b/modules/presets/collection.js @@ -1,5 +1,6 @@ import _filter from 'lodash-es/filter'; import _find from 'lodash-es/find'; +import _findIndex from 'lodash-es/findIndex'; import _some from 'lodash-es/some'; import _uniq from 'lodash-es/uniq'; import _values from 'lodash-es/values'; @@ -23,6 +24,11 @@ export function presetCollection(collection) { }); }, + index: function(id) { + return _findIndex(this.collection, function(d) { + return d.id === id; + }); + }, matchGeometry: function(geometry) { return presetCollection(this.collection.filter(function(d) { diff --git a/modules/presets/field.js b/modules/presets/field.js index b658c43a9..33b51ab8d 100644 --- a/modules/presets/field.js +++ b/modules/presets/field.js @@ -21,7 +21,7 @@ export function presetField(id, field) { field.label = function() { - return field.t('label', {'default': id}); + return field.overrideLabel || field.t('label', {'default': id}); }; diff --git a/modules/presets/index.js b/modules/presets/index.js index 9a7297803..ddeeb3ba9 100644 --- a/modules/presets/index.js +++ b/modules/presets/index.js @@ -3,6 +3,8 @@ import _forEach from 'lodash-es/forEach'; import _reject from 'lodash-es/reject'; import _uniq from 'lodash-es/uniq'; +import { json as d3_json } from 'd3-request'; + import { data } from '../../data/index'; import { presetCategory } from './category'; import { presetCollection } from './collection'; @@ -70,7 +72,6 @@ export function presetIndex() { if (address && (!match || match.isFallback())) { match = address; } - return match || all.item(geometry); }); }; @@ -120,16 +121,7 @@ export function presetIndex() { return areaKeys; }; - - all.init = function() { - var d = data.presets; - - all.collection = []; - _recent.collection = []; - _fields = {}; - _universal = []; - _index = { point: {}, vertex: {}, line: {}, area: {}, relation: {} }; - + all.build = function(d, visible) { if (d.fields) { _forEach(d.fields, function(d, id) { _fields[id] = presetField(id, d); @@ -145,13 +137,23 @@ export function presetIndex() { if (d.presets) { _forEach(d.presets, function(d, id) { - all.collection.push(presetPreset(id, d, _fields)); + var existing = all.index(id); + if (existing !== -1) { + all.collection[existing] = presetPreset(id, d, _fields, visible); + } else { + all.collection.push(presetPreset(id, d, _fields, visible)); + } }); } if (d.categories) { _forEach(d.categories, function(d, id) { - all.collection.push(presetCategory(id, d, all)); + var existing = all.index(id); + if (existing !== -1) { + all.collection[existing] = presetCategory(id, d, all); + } else { + all.collection.push(presetCategory(id, d, all)); + } }); } @@ -177,10 +179,52 @@ export function presetIndex() { } } } + return all; + }; + + all.init = function() { + all.collection = []; + _recent.collection = []; + _fields = {}; + _universal = []; + _index = { point: {}, vertex: {}, line: {}, area: {}, relation: {} }; + + return all.build(data.presets, true); + }; + + + all.reset = function() { + all.collection = []; + _defaults = { area: all, line: all, point: all, vertex: all, relation: all }; + _fields = {}; + _universal = []; + _recent = presetCollection([]); + + // Index of presets by (geometry, tag key). + _index = { + point: {}, + vertex: {}, + line: {}, + area: {}, + relation: {} + }; return all; }; + all.fromExternal = function(external, done) { + all.reset(); + d3_json(external, function(err, externalPresets) { + if (err) { + all.init(); + } else { + all.build(data.presets, false); // make default presets hidden to begin + all.build(externalPresets, true); // make the external visible + } + done(all); + }); + }; + all.field = function(id) { return _fields[id]; }; diff --git a/modules/presets/preset.js b/modules/presets/preset.js index f2ca44d86..bfd58d266 100644 --- a/modules/presets/preset.js +++ b/modules/presets/preset.js @@ -5,7 +5,7 @@ import { t } from '../util/locale'; import { areaKeys } from '../core/context'; -export function presetPreset(id, preset, fields) { +export function presetPreset(id, preset, fields, visible) { preset = _clone(preset); preset.id = id; @@ -13,6 +13,7 @@ export function presetPreset(id, preset, fields) { preset.moreFields = (preset.moreFields || []).map(getFields); preset.geometry = (preset.geometry || []); + visible = visible || false; function getFields(f) { return fields[f]; @@ -71,6 +72,12 @@ export function presetPreset(id, preset, fields) { return tagCount === 0 || (tagCount === 1 && preset.tags.hasOwnProperty('area')); }; + preset.visible = function(_) { + if (!arguments.length) return visible; + visible = _; + return visible; + }; + var reference = preset.reference || {}; preset.reference = function(geometry) { diff --git a/modules/services/index.js b/modules/services/index.js index 83cc8114b..59c9d9524 100644 --- a/modules/services/index.js +++ b/modules/services/index.js @@ -1,4 +1,5 @@ import serviceMapillary from './mapillary'; +import serviceMapRules from './maprules'; import serviceNominatim from './nominatim'; import serviceOpenstreetcam from './openstreetcam'; import serviceOsm from './osm'; @@ -8,11 +9,13 @@ import serviceVectorTile from './vector_tile'; import serviceWikidata from './wikidata'; import serviceWikipedia from './wikipedia'; + export var services = { geocoder: serviceNominatim, mapillary: serviceMapillary, openstreetcam: serviceOpenstreetcam, osm: serviceOsm, + maprules: serviceMapRules, streetside: serviceStreetside, taginfo: serviceTaginfo, vectorTile: serviceVectorTile, @@ -22,6 +25,7 @@ export var services = { export { serviceMapillary, + serviceMapRules, serviceNominatim, serviceOpenstreetcam, serviceOsm, diff --git a/modules/services/maprules.js b/modules/services/maprules.js new file mode 100644 index 000000000..9e2f44a4f --- /dev/null +++ b/modules/services/maprules.js @@ -0,0 +1,227 @@ +import _isMatch from 'lodash-es/isMatch'; +import _intersection from 'lodash-es/intersection'; +import _reduce from 'lodash-es/reduce'; +import _every from 'lodash-es/every'; + +var buildRuleChecks = function() { + return { + equals: function (equals) { + return function(tags) { + return _isMatch(tags, equals); + }; + }, + notEquals: function (notEquals) { + return function(tags) { + return !_isMatch(tags, notEquals); + }; + }, + absence: function(absence) { + return function(tags) { + return Object.keys(tags).indexOf(absence) === -1; + }; + }, + presence: function(presence) { + return function(tags) { + return Object.keys(tags).indexOf(presence) > -1; + }; + }, + greaterThan: function(greaterThan) { + var key = Object.keys(greaterThan)[0]; + var value = greaterThan[key]; + + return function(tags) { + return tags[key] > value; + }; + }, + greaterThanEqual: function(greaterThanEqual) { + var key = Object.keys(greaterThanEqual)[0]; + var value = greaterThanEqual[key]; + + return function(tags) { + return tags[key] >= value; + }; + }, + lessThan: function(lessThan) { + var key = Object.keys(lessThan)[0]; + var value = lessThan[key]; + + return function(tags) { + return tags[key] < value; + }; + }, + lessThanEqual: function(lessThanEqual) { + var key = Object.keys(lessThanEqual)[0]; + var value = lessThanEqual[key]; + + return function(tags) { + return tags[key] <= value; + }; + }, + positiveRegex: function(positiveRegex) { + var tagKey = Object.keys(positiveRegex)[0]; + var expression = positiveRegex[tagKey].join('|'); + var regex = new RegExp(expression); + + return function(tags) { + return regex.test(tags[tagKey]); + }; + }, + negativeRegex: function(negativeRegex) { + var tagKey = Object.keys(negativeRegex)[0]; + var expression = negativeRegex[tagKey].join('|'); + var regex = new RegExp(expression); + + return function(tags) { + return !regex.test(tags[tagKey]); + }; + } + }; +}; + +var buildLineKeys = function() { + return { + highway: { + rest_area: true, + services: true + }, + railway: { + roundhouse: true, + station: true, + traverser: true, + turntable: true, + wash: true + } + }; +}; + +export default { + init: function(areaKeys) { + this._ruleChecks = buildRuleChecks(); + this._validationRules = []; + this._areaKeys = areaKeys; + this._lineKeys = buildLineKeys(); + }, + // list of rules only relevant to tag checks... + filterRuleChecks: function(selector) { + var _ruleChecks = this._ruleChecks; + return _reduce(Object.keys(selector), function(rules, key) { + if (['geometry', 'error', 'warning'].indexOf(key) === -1) { + rules.push(_ruleChecks[key](selector[key])); + } + return rules; + }, []); + }, + // builds tagMap from mapcss-parse selector object... + buildTagMap: function(selector) { + var getRegexValues = function(regexes) { + return regexes.map(function(regex) { + return regex.replace(/\$|\^/g, ''); + }); + }; + + var selectorKeys = Object.keys(selector); + var tagMap = _reduce(selectorKeys, function (expectedTags, key) { + var values; + var isRegex = /regex/gi.test(key); + var isEqual = /equals/gi.test(key); + + if (isRegex || isEqual) { + Object.keys(selector[key]).forEach(function(selectorKey) { + values = isEqual ? [selector[key][selectorKey]] : getRegexValues(selector[key][selectorKey]); + + if (expectedTags.hasOwnProperty(selectorKey)) { + values = values.concat(expectedTags[selectorKey]); + } + + expectedTags[selectorKey] = values; + }); + + } else if (/(greater|less)Than(Equal)?|presence/g.test(key)) { + var tagKey = /presence/.test(key) ? selector[key] : Object.keys(selector[key])[0]; + + values = [selector[key][tagKey]]; + + if (expectedTags.hasOwnProperty(tagKey)) { + values = values.concat(expectedTags[tagKey]); + } + + expectedTags[tagKey] = values; + } + + return expectedTags; + }, {}); + + return tagMap; + }, + // inspired by osmWay#isArea() + inferGeometry: function(tagMap) { + var _lineKeys = this._lineKeys; + var _areaKeys = this._areaKeys; + + var isAreaKeyBlackList = function(key) { + return _intersection(tagMap[key], Object.keys(_areaKeys[key])).length > 0; + }; + var isLineKeysWhiteList = function(key) { + return _intersection(tagMap[key], Object.keys(_lineKeys[key])).length > 0; + }; + + if (tagMap.hasOwnProperty('area')) { + if (tagMap.area.indexOf('yes') > -1) { + return 'area'; + } + if (tagMap.area.indexOf('no') > -1) { + return 'line'; + } + } + + for (var key in tagMap) { + if (key in _areaKeys && !isAreaKeyBlackList(key)) { + return 'area'; + } + if (key in _lineKeys && isLineKeysWhiteList(key)) { + return 'area'; + } + } + + return 'line'; + }, + // adds from mapcss-parse selector check... + addRule: function(selector) { + var rule = { + // checks relevant to mapcss-selector + checks: this.filterRuleChecks(selector), + // true if all conditions for a tag error are true.. + matches: function(entity) { + return _every(this.checks, function(check) { + return check(entity.tags); + }); + }, + // borrowed from Way#isArea() + inferredGeometry: this.inferGeometry(this.buildTagMap(selector), this._areaKeys), + geometryMatches: function(entity, graph) { + if (entity.type === 'node' || entity.type === 'relation') { + return selector.geometry === entity.type; + } else if (entity.type === 'way') { + return this.inferredGeometry === entity.geometry(graph); + } + }, + // when geometries match and tag matches are present, return a warning... + findWarnings: function (entity, graph, warnings) { + if (this.geometryMatches(entity, graph) && this.matches(entity)) { + var type = Object.keys(selector).indexOf('error') > -1 ? 'error' : 'warning'; + warnings.push({ + severity: type, + message: selector[type], + entity: entity + }); + } + } + }; + this._validationRules.push(rule); + }, + clearRules: function() { this._validationRules = []; }, + // returns validationRules... + validationRules: function() { return this._validationRules; }, + // returns ruleChecks + ruleChecks: function() { return this._ruleChecks; } +}; diff --git a/modules/ui/commit_warnings.js b/modules/ui/commit_warnings.js index 0c83cfbf5..8b517c9c3 100644 --- a/modules/ui/commit_warnings.js +++ b/modules/ui/commit_warnings.js @@ -3,91 +3,108 @@ import { modeSelect } from '../modes'; import { svgIcon } from '../svg'; import { tooltip } from '../util/tooltip'; import { utilEntityOrMemberSelector } from '../util'; - +import _reduce from 'lodash-es/reduce'; +import _forEach from 'lodash-es/forEach'; +import _uniqBy from 'lodash-es/uniqBy'; export function uiCommitWarnings(context) { function commitWarnings(selection) { var changes = context.history().changes(); - var warnings = context.history().validate(changes); + var validations = context.history().validate(changes); - var container = selection.selectAll('.warning-section') - .data(warnings.length ? [0] : []); - - container.exit() - .remove(); - - var containerEnter = container.enter() - .append('div') - .attr('class', 'modal-section warning-section fillL2'); - - containerEnter - .append('h3') - .text(t('commit.warnings')); - - containerEnter - .append('ul') - .attr('class', 'changeset-list'); - - container = containerEnter - .merge(container); - - - var items = container.select('ul').selectAll('li') - .data(warnings); - - items.exit() - .remove(); - - var itemsEnter = items.enter() - .append('li') - .attr('class', 'warning-item'); - - itemsEnter - .call(svgIcon('#iD-icon-alert', 'pre-text')); - - itemsEnter - .append('strong') - .text(function(d) { return d.message; }); - - itemsEnter.filter(function(d) { return d.tooltip; }) - .call(tooltip() - .title(function(d) { return d.tooltip; }) - .placement('top') - ); - - items = itemsEnter - .merge(items); - - items - .on('mouseover', mouseover) - .on('mouseout', mouseout) - .on('click', warningClick); - - - function mouseover(d) { - if (d.entity) { - context.surface().selectAll( - utilEntityOrMemberSelector([d.entity.id], context.graph()) - ).classed('hover', true); + validations = _reduce(validations, function(validations, val) { + var severity = val.severity; + if (validations.hasOwnProperty(severity)) { + validations[severity].push(val); + } else { + validations[severity] = [val]; } - } + return validations; + }, {}); + + _forEach(validations, function(instances, type) { + instances = _uniqBy(instances, function(val) { return val.id + '_' + val.message.replace(/\s+/g,''); }); + var section = type + '-section'; + var instanceItem = type + '-item'; + + var container = selection.selectAll('.' + section) + .data(instances.length ? [0] : []); + + container.exit() + .remove(); + + var containerEnter = container.enter() + .append('div') + .attr('class', 'modal-section ' + section + ' fillL2'); + + containerEnter + .append('h3') + .text(type === 'warning' ? t('commit.warnings') : t('commit.errors')); + + containerEnter + .append('ul') + .attr('class', 'changeset-list'); + + container = containerEnter + .merge(container); - function mouseout() { - context.surface().selectAll('.hover') - .classed('hover', false); - } + var items = container.select('ul').selectAll('li') + .data(instances); + + items.exit() + .remove(); + + var itemsEnter = items.enter() + .append('li') + .attr('class', instanceItem); + + itemsEnter + .call(svgIcon('#iD-icon-alert', 'pre-text')); + + itemsEnter + .append('strong') + .text(function(d) { return d.message; }); + + itemsEnter.filter(function(d) { return d.tooltip; }) + .call(tooltip() + .title(function(d) { return d.tooltip; }) + .placement('top') + ); + + items = itemsEnter + .merge(items); + + items + .on('mouseover', mouseover) + .on('mouseout', mouseout) + .on('click', warningClick); - function warningClick(d) { - if (d.entity) { - context.map().zoomTo(d.entity); - context.enter(modeSelect(context, [d.entity.id])); + function mouseover(d) { + if (d.entity) { + context.surface().selectAll( + utilEntityOrMemberSelector([d.entity.id], context.graph()) + ).classed('hover', true); + } } - } + + function mouseout() { + context.surface().selectAll('.hover') + .classed('hover', false); + } + + + function warningClick(d) { + if (d.entity) { + context.map().zoomTo(d.entity); + context.enter(modeSelect(context, [d.entity.id])); + } + } + }); } diff --git a/modules/ui/entity_editor.js b/modules/ui/entity_editor.js index e55c9b054..68f41dea6 100644 --- a/modules/ui/entity_editor.js +++ b/modules/ui/entity_editor.js @@ -38,7 +38,6 @@ export function uiEntityEditor(context) { var rawMemberEditor = uiRawMemberEditor(context); var rawMembershipEditor = uiRawMembershipEditor(context); - function entityEditor(selection) { var entity = context.entity(_entityID); var tags = _clone(entity.tags); diff --git a/modules/ui/map_data.js b/modules/ui/map_data.js index 868ae141c..fc07eb7e7 100644 --- a/modules/ui/map_data.js +++ b/modules/ui/map_data.js @@ -467,7 +467,7 @@ export function uiMapData(context) { function renderDataLayers(selection) { - var container = selection.selectAll('data-layer-container') + var container = selection.selectAll('.data-layer-container') .data([0]); _dataLayerContainer = container.enter() @@ -478,7 +478,7 @@ export function uiMapData(context) { function renderFillList(selection) { - var container = selection.selectAll('layer-fill-list') + var container = selection.selectAll('.layer-fill-list') .data([0]); _fillList = container.enter() @@ -489,7 +489,7 @@ export function uiMapData(context) { function renderFeatureList(selection) { - var container = selection.selectAll('layer-feature-list') + var container = selection.selectAll('.layer-feature-list') .data([0]); _featureList = container.enter() diff --git a/modules/ui/modes.js b/modules/ui/modes.js index d27f31dcf..238d2794e 100644 --- a/modules/ui/modes.js +++ b/modules/ui/modes.js @@ -14,7 +14,6 @@ import { svgIcon } from '../svg'; import { tooltip } from '../util/tooltip'; import { uiTooltipHtml } from './tooltipHtml'; - export function uiModes(context) { var modes = [ modeAddPoint(context), @@ -23,7 +22,6 @@ export function uiModes(context) { modeAddNote(context) ]; - function editable() { var mode = context.mode(); return context.editable() && mode && mode.id !== 'save'; @@ -39,7 +37,6 @@ export function uiModes(context) { return context.map().notesEditable() && mode && mode.id !== 'save'; } - return function(selection) { context .on('enter.editor', function(entered) { diff --git a/modules/ui/preset_list.js b/modules/ui/preset_list.js index d50ba693f..336cb3155 100644 --- a/modules/ui/preset_list.js +++ b/modules/ui/preset_list.js @@ -151,9 +151,14 @@ export function uiPresetList(context) { function drawList(list, presets) { - var collection = presets.collection.map(function(preset) { - return preset.members ? CategoryItem(preset) : PresetItem(preset); - }); + var collection = presets.collection.reduce(function(collection, preset) { + if (preset.members) { + collection.push(CategoryItem(preset)); + } else if (preset.visible()) { + collection.push(PresetItem(preset)); + } + return collection; + }, []); var items = list.selectAll('.preset-list-item') .data(collection, function(d) { return d.preset.id; }); diff --git a/modules/util/index.js b/modules/util/index.js index 420629e5a..0517ce874 100644 --- a/modules/util/index.js +++ b/modules/util/index.js @@ -8,6 +8,8 @@ export { utilEditDistance } from './util'; export { utilEntitySelector } from './util'; export { utilEntityOrMemberSelector } from './util'; export { utilEntityOrDeepMemberSelector } from './util'; +export { utilExternalPresets } from './util'; +export { utilExternalValidationRules } from './util'; export { utilFastMouse } from './util'; export { utilFunctor } from './util'; export { utilGetAllNodes } from './util'; @@ -25,6 +27,7 @@ export { utilRebind } from './rebind'; export { utilSetTransform } from './util'; export { utilSessionMutex } from './session_mutex'; export { utilStringQs } from './util'; +// export { utilSuggestNames } from './suggest_names'; export { utilTagText } from './util'; export { utilTiler } from './tiler'; export { utilTriggerEvent } from './trigger_event'; diff --git a/modules/util/util.js b/modules/util/util.js index 5eb821ced..d9c613263 100644 --- a/modules/util/util.js +++ b/modules/util/util.js @@ -293,6 +293,13 @@ export function utilNoAuto(selection) { .attr('spellcheck', isText ? 'true' : 'false'); } +export function utilExternalPresets() { + return utilStringQs(window.location.hash).hasOwnProperty('presets'); +} + +export function utilExternalValidationRules() { + return utilStringQs(window.location.hash).hasOwnProperty('validations'); +} // https://stackoverflow.com/questions/194846/is-there-any-kind-of-hash-code-function-in-javascript // https://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/ diff --git a/modules/validations/index.js b/modules/validations/index.js index 4f99fa485..112b38e6c 100644 --- a/modules/validations/index.js +++ b/modules/validations/index.js @@ -1,6 +1,7 @@ export { validationDeprecatedTag } from './deprecated_tag'; export { validationDisconnectedHighway } from './disconnected_highway'; export { validationManyDeletions } from './many_deletions'; +export { validationMapCSSChecks } from './mapcss_checks'; export { validationMissingTag } from './missing_tag'; export { validationOldMultipolygon } from './old_multipolygon'; export { validationTagSuggestsArea } from './tag_suggests_area'; diff --git a/modules/validations/mapcss_checks.js b/modules/validations/mapcss_checks.js new file mode 100644 index 000000000..13fa6fe2b --- /dev/null +++ b/modules/validations/mapcss_checks.js @@ -0,0 +1,25 @@ +import { services } from '../services'; + +export function validationMapCSSChecks() { + var validation = function(changes, graph) { + if (!services.maprules) return []; + + var rules = services.maprules.validationRules(); + var warnings = []; + var createdModified = ['created', 'modified']; + + for (var i = 0; i < rules.length; i++) { + var rule = rules[i]; + for (var j = 0; j < createdModified.length; j++) { + var type = createdModified[j]; + var entities = changes[type]; + for (var k = 0; k < entities.length; k++) { + rule.findWarnings(entities[k], graph, warnings); + } + } + } + + return warnings; + }; + return validation; +} diff --git a/test/index.html b/test/index.html index 630d93531..b455624b3 100644 --- a/test/index.html +++ b/test/index.html @@ -106,6 +106,7 @@ + @@ -118,6 +119,7 @@ + diff --git a/test/spec/presets/collection.js b/test/spec/presets/collection.js index 5b1b559b9..64db4bcb8 100644 --- a/test/spec/presets/collection.js +++ b/test/spec/presets/collection.js @@ -84,6 +84,15 @@ describe('iD.presetCollection', function() { }); }); + describe('#index', function() { + it('returns preset position in the collection', function() { + expect(c.index('point')).to.equal(0); + }); + it('return -1 when given id for preset not in the collection', function() { + expect(c.index('foobar')).to.equal(-1); + }); + }); + describe('#matchGeometry', function() { it('returns a new collection only containing presets matching a geometry', function() { expect(c.matchGeometry('area').collection).to.include.members( diff --git a/test/spec/presets/index.js b/test/spec/presets/index.js index 9178a4519..229726bf3 100644 --- a/test/spec/presets/index.js +++ b/test/spec/presets/index.js @@ -1,5 +1,5 @@ -describe('iD.presetIndex', function() { - var savedPresets; +describe('iD.presetIndex', function () { + var savedPresets, server; before(function () { savedPresets = iD.data.presets; @@ -9,7 +9,7 @@ describe('iD.presetIndex', function() { iD.data.presets = savedPresets; }); - describe('#match', function() { + describe('#match', function () { var testPresets = { presets: { point: { @@ -35,7 +35,7 @@ describe('iD.presetIndex', function() { } }; - it('returns a collection containing presets matching a geometry and tags', function() { + it('returns a collection containing presets matching a geometry and tags', function () { iD.data.presets = testPresets; var presets = iD.Context().presets(), way = iD.Way({ tags: { highway: 'residential' } }), @@ -44,7 +44,7 @@ describe('iD.presetIndex', function() { expect(presets.match(way, graph).id).to.eql('residential'); }); - it('returns the appropriate fallback preset when no tags match', function() { + it('returns the appropriate fallback preset when no tags match', function () { iD.data.presets = testPresets; var presets = iD.Context().presets(), point = iD.Node(), @@ -55,7 +55,7 @@ describe('iD.presetIndex', function() { expect(presets.match(line, graph).id).to.eql('line'); }); - it('matches vertices on a line as vertices', function() { + it('matches vertices on a line as vertices', function () { iD.data.presets = testPresets; var presets = iD.Context().presets(), point = iD.Node({ tags: { leisure: 'park' } }), @@ -65,7 +65,7 @@ describe('iD.presetIndex', function() { expect(presets.match(point, graph).id).to.eql('vertex'); }); - it('matches vertices on an addr:interpolation line as points', function() { + it('matches vertices on an addr:interpolation line as points', function () { iD.data.presets = testPresets; var presets = iD.Context().presets(), point = iD.Node({ tags: { leisure: 'park' } }), @@ -77,12 +77,12 @@ describe('iD.presetIndex', function() { }); - describe('#areaKeys', function() { + describe('#areaKeys', function () { var testPresets = { presets: { 'amenity/fuel/shell': { tags: { 'amenity': 'fuel' }, - geometry: ['point','area'], + geometry: ['point', 'area'], suggestion: true }, 'highway/foo': { @@ -110,78 +110,338 @@ describe('iD.presetIndex', function() { geometry: ['point', 'area'] } } + }; - it('whitelists keys for presets with area geometry', function() { + it('whitelists keys for presets with area geometry', function () { iD.data.presets = testPresets; var presets = iD.Context().presets(); expect(presets.areaKeys()).to.include.keys('natural'); }); - it('blacklists key-values for presets with a line geometry', function() { + it('blacklists key-values for presets with a line geometry', function () { iD.data.presets = testPresets; var presets = iD.Context().presets(); expect(presets.areaKeys().natural).to.include.keys('tree_row'); expect(presets.areaKeys().natural.tree_row).to.be.true; }); - it('blacklists key-values for presets with both area and line geometry', function() { + it('blacklists key-values for presets with both area and line geometry', function () { iD.data.presets = testPresets; var presets = iD.Context().presets(); expect(presets.areaKeys().leisure).to.include.keys('track'); }); - it('does not blacklist key-values for presets with neither area nor line geometry', function() { + it('does not blacklist key-values for presets with neither area nor line geometry', function () { iD.data.presets = testPresets; var presets = iD.Context().presets(); expect(presets.areaKeys().natural).not.to.include.keys('peak'); }); - it('does not blacklist generic \'*\' key-values', function() { + it('does not blacklist generic \'*\' key-values', function () { iD.data.presets = testPresets; var presets = iD.Context().presets(); expect(presets.areaKeys().natural).not.to.include.keys('natural'); }); - it('ignores keys like \'highway\' that are assumed to be lines', function() { + it('ignores keys like \'highway\' that are assumed to be lines', function () { iD.data.presets = testPresets; var presets = iD.Context().presets(); expect(presets.areaKeys()).not.to.include.keys('highway'); }); - it('ignores suggestion presets', function() { + it('ignores suggestion presets', function () { iD.data.presets = testPresets; var presets = iD.Context().presets(); expect(presets.areaKeys()).not.to.include.keys('amenity'); }); }); + describe('#build', function () { + it('builds presets from provided', function () { + var surfShop = iD.Node({ tags: { amenity: 'shop', 'shop:type': 'surf' } }), + graph = iD.Graph([surfShop]), + presets = iD.Context().presets(), + morePresets = { + presets: { + 'amenity/shop/surf': { + tags: { amenity: 'shop', 'shop:type': 'surf' }, + geometry: ['point', 'area'] + } + } + }; - describe('expected matches', function() { + expect(presets.match(surfShop, graph)).to.eql(undefined); // no surfshop preset yet... + presets.build(morePresets, true); + expect(presets.match(surfShop, graph).addTags).to.eql({ amenity: 'shop', 'shop:type': 'surf' }); + }); + it('configures presets\' initial visibility', function () { + var surfShop = iD.Node({ tags: { amenity: 'shop', 'shop:type': 'surf' } }), + firstStreetJetty = iD.Node({ tags: { man_made: 'jetty' } }), + entities = [surfShop, firstStreetJetty], + graph = iD.Graph(entities), + presets = iD.Context().presets(), + morePresets = { + presets: { + 'amenity/shop/surf': { + tags: { amenity: 'shop', 'shop:type': 'surf' }, + geometry: ['point', 'area'] + }, + 'man_made/jetty': { + tags: { man_made: 'jetty' }, + geometry: ['point'] + } + } + }; - it('prefers building to multipolygon', function() { + presets.build(morePresets, false); + entities.forEach(function (entity) { + var preset = presets.match(entity, graph); + expect(preset.visible()).to.be.false; + }); + }); + }); + + describe('expected matches', function () { + + it('prefers building to multipolygon', function () { iD.data.presets = savedPresets; var presets = iD.Context().presets(), - relation = iD.Relation({ tags: { type: 'multipolygon', building: 'yes' }}), + relation = iD.Relation({ tags: { type: 'multipolygon', building: 'yes' } }), graph = iD.Graph([relation]); expect(presets.match(relation, graph).id).to.eql('building'); }); - it('prefers building to address', function() { + it('prefers building to address', function () { iD.data.presets = savedPresets; var presets = iD.Context().presets(), - way = iD.Way({ tags: { area: 'yes', building: 'yes', 'addr:housenumber': '1234' }}), + way = iD.Way({ tags: { area: 'yes', building: 'yes', 'addr:housenumber': '1234' } }), graph = iD.Graph([way]); expect(presets.match(way, graph).id).to.eql('building'); }); - it('prefers pedestrian to area', function() { + it('prefers pedestrian to area', function () { iD.data.presets = savedPresets; var presets = iD.Context().presets(), - way = iD.Way({ tags: { area: 'yes', highway: 'pedestrian' }}), + way = iD.Way({ tags: { area: 'yes', highway: 'pedestrian' } }), graph = iD.Graph([way]); expect(presets.match(way, graph).id).to.eql('highway/pedestrian_area'); }); }); + describe('#fromExternal', function () { + var morePresets; + before(function () { + morePresets = { + 'categories': { + 'category-area': { + 'icon': 'maki-natural', + 'geometry': 'area', + 'name': 'MapRules area Features', + 'members': [ + '8bc64d6d-1dbb-44a8-a2f9-80d41d067d78', + 'a9b78746-ca8a-4380-b340-157414f1464d' + ] + }, + 'category-point': { + 'icon': 'maki-natural', + 'geometry': 'point', + 'name': 'MapRules point Features', + 'members': [ + '8bc64d6d-1dbb-44a8-a2f9-80d41d067d78', + '8f83ed0b-6514-4772-a644-f04aad9d2308' + ] + } + }, + 'presets': { + '8bc64d6d-1dbb-44a8-a2f9-80d41d067d78': { + 'geometry': ['area', 'point'], + 'tags': { 'amenity': 'shop', 'shop:type': 'surf' }, + 'icon': 'maki-natural', + 'name': 'Surf Shop', + 'fields': ['358f404a-c7d5-4267-94ed-41f789b16228'], + 'matchScore': 0.99 + }, + 'a9b78746-ca8a-4380-b340-157414f1464d': { + 'geometry': ['area'], + 'tags': { 'amenity': 'marketplace' }, + 'icon': 'maki-natural', + 'name': 'Market', + 'fields': [ + 'name', + 'source', + '2161a712-f67f-4759-92fa-f5d9488ba969', + '368ecbdf-bc02-4de2-a82e-d51c250602da', + '1887834c-0cdd-4d40-852b-d29b8df94567' + ], + 'matchScore': 0.99 + }, + '8f83ed0b-6514-4772-a644-f04aad9d2308': { + 'geometry': ['point'], + 'tags': { + 'amenity': 'drinking_water', + 'man_made': 'water_tap' + }, + 'icon': 'maki-natural', + 'name': 'Water Tap', + 'fields': ['name'], + 'matchScore': 0.99 + } + }, + 'fields': { + '358f404a-c7d5-4267-94ed-41f789b16228': { + 'key': 'healthcare', + 'label': 'Healthcare', + 'overrideLabel': 'Healthcare', + 'placeholder': '...', + 'type': 'text' + }, + 'name': { + 'key': 'name', + 'type': 'localized', + 'label': 'Name', + 'universal': true, + 'placeholder': 'Common name (if any)' + }, + 'source': { + 'key': 'source', + 'type': 'semiCombo', + 'icon': 'source', + 'universal': true, + 'label': 'Sources', + 'snake_case': false, + 'caseSensitive': true, + 'options': [ + 'survey', + 'local knowledge', + 'gps', + 'aerial imagery', + 'streetlevel imagery' + ] + }, + '2161a712-f67f-4759-92fa-f5d9488ba969': { + 'key': 'building', + 'label': 'Building', + 'overrideLabel': 'Building', + 'placeholder': '...', + 'type': 'text' + }, + '368ecbdf-bc02-4de2-a82e-d51c250602da': { + 'key': 'opening_hours', + 'label': 'Opening Hours', + 'overrideLabel': 'Opening Hours', + 'placeholder': '24/7, sunrise to sunset...', + 'strings': { + 'options': { + '24/7': '24/7', + 'sunrise to sunset': 'sunrise to sunset' + } + }, + 'type': 'combo' + }, + '1887834c-0cdd-4d40-852b-d29b8df94567': { + 'key': 'height', + 'label': 'Height', + 'overrideLabel': 'Height', + 'placeholder': '...', + 'minValue': 1, 'type': 'number' + }, + 'relation': { + 'key': 'type', + 'type': 'combo', + 'label': 'Type' + }, + 'comment': { + 'key': 'comment', + 'type': 'textarea', + 'label': 'Changeset Comment', + 'placeholder': 'Brief description of your contributions (required)' + }, + 'hashtags': { + 'key': 'hashtags', + 'type': 'semiCombo', + 'label': 'Suggested Hashtags', + 'placeholder': '#example' + } + }, + 'defaults': { + 'point': [ + 'point', + '8bc64d6d-1dbb-44a8-a2f9-80d41d067d78', + '8f83ed0b-6514-4772-a644-f04aad9d2308' + ], + 'line': ['line'], + 'area': [ + 'area', + '8bc64d6d-1dbb-44a8-a2f9-80d41d067d78', + 'a9b78746-ca8a-4380-b340-157414f1464d' + ], + 'vertex': ['vertex'], + 'relation': ['relation'] + } + }; + + + }); + beforeEach(function () { + server = sinon.fakeServer.create(); + }); + afterEach(function () { + server.restore(); + }); + it('builds presets w/external sources set to visible', function () { + var surfShop = iD.Node({ tags: { amenity: 'shop', 'shop:type': 'surf' } }), + graph = iD.Graph([surfShop]), + maprules = 'https://fakemaprules.io', + presetLocation = '/config/dfcfac13-ba7c-4223-8880-c856180e5c5b/presets/iD/', + match = new RegExp(presetLocation), + external = maprules + presetLocation; + + // no exernal presets yet + expect(iD.Context().presets().match(surfShop, graph).id).to.eql('amenity'); + // reset graph... + graph = iD.Graph([surfShop]); + + // add the validations query param... + iD.Context().presets().fromExternal(external, function (externalPresets) { + // includes newer presets... + expect(externalPresets.match(surfShop, graph).id).to.eql('8bc64d6d-1dbb-44a8-a2f9-80d41d067d78'); + }); + + server.respondWith('GET', match, + [200, { 'Content-Type': 'application/json' }, JSON.stringify(morePresets)] + ); + server.respond(); + }); + it('makes only the external presets initially visible', function () { + var maprules = 'https://fakemaprules.io', + presetLocation = '/config/dfcfac13-ba7c-4223-8880-c856180e5c5b/presets/iD/', + match = new RegExp(presetLocation), + external = maprules + presetLocation; + + iD.Context().presets().fromExternal(external, function(externalPresets) { + var external = externalPresets.collection.reduce(function(presets, preset) { + if (!preset.hasOwnProperty('members') && preset.visible()) { + presets.push(preset.id); + } + return presets; + }, []); + + var morePresetKeys = Object.keys(morePresets.presets); + + expect(morePresetKeys.length).to.eql(external.length); + + morePresetKeys.forEach(function(presetId) { + expect(external.indexOf(presetId)).to.be.at.least(0); + }); + }); + + + server.respondWith('GET', match, + [200, { 'Content-Type': 'application/json' }, JSON.stringify(morePresets)] + ); + server.respond(); + }); + }); + }); diff --git a/test/spec/presets/preset.js b/test/spec/presets/preset.js index 7ffb1cd3d..3d2ca6178 100644 --- a/test/spec/presets/preset.js +++ b/test/spec/presets/preset.js @@ -149,4 +149,13 @@ describe('iD.presetPreset', function() { expect(preset.unsetTags({a: 'b'}, 'area')).to.eql({a: 'b'}); }); }); + + describe('#visible', function() { + it('sets/gets visibility of preset', function() { + var preset = iD.presetPreset('test', {}, false); + expect(preset.visible()).to.be.false; + preset.visible(true); + expect(preset.visible()).to.be.true; + }); + }); }); diff --git a/test/spec/services/maprules.js b/test/spec/services/maprules.js new file mode 100644 index 000000000..188588d0e --- /dev/null +++ b/test/spec/services/maprules.js @@ -0,0 +1,569 @@ +describe('maprules', function() { + var _ruleChecks, validationRules; + + before(function() { + iD.services.maprules = iD.serviceMapRules; + var areaKeys = iD.Context().presets().areaKeys(); + iD.serviceMapRules.init(areaKeys); + _ruleChecks = iD.serviceMapRules.ruleChecks(); + }); + + after(function() { + delete iD.services.maprules; + }); + + describe('#filterRuleChecks', function() { + it('returns shortlist of mapcss checks relevant to provided selector', function() { + var selector = { + geometry: 'closedway', + equals: {amenity: 'marketplace'}, + absence: 'name', + error: '\'Marketplace\' preset must be coupled with name' + }; + var filteredChecks = iD.serviceMapRules.filterRuleChecks(selector); + var equalsCheck = filteredChecks[0]; + var absenceCheck = filteredChecks[1]; + var entityTags = {amenity: 'marketplace'}; + + expect(filteredChecks.length).eql(2); + expect(equalsCheck(entityTags)).to.be.true; + expect(absenceCheck(entityTags)).to.be.true; + }); + }); + + describe('#buildTagMap', function() { + it('builds a map of tag keys/values found in mapcss selector', function() { + [ + { + t: { + equals: { + man_made: 'tower', + 'tower:type': 'communication' + } + }, + r: { + man_made: ['tower'], + 'tower:type': ['communication'] + } + }, + { + t: { + equals: { + building: 'yes', + amenity: 'school' + }, + positiveRegex: { + opening_hours: [ + '24/7', + 'sunrise_sundown' + ] + }, + negativeRegex: { + source: [ + 'missing_maps', + 'american_red_cross' + ] + }, + greaterThanEqual: { floors: 2 }, + lessThanEqual: { floors: 4 } + + }, + r: { + building: ['yes'], + amenity: ['school'], + opening_hours: ['24/7', 'sunrise_sundown'], + source: ['missing_maps', 'american_red_cross'], + floors: [4, 2] + } + }, + { + t: { + equals: { highway: 'yes' }, + greaterThan: { lanes: 1 }, + lessThan: { lanes: 4 } + }, + r: { + highway: ['yes'], + lanes: [4, 1] + } + } + ].forEach(function(test) { + expect(iD.serviceMapRules.buildTagMap(test.t)).to.eql(test.r); + }); + }); + }); + + describe('#inferGeometry', function() { + it('infers geometry using selector keys', function() { + + var amenityDerivedArea = { + geometry: 'closedway', + presence: 'amenity', + positiveRegex: { amenity: ['^school$', '^healthcare$'] }, + error: 'amenity cannot be healthcare or school!' + }; + + var areaDerivedArea = { + geometry: 'closedway', + equals: { area: 'yes' }, + }; + + var badAreaDerivedLine = { + geometry: 'closedway', + equals: { 'area': 'no' } + }; + + var roundHouseRailwayDerivedArea = { + geometry: 'closedway', + equals: { 'railway': 'roundhouse' } + }; + + var justClosedWayDerivedLine = { + geometry: 'closedway' + }; + + var tagMap, geom; + tagMap = iD.serviceMapRules.buildTagMap(amenityDerivedArea); + geom = iD.serviceMapRules.inferGeometry(tagMap); + expect(geom).to.be.eql('area'); + + tagMap = iD.serviceMapRules.buildTagMap(areaDerivedArea); + geom = iD.serviceMapRules.inferGeometry(tagMap); + expect(geom).to.be.eql('area'); + + tagMap = iD.serviceMapRules.buildTagMap(badAreaDerivedLine); + geom = iD.serviceMapRules.inferGeometry(tagMap); + expect(geom).to.be.eql('line'); + + tagMap = iD.serviceMapRules.buildTagMap(roundHouseRailwayDerivedArea); + geom = iD.serviceMapRules.inferGeometry(tagMap); + expect(geom).to.be.eql('area'); + + tagMap = iD.serviceMapRules.buildTagMap(justClosedWayDerivedLine); + geom = iD.serviceMapRules.inferGeometry(tagMap); + expect(geom).to.be.eql('line'); + }); + }); + + describe('#addRule', function() { + it ('builds a rule from provided selector and adds it to _validationRules', function () { + var selector = { + geometry:'node', + equals: {amenity:'marketplace'}, + absence:'name', + warning:'\'Marketplace\' preset must be coupled with name' + }; + expect(iD.serviceMapRules.validationRules()).to.be.empty; + iD.serviceMapRules.addRule(selector); + expect(iD.serviceMapRules.validationRules().length).to.eql(1); + }); + }); + describe('#clearRules', function() { + it ('clears _validationRules array', function() { + expect(iD.serviceMapRules.validationRules().length).to.eql(1); + iD.serviceMapRules.clearRules(); + expect(iD.serviceMapRules.validationRules()).to.be.empty; + }); + }); + + describe('#validationRules', function() { + it('returns _validationRules array', function() { + var selector = { + geometry: 'closedway', + equals: {amenity: 'marketplace'}, + absence: 'name', + error: '\'Marketplace\' preset must be coupled with name' + }; + iD.serviceMapRules.addRule(selector); + var rules = iD.serviceMapRules.validationRules(); + expect(rules).instanceof(Array); + expect(rules.length).to.eql(1); + }); + }); + + describe('_ruleChecks', function () { + describe('#equals', function() { + it('is true when two tag maps intersect', function() { + var a = { amenity: 'school'}; + var b = { amenity: 'school' }; + expect(_ruleChecks.equals(a)(b)).to.be.true; + }); + it('is false when two tag maps intersect', function() { + var a = { man_made: 'water_tap'}; + var b = { amenity: 'school'}; + expect(_ruleChecks.equals(a)(b)).to.be.false; + }); + }); + describe('#notEquals', function() { + it('is true when two tag maps do not intersect', function() { + var a = { man_made: 'water_tap'}; + var b = { amenity: 'school' }; + expect(_ruleChecks.notEquals(a)(b)).to.be.true; + }); + it('is not true when two tag maps intersect', function() { + var a = { amenity: 'school' }; + var b = { amenity: 'school', opening_hours: '9-5' }; + expect(_ruleChecks.notEquals(a)(b)).to.be.false; + }); + }); + describe('absence', function() { + it('is true when tag map keys does not include key in question', function() { + var key = 'amenity'; + var map = { building: 'yes' }; + expect(_ruleChecks.absence(key)(map)).to.be.true; + }); + it('is false when tag map keys does include key in question', function() { + var key = 'amenity'; + var map = { amenity: 'school' }; + expect(_ruleChecks.absence(key)(map)).to.be.false; + }); + }); + describe('presence', function() { + it('is true when tag map keys includes key in question', function() { + var key = 'amenity'; + var map = { amenity: 'school'}; + expect(_ruleChecks.presence(key)(map)).to.be.true; + }); + it('is false when tag map keys do not include key in question', function() { + var key = 'amenity'; + var map = { building: 'yes'}; + expect(_ruleChecks.presence(key)(map)).to.be.false; + }); + }); + describe('greaterThan', function() { + it ('is true when a tag value is greater than the selector value', function() { + var selectorTags = { lanes: 5 }; + var tags = { lanes : 6 }; + expect(_ruleChecks.greaterThan(selectorTags)(tags)).to.be.true; + }); + it ('is false when a tag value is less than or equal to the selector value', function() { + var selectorTags = { lanes: 5 }; + [4, 5].forEach(function(val) { + expect(_ruleChecks.greaterThan(selectorTags)({ lanes: val })).to.be.false; + }); + }); + }); + describe('greaterThanEqual', function() { + it ('is true when a tag value is greater than or equal to the selector value', function() { + var selectorTags = { lanes: 5 }; + [5, 6].forEach(function(val) { + expect(_ruleChecks.greaterThanEqual(selectorTags)({ lanes: val })).to.be.true; + }); + }); + it ('is false when a tag value is less than the selector value', function () { + var selectorTags = { lanes: 5 }; + var tags = { lanes: 4 }; + expect(_ruleChecks.greaterThanEqual(selectorTags)(tags)).to.be.false; + }); + }); + describe('lessThan', function() { + it ('is true when a tag value is less than the selector value', function() { + var selectorTags = { lanes: 5 }; + var tags = { lanes: 4 }; + expect(_ruleChecks.lessThan(selectorTags)(tags)).to.be.true; + }); + it ('is false when a tag value is greater than or equal to the selector value', function() { + var selectorTags = { lanes: 5 }; + [6, 7].forEach(function(val) { + expect(_ruleChecks.lessThan(selectorTags)({ lanes: val })).to.be.false; + }); + }); + }); + describe('lessThanEqual', function() { + it ('is true when a tag value is less than or equal to the selector value', function() { + var selectorTags = { lanes: 5 }; + [4, 5].forEach(function(val) { + expect(_ruleChecks.lessThanEqual(selectorTags)({ lanes: val })).to.be.true; + }); + }); + it ('is false when a tag value is greater than the selector value', function() { + var selectorTags = { lanes: 5 }; + var tags = { lanes: 6 }; + expect(_ruleChecks.lessThanEqual(selectorTags)(tags)).to.be.false; + }); + }); + describe('positiveRegex', function() { + var positiveRegex = { amenity: ['^hospital$','^clinic$']}; + it ('is true when tag value matches positiveRegex', function() { + var tags = { amenity: 'hospital' }; + expect(_ruleChecks.positiveRegex(positiveRegex)(tags)).to.be.true; + }); + it ('is false when tag value does not match negative regex', function() { + var tags = { amenity: 'school' }; + expect(_ruleChecks.positiveRegex(positiveRegex)(tags)).to.be.false; + }); + }); + describe('negativeRegex', function() { + var negativeRegex = { bicycle: [ 'use_path', 'designated' ] }; + it ('is true when tag value does not match negativeRegex', function() { + var tags = { bicycle: 'yes' }; + expect(_ruleChecks.negativeRegex(negativeRegex)(tags)).to.be.true; + }); + it ('is false when tag value matches negativeRegex', function() { + var tags = { bicycle: 'designated' }; + expect(_ruleChecks.negativeRegex(negativeRegex)(tags)).to.be.false; + }); + }); + }); + describe('rule', function() { + var selectors; + before(function() { + selectors = [ + { + geometry:'node', + equals: {amenity:'marketplace'}, + absence:'name', + error:'\'Marketplace\' preset must be coupled with name' + }, + { + geometry: 'closedway', + notEquals: { building: 'yes', amenity: 'clinic' }, + error: '\'Clinic\' preset must be coupled with building=yes' + }, + { + geometry:'node', + equals: {man_made: 'tower', 'tower:type': 'communication'}, + presence: 'height', + error:'\'Communication Tower\' preset must not be coupled with height' + }, + { + geometry: 'node', + equals: { man_made: 'tower' }, + lessThanEqual: { height: 6 }, + error: '\'Tower\' preset height must be greater than 6' + }, + { + geometry: 'node', + equals: { man_made: 'tower' }, + greaterThanEqual: { height: 9 }, + error: '\'Tower\' preset height must be less than 9' + }, + { + geometry: 'node', + equals: { man_made: 'tower' }, + lessThan: { height: 6 }, + error: '\'Tower\' preset height must be greater than or equal to 6' + }, + { + geometry: 'node', + equals: { man_made: 'tower' }, + greaterThan: { height: 9 }, + error: '\'Tower\' preset height must be greater less than or equal to 9' + }, + { + geometry: 'closedway', + equals: { amenity: 'clinic' }, + negativeRegex: { emergency: ['yes', 'no'] }, + error: '\'Clinic\' preset\'s emergency tag must be equal to \'yes\' or \'no\'' + }, + { + geometry: 'way', + equals: { highway: 'residential' }, + positiveRegex: { structure: ['bridge', 'tunnel'] }, + error: '\'suburban road\' structure tag cannot be \'bridge\' or \'tunnel\'' + } + ]; + + iD.serviceMapRules.clearRules(); + selectors.forEach(function(selector) { iD.serviceMapRules.addRule(selector); }); + validationRules = iD.serviceMapRules.validationRules(); + }); + describe('#matches', function() { + var selectors, entities; + before(function() { + selectors = [ + { + geometry:'node', + equals: {amenity:'marketplace'}, + absence:'name', + error:'\'Marketplace\' preset must be coupled with name' + }, + { + geometry: 'closedway', + notEquals: { building: 'yes', amenity: 'clinic' }, + error: '\'Clinic\' preset must be coupled with building=yes' + }, + { + geometry:'node', + equals: {man_made: 'tower', 'tower:type': 'communication'}, + presence: 'height', + error:'\'Communication Tower\' preset must not be coupled with height' + }, + { + geometry: 'node', + equals: { man_made: 'tower' }, + lessThanEqual: { height: 6 }, + error: '\'Tower\' preset height must be greater than 6' + }, + { + geometry: 'node', + equals: { man_made: 'tower' }, + greaterThanEqual: { height: 9 }, + error: '\'Tower\' preset height must be less than 9' + }, + { + geometry: 'node', + equals: { man_made: 'tower' }, + lessThan: { height: 6 }, + error: '\'Tower\' preset height must be greater than or equal to 6' + }, + { + geometry: 'node', + equals: { man_made: 'tower' }, + greaterThan: { height: 9 }, + error: '\'Tower\' preset height must be greater less than or equal to 9' + }, + { + geometry: 'closedway', + equals: { amenity: 'clinic' }, + negativeRegex: { emergency: ['yes', 'no'] }, + error: '\'Clinic\' preset\'s emergency tag must be equal to \'yes\' or \'no\'' + }, + { + geometry: 'way', + equals: { highway: 'residential' }, + positiveRegex: { structure: ['bridge', 'tunnel'] }, + error: '\'suburban road\' structure tag cannot be \'bridge\' or \'tunnel\'' + } + ]; + entities = [ + iD.Entity({ type: 'node', tags: { amenity: 'marketplace' }}), + iD.Way({ tags: { building: 'house', amenity: 'clinic' }, nodes: [ 'a', 'b', 'c', 'a' ]}), + iD.Entity({ type: 'node', tags: { man_made: 'tower', 'tower:type': 'communication', height: 5 }}), + iD.Entity({ type: 'node', tags: { man_made: 'tower', height: 6 }}), + iD.Entity({ type: 'node', tags: { man_made: 'tower', height: 9 }}), + iD.Entity({ type: 'node', tags: { man_made: 'tower', height: 5 }}), + iD.Entity({ type: 'node', tags: { man_made: 'tower', height: 10 }}), + iD.Way({ tags: { amenity: 'clinic', emergency: 'definitely' }, nodes: [ 'd', 'e', 'f', 'd' ]}), + iD.Way({ tags: { highway: 'residential', structure: 'bridge' }}), + ]; + + iD.serviceMapRules.clearRules(); + selectors.forEach(function(selector) { iD.serviceMapRules.addRule(selector); }); + validationRules = iD.serviceMapRules.validationRules(); + }); + it('is true when each rule check is \'true\'', function() { + validationRules.forEach(function(rule, i) { + expect(rule.matches(entities[i])).to.be.true; + }); + }); + it ('is true when at least one rule check is \'false\'', function() { + var selector = { + geometry: 'way', + equals: { highway: 'residential' }, + positiveRegex: { structure: ['embarkment', 'bridge'] }, + error: '\'suburban road\' structure tag cannot be \'bridge\' or \'tunnel\'' + }; + var entity = iD.Way({ tags: { highway: 'residential', structure: 'tunnel' }}); + iD.serviceMapRules.clearRules(); + iD.serviceMapRules.addRule(selector); + var rule = iD.serviceMapRules.validationRules()[0]; + + expect(rule.matches(entity)).to.be.false; + }); + }); + describe('#findWarnings', function() { + var selectors, entities, _graph; + + before(function() { + selectors = [ + { + geometry:'node', + equals: {amenity:'marketplace'}, + absence:'name', + error:'\'Marketplace\' preset must be coupled with name' + }, + { + geometry: 'closedway', + notEquals: { building: 'yes', amenity: 'clinic' }, + error: '\'Clinic\' preset must be coupled with building=yes' + }, + { + geometry:'node', + equals: {man_made: 'tower', 'tower:type': 'communication'}, + presence: 'height', + error:'\'Communication Tower\' preset must not be coupled with height' + }, + { + geometry: 'node', + equals: { man_made: 'tower' }, + lessThanEqual: { height: 6 }, + error: '\'Tower\' preset height must be greater than 6' + }, + { + geometry: 'node', + equals: { man_made: 'tower' }, + greaterThanEqual: { height: 9 }, + error: '\'Tower\' preset height must be less than 9' + }, + { + geometry: 'node', + equals: { man_made: 'tower' }, + lessThan: { height: 6 }, + error: '\'Tower\' preset height must be greater than or equal to 6' + }, + { + geometry: 'node', + equals: { man_made: 'tower' }, + greaterThan: { height: 9 }, + error: '\'Tower\' preset height must be greater less than or equal to 9' + }, + { + geometry: 'closedway', + equals: { amenity: 'clinic' }, + negativeRegex: { emergency: ['yes', 'no'] }, + error: '\'Clinic\' preset\'s emergency tag must be equal to \'yes\' or \'no\'' + }, + { + geometry: 'way', + equals: { highway: 'residential' }, + positiveRegex: { structure: ['bridge', 'tunnel'] }, + error: '\'suburban road\' structure tag cannot be \'bridge\' or \'tunnel\'' + } + ]; + entities = [ + iD.Entity({ type: 'node', tags: { amenity: 'marketplace' }}), + iD.Way({ tags: { building: 'house', amenity: 'clinic' }, nodes: [ 'a', 'b', 'c', 'a' ]}), + iD.Entity({ type: 'node', tags: { man_made: 'tower', 'tower:type': 'communication', height: 5 }}), + iD.Entity({ type: 'node', tags: { man_made: 'tower', height: 6 }}), + iD.Entity({ type: 'node', tags: { man_made: 'tower', height: 9 }}), + iD.Entity({ type: 'node', tags: { man_made: 'tower', height: 5 }}), + iD.Entity({ type: 'node', tags: { man_made: 'tower', height: 10 }}), + iD.Way({ tags: { amenity: 'clinic', emergency: 'definitely' }, nodes: [ 'd', 'e', 'f', 'd' ]}), + iD.Way({ tags: { highway: 'residential', structure: 'bridge' }}), + ]; + + var wayNodes = [ + iD.osmNode({ id: 'a' }), + iD.osmNode({ id: 'b' }), + iD.osmNode({ id: 'c' }), + iD.osmNode({ id: 'd' }), + iD.osmNode({ id: 'e' }), + iD.osmNode({ id: 'f' }), + ]; + _graph = iD.Graph(entities.concat(wayNodes)); + iD.serviceMapRules.clearRules(); + selectors.forEach(function(selector) { iD.serviceMapRules.addRule(selector); }); + validationRules = iD.serviceMapRules.validationRules(); + }); + it('finds warnings', function() { + validationRules.forEach(function(rule, i) { + var warnings = []; + var entity = entities[i]; + var selector = selectors[i]; + + rule.findWarnings(entity, _graph, warnings); + + var warning = warnings[0]; + var type = Object.keys(selector).indexOf('error') ? 'error' : 'warning'; + + expect(warnings.length).to.eql(1); + expect(warning.entity).to.eql(entity); + expect(warning.message).to.eql(selector[type]); + expect(type).to.eql(warning.severity); + }); + }); + }); + }); +}); + diff --git a/test/spec/spec_helpers.js b/test/spec/spec_helpers.js index f8b0b13f4..262159477 100644 --- a/test/spec/spec_helpers.js +++ b/test/spec/spec_helpers.js @@ -1,6 +1,5 @@ /* globals chai:false */ /* eslint no-extend-native:off */ - iD.debug = true; // disable things that use the network