From 01567a4b8070b3a9b4ae8d9c1f9b30e57dd11244 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Thu, 14 Apr 2022 00:53:38 +0200 Subject: [PATCH] Improve tag optimazations, fixes rendering of climbing map --- Logic/Tags/And.ts | 197 +++++++--- Logic/Tags/ComparingTag.ts | 2 +- Logic/Tags/Or.ts | 156 ++++++-- Logic/Tags/RegexTag.ts | 92 ++++- Logic/Tags/SubstitutingTag.ts | 2 +- Logic/Tags/Tag.ts | 23 +- Logic/Tags/TagUtils.ts | 119 +++++- Logic/Tags/TagsFilter.ts | 8 +- Models/ThemeConfig/LayerConfig.ts | 1 + Models/ThemeConfig/SourceConfig.ts | 12 +- UI/Popup/TagRenderingQuestion.ts | 6 +- .../mapcomplete-changes.json | 42 +-- test/Logic/Tags/OptimizeTags.spec.ts | 347 ++++++++++++++++++ test/Logic/Tags/OptimzeTags.spec.ts | 150 -------- test/Models/ThemeConfig/SourceConfig.spec.ts | 19 + test/scripts/GenerateCache.spec.ts | 2 +- 16 files changed, 875 insertions(+), 303 deletions(-) create mode 100644 test/Logic/Tags/OptimizeTags.spec.ts delete mode 100644 test/Logic/Tags/OptimzeTags.spec.ts create mode 100644 test/Models/ThemeConfig/SourceConfig.spec.ts diff --git a/Logic/Tags/And.ts b/Logic/Tags/And.ts index 5a2eecbc9..340c2161f 100644 --- a/Logic/Tags/And.ts +++ b/Logic/Tags/And.ts @@ -8,6 +8,13 @@ export class And extends TagsFilter { super(); this.and = and } + + public static construct(and: TagsFilter[]): TagsFilter{ + if(and.length === 1){ + return and[0] + } + return new And(and) + } private static combine(filter: string, choices: string[]): string[] { const values = []; @@ -45,7 +52,7 @@ export class And extends TagsFilter { * import {RegexTag} from "./RegexTag"; * * const and = new And([new Tag("boundary","protected_area"), new RegexTag("protect_class","98",true)]) - * and.asOverpass() // => [ "[\"boundary\"=\"protected_area\"][\"protect_class\"!~\"^98$\"]" ] + * and.asOverpass() // => [ "[\"boundary\"=\"protected_area\"][\"protect_class\"!=\"98\"]" ] */ asOverpass(): string[] { let allChoices: string[] = null; @@ -87,17 +94,17 @@ export class And extends TagsFilter { * ]) * const t1 = new And([new Tag("valves", "A")]) * const t2 = new And([new Tag("valves", "B")]) - * t0.isEquivalent(t0) // => true - * t1.isEquivalent(t1) // => true - * t2.isEquivalent(t2) // => true - * t0.isEquivalent(t1) // => false - * t0.isEquivalent(t2) // => false - * t1.isEquivalent(t0) // => false - * t1.isEquivalent(t2) // => false - * t2.isEquivalent(t0) // => false - * t2.isEquivalent(t1) // => false + * t0.shadows(t0) // => true + * t1.shadows(t1) // => true + * t2.shadows(t2) // => true + * t0.shadows(t1) // => false + * t0.shadows(t2) // => false + * t1.shadows(t0) // => false + * t1.shadows(t2) // => false + * t2.shadows(t0) // => false + * t2.shadows(t1) // => false */ - isEquivalent(other: TagsFilter): boolean { + shadows(other: TagsFilter): boolean { if (!(other instanceof And)) { return false; } @@ -105,7 +112,7 @@ export class And extends TagsFilter { for (const selfTag of this.and) { let matchFound = false; for (const otherTag of other.and) { - matchFound = selfTag.isEquivalent(otherTag); + matchFound = selfTag.shadows(otherTag); if (matchFound) { break; } @@ -118,7 +125,7 @@ export class And extends TagsFilter { for (const otherTag of other.and) { let matchFound = false; for (const selfTag of this.and) { - matchFound = selfTag.isEquivalent(otherTag); + matchFound = selfTag.shadows(otherTag); if (matchFound) { break; } @@ -148,23 +155,90 @@ export class And extends TagsFilter { return result; } + /** + * IN some contexts, some expressions can be considered true, e.g. + * (X=Y | (A=B & X=Y)) + * ^---------^ + * When the evaluation hits (A=B & X=Y), we know _for sure_ that X=Y does _not_ match, as it would have matched the first clause otherwise. + * This means that the entire 'AND' is considered FALSE + * + * new And([ new Tag("key","value") ,new Tag("other_key","value")]).removePhraseConsideredKnown(new Tag("key","value"), true) // => new Tag("other_key","value") + * new And([ new Tag("key","value") ,new Tag("other_key","value")]).removePhraseConsideredKnown(new Tag("key","value"), false) // => false + * new And([ new RegexTag("key",/^..*$/) ,new Tag("other_key","value")]).removePhraseConsideredKnown(new Tag("key","value"), true) // => new Tag("other_key","value") + * new And([ new Tag("key","value") ]).removePhraseConsideredKnown(new Tag("key","value"), true) // => true + * + * // should remove 'club~*' if we know that 'club=climbing' + * const expr = TagUtils.Tag({and: ["sport=climbing", {or:["club~*", "office~*"]}]} ) + * expr.removePhraseConsideredKnown(new Tag("club","climbing"), true) // => new Tag("sport","climbing") + * + * const expr = TagUtils.Tag({and: ["sport=climbing", {or:["club~*", "office~*"]}]} ) + * expr.removePhraseConsideredKnown(new Tag("club","climbing"), false) // => expr + */ + removePhraseConsideredKnown(knownExpression: TagsFilter, value: boolean): TagsFilter | boolean { + const newAnds: TagsFilter[] = [] + for (const tag of this.and) { + if(tag instanceof And){ + throw "Optimize expressions before using removePhraseConsideredKnown" + } + if(tag instanceof Or){ + const r = tag.removePhraseConsideredKnown(knownExpression, value) + if(r === true){ + continue + } + if(r === false){ + return false; + } + newAnds.push(r) + continue + } + if(value && knownExpression.shadows(tag)){ + /** + * At this point, we do know that 'knownExpression' is true in every case + * As `shadows` does define that 'tag' MUST be true if 'knownExpression' is true, + * we can be sure that 'tag' is true as well. + * + * "True" is the neutral element in an AND, so we can skip the tag + */ + continue + } + if(!value && tag.shadows(knownExpression)){ + + /** + * We know that knownExpression is unmet. + * if the tag shadows 'knownExpression' (which is the case when control flows gets here), + * then tag CANNOT be met too, as known expression is not met. + * + * This implies that 'tag' must be false too! + */ + + // false is the element which absorbs all + return false + } + + newAnds.push(tag) + } + if(newAnds.length === 0){ + return true + } + return And.construct(newAnds) + } + optimize(): TagsFilter | boolean { if(this.and.length === 0){ return true } - const optimized = this.and.map(t => t.optimize()) + const optimizedRaw = this.and.map(t => t.optimize()) + .filter(t => t !== true /* true is the neutral element in an AND, we drop them*/ ) + if(optimizedRaw.some(t => t === false)){ + // We have an AND with a contained false: this is always 'false' + return false; + } + const optimized = optimizedRaw; const newAnds : TagsFilter[] = [] let containedOrs : Or[] = [] for (const tf of optimized) { - if(tf === false){ - return false - } - if(tf === true){ - continue - } - if(tf instanceof And){ newAnds.push(...tf.and) }else if(tf instanceof Or){ @@ -173,27 +247,56 @@ export class And extends TagsFilter { newAnds.push(tf) } } - - containedOrs = containedOrs.filter(ca => { - for (const element of ca.or) { - if(optimized.some(opt => typeof opt !== "boolean" && element.isEquivalent(opt) )){ - // At least one part of the 'OR' is matched by the outer or, so this means that this OR isn't needed at all - // XY & (XY | AB) === XY - return false + + { + let dirty = false; + do { + const cleanedContainedOrs : Or[] = [] + outer: for (let containedOr of containedOrs) { + for (const known of newAnds) { + // input for optimazation: (K=V & (X=Y | K=V)) + // containedOr: (X=Y | K=V) + // newAnds (and thus known): (K=V) --> true + const cleaned = containedOr.removePhraseConsideredKnown(known, true) + if (cleaned === true) { + // The neutral element within an AND + continue outer // skip addition too + } + if (cleaned === false) { + // zero element + return false + } + if (cleaned instanceof Or) { + containedOr = cleaned + continue + } + // the 'or' dissolved into a normal tag -> it has to be added to the newAnds + newAnds.push(cleaned) + dirty = true; // rerun this algo later on + continue outer; + } + cleanedContainedOrs.push(containedOr) } - } - return true; + containedOrs = cleanedContainedOrs + } while(dirty) + } + + + containedOrs = containedOrs.filter(ca => { + const isShadowed = TagUtils.containsEquivalents(newAnds, ca.or) + // If 'isShadowed', then at least one part of the 'OR' is matched by the outer and, so this means that this OR isn't needed at all + // XY & (XY | AB) === XY + return !isShadowed; }) // Extract common keys from the OR if(containedOrs.length === 1){ newAnds.push(containedOrs[0]) - } - if(containedOrs.length > 1){ + }else if(containedOrs.length > 1){ let commonValues : TagsFilter [] = containedOrs[0].or for (let i = 1; i < containedOrs.length && commonValues.length > 0; i++){ const containedOr = containedOrs[i]; - commonValues = commonValues.filter(cv => containedOr.or.some(candidate => candidate.isEquivalent(cv))) + commonValues = commonValues.filter(cv => containedOr.or.some(candidate => candidate.shadows(cv))) } if(commonValues.length === 0){ newAnds.push(...containedOrs) @@ -201,19 +304,11 @@ export class And extends TagsFilter { const newOrs: TagsFilter[] = [] for (const containedOr of containedOrs) { const elements = containedOr.or - .filter(candidate => !commonValues.some(cv => cv.isEquivalent(candidate))) - const or = new Or(elements).optimize() - if(or === true){ - // neutral element - continue - } - if(or === false){ - return false - } - newOrs.push(or) + .filter(candidate => !commonValues.some(cv => cv.shadows(candidate))) + newOrs.push(Or.construct(elements)) } - commonValues.push(new And(newOrs)) + commonValues.push(And.construct(newOrs)) const result = new Or(commonValues).optimize() if(result === false){ return false @@ -224,16 +319,22 @@ export class And extends TagsFilter { } } } - - if(newAnds.length === 1){ - return newAnds[0] + if(newAnds.length === 0){ + return true } + + if(TagUtils.ContainsOppositeTags(newAnds)){ + return false + } + TagUtils.sortFilters(newAnds, true) - return new And(newAnds) + return And.construct(newAnds) } isNegative(): boolean { return !this.and.some(t => !t.isNegative()); } + + } \ No newline at end of file diff --git a/Logic/Tags/ComparingTag.ts b/Logic/Tags/ComparingTag.ts index 6b790311b..fa543350f 100644 --- a/Logic/Tags/ComparingTag.ts +++ b/Logic/Tags/ComparingTag.ts @@ -23,7 +23,7 @@ export default class ComparingTag implements TagsFilter { throw "A comparable tag can not be used as overpass filter" } - isEquivalent(other: TagsFilter): boolean { + shadows(other: TagsFilter): boolean { return other === this; } diff --git a/Logic/Tags/Or.ts b/Logic/Tags/Or.ts index 4d3ab20e0..b2aa7ec27 100644 --- a/Logic/Tags/Or.ts +++ b/Logic/Tags/Or.ts @@ -11,6 +11,14 @@ export class Or extends TagsFilter { this.or = or; } + public static construct(or: TagsFilter[]): TagsFilter{ + if(or.length === 1){ + return or[0] + } + return new Or(or) + } + + matchesProperties(properties: any): boolean { for (const tagsFilter of this.or) { if (tagsFilter.matchesProperties(properties)) { @@ -28,7 +36,7 @@ export class Or extends TagsFilter { * * const and = new And([new Tag("boundary","protected_area"), new RegexTag("protect_class","98",true)]) * const or = new Or([and, new Tag("leisure", "nature_reserve"]) - * or.asOverpass() // => [ "[\"boundary\"=\"protected_area\"][\"protect_class\"!~\"^98$\"]", "[\"leisure\"=\"nature_reserve\"]" ] + * or.asOverpass() // => [ "[\"boundary\"=\"protected_area\"][\"protect_class\"!=\"98\"]", "[\"leisure\"=\"nature_reserve\"]" ] * * // should fuse nested ors into a single list * const or = new Or([new Tag("key","value"), new Or([new Tag("key1","value1"), new Tag("key2","value2")])]) @@ -51,14 +59,14 @@ export class Or extends TagsFilter { return false; } - isEquivalent(other: TagsFilter): boolean { + shadows(other: TagsFilter): boolean { if (other instanceof Or) { for (const selfTag of this.or) { let matchFound = false; for (let i = 0; i < other.or.length && !matchFound; i++) { let otherTag = other.or[i]; - matchFound = selfTag.isEquivalent(otherTag); + matchFound = selfTag.shadows(otherTag); } if (!matchFound) { return false; @@ -85,45 +93,127 @@ export class Or extends TagsFilter { return result; } + /** + * IN some contexts, some expressions can be considered true, e.g. + * (X=Y & (A=B | X=Y)) + * ^---------^ + * When the evaluation hits (A=B | X=Y), we know _for sure_ that X=Y _does match, as it would have failed the first clause otherwise. + * This means we can safely ignore this in the OR + * + * new Or([ new Tag("key","value") ,new Tag("other_key","value")]).removePhraseConsideredKnown(new Tag("key","value"), true) // =>true + * new Or([ new Tag("key","value") ,new Tag("other_key","value")]).removePhraseConsideredKnown(new Tag("key","value"), false) // => new Tag("other_key","value") + * new Or([ new Tag("key","value") ]).removePhraseConsideredKnown(new Tag("key","value"), true) // => true + * new Or([ new Tag("key","value") ]).removePhraseConsideredKnown(new Tag("key","value"), false) // => false + * new Or([new RegexTag("x", "y", true),new RegexTag("c", "d")]).removePhraseConsideredKnown(new Tag("foo","bar"), false) // => new Or([new RegexTag("x", "y", true),new RegexTag("c", "d")]) + */ + removePhraseConsideredKnown(knownExpression: TagsFilter, value: boolean): TagsFilter | boolean { + const newOrs: TagsFilter[] = [] + for (const tag of this.or) { + if(tag instanceof Or){ + throw "Optimize expressions before using removePhraseConsideredKnown" + } + if(tag instanceof And){ + const r = tag.removePhraseConsideredKnown(knownExpression, value) + if(r === false){ + continue + } + if(r === true){ + return true; + } + newOrs.push(r) + continue + } + if(value && knownExpression.shadows(tag)){ + /** + * At this point, we do know that 'knownExpression' is true in every case + * As `shadows` does define that 'tag' MUST be true if 'knownExpression' is true, + * we can be sure that 'tag' is true as well. + * + * "True" is the absorbing element in an OR, so we can return true + */ + return true; + } + if(!value && tag.shadows(knownExpression)){ + + /** + * We know that knownExpression is unmet. + * if the tag shadows 'knownExpression' (which is the case when control flows gets here), + * then tag CANNOT be met too, as known expression is not met. + * + * This implies that 'tag' must be false too! + * false is the neutral element in an OR + */ + continue + } + newOrs.push(tag) + } + if(newOrs.length === 0){ + return false + } + return Or.construct(newOrs) + } + optimize(): TagsFilter | boolean { if(this.or.length === 0){ return false; } - const optimized = this.or.map(t => t.optimize()) + const optimizedRaw = this.or.map(t => t.optimize()) + .filter(t => t !== false /* false is the neutral element in an OR, we drop them*/ ) + if(optimizedRaw.some(t => t === true)){ + // We have an OR with a contained true: this is always 'true' + return true; + } + const optimized = optimizedRaw; + const newOrs : TagsFilter[] = [] - let containedAnds : And[] = [] for (const tf of optimized) { - if(tf === true){ - return true - } - if(tf === false){ - continue - } - if(tf instanceof Or){ + // expand all the nested ors... newOrs.push(...tf.or) }else if(tf instanceof And){ + // partition of all the ands containedAnds.push(tf) } else { newOrs.push(tf) } } - containedAnds = containedAnds.filter(ca => { - for (const element of ca.and) { - if(optimized.some(opt => typeof opt !== "boolean" && element.isEquivalent(opt) )){ - // At least one part of the 'AND' is matched by the outer or, so this means that this OR isn't needed at all - // XY | (XY & AB) === XY - return false + { + let dirty = false; + do { + const cleanedContainedANds : And[] = [] + outer: for (let containedAnd of containedAnds) { + for (const known of newOrs) { + // input for optimazation: (K=V | (X=Y & K=V)) + // containedAnd: (X=Y & K=V) + // newOrs (and thus known): (K=V) --> false + const cleaned = containedAnd.removePhraseConsideredKnown(known, false) + if (cleaned === false) { + // The neutral element within an OR + continue outer // skip addition too + } + if (cleaned === true) { + // zero element + return true + } + if (cleaned instanceof And) { + containedAnd = cleaned + continue // clean up with the other known values + } + // the 'and' dissolved into a normal tag -> it has to be added to the newOrs + newOrs.push(cleaned) + dirty = true; // rerun this algo later on + continue outer; + } + cleanedContainedANds.push(containedAnd) } - } - return true; - }) - + containedAnds = cleanedContainedANds + } while(dirty) + } // Extract common keys from the ANDS if(containedAnds.length === 1){ newOrs.push(containedAnds[0]) @@ -131,40 +221,46 @@ export class Or extends TagsFilter { let commonValues : TagsFilter [] = containedAnds[0].and for (let i = 1; i < containedAnds.length && commonValues.length > 0; i++){ const containedAnd = containedAnds[i]; - commonValues = commonValues.filter(cv => containedAnd.and.some(candidate => candidate.isEquivalent(cv))) + commonValues = commonValues.filter(cv => containedAnd.and.some(candidate => candidate.shadows(cv))) } if(commonValues.length === 0){ newOrs.push(...containedAnds) }else{ const newAnds: TagsFilter[] = [] for (const containedAnd of containedAnds) { - const elements = containedAnd.and.filter(candidate => !commonValues.some(cv => cv.isEquivalent(candidate))) - newAnds.push(new And(elements)) + const elements = containedAnd.and.filter(candidate => !commonValues.some(cv => cv.shadows(candidate))) + newAnds.push(And.construct(elements)) } - commonValues.push(new Or(newAnds)) + commonValues.push(Or.construct(newAnds)) const result = new And(commonValues).optimize() if(result === true){ return true }else if(result === false){ // neutral element: skip }else{ - newOrs.push(new And(commonValues)) + newOrs.push(And.construct(commonValues)) } } } - if(newOrs.length === 1){ - return newOrs[0] + if(newOrs.length === 0){ + return false } + + if(TagUtils.ContainsOppositeTags(newOrs)){ + return true + } + TagUtils.sortFilters(newOrs, false) - return new Or(newOrs) + return Or.construct(newOrs) } isNegative(): boolean { return this.or.some(t => t.isNegative()); } + } diff --git a/Logic/Tags/RegexTag.ts b/Logic/Tags/RegexTag.ts index 8a4c7ad3b..a5db5a77f 100644 --- a/Logic/Tags/RegexTag.ts +++ b/Logic/Tags/RegexTag.ts @@ -10,13 +10,6 @@ export class RegexTag extends TagsFilter { constructor(key: string | RegExp, value: RegExp | string, invert: boolean = false) { super(); this.key = key; - if (typeof value === "string") { - if (value.indexOf("^") < 0 && value.indexOf("$") < 0) { - value = "^" + value + "$" - } - value = new RegExp(value) - } - this.value = value; this.invert = invert; this.matchesEmpty = RegexTag.doesMatch("", this.value); @@ -79,14 +72,14 @@ export class RegexTag extends TagsFilter { /** * Checks if this tag matches the given properties * - * const isNotEmpty = new RegexTag("key","^$", true); + * const isNotEmpty = new RegexTag("key",/^$/, true); * isNotEmpty.matchesProperties({"key": "value"}) // => true * isNotEmpty.matchesProperties({"key": "other_value"}) // => true * isNotEmpty.matchesProperties({"key": ""}) // => false * isNotEmpty.matchesProperties({"other_key": ""}) // => false * isNotEmpty.matchesProperties({"other_key": "value"}) // => false * - * const isNotEmpty = new RegexTag("key","^..*$", true); + * const isNotEmpty = new RegexTag("key",/^..*$/, true); * isNotEmpty.matchesProperties({"key": "value"}) // => false * isNotEmpty.matchesProperties({"key": "other_value"}) // => false * isNotEmpty.matchesProperties({"key": ""}) // => true @@ -121,6 +114,9 @@ export class RegexTag extends TagsFilter { * importMatch.matchesProperties({"tags": "amenity=public_bookcase"}) // =>true * importMatch.matchesProperties({"tags": "name=test;amenity=public_bookcase"}) // =>true * importMatch.matchesProperties({"tags": "amenity=bench"}) // =>false + * + * new RegexTag("key","value").matchesProperties({"otherkey":"value"}) // => false + * new RegexTag("key","value",true).matchesProperties({"otherkey":"something"}) // => true */ matchesProperties(tags: any): boolean { if (typeof this.key === "string") { @@ -147,17 +143,87 @@ export class RegexTag extends TagsFilter { asHumanString() { if (typeof this.key === "string") { - return `${this.key}${this.invert ? "!" : ""}~${RegexTag.source(this.value)}`; + const oper = typeof this.value === "string" ? "=" : "~" + return `${this.key}${this.invert ? "!" : ""}${oper}${RegexTag.source(this.value)}`; } return `${this.key.source}${this.invert ? "!" : ""}~~${RegexTag.source(this.value)}` } - isEquivalent(other: TagsFilter): boolean { + /** + * + * new RegexTag("key","value").shadows(new Tag("key","value")) // => true + * new RegexTag("key",/value/).shadows(new RegexTag("key","value")) // => true + * new RegexTag("key",/^..*$/).shadows(new Tag("key","value")) // => false + * new RegexTag("key",/^..*$/).shadows(new Tag("other_key","value")) // => false + * new RegexTag("key", /^a+$/).shadows(new Tag("key", "a")) // => false + * + * + * // should not shadow too eagerly: the first tag might match 'key=abc', the second won't + * new RegexTag("key", /^..*$/).shadows(new Tag("key", "some_value")) // => false + * + * // should handle 'invert' + * new RegexTag("key",/^..*$/, true).shadows(new Tag("key","value")) // => false + * new RegexTag("key",/^..*$/, true).shadows(new Tag("key","")) // => true + * new RegexTag("key","value", true).shadows(new Tag("key","value")) // => false + * new RegexTag("key","value", true).shadows(new Tag("key","some_other_value")) // => false + */ + shadows(other: TagsFilter): boolean { if (other instanceof RegexTag) { - return other.asHumanString() == this.asHumanString(); + if((other.key["source"] ?? other.key) !== (this.key["source"] ?? this.key) ){ + // Keys don't match, never shadowing + return false + } + if((other.value["source"] ?? other.key) === (this.value["source"] ?? this.key) && this.invert == other.invert ){ + // Values (and inverts) match + return true + } + if(typeof other.value ==="string"){ + const valuesMatch = RegexTag.doesMatch(other.value, this.value) + if(!this.invert && !other.invert){ + // this: key~value, other: key=value + return valuesMatch + } + if(this.invert && !other.invert){ + // this: key!~value, other: key=value + return !valuesMatch + } + if(!this.invert && other.invert){ + // this: key~value, other: key!=value + return !valuesMatch + } + if(!this.invert && !other.invert){ + // this: key!~value, other: key!=value + return valuesMatch + } + + } + return false; } if (other instanceof Tag) { - return RegexTag.doesMatch(other.key, this.key) && RegexTag.doesMatch(other.value, this.value); + if(!RegexTag.doesMatch(other.key, this.key)){ + // Keys don't match + return false; + } + + + if(this.value["source"] === "^..*$") { + if(this.invert){ + return other.value === "" + } + return false + } + + if (this.invert) { + /* + * this: "a!=b" + * other: "a=c" + * actual property: a=x + * In other words: shadowing will never occur here + */ + return false; + } + // Unless the values are the same, it is pretty hard to figure out if they are shadowing. This is future work + return (this.value["source"] ?? this.value) === other.value; } return false; } diff --git a/Logic/Tags/SubstitutingTag.ts b/Logic/Tags/SubstitutingTag.ts index 532c2586e..0cb941f14 100644 --- a/Logic/Tags/SubstitutingTag.ts +++ b/Logic/Tags/SubstitutingTag.ts @@ -35,7 +35,7 @@ export default class SubstitutingTag implements TagsFilter { throw "A variable with substitution can not be used to query overpass" } - isEquivalent(other: TagsFilter): boolean { + shadows(other: TagsFilter): boolean { if (!(other instanceof SubstitutingTag)) { return false; } diff --git a/Logic/Tags/Tag.ts b/Logic/Tags/Tag.ts index 9a4919688..1294c6a53 100644 --- a/Logic/Tags/Tag.ts +++ b/Logic/Tags/Tag.ts @@ -88,14 +88,23 @@ export class Tag extends TagsFilter { return true; } - isEquivalent(other: TagsFilter): boolean { - if (other instanceof Tag) { - return this.key === other.key && this.value === other.value; + /** + * // should handle advanced regexes + * new Tag("key", "aaa").shadows(new RegexTag("key", /a+/)) // => true + * new Tag("key","value").shadows(new RegexTag("key", /^..*$/, true)) // => false + * new Tag("key","value").shadows(new Tag("key","value")) // => true + * new Tag("key","some_other_value").shadows(new RegexTag("key", "value", true)) // => true + * new Tag("key","value").shadows(new RegexTag("key", "value", true)) // => false + * new Tag("key","value").shadows(new RegexTag("otherkey", "value", true)) // => false + * new Tag("key","value").shadows(new RegexTag("otherkey", "value", false)) // => false + */ + shadows(other: TagsFilter): boolean { + if(other["key"] !== undefined){ + if(other["key"] !== this.key){ + return false + } } - if (other instanceof RegexTag) { - other.isEquivalent(this); - } - return false; + return other.matchesProperties({[this.key]: this.value}); } usedKeys(): string[] { diff --git a/Logic/Tags/TagUtils.ts b/Logic/Tags/TagUtils.ts index 9ff07534f..064c9cab3 100644 --- a/Logic/Tags/TagUtils.ts +++ b/Logic/Tags/TagUtils.ts @@ -200,15 +200,16 @@ export class TagUtils { * * TagUtils.Tag("key=value") // => new Tag("key", "value") * TagUtils.Tag("key=") // => new Tag("key", "") - * TagUtils.Tag("key!=") // => new RegexTag("key", "^..*$") - * TagUtils.Tag("key!=value") // => new RegexTag("key", /^value$/, true) + * TagUtils.Tag("key!=") // => new RegexTag("key", /^..*$/) + * TagUtils.Tag("key~*") // => new RegexTag("key", /^..*$/) + * TagUtils.Tag("key!=value") // => new RegexTag("key", "value", true) * TagUtils.Tag("vending~.*bicycle_tube.*") // => new RegexTag("vending", /^.*bicycle_tube.*$/) * TagUtils.Tag("x!~y") // => new RegexTag("x", /^y$/, true) * TagUtils.Tag({"and": ["key=value", "x=y"]}) // => new And([new Tag("key","value"), new Tag("x","y")]) * TagUtils.Tag("name~[sS]peelbos.*") // => new RegexTag("name", /^[sS]peelbos.*$/) * TagUtils.Tag("survey:date:={_date:now}") // => new SubstitutingTag("survey:date", "{_date:now}") * TagUtils.Tag("xyz!~\\[\\]") // => new RegexTag("xyz", /^\[\]$/, true) - * TagUtils.Tag("tags~(^|.*;)amenity=public_bookcase($|;.*)") // => new RegexTag("tags", /(^|.*;)amenity=public_bookcase($|;.*)/) + * TagUtils.Tag("tags~(.*;)?amenity=public_bookcase(;.*)?") // => new RegexTag("tags", /^(.*;)?amenity=public_bookcase(;.*)?$/) * TagUtils.Tag("service:bicycle:.*~~*") // => new RegexTag(/^service:bicycle:.*$/, /^..*$/) * * TagUtils.Tag("xyz<5").matchesProperties({xyz: 4}) // => true @@ -306,7 +307,7 @@ export class TagUtils { } return new RegexTag( split[0], - split[1], + new RegExp("^"+ split[1]+"$"), true ); } @@ -338,17 +339,6 @@ export class TagUtils { split[1] = "..*" return new RegexTag(split[0], /^..*$/) } - return new RegexTag( - split[0], - new RegExp("^" + split[1] + "$"), - true - ); - } - if (tag.indexOf("!~") >= 0) { - const split = Utils.SplitFirst(tag, "!~"); - if (split[1] === "*") { - split[1] = "..*" - } return new RegexTag( split[0], split[1], @@ -357,15 +347,18 @@ export class TagUtils { } if (tag.indexOf("~") >= 0) { const split = Utils.SplitFirst(tag, "~"); + let value : string | RegExp = split[1] if (split[1] === "") { throw "Detected a regextag with an empty regex; this is not allowed. Use '" + split[0] + "='instead (at " + context + ")" } - if (split[1] === "*") { - split[1] = "..*" + if (value === "*") { + value = /^..*$/ + }else { + value = new RegExp("^"+value+"$") } return new RegexTag( split[0], - split[1] + value ); } if (tag.indexOf("=") >= 0) { @@ -431,4 +424,94 @@ export class TagUtils { return " (" + joined + ") " } + /** + * Returns 'true' is opposite tags are detected. + * Note that this method will never work perfectly + * + * // should be false for some simple cases + * TagUtils.ContainsOppositeTags([new Tag("key", "value"), new Tag("key0", "value")]) // => false + * TagUtils.ContainsOppositeTags([new Tag("key", "value"), new Tag("key", "value0")]) // => false + * + * // should detect simple cases + * TagUtils.ContainsOppositeTags([new Tag("key", "value"), new RegexTag("key", "value", true)]) // => true + * TagUtils.ContainsOppositeTags([new Tag("key", "value"), new RegexTag("key", /value/, true)]) // => true + */ + public static ContainsOppositeTags(tags: (TagsFilter)[]) : boolean{ + for (let i = 0; i < tags.length; i++){ + const tag = tags[i]; + if(!(tag instanceof Tag || tag instanceof RegexTag)){ + continue + } + for (let j = i + 1; j < tags.length; j++){ + const guard = tags[j]; + if(!(guard instanceof Tag || guard instanceof RegexTag)){ + continue + } + if(guard.key !== tag.key) { + // Different keys: they can _never_ be opposites + continue + } + if((guard.value["source"] ?? guard.value) !== (tag.value["source"] ?? tag.value)){ + // different values: the can _never_ be opposites + continue + } + if( (guard["invert"] ?? false) !== (tag["invert"] ?? false) ) { + // The 'invert' flags are opposite, the key and value is the same for both + // This means we have found opposite tags! + return true + } + } + } + + return false + } + + /** + * Returns a filtered version of 'listToFilter'. + * For a list [t0, t1, t2], If `blackList` contains an equivalent (or broader) match of any `t`, this respective `t` is dropped from the returned list + * Ignores nested ORS and ANDS + * + * TagUtils.removeShadowedElementsFrom([new Tag("key","value")], [new Tag("key","value"), new Tag("other_key","value")]) // => [new Tag("other_key","value")] + */ + public static removeShadowedElementsFrom(blacklist: TagsFilter[], listToFilter: TagsFilter[] ) : TagsFilter[] { + return listToFilter.filter(tf => !blacklist.some(guard => guard.shadows(tf))) + } + + /** + * Returns a filtered version of 'listToFilter', where no duplicates and no equivalents exists. + * + * TagUtils.removeEquivalents([new RegexTag("key", /^..*$/), new Tag("key","value")]) // => [new Tag("key", "value")] + */ + public static removeEquivalents( listToFilter: (Tag | RegexTag)[]) : TagsFilter[] { + const result: TagsFilter[] = [] + outer: for (let i = 0; i < listToFilter.length; i++){ + const tag = listToFilter[i]; + for (let j = 0; j < listToFilter.length; j++){ + if(i === j){ + continue + } + const guard = listToFilter[j]; + if(guard.shadows(tag)) { + // the guard 'kills' the tag: we continue the outer loop without adding the tag + continue outer; + } + } + result.push(tag) + } + return result + } + + /** + * Returns `true` if at least one element of the 'guards' shadows one element of the 'listToFilter'. + * + * TagUtils.containsEquivalents([new Tag("key","value")], [new Tag("key","value"), new Tag("other_key","value")]) // => true + * TagUtils.containsEquivalents([new Tag("key","value")], [ new Tag("other_key","value")]) // => false + * TagUtils.containsEquivalents([new Tag("key","value")], [ new Tag("key","other_value")]) // => false + */ + public static containsEquivalents( guards: TagsFilter[], listToFilter: TagsFilter[] ) : boolean { + return listToFilter.some(tf => guards.some(guard => guard.shadows(tf))) + } + + + } \ No newline at end of file diff --git a/Logic/Tags/TagsFilter.ts b/Logic/Tags/TagsFilter.ts index f3794436f..a99251bb2 100644 --- a/Logic/Tags/TagsFilter.ts +++ b/Logic/Tags/TagsFilter.ts @@ -4,7 +4,11 @@ export abstract class TagsFilter { abstract isUsableAsAnswer(): boolean; - abstract isEquivalent(other: TagsFilter): boolean; + /** + * Indicates some form of equivalency: + * if `this.shadows(t)`, then `this.matches(properties)` implies that `t.matches(properties)` for all possible properties + */ + abstract shadows(other: TagsFilter): boolean; abstract matchesProperties(properties: any): boolean; @@ -30,7 +34,7 @@ export abstract class TagsFilter { * Returns an optimized version (or self) of this tagsFilter */ abstract optimize(): TagsFilter | boolean; - + /** * Returns 'true' if the tagsfilter might select all features (i.e. the filter will return everything from OSM, except a few entries). * diff --git a/Models/ThemeConfig/LayerConfig.ts b/Models/ThemeConfig/LayerConfig.ts index ce2e56f05..b7863b427 100644 --- a/Models/ThemeConfig/LayerConfig.ts +++ b/Models/ThemeConfig/LayerConfig.ts @@ -127,6 +127,7 @@ export default class LayerConfig extends WithContextLoader { idKey: json.source["idKey"] }, + Constants.priviliged_layers.indexOf(this.id) > 0, json.id ); diff --git a/Models/ThemeConfig/SourceConfig.ts b/Models/ThemeConfig/SourceConfig.ts index 0edd9b7b2..5c31bdbcf 100644 --- a/Models/ThemeConfig/SourceConfig.ts +++ b/Models/ThemeConfig/SourceConfig.ts @@ -1,5 +1,6 @@ import {TagsFilter} from "../../Logic/Tags/TagsFilter"; import {RegexTag} from "../../Logic/Tags/RegexTag"; +import {param} from "jquery"; export default class SourceConfig { @@ -19,7 +20,7 @@ export default class SourceConfig { isOsmCache?: boolean, geojsonSourceLevel?: number, idKey?: string - }, context?: string) { + }, isSpecialLayer: boolean, context?: string) { let defined = 0; if (params.osmTags) { @@ -43,6 +44,15 @@ export default class SourceConfig { throw `Source defines a geojson-zoomLevel, but does not specify {x} nor {y} (or equivalent), this is probably a bug (in context ${context})` } } + if(params.osmTags !== undefined && !isSpecialLayer){ + const optimized = params.osmTags.optimize() + if(optimized === false){ + throw "Error at "+context+": the specified tags are conflicting with each other: they will never match anything at all" + } + if(optimized === true){ + throw "Error at "+context+": the specified tags are very wide: they will always match everything" + } + } this.osmTags = params.osmTags ?? new RegexTag("id", /.*/); this.overpassScript = params.overpassScript; this.geojsonSource = params.geojsonSource; diff --git a/UI/Popup/TagRenderingQuestion.ts b/UI/Popup/TagRenderingQuestion.ts index a20019d99..0348bccb9 100644 --- a/UI/Popup/TagRenderingQuestion.ts +++ b/UI/Popup/TagRenderingQuestion.ts @@ -248,7 +248,7 @@ export default class TagRenderingQuestion extends Combine { const inputEl = new InputElementMap( checkBoxes, (t0, t1) => { - return t0?.isEquivalent(t1) ?? false + return t0?.shadows(t1) ?? false }, (indices) => { if (indices.length === 0) { @@ -370,7 +370,7 @@ export default class TagRenderingQuestion extends Combine { return new FixedInputElement( TagRenderingQuestion.GenerateMappingContent(mapping, tagsSource, state), tagging, - (t0, t1) => t1.isEquivalent(t0)); + (t0, t1) => t1.shadows(t0)); } private static GenerateMappingContent(mapping: { @@ -450,7 +450,7 @@ export default class TagRenderingQuestion extends Combine { }) let inputTagsFilter: InputElement = new InputElementMap( - input, (a, b) => a === b || (a?.isEquivalent(b) ?? false), + input, (a, b) => a === b || (a?.shadows(b) ?? false), pickString, toString ); diff --git a/assets/themes/mapcomplete-changes/mapcomplete-changes.json b/assets/themes/mapcomplete-changes/mapcomplete-changes.json index 2f479a092..c90abd4c4 100644 --- a/assets/themes/mapcomplete-changes/mapcomplete-changes.json +++ b/assets/themes/mapcomplete-changes/mapcomplete-changes.json @@ -1,16 +1,13 @@ { "id": "mapcomplete-changes", "title": { - "en": "Changes made with MapComplete", - "de": "Änderungen mit MapComplete" + "en": "Changes made with MapComplete" }, "shortDescription": { - "en": "Shows changes made by MapComplete", - "de": "Zeigt Änderungen von MapComplete" + "en": "Shows changes made by MapComplete" }, "description": { - "en": "This maps shows all the changes made with MapComplete", - "de": "Diese Karte zeigt alle Änderungen die mit MapComplete gemacht wurden" + "en": "This maps shows all the changes made with MapComplete" }, "maintainer": "", "icon": "./assets/svg/logo.svg", @@ -25,8 +22,7 @@ { "id": "mapcomplete-changes", "name": { - "en": "Changeset centers", - "de": "Schwerpunkte von Änderungssätzen" + "en": "Changeset centers" }, "minzoom": 0, "source": { @@ -40,41 +36,35 @@ ], "title": { "render": { - "en": "Changeset for {theme}", - "de": "Änderungen für {theme}" + "en": "Changeset for {theme}" } }, "description": { - "en": "Shows all MapComplete changes", - "de": "Zeigt alle MapComplete Änderungen" + "en": "Shows all MapComplete changes" }, "tagRenderings": [ { "id": "render_id", "render": { - "en": "Changeset {id}", - "de": "Änderung {id}" + "en": "Changeset {id}" } }, { "id": "contributor", "render": { - "en": "Change made by {_last_edit:contributor}", - "de": "Änderung wurde von {_last_edit:contributor} gemacht" + "en": "Change made by {_last_edit:contributor}" } }, { "id": "theme", "render": { - "en": "Change with theme {theme}", - "de": "Änderung mit Thema {theme}" + "en": "Change with theme {theme}" }, "mappings": [ { "if": "theme~http.*", "then": { - "en": "Change with unofficial theme {theme}", - "de": "Änderung mit inoffiziellem Thema {theme}" + "en": "Change with unofficial theme {theme}" } } ] @@ -338,8 +328,7 @@ } ], "question": { - "en": "Themename contains {search}", - "de": "Themenname enthält {search}" + "en": "Themename contains {search}" } } ] @@ -355,8 +344,7 @@ } ], "question": { - "en": "Made by contributor {search}", - "de": "Erstellt von {search}" + "en": "Made by contributor {search}" } } ] @@ -372,8 +360,7 @@ } ], "question": { - "en": "Not made by contributor {search}", - "de": "Nicht erstellt von {search}" + "en": "Not made by contributor {search}" } } ] @@ -388,8 +375,7 @@ { "id": "link_to_more", "render": { - "en": "More statistics can be found here", - "de": "Weitere Statistiken finden Sie hier" + "en": "More statistics can be found here" } }, { diff --git a/test/Logic/Tags/OptimizeTags.spec.ts b/test/Logic/Tags/OptimizeTags.spec.ts new file mode 100644 index 000000000..5229c94a0 --- /dev/null +++ b/test/Logic/Tags/OptimizeTags.spec.ts @@ -0,0 +1,347 @@ +import {describe} from 'mocha' +import {expect} from 'chai' +import {TagsFilter} from "../../../Logic/Tags/TagsFilter"; +import {And} from "../../../Logic/Tags/And"; +import {Tag} from "../../../Logic/Tags/Tag"; +import {TagUtils} from "../../../Logic/Tags/TagUtils"; +import {Or} from "../../../Logic/Tags/Or"; +import {RegexTag} from "../../../Logic/Tags/RegexTag"; + +describe("Tag optimalization", () => { + + describe("And", () => { + it("with condition and nested and should be flattened", () => { + const t = new And( + [ + new And([ + new Tag("x", "y") + ]), + new Tag("a", "b") + ] + ) + const opt = t.optimize() + expect(TagUtils.toString(opt)).eq(`a=b&x=y`) + }) + + it("should be 'true' if no conditions are given", () => { + const t = new And( + [] + ) + const opt = t.optimize() + expect(opt).eq(true) + }) + + it("with nested ors and common property should be extracted", () => { + + // foo&bar & (x=y | a=b) & (x=y | c=d) & foo=bar is equivalent too foo=bar & ((x=y) | (a=b & c=d)) + const t = new And([ + new Tag("foo", "bar"), + new Or([ + new Tag("x", "y"), + new Tag("a", "b") + ]), + new Or([ + new Tag("x", "y"), + new Tag("c", "d") + ]) + ]) + const opt = t.optimize() + expect(TagUtils.toString(opt)).eq("foo=bar& (x=y| (a=b&c=d) )") + }) + + it("with nested ors and common regextag should be extracted", () => { + + // foo&bar & (x=y | a=b) & (x=y | c=d) & foo=bar is equivalent too foo=bar & ((x=y) | (a=b & c=d)) + const t = new And([ + new Tag("foo", "bar"), + new Or([ + new RegexTag("x", "y"), + new RegexTag("a", "b") + ]), + new Or([ + new RegexTag("x", "y"), + new RegexTag("c", "d") + ]) + ]) + const opt = t.optimize() + expect(TagUtils.toString(opt)).eq("foo=bar& ( (a=b&c=d) |x=y)") + }) + + it("with nested ors and inverted regextags should _not_ be extracted", () => { + + // foo&bar & (x=y | a=b) & (x=y | c=d) & foo=bar is equivalent too foo=bar & ((x=y) | (a=b & c=d)) + const t = new And([ + new Tag("foo", "bar"), + new Or([ + new RegexTag("x", "y"), + new RegexTag("a", "b") + ]), + new Or([ + new RegexTag("x", "y", true), + new RegexTag("c", "d") + ]) + ]) + const opt = t.optimize() + expect(TagUtils.toString(opt)).eq("foo=bar& (a=b|x=y) & (c=d|x!=y)") + }) + + it("should move regextag to the end", () => { + const t = new And([ + new RegexTag("x", "y"), + new Tag("a", "b") + ]) + const opt = t.optimize() + expect(TagUtils.toString(opt)).eq("a=b&x=y") + + }) + + it("should sort tags by their popularity (least popular first)", () => { + const t = new And([ + new Tag("bicycle", "yes"), + new Tag("amenity", "binoculars") + ]) + const opt = t.optimize() + expect(TagUtils.toString(opt)).eq("amenity=binoculars&bicycle=yes") + + }) + + it("should optimize nested ORs", () => { + const filter = TagUtils.Tag({ + or: [ + "X=Y", "FOO=BAR", + { + "and": [ + { + "or": ["X=Y", "FOO=BAR"] + }, + "bicycle=yes" + ] + } + ] + }) + // (X=Y | FOO=BAR | (bicycle=yes & (X=Y | FOO=BAR)) ) + // This is equivalent to (X=Y | FOO=BAR) + const opt = filter.optimize() + console.log(opt) + }) + + it("should optimize an advanced, real world case", () => { + const filter = TagUtils.Tag({ + or: [ + { + "and": [ + { + "or": ["amenity=charging_station", "disused:amenity=charging_station", "planned:amenity=charging_station", "construction:amenity=charging_station"] + }, + "bicycle=yes" + ] + }, + { + "and": [ + { + "or": ["amenity=charging_station", "disused:amenity=charging_station", "planned:amenity=charging_station", "construction:amenity=charging_station"] + }, + ] + }, + "amenity=toilets", + "amenity=bench", + "leisure=picnic_table", + { + "and": [ + "tower:type=observation" + ] + }, + { + "and": [ + "amenity=bicycle_repair_station" + ] + }, + { + "and": [ + { + "or": [ + "amenity=bicycle_rental", + "bicycle_rental~*", + "service:bicycle:rental=yes", + "rental~.*bicycle.*" + ] + }, + "bicycle_rental!=docking_station" + ] + }, + { + "and": [ + "leisure=playground", + "playground!=forest" + ] + } + ] + }); + const opt = filter.optimize() + const expected = ["amenity=charging_station", + "amenity=toilets", + "amenity=bench", + "amenity=bicycle_repair_station", + "construction:amenity=charging_station", + "disused:amenity=charging_station", + "leisure=picnic_table", + "planned:amenity=charging_station", + "tower:type=observation", + "(amenity=bicycle_rental|service:bicycle:rental=yes|bicycle_rental~^..*$|rental~^.*bicycle.*$) &bicycle_rental!=docking_station", + "leisure=playground&playground!=forest"] + + expect((opt).or.map(f => TagUtils.toString(f))).deep.eq( + expected + ) + }) + + it("should detect conflicting tags", () => { + const q = new And([new Tag("key", "value"), new RegexTag("key", "value", true)]) + expect(q.optimize()).eq(false) + }) + + it("should detect conflicting tags with a regex", () => { + const q = new And([new Tag("key", "value"), new RegexTag("key", /value/, true)]) + expect(q.optimize()).eq(false) + }) + + }) + + describe("Or", () => { + + + it("with nested And which has a common property should be dropped", () => { + + const t = new Or([ + new Tag("foo", "bar"), + new And([ + new Tag("foo", "bar"), + new Tag("x", "y"), + ]) + ]) + const opt = t.optimize() + expect(TagUtils.toString(opt)).eq("foo=bar") + + }) + + it("should flatten nested ors", () => { + const t = new Or([ + new Or([ + new Tag("x", "y") + ]) + ]).optimize() + expect(t).deep.eq(new Tag("x", "y")) + }) + + it("should flatten nested ors", () => { + const t = new Or([ + new Tag("a", "b"), + new Or([ + new Tag("x", "y") + ]) + ]).optimize() + expect(t).deep.eq(new Or([new Tag("a", "b"), new Tag("x", "y")])) + }) + + }) + + it("should not generate a conflict for climbing tags", () => { + const club_tags = TagUtils.Tag( + { + "or": [ + "club=climbing", + { + "and": [ + "sport=climbing", + { + "or": [ + "office~*", + "club~*" + ] + } + ] + } + ] + }) + const gym_tags = TagUtils.Tag({ + "and": [ + "sport=climbing", + "leisure=sports_centre" + ] + }) + const other_climbing = TagUtils.Tag({ + "and": [ + "sport=climbing", + "climbing!~route", + "leisure!~sports_centre", + "climbing!=route_top", + "climbing!=route_bottom" + ] + }) + const together = new Or([club_tags, gym_tags, other_climbing]) + const opt = together.optimize() + + /* + club=climbing | (sport=climbing&(office~* | club~*)) + OR + sport=climbing & leisure=sports_centre + OR + sport=climbing & climbing!~route & leisure!~sports_centre + */ + + /* + > When the first OR is written out, this becomes + club=climbing + OR + (sport=climbing&(office~* | club~*)) + OR + (sport=climbing & leisure=sports_centre) + OR + (sport=climbing & climbing!~route & leisure!~sports_centre & ...) + */ + + /* + > We can join the 'sport=climbing' in the last 3 phrases + club=climbing + OR + (sport=climbing AND + (office~* | club~*)) + OR + (leisure=sports_centre) + OR + (climbing!~route & leisure!~sports_centre & ...) + ) + */ + + + expect(opt).deep.eq( + TagUtils.Tag({ + or: [ + "club=climbing", + { + and: ["sport=climbing", + {or: ["club~*", "office~*"]}] + }, + { + and: ["sport=climbing", + { + or: [ + "leisure=sports_centre", + { + and: [ + "climbing!~route", + "climbing!=route_top", + "climbing!=route_bottom", + "leisure!~sports_centre" + ] + } + ] + }] + } + + ], + + }) + ) + }) +}) \ No newline at end of file diff --git a/test/Logic/Tags/OptimzeTags.spec.ts b/test/Logic/Tags/OptimzeTags.spec.ts deleted file mode 100644 index 46870ff53..000000000 --- a/test/Logic/Tags/OptimzeTags.spec.ts +++ /dev/null @@ -1,150 +0,0 @@ -import {describe} from 'mocha' -import {expect} from 'chai' -import {TagsFilter} from "../../../Logic/Tags/TagsFilter"; -import {And} from "../../../Logic/Tags/And"; -import {Tag} from "../../../Logic/Tags/Tag"; -import {TagUtils} from "../../../Logic/Tags/TagUtils"; -import {Or} from "../../../Logic/Tags/Or"; -import {RegexTag} from "../../../Logic/Tags/RegexTag"; - -describe("Tag optimalization", () => { - - describe("And", () => { - it("with condition and nested and should be flattened", () => { - const t = new And( - [ - new And([ - new Tag("x", "y") - ]), - new Tag("a", "b") - ] - ) - const opt = t.optimize() - expect(TagUtils.toString(opt)).eq(`a=b&x=y`) - }) - - it("with nested ors and commons property should be extracted", () => { - - // foo&bar & (x=y | a=b) & (x=y | c=d) & foo=bar is equivalent too foo=bar & ((x=y) | (a=b & c=d)) - const t = new And([ - new Tag("foo","bar"), - new Or([ - new Tag("x", "y"), - new Tag("a", "b") - ]), - new Or([ - new Tag("x", "y"), - new Tag("c", "d") - ]) - ]) - const opt = t.optimize() - expect(TagUtils.toString(opt)).eq("foo=bar& (x=y| (a=b&c=d) )") - }) - - it("should move regextag to the end", () => { - const t = new And([ - new RegexTag("x","y"), - new Tag("a","b") - ]) - const opt = t.optimize() - expect(TagUtils.toString(opt)).eq("a=b&x~^y$") - - }) - - it("should sort tags by their popularity (least popular first)", () => { - const t = new And([ - new Tag("bicycle","yes"), - new Tag("amenity","binoculars") - ]) - const opt = t.optimize() - expect(TagUtils.toString(opt)).eq("amenity=binoculars&bicycle=yes") - - }) - - it("should optimize an advanced, real world case", () => { - const filter = TagUtils.Tag( {or: [ - { - "and": [ - { - "or": ["amenity=charging_station","disused:amenity=charging_station","planned:amenity=charging_station","construction:amenity=charging_station"] - }, - "bicycle=yes" - ] - }, - { - "and": [ - { - "or": ["amenity=charging_station","disused:amenity=charging_station","planned:amenity=charging_station","construction:amenity=charging_station"] - }, - ] - }, - "amenity=toilets", - "amenity=bench", - "leisure=picnic_table", - { - "and": [ - "tower:type=observation" - ] - }, - { - "and": [ - "amenity=bicycle_repair_station" - ] - }, - { - "and": [ - { - "or": [ - "amenity=bicycle_rental", - "bicycle_rental~*", - "service:bicycle:rental=yes", - "rental~.*bicycle.*" - ] - }, - "bicycle_rental!=docking_station" - ] - }, - { - "and": [ - "leisure=playground", - "playground!=forest" - ] - } - ]}); - const opt = filter.optimize() - const expected = "amenity=charging_station|" + - "amenity=toilets|" + - "amenity=bench|" + - "amenity=bicycle_repair_station" + - "|construction:amenity=charging_station|" + - "disused:amenity=charging_station|" + - "leisure=picnic_table|" + - "planned:amenity=charging_station|" + - "tower:type=observation| " + - "( (amenity=bicycle_rental|service:bicycle:rental=yes|bicycle_rental~^..*$|rental~^.*bicycle.*$) &bicycle_rental!~^docking_station$) |" + - " (leisure=playground&playground!~^forest$)" - - expect(TagUtils.toString(opt).replace(/ /g, "")) - .eq(expected.replace(/ /g, "")) - - }) - - }) - - describe("Or", () => { - it("with nested And which has a common property should be dropped", () => { - - const t = new Or([ - new Tag("foo","bar"), - new And([ - new Tag("foo", "bar"), - new Tag("x", "y"), - ]) - ]) - const opt = t.optimize() - expect(TagUtils.toString(opt)).eq("foo=bar") - - }) - - }) -}) \ No newline at end of file diff --git a/test/Models/ThemeConfig/SourceConfig.spec.ts b/test/Models/ThemeConfig/SourceConfig.spec.ts new file mode 100644 index 000000000..97c590e87 --- /dev/null +++ b/test/Models/ThemeConfig/SourceConfig.spec.ts @@ -0,0 +1,19 @@ +import {describe} from 'mocha' +import {expect} from 'chai' +import SourceConfig from "../../../Models/ThemeConfig/SourceConfig"; +import {TagUtils} from "../../../Logic/Tags/TagUtils"; + +describe("SourceConfig", () => { + + it("should throw an error on conflicting tags", () => { + expect(() => { + new SourceConfig( + { + osmTags: TagUtils.Tag({ + and: ["x=y", "a=b", "x!=y"] + }) + }, false + ) + }).to.throw(/tags are conflicting/) + }) +}) diff --git a/test/scripts/GenerateCache.spec.ts b/test/scripts/GenerateCache.spec.ts index e5be78431..51558bcd8 100644 --- a/test/scripts/GenerateCache.spec.ts +++ b/test/scripts/GenerateCache.spec.ts @@ -34,7 +34,7 @@ describe("GenerateCache", () => { } mkdirSync("/tmp/np-cache") initDownloads( - "(nwr%5B%22amenity%22%3D%22toilets%22%5D%3Bnwr%5B%22amenity%22%3D%22parking%22%5D%3Bnwr%5B%22amenity%22%3D%22bench%22%5D%3Bnwr%5B%22id%22%3D%22location_track%22%5D%3Bnwr%5B%22id%22%3D%22gps%22%5D%3Bnwr%5B%22information%22%3D%22board%22%5D%3Bnwr%5B%22leisure%22%3D%22picnic_table%22%5D%3Bnwr%5B%22man_made%22%3D%22watermill%22%5D%3Bnwr%5B%22user%3Ahome%22%3D%22yes%22%5D%3Bnwr%5B%22user%3Alocation%22%3D%22yes%22%5D%3Bnwr%5B%22leisure%22%3D%22nature_reserve%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22boundary%22%3D%22protected_area%22%5D%5B%22protect_class%22!~%22%5E98%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22information%22%3D%22visitor_centre%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22information%22%3D%22office%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*foot.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*hiking.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*bycicle.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*horse.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22leisure%22%3D%22bird_hide%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22amenity%22%3D%22drinking_water%22%5D%5B%22access%22!~%22%5Epermissive%24%22%5D%5B%22access%22!~%22%5Eprivate%24%22%5D%3B)%3Bout%20body%3Bout%20meta%3B%3E%3Bout%20skel%20qt%3B" + "(nwr%5B%22amenity%22%3D%22toilets%22%5D%3Bnwr%5B%22amenity%22%3D%22parking%22%5D%3Bnwr%5B%22amenity%22%3D%22bench%22%5D%3Bnwr%5B%22id%22%3D%22location_track%22%5D%3Bnwr%5B%22id%22%3D%22gps%22%5D%3Bnwr%5B%22information%22%3D%22board%22%5D%3Bnwr%5B%22leisure%22%3D%22picnic_table%22%5D%3Bnwr%5B%22man_made%22%3D%22watermill%22%5D%3Bnwr%5B%22user%3Ahome%22%3D%22yes%22%5D%3Bnwr%5B%22user%3Alocation%22%3D%22yes%22%5D%3Bnwr%5B%22leisure%22%3D%22nature_reserve%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22boundary%22%3D%22protected_area%22%5D%5B%22protect_class%22!%3D%2298%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22information%22%3D%22visitor_centre%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22information%22%3D%22office%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*foot.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*hiking.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*bycicle.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22route%22~%22%5E.*horse.*%24%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22leisure%22%3D%22bird_hide%22%5D%5B%22operator%22~%22%5E.*%5BnN%5Datuurpunt.*%24%22%5D%3Bnwr%5B%22amenity%22%3D%22drinking_water%22%5D%5B%22access%22!%3D%22permissive%22%5D%5B%22access%22!%3D%22private%22%5D%3B)%3Bout%20body%3Bout%20meta%3B%3E%3Bout%20skel%20qt%3B" ); await main([ "natuurpunt",