From ffe3d213a866b3fa5bba9d0950378d3aa818f646 Mon Sep 17 00:00:00 2001 From: Brian Jorgenson <22751140+brianjor@users.noreply.github.com> Date: Wed, 10 Feb 2021 13:24:33 -0800 Subject: [PATCH] [New Check] SimilarTagValueCheck (#500) * SimilarTagCheck * Create instructions Create instructions. Added Similar class. Replaced filter similars function chain to its own function to help clean up flag function body. * Duplicates fix suggestion * Documentation * Tests * Spotless * Live examples * Single character variable names * Fix suggestion doc * Configurables description * More tests * More configurables * Code smell * SpotlessApply * Function ordering * HTML characters in javadoc * Configurables in docs --- config/configuration.json | 29 ++ docs/available_checks.md | 1 + docs/checks/similarTagValueCheck.md | 52 +++ .../validation/tag/SimilarTagValueCheck.java | 346 ++++++++++++++++++ .../tag/SimilarTagValueCheckTest.java | 74 ++++ .../tag/SimilarTagValueCheckTestRule.java | 70 ++++ 6 files changed, 572 insertions(+) create mode 100644 docs/checks/similarTagValueCheck.md create mode 100644 src/main/java/org/openstreetmap/atlas/checks/validation/tag/SimilarTagValueCheck.java create mode 100644 src/test/java/org/openstreetmap/atlas/checks/validation/tag/SimilarTagValueCheckTest.java create mode 100644 src/test/java/org/openstreetmap/atlas/checks/validation/tag/SimilarTagValueCheckTestRule.java diff --git a/config/configuration.json b/config/configuration.json index df364154c..2dc8dd7d2 100644 --- a/config/configuration.json +++ b/config/configuration.json @@ -959,6 +959,35 @@ "tags":"highway" } }, + "SimilarTagValueCheck": { + "filter": { + "commonSimilars": [ + ["american", "mexican"], ["cafe", "cake"], ["male", "female"], ["woman", "man"], + ["women", "men"], ["male_toilet", "female_toilet"], ["radiology", "cardiology"], + ["baseball", "basketball"], ["bowls", "boules"], ["padel", "paddel"], ["formal", "informal"], + ["hotel", "hostel"], ["hump", "bump"], ["seed", "feed"] + ], + "tags": [ + "asset_ref", "collection_times", "except", "is_in", "junction:ref", "maxspeed:conditional", + "old_name", "old_ref", "opening_hours", "ref", "restriction_hours", "route_ref", "supervised", + "source_ref", "target", "telescope" + ], + "tagsWithSubCategories": [ + "addr", "alt_name", "destination", "name", "seamark", "turn" + ] + }, + "similarity.threshold": { + "min": 0.0, + "max": 1.0 + }, + "value.length.min": 4.0, + "challenge": { + "description": "Tasks identify duplicate/similar values in tags.", + "blurb": "Duplicate/Similar tag values.", + "instruction": "Determine if the duplicate/similar value is necessary or can be removed.", + "difficulty": "EASY" + } + }, "SingleSegmentMotorwayCheck": { "challenge": { "description": "Tasks that identify ways tagged with highway=motorway that are not connected to any ways tagged the same.", diff --git a/docs/available_checks.md b/docs/available_checks.md index b09620079..52cfea963 100644 --- a/docs/available_checks.md +++ b/docs/available_checks.md @@ -86,6 +86,7 @@ This document is a list of tables with a description and link to documentation f | [MixedCaseNameCheck](checks/mixedCaseNameCheck.md) | The purpose of this check is to identify names that contain invalid mixed cases so that they can be edited to be the standard format. | | [RoadNameGapCheck](checks/RoadNameGapCheck.md) | The purpose of this check is to identify edge connected between two edges whose name tag is same. Flag the edge if the edge has a name tag different to name tag of edges connected to it or if there is no name tag itself. | [RoadNameSpellingConsistencyCheck](checks/RoadNameSpellingConsistencyCheck.md) | The purpose of this check is to identify road segments that have a name Tag with a different spelling from that of other segments of the same road. This check is primarily meant to catch small errors in spelling, such as a missing letter, letter accent mixups, or capitalization errors. | +| [SimilarTagValueCheck](checks/SimilarTagValueCheck.md) | The purpose of this check is to identify tags whose values are either duplicates or similar enough to warrant someone to look at them. | | ShortNameCheck | The short name check will validate that any and all names contain at least 2 letters in the name. | | [StreetNameIntegersOnlyCheck](checks/streetNameIntegersOnlyCheck.md) | The purpose of this check is to identify streets whose names contain integers only. | | [TollValidationCheck](checks/tollValidationCheck) | The purpose of this check is to identify ways that need to have their toll tags investigated/added/removed. diff --git a/docs/checks/similarTagValueCheck.md b/docs/checks/similarTagValueCheck.md new file mode 100644 index 000000000..d6829aad8 --- /dev/null +++ b/docs/checks/similarTagValueCheck.md @@ -0,0 +1,52 @@ +# SimilarTagValueCheck + +#### Description + +The purpose of this check is to identify tags whose values are either duplicates or similar +enough to warrant someone to look at them. + +Configurables: +* "value.length.min": Minimum length an individual value must be to be considered for inspection, value.length >= min. +* "similarity.threshold.min": Minimum edit distance between two values to be added to the flag where a value of 0 is + used to include duplicates, value >= min. +* "similarity.threshold.max": Maximum edit distance between two values to be added to the flag, value <= max. +* "filter.commonSimilars": values that can commonly be found together validly on a tag that are similar but with no + action needed to be taken. +* "filter.tags": tags that commonly have values that are duplicates/similars that are valid. +* "filter.tagsWithSubCategories": tags that contain one or many sub-categories that commonly have valid + duplicate/similar values. + +#### Live Examples +Similar tag values +1. The node [5142510561](https://www.openstreetmap.org/way/5142510561) has the similar values: "crayfish" and "Crayfish" + +Duplicate tag values +1. The way [173171120](https://www.openstreetmap.org/way/173171120) has multiple duplicate values in the "source" tag + +#### Code Review + +This check evaluates all atlas objects that can hold OSM tags. +Any duplicate tags are removed in a feature change, while similars are flagged for user review. + +#### Validating the object +The incoming object must: +* have at least one tag with multiple values (contains a ";") + +#### Flagging the object +We filter out all tags that: +* are tags that commonly contain valid duplicate/similar values +* values that are similar to others that commonly occur on the same tag +* values that either contain: length shorter than the defined min length, a number, non-latin characters +* the last filtering step we remove any tags that do not contain multiple values + +We then take the valid tags and compare each value computing similarity between each, using the +Levenshtein Edit Distance algorithm. We keep value pairs with a similarity that falls within our +similarity threshold. + +From there we split the gathered pairs between those that are duplicate values, and those that are similar. +The duplicates are added to the instructions and used to create a fix suggestion. +The similars are just added to the instructions. + +#### Fix Suggestion +We create fix suggestions only on duplicate values, as similar values are difficult to determine which one (if not both) +should be kept. The fix, for duplicates, is to remove all but one occurrence of the duplicate value from the tag. diff --git a/src/main/java/org/openstreetmap/atlas/checks/validation/tag/SimilarTagValueCheck.java b/src/main/java/org/openstreetmap/atlas/checks/validation/tag/SimilarTagValueCheck.java new file mode 100644 index 000000000..e2722eb5d --- /dev/null +++ b/src/main/java/org/openstreetmap/atlas/checks/validation/tag/SimilarTagValueCheck.java @@ -0,0 +1,346 @@ +package org.openstreetmap.atlas.checks.validation.tag; + +import java.util.AbstractMap.SimpleEntry; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.apache.commons.text.similarity.LevenshteinDistance; +import org.openstreetmap.atlas.checks.base.BaseCheck; +import org.openstreetmap.atlas.checks.flag.CheckFlag; +import org.openstreetmap.atlas.geography.atlas.change.FeatureChange; +import org.openstreetmap.atlas.geography.atlas.complete.CompleteEntity; +import org.openstreetmap.atlas.geography.atlas.items.AtlasEntity; +import org.openstreetmap.atlas.geography.atlas.items.AtlasObject; +import org.openstreetmap.atlas.utilities.configuration.Configuration; + +/** + * This check looks for tags with multiple values that are duplicates or values are similar that + * contain a typo. Configurables: "value.length.min": Minimum length an individual value must be to + * be considered for inspection, {@literal value.length >= min}. "similarity.threshold.min": Minimum + * edit distance between two values to be add to the flag where a value of 0 is used to include + * duplicates, {@literal value >= min}. "similarity.threshold.max": Maximum edit distance between + * two values to be add to the flag, {@literal value <= max}. "filter.commonSimilars": values that + * can commonly be found together validly on a tag that are similar but with no action needed to be + * taken. "filter.tags": tags that commonly have values that are duplicates/similars that are valid. + * "filter.tagsWithSubCategories": tags that contain one or many sub-categories that commonly have + * valid duplicate/similar values. + * + * @author brianjor + */ +public class SimilarTagValueCheck extends BaseCheck +{ + private static final long serialVersionUID = 6115389966264680867L; + private static final Predicate HAS_NON_LATIN = Pattern + .compile("[^\\d\\s\\p{Punct}\\p{IsLatin}]").asPredicate(); + private static final Predicate HAS_NUMBER = Pattern.compile("\\d").asPredicate(); + private static final String SEMICOLON = ";"; + private static final String DUPLICATE_TAG_VALUE_INSTRUCTION = "The tag \"%s\" contains duplicate values: %s"; + private static final int DUPLICATE_INSTRUCTION_INDEX = 0; + private static final String SIMILAR_TAG_VALUE_INSTRUCTION = "The tag \"%s\" contains similar values: %s"; + private static final int SIMILAR_INSTRUCTION_INDEX = 1; + private static final List FALLBACK_INSTRUCTIONS = Arrays + .asList(DUPLICATE_TAG_VALUE_INSTRUCTION, SIMILAR_TAG_VALUE_INSTRUCTION); + private static final Double MIN_VALUE_LENGTH = 4.0; + private static final Double MIN_SIMILARITY_THRESHOLD_DEFAULT = 0.0; + private static final Double MAX_SIMILARITY_THRESHOLD_DEFAULT = 1.0; + private static final List TAGS_TO_IGNORE_DEFAULT = List.of("asset_ref", + "collection_times", "except", "is_in", "junction:ref", "maxspeed:conditional", + "old_name", "old_ref", "opening_hours", "ref", "restriction_hours", "route_ref", + "supervised", "source_ref", "target", "telescope"); + // Set of tags with many sub-categories that create a lot of False positives + private static final List TAGS_WITH_SUB_CATEGORIES_TO_IGNORE_DEFAULT = List.of("addr", + "alt_name", "destination", "name", "seamark", "turn"); + // Common value sets that are similar but can be on the same tag + private static final List> COMMON_SIMILARS_DEFAULT = List.of( + // Ethnonyms + List.of("american", "mexican"), + // Food + List.of("cafe", "cake"), + // Gender + List.of("male", "female"), List.of("woman", "man"), List.of("women", "men"), + List.of("male_toilet", "female_toilet"), + // Medical + List.of("radiology", "cardiology"), + // Sports + List.of("baseball", "basketball"), List.of("bowls", "boules"), + List.of("padel", "paddel"), + // Other + List.of("formal", "informal"), List.of("hotel", "hostel"), List.of("hump", "bump"), + List.of("seed", "feed")); + + private final List tagsToIgnore; + private final List tagsWithSubCategoriesToIgnore; + private final List> commonSimilars; + private final Double minValueLength; + private final Double minSimilarityThreshold; + private final Double maxSimilarityThreshold; + + /** + * @param configuration + * the JSON configuration for this check + */ + public SimilarTagValueCheck(final Configuration configuration) + { + super(configuration); + this.commonSimilars = this.configurationValue(configuration, "filter.commonSimilars", + COMMON_SIMILARS_DEFAULT); + this.tagsToIgnore = this.configurationValue(configuration, "filter.tags", + TAGS_TO_IGNORE_DEFAULT); + this.tagsWithSubCategoriesToIgnore = this.configurationValue(configuration, + "filter.tagsWithSubCategories", TAGS_WITH_SUB_CATEGORIES_TO_IGNORE_DEFAULT); + this.minValueLength = this.configurationValue(configuration, "value.length.min", + MIN_VALUE_LENGTH, Double::doubleValue); + this.minSimilarityThreshold = this.configurationValue(configuration, + "similarity.threshold.min", MIN_SIMILARITY_THRESHOLD_DEFAULT, Double::doubleValue); + this.maxSimilarityThreshold = this.configurationValue(configuration, + "similarity.threshold.max", MAX_SIMILARITY_THRESHOLD_DEFAULT, Double::doubleValue); + } + + /** + * Valid objects for this check objects with tags that contain multiple values. + * + * @param object + * the atlas object supplied by the Atlas-Checks framework for evaluation + * @return {@code true} if this object should be checked + */ + @Override + public boolean validCheckForObject(final AtlasObject object) + { + // Only want objects with tags that contain multiple values. + return object.getOsmTags().values().stream().anyMatch(value -> value.contains(SEMICOLON)); + } + + /** + * This is the actual function that will check to see whether the object needs to be flagged. + * + * @param object + * the atlas object supplied by the Atlas-Checks framework for evaluation + * @return an optional {@link CheckFlag} object + */ + @Override + protected Optional flag(final AtlasObject object) + { + final Map tagsWithMultipleValues = object.getOsmTags().entrySet().stream() + .filter(entry -> !this.tagsToIgnore.contains(entry.getKey())) + .filter(Predicate.not(this::isTagWithSubCategoriesToIgnore)) + .map(this::removeCommonFalsePositiveValues) + // Only keep tags that still have multiple values + .filter(entry -> entry.getValue().contains(SEMICOLON)) + .collect(Collectors.toMap(Entry::getKey, Entry::getValue)); + + // Gather all tags with a list of their similars + final Map> tagsWithSimilars = tagsWithMultipleValues.entrySet() + .stream().map(this::findSimilars).filter(entry -> !entry.getValue().isEmpty()) + .collect(Collectors.toMap(Entry::getKey, Entry::getValue)); + + // Pull out tags + values to only contain similars that are duplicates + final Map> duplicates = this.filterSimilars(tagsWithSimilars, + similar -> similar.getSimilarity() == 0); + + // Pull out tags + values to only contain similars that are not duplicates + final Map> similars = this.filterSimilars(tagsWithSimilars, + similar -> similar.getSimilarity() != 0); + + final List instructions = new ArrayList<>(); + if (!duplicates.isEmpty()) + { + instructions.addAll(duplicates.entrySet().stream() + .map(entry -> String.format( + this.getFallbackInstructions().get(DUPLICATE_INSTRUCTION_INDEX), + entry.getKey(), entry.getValue())) + .collect(Collectors.toList())); + } + + if (!similars.isEmpty()) + { + instructions.addAll(similars.entrySet().stream() + .map(entry -> String.format( + this.getFallbackInstructions().get(SIMILAR_INSTRUCTION_INDEX), + entry.getKey(), entry.getValue())) + .collect(Collectors.toList())); + } + + if (!instructions.isEmpty()) + { + final Map newTags = object.getOsmTags().entrySet().stream().map(entry -> + { + final Entry newEntry = new SimpleEntry<>(entry); + if (duplicates.containsKey(entry.getKey())) + { + final String valueWithDuplicatesRemoved = Arrays + .stream(entry.getValue().split(SEMICOLON)).distinct() + .collect(Collectors.joining(SEMICOLON)); + newEntry.setValue(valueWithDuplicatesRemoved); + } + return newEntry; + }).collect(Collectors.toMap(Entry::getKey, Entry::getValue)); + final String instruction = String.join(". ", instructions); + return Optional.of(this.createFlag(object, instruction).addFixSuggestion(FeatureChange + .add((AtlasEntity) ((CompleteEntity) CompleteEntity.from((AtlasEntity) object)) + .withTags(newTags), object.getAtlas()))); + } + return Optional.empty(); + } + + @Override + protected List getFallbackInstructions() + { + return FALLBACK_INSTRUCTIONS; + } + + /** + * Filters out similars from tags according to the filterBy predicate. + * + * @param tagsWithSimilars + * tags with similars. + * @param filterBy + * predicate to apply to the tags similars. + * @return the tag with the similars filtered out. + */ + private Map> filterSimilars( + final Map> tagsWithSimilars, final Predicate filterBy) + { + return tagsWithSimilars.entrySet().stream().map(entry -> + { + final Entry> newEntry = new SimpleEntry<>(entry); + final List newValue = newEntry.getValue().stream().filter(filterBy) + .collect(Collectors.toList()); + newEntry.setValue(newValue); + return newEntry; + }).filter(entry -> !entry.getValue().isEmpty()) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + /** + * Finds the Levenshtein edit distance for the strings. + * + * @return edit distance between the two strings, or -1 if either string is null. + */ + private int findEditDistance(final String left, final String right) + { + try + { + return LevenshteinDistance.getDefaultInstance().apply(left, right); + } + catch (final IllegalArgumentException exception) + { + return -1; + } + } + + /** + * Map a tag entry to an entry where their value is a list of tuples containing the two similar + * tags and their edit distance. + */ + private Entry> findSimilars(final Entry entry) + { + final List values = Arrays.asList(entry.getValue().split(SEMICOLON)); + final List similars = new ArrayList<>(); + for (int leftIndex = 0; leftIndex < values.size() - 1; leftIndex++) + { + for (int rightIndex = leftIndex + 1; rightIndex < values.size(); rightIndex++) + { + final String left = values.get(leftIndex); + final String right = values.get(rightIndex); + final boolean isCommonSimilars = this.isCommonSimilars(left, right); + final boolean duplicates = left.equals(right); + // Keep duplicates even if they are common similars + if (!isCommonSimilars || duplicates) + { + final int editDistance = this.findEditDistance(left, right); + if (this.minSimilarityThreshold <= editDistance + && editDistance <= this.maxSimilarityThreshold) + { + similars.add(new Similar(left, right, editDistance)); + } + } + } + } + return new SimpleEntry<>(entry.getKey(), similars); + } + + /** + * Checks if the strings are contained in the same set of common similars. + */ + private boolean isCommonSimilars(final String left, final String right) + { + return this.commonSimilars.stream().anyMatch( + setOfSimilars -> setOfSimilars.contains(left) && setOfSimilars.contains(right)); + } + + /** + * Checks if the tag for the entry starts with any of the tags with sub-categories to ignore. + */ + private boolean isTagWithSubCategoriesToIgnore(final Map.Entry entry) + { + return this.tagsWithSubCategoriesToIgnore.stream().anyMatch(entry.getKey()::startsWith); + } + + /** + * Remove values that are susceptible to being false positives.
+ * Removes: + *
    + *
  • Values whose length < {@value MIN_VALUE_LENGTH}
  • + *
  • Numbers
  • + *
  • Values with characters that match the HAS_NON_LATIN regex
  • + *
+ */ + private Entry removeCommonFalsePositiveValues(final Entry tag) + { + final Entry newTag = new SimpleEntry<>(tag); + final List splitValues = Arrays.stream(newTag.getValue().split(SEMICOLON)) + // Values must be greater than or equal to the specified minimum length. + .filter(value -> value.length() >= this.minValueLength) + // Remove values that contain numbers. + .filter(Predicate.not(HAS_NUMBER)) + // Remove values that contain non-Latin characters, and others (see regex). + .filter(Predicate.not(HAS_NON_LATIN)).collect(Collectors.toList()); + newTag.setValue(String.join(SEMICOLON, splitValues)); + return newTag; + } + + /** + * Contains the two similar strings and their similarity. Convenience class for readability. + */ + private static class Similar + { + private final String similar1; + private final String similar2; + private final Integer similarity; + + Similar(final String similar1, final String similar2, final Integer similarity) + { + this.similar1 = similar1; + this.similar2 = similar2; + this.similarity = similarity; + } + + public String getSimilar1() + { + return this.similar1; + } + + public String getSimilar2() + { + return this.similar2; + } + + public Integer getSimilarity() + { + return this.similarity; + } + + @Override + public String toString() + { + return String.format("(%s,%s,%d)", this.similar1, this.similar2, this.similarity); + } + } +} diff --git a/src/test/java/org/openstreetmap/atlas/checks/validation/tag/SimilarTagValueCheckTest.java b/src/test/java/org/openstreetmap/atlas/checks/validation/tag/SimilarTagValueCheckTest.java new file mode 100644 index 000000000..b04d28fb8 --- /dev/null +++ b/src/test/java/org/openstreetmap/atlas/checks/validation/tag/SimilarTagValueCheckTest.java @@ -0,0 +1,74 @@ +package org.openstreetmap.atlas.checks.validation.tag; + +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.openstreetmap.atlas.checks.configuration.ConfigurationResolver; +import org.openstreetmap.atlas.checks.validation.verifier.ConsumerBasedExpectedCheckVerifier; + +/** + * Tests for {@link SimilarTagValueCheck} + * + * @author v-brjor + */ +public class SimilarTagValueCheckTest +{ + @Rule + public SimilarTagValueCheckTestRule setup = new SimilarTagValueCheckTestRule(); + + @Rule + public ConsumerBasedExpectedCheckVerifier verifier = new ConsumerBasedExpectedCheckVerifier(); + + private final SimilarTagValueCheck check = new SimilarTagValueCheck( + ConfigurationResolver.inlineConfiguration( + "{\"SimilarTagValueCheck\":{" + "\"similarity.threshold\":{\"min\":" + 0.0 + "," + + "\"max\":" + 1.0 + "},\"value.length.min\":" + 4.0 + "}}")); + + @Test + public void testCommonSimilars() + { + this.verifier.actual(this.setup.getIgnoreCommonSimilarsTest(), this.check); + this.verifier.verifyEmpty(); + } + + @Test + public void testDuplicateCommonSimilars() + { + this.verifier.actual(this.setup.getDuplicateCommonSimilarsTest(), this.check); + this.verifier.verify(flag -> Assert.assertEquals( + "1. The tag \"cuisine\" contains duplicate values: [(cake,cake,0)]", + flag.getInstructions())); + } + + @Test + public void testHasDuplicateTagValue() + { + this.verifier.actual(this.setup.getHasDuplicateTagTest(), this.check); + this.verifier.verify(flag -> Assert.assertEquals( + "1. The tag \"hasDupe\" contains duplicate values: [(dupe,dupe,0)]", + flag.getInstructions())); + } + + @Test + public void testHasSimilarTagValue() + { + this.verifier.actual(this.setup.getHasSimilarTagTest(), this.check); + this.verifier.verify(flag -> Assert.assertEquals( + "1. The tag \"hasSimilar\" contains similar values: [(similar,similer,1)]", + flag.getInstructions())); + } + + @Test + public void testIgnoreTag() + { + this.verifier.actual(this.setup.getIgnoreTagTest(), this.check); + this.verifier.verifyEmpty(); + } + + @Test + public void testIgnoreTagSubclass() + { + this.verifier.actual(this.setup.getIgnoreTagSubclassTest(), this.check); + this.verifier.verifyEmpty(); + } +} diff --git a/src/test/java/org/openstreetmap/atlas/checks/validation/tag/SimilarTagValueCheckTestRule.java b/src/test/java/org/openstreetmap/atlas/checks/validation/tag/SimilarTagValueCheckTestRule.java new file mode 100644 index 000000000..b504a86ae --- /dev/null +++ b/src/test/java/org/openstreetmap/atlas/checks/validation/tag/SimilarTagValueCheckTestRule.java @@ -0,0 +1,70 @@ +package org.openstreetmap.atlas.checks.validation.tag; + +import org.openstreetmap.atlas.geography.atlas.Atlas; +import org.openstreetmap.atlas.utilities.testing.CoreTestRule; +import org.openstreetmap.atlas.utilities.testing.TestAtlas; +import org.openstreetmap.atlas.utilities.testing.TestAtlas.Loc; +import org.openstreetmap.atlas.utilities.testing.TestAtlas.Node; + +/** + * Tests for {@link SimilarTagValueCheck} + * + * @author v-brjor + */ +public class SimilarTagValueCheckTestRule extends CoreTestRule +{ + private static final String TEST_1 = "0, 0"; + + @TestAtlas(nodes = { + @Node(coordinates = @Loc(value = TEST_1), tags = { "cuisine=cake;cake" }) }) + private Atlas duplicateCommonSimilarsTest; + + @TestAtlas(nodes = { + @Node(coordinates = @Loc(value = TEST_1), tags = { "hasDupe=dupe;dupe" }) }) + private Atlas hasDuplicateTagTest; + + @TestAtlas(nodes = { + @Node(coordinates = @Loc(value = TEST_1), tags = { "hasSimilar=similar;similer" }) }) + private Atlas hasSimilarTagTest; + + @TestAtlas(nodes = { + @Node(coordinates = @Loc(value = TEST_1), tags = { "cuisine=cafe;cake" }) }) + private Atlas ignoreCommonSimilarsTest; + + @TestAtlas(nodes = { + @Node(coordinates = @Loc(value = TEST_1), tags = { "turn:lanes:forward=dupe;dupe" }) }) + private Atlas ignoreTagSubclassTest; + + @TestAtlas(nodes = { @Node(coordinates = @Loc(value = TEST_1), tags = { "ref=dupe;dupe" }) }) + private Atlas ignoreTagTest; + + public Atlas getDuplicateCommonSimilarsTest() + { + return this.duplicateCommonSimilarsTest; + } + + public Atlas getHasDuplicateTagTest() + { + return this.hasDuplicateTagTest; + } + + public Atlas getHasSimilarTagTest() + { + return this.hasSimilarTagTest; + } + + public Atlas getIgnoreCommonSimilarsTest() + { + return this.ignoreCommonSimilarsTest; + } + + public Atlas getIgnoreTagSubclassTest() + { + return this.ignoreTagSubclassTest; + } + + public Atlas getIgnoreTagTest() + { + return this.ignoreTagTest; + } +}