/** * Copyright (c) Codice Foundation * <p/> * This is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser * General Public License as published by the Free Software Foundation, either version 3 of the * License, or any later version. * <p/> * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. A copy of the GNU Lesser General Public License * is distributed along with this program and can be found at * <http://www.gnu.org/licenses/lgpl.html>. **/ package org.codice.ddf.spatial.geocoding.query; import static org.apache.lucene.index.IndexWriterConfig.OpenMode; import static org.hamcrest.Matchers.empty; import static org.hamcrest.core.Is.is; import static org.hamcrest.core.IsInstanceOf.instanceOf; import static org.junit.Assert.assertThat; import static org.junit.Assert.fail; import static org.mockito.Matchers.any; import static org.mockito.Matchers.anyString; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import java.io.IOException; import java.util.Collections; import java.util.List; import java.util.NoSuchElementException; import java.util.Optional; import org.apache.lucene.analysis.standard.StandardAnalyzer; import org.apache.lucene.document.Document; import org.apache.lucene.document.Field; import org.apache.lucene.document.NumericDocValuesField; import org.apache.lucene.document.StoredField; import org.apache.lucene.document.StringField; import org.apache.lucene.document.TextField; import org.apache.lucene.index.IndexWriter; import org.apache.lucene.index.IndexWriterConfig; import org.apache.lucene.index.IndexableField; import org.apache.lucene.queryparser.classic.ParseException; import org.apache.lucene.spatial.SpatialStrategy; import org.apache.lucene.spatial.prefix.RecursivePrefixTreeStrategy; import org.apache.lucene.spatial.prefix.tree.GeohashPrefixTree; import org.apache.lucene.spatial.prefix.tree.SpatialPrefixTree; import org.apache.lucene.store.Directory; import org.apache.lucene.store.RAMDirectory; import org.codice.ddf.spatial.geocoding.GeoEntry; import org.codice.ddf.spatial.geocoding.GeoEntryQueryException; import org.codice.ddf.spatial.geocoding.TestBase; import org.codice.ddf.spatial.geocoding.context.NearbyLocation; import org.codice.ddf.spatial.geocoding.index.GeoNamesLuceneConstants; import org.junit.Before; import org.junit.Test; import org.locationtech.spatial4j.context.SpatialContext; import org.locationtech.spatial4j.shape.Shape; public class TestGeoNamesQueryLuceneIndex extends TestBase { private static final String NAME_1 = "Phoenix"; private static final String NAME_2 = "Phoenix Airport"; private static final String NAME_3 = "Glendale"; private static final double LAT_1 = 1.234; private static final double LAT_2 = 1.25; private static final double LAT_3 = 1; private static final double LON_1 = 56.78; private static final double LON_2 = 56.5; private static final double LON_3 = 57; private static final String FEATURE_CODE_1 = "PPL"; private static final String FEATURE_CODE_2 = "AIRP"; private static final String FEATURE_CODE_3 = "PPLC"; private static final long POP_1 = 100000000; private static final long POP_2 = 10000000; private static final long POP_3 = 1000000; private static final String ALT_NAMES_1 = "alt1,alt2"; private static final String ALT_NAMES_2 = "alt3"; private static final String ALT_NAMES_3 = ""; private static final String COUNTRY_CODE1 = "C1"; private static final String COUNTRY_CODE2 = "C2"; private static final String COUNTRY_CODE3 = "C3"; private static final SpatialContext SPATIAL_CONTEXT = SpatialContext.GEO; private static final String TEST_POINT = "POINT (56.78 1.5)"; private static final GeoEntry GEO_ENTRY_1 = new GeoEntry.Builder().name(NAME_1) .latitude(LAT_1) .longitude(LON_1) .featureCode(FEATURE_CODE_1) .population(POP_1) .alternateNames(ALT_NAMES_1) .countryCode(COUNTRY_CODE1) .build(); private static final GeoEntry GEO_ENTRY_2 = new GeoEntry.Builder().name(NAME_2) .latitude(LAT_2) .longitude(LON_2) .featureCode(FEATURE_CODE_2) .population(POP_2) .alternateNames(ALT_NAMES_2) .countryCode(COUNTRY_CODE2) .build(); private static final GeoEntry GEO_ENTRY_3 = new GeoEntry.Builder().name(NAME_3) .latitude(LAT_3) .longitude(LON_3) .featureCode(FEATURE_CODE_3) .population(POP_3) .alternateNames(ALT_NAMES_3) .countryCode(COUNTRY_CODE3) .build(); private Directory directory; private GeoNamesQueryLuceneDirectoryIndex directoryIndex; private SpatialStrategy strategy; private void initializeIndex() throws IOException { directory = new RAMDirectory(); final IndexWriterConfig indexWriterConfig = new IndexWriterConfig(new StandardAnalyzer()); indexWriterConfig.setOpenMode(OpenMode.CREATE); final IndexWriter indexWriter = new IndexWriter(directory, indexWriterConfig); indexWriter.addDocument(createDocumentFromGeoEntry(GEO_ENTRY_1)); indexWriter.addDocument(createDocumentFromGeoEntry(GEO_ENTRY_2)); indexWriter.addDocument(createDocumentFromGeoEntry(GEO_ENTRY_3)); indexWriter.close(); } @Before public void setUp() throws IOException { directoryIndex = spy(new GeoNamesQueryLuceneDirectoryIndex()); directoryIndex.setIndexLocation(null); final SpatialPrefixTree grid = new GeohashPrefixTree(SPATIAL_CONTEXT, GeoNamesLuceneConstants.GEOHASH_LEVELS); strategy = new RecursivePrefixTreeStrategy(grid, GeoNamesLuceneConstants.GEO_FIELD); initializeIndex(); doReturn(directory).when(directoryIndex) .openDirectory(); } private Document createDocumentFromGeoEntry(final GeoEntry geoEntry) { final Document document = new Document(); document.add(new TextField(GeoNamesLuceneConstants.NAME_FIELD, geoEntry.getName(), Field.Store.YES)); document.add(new StoredField(GeoNamesLuceneConstants.LATITUDE_FIELD, geoEntry.getLatitude())); document.add(new StoredField(GeoNamesLuceneConstants.LONGITUDE_FIELD, geoEntry.getLongitude())); document.add(new StringField(GeoNamesLuceneConstants.FEATURE_CODE_FIELD, geoEntry.getFeatureCode(), Field.Store.YES)); document.add(new StoredField(GeoNamesLuceneConstants.POPULATION_FIELD, geoEntry.getPopulation())); document.add(new NumericDocValuesField(GeoNamesLuceneConstants.POPULATION_DOCVALUES_FIELD, geoEntry.getPopulation())); document.add(new StringField(GeoNamesLuceneConstants.COUNTRY_CODE_FIELD, geoEntry.getCountryCode(), Field.Store.YES)); document.add(new TextField(GeoNamesLuceneConstants.ALTERNATE_NAMES_FIELD, geoEntry.getAlternateNames(), Field.Store.NO)); final Shape point = SPATIAL_CONTEXT.getShapeFactory() .pointXY(geoEntry.getLongitude(), geoEntry.getLatitude()); for (IndexableField field : strategy.createIndexableFields(point)) { document.add(field); } return document; } @Test public void testQueryWithExactlyMaxResults() throws IOException, ParseException, GeoEntryQueryException { final int requestedMaxResults = 2; final String queryString = "phoenix"; final List<GeoEntry> results = directoryIndex.query(queryString, requestedMaxResults); assertThat(results.size(), is(requestedMaxResults)); final GeoEntry firstResult = results.get(0); // We don't store the alternate names, so we don't get them back with the query results. // The entry with the name "phoenix" will come first because the name matches the query // exactly. verifyGeoEntry(firstResult, NAME_1, LAT_1, LON_1, FEATURE_CODE_1, POP_1, null, COUNTRY_CODE1); final GeoEntry secondResult = results.get(1); // We don't store the alternate names, so we don't get them back with the query results. verifyGeoEntry(secondResult, NAME_2, LAT_2, LON_2, FEATURE_CODE_2, POP_2, null, COUNTRY_CODE2); } @Test public void testQueryWithLessThanMaxResults() throws IOException, ParseException, GeoEntryQueryException { final int requestedMaxResults = 2; final int actualResults = 1; final String queryString = "glendale"; final List<GeoEntry> results = directoryIndex.query(queryString, requestedMaxResults); assertThat(results.size(), is(actualResults)); final GeoEntry firstResult = results.get(0); // We don't store the alternate names, so we don't get them back with the query results. verifyGeoEntry(firstResult, NAME_3, LAT_3, LON_3, FEATURE_CODE_3, POP_3, null, COUNTRY_CODE3); } @Test public void testQueryWithNoResults() throws IOException, ParseException, GeoEntryQueryException { final int requestedMaxResults = 2; final int actualResults = 0; final String queryString = "another place"; final List<GeoEntry> results = directoryIndex.query(queryString, requestedMaxResults); assertThat(results.size(), is(actualResults)); } @Test(expected = IllegalArgumentException.class) public void testBlankQuery() throws GeoEntryQueryException { directoryIndex.query("", 1); } @Test(expected = IllegalArgumentException.class) public void testNullQuery() throws GeoEntryQueryException { directoryIndex.query(null, 1); } @Test(expected = IllegalArgumentException.class) public void testQueryZeroMaxResults() throws GeoEntryQueryException { directoryIndex.query("phoenix", 0); } @Test(expected = IllegalArgumentException.class) public void testQueryNegativeMaxResults() throws GeoEntryQueryException { directoryIndex.query("phoenix", -1); } @Test public void testExceptionInDirectoryCreation() throws IOException { doThrow(IOException.class).when(directoryIndex) .openDirectory(); try { directoryIndex.query("phoenix", 1); fail("Should have thrown a GeoEntryQueryException because an IOException was thrown " + " when creating the directory."); } catch (GeoEntryQueryException e) { assertThat("The GeoEntryQueryException was not caused by an IOException.", e.getCause(), instanceOf(IOException.class)); } } @Test public void testExceptionInIndexReaderCreation() throws IOException { doThrow(IOException.class).when(directoryIndex) .createIndexReader(any(Directory.class)); try { directoryIndex.query("phoenix", 1); fail("Should have thrown a GeoEntryQueryException because an IOException was thrown " + "when creating the IndexReader."); } catch (GeoEntryQueryException e) { assertThat("The GeoEntryQueryException was not caused by an IOException.", e.getCause(), instanceOf(IOException.class)); } } @Test public void testExceptionInQueryParsing() throws ParseException { doThrow(ParseException.class).when(directoryIndex) .createQuery(anyString()); try { directoryIndex.query("phoenix", 1); fail("Should have thrown a GeoEntryQueryException because a ParseException was " + "thrown when creating the Query."); } catch (GeoEntryQueryException e) { assertThat("The GeoEntryQueryException was not caused by a ParseException.", e.getCause(), instanceOf(ParseException.class)); } } @Test(expected = IllegalArgumentException.class) public void testNearestCitiesNullMetacard() throws java.text.ParseException, GeoEntryQueryException { directoryIndex.getNearestCities(null, 1, 1); } @Test(expected = IllegalArgumentException.class) public void testNearestCitiesNegativeRadius() throws java.text.ParseException, GeoEntryQueryException { directoryIndex.getNearestCities(TEST_POINT, -1, 1); } @Test(expected = IllegalArgumentException.class) public void testNearestCitiesNegativeMaxResults() throws java.text.ParseException, GeoEntryQueryException { directoryIndex.getNearestCities(TEST_POINT, 1, -1); } @Test public void testNearestCitiesWithMaxResults() throws java.text.ParseException, GeoEntryQueryException { String testPoint = "POINT (56.78 1)"; final int requestedMaxResults = 2; final List<NearbyLocation> nearestCities = directoryIndex.getNearestCities(testPoint, 50, requestedMaxResults); assertThat(nearestCities.size(), is(requestedMaxResults)); /* These distances values were obtained from http://www.movable-type.co.uk/scripts/latlong.html Phoenix is first because it has a higher population. Additionally, "Phoenix Airport" (GEO_ENTRY_2) is within 50 km of (56.78, 1), but it should not be included in the results because its feature code is AIRP (not a city). */ final NearbyLocation first = nearestCities.get(0); assertThat(first.getCardinalDirection(), is("S")); final double firstDistance = first.getDistance(); assertThat(String.format("%.2f", firstDistance), is("26.02")); assertThat(first.getName(), is("Phoenix")); final NearbyLocation second = nearestCities.get(1); assertThat(second.getCardinalDirection(), is("W")); final double secondDistance = second.getDistance(); assertThat(String.format("%.2f", secondDistance), is("24.46")); assertThat(second.getName(), is("Glendale")); } @Test public void testNearestCitiesWithLessThanMaxResults() throws java.text.ParseException, GeoEntryQueryException { String testPoint = "POINT (56.78 1.5)"; final int requestedMaxResults = 2; final int actualResults = 1; final List<NearbyLocation> nearestCities = directoryIndex.getNearestCities(testPoint, 50, requestedMaxResults); assertThat(nearestCities.size(), is(actualResults)); /* This distance value was obtained from http://www.movable-type.co.uk/scripts/latlong.html Additionally, "Phoenix Airport" (GEO_ENTRY_2) is within 50 km of (56.78, 1.5), but it should not be included in the results because its feature code is AIRP (not a city). */ final NearbyLocation first = nearestCities.get(0); assertThat(first.getCardinalDirection(), is("N")); final double distance = first.getDistance(); assertThat(String.format("%.2f", distance), is("29.58")); assertThat(first.getName(), is("Phoenix")); } @Test public void testNearestCitiesWithNoResults() throws java.text.ParseException, GeoEntryQueryException { String testPoint = "POINT (0 1)"; final int requestedMaxResults = 2; final int actualResults = 0; final List<NearbyLocation> nearestCities = directoryIndex.getNearestCities(testPoint, 50, requestedMaxResults); assertThat(nearestCities.size(), is(actualResults)); } @Test(expected = java.text.ParseException.class) public void testNearestCitiesWithBadWKT() throws java.text.ParseException, GeoEntryQueryException { String testPoint = "POINT 56.78 1.5)"; final int requestedMaxResults = 2; directoryIndex.getNearestCities(testPoint, 50, requestedMaxResults); } @Test(expected = java.text.ParseException.class) public void testNearestCitiesWithBlankWKT() throws java.text.ParseException, GeoEntryQueryException { String testPoint = ""; final int requestedMaxResults = 2; final int actualResults = 0; final List<NearbyLocation> nearestCities = directoryIndex.getNearestCities(testPoint, 50, requestedMaxResults); assertThat(nearestCities.size(), is(actualResults)); } @Test(expected = IllegalArgumentException.class) public void testDoGetNearestCitiesNullShape() throws GeoEntryQueryException { directoryIndex.doGetNearestCities(null, 10, 10, directory); } @Test(expected = GeoEntryQueryException.class) public void testDoGetNearestCitiesIOExceptionBranch() throws IOException, GeoEntryQueryException { doThrow(IOException.class).when(directoryIndex) .createIndexReader(directory); Shape shape = mock(Shape.class); directoryIndex.doGetNearestCities(shape, 10, 10, directory); } @Test public void testDoGetNearestCitiesNullDirectory() throws IOException, GeoEntryQueryException { Shape shape = mock(Shape.class); List<NearbyLocation> nearestCities = directoryIndex.doGetNearestCities(shape, 10, 10, null); assertThat(nearestCities, is(Collections.emptyList())); } @Test(expected = IllegalArgumentException.class) public void testDoGetCountryCodeNullShape() throws GeoEntryQueryException { Shape shape = null; directoryIndex.doGetCountryCode(shape, 10, null); } @Test(expected = IllegalArgumentException.class) public void testDoGetCountryCodeNullLuceneDirectory() throws GeoEntryQueryException { Shape shape = mock(Shape.class); Directory directory = null; directoryIndex.doGetCountryCode(shape, 10, directory); } @Test public void testDoGetCountryCodeSuccess() throws GeoEntryQueryException, java.text.ParseException { final int radiusKm = 50; final Optional<String> countryCode = directoryIndex.getCountryCode(TEST_POINT, radiusKm); assertThat(countryCode.get(), is(COUNTRY_CODE1)); } @Test(expected = NoSuchElementException.class) public void testDoGetCountryCodeNullResponse() throws GeoEntryQueryException, java.text.ParseException { final int radiusKm = 1; final Optional<String> countryCode = directoryIndex.getCountryCode(TEST_POINT, radiusKm); countryCode.get(); } @Test(expected = IllegalArgumentException.class) public void testDoGetCountryCodeNonPositiveRadius() throws GeoEntryQueryException, java.text.ParseException { final int radiusKm = 0; directoryIndex.getCountryCode(TEST_POINT, radiusKm); } @Test(expected = GeoEntryQueryException.class) public void testDoGetCountryCodeIOExceptionBranch() throws IOException, GeoEntryQueryException, java.text.ParseException { doThrow(IOException.class).when(directoryIndex) .createIndexReader(directory); final int radiusKm = 1; directoryIndex.getCountryCode(TEST_POINT, radiusKm); } @Test public void testDoQueryNullDirectory() throws GeoEntryQueryException { List<GeoEntry> result = directoryIndex.doQuery("test", 1, null); assertThat(result, empty()); } }